AIR Specification v0.1 — The Language of Accounting
AIR (Accounting Intermediate Representation) is to accounting what LLVM IR is to compilation: a universal intermediate language. AIR describes economic events — what actually happened in the world — and a deterministic compiler (AIC) lowers them into journal entries under versioned rule sets (ALSL).
The core contract:
An LLM (or any frontend) may only ever produce AIR. It never produces a journal entry. The compiler applies taxes, standards, and policies deterministically, traceably, and testably.
AIR is self-sufficient: with the native backend, AIR is a complete standalone accounting system (hash-chained ledger + general ledger, trial balance, income statement, balance sheet in text/markdown/csv/json). Third party systems (QuickBooks, Xero, Odoo, SAP...) are optional export targets.
1. Document form
An AIR document is YAML or JSON validated against
schemas/air-0.1.schema.json. Three isomorphic forms exist (as in LLVM):
in-memory typed objects (core/events.py), canonical JSON, and readable YAML.
air_version: "0.1"
events:
- id: evt_01H8XGJWBWBAQ4Z1
type: Sale
date: 2026-07-20
parties: {seller: "company:acme", buyer: "customer:cust_123"}
items:
- {sku: chair-std, qty: "3", unit_price: {amount: "333.33", currency: CAD}}
payment: {method: card.visa, immediate: true}
tax: {jurisdiction: CA-QC, codes: [GST, QST]}
meta:
source: {kind: invoice_pdf, uri: "s3://...", ocr_score: "0.97"}
llm: {model: "claude-fable-5", confidence: "0.93"}
policy_version: "2026.08"1.1 Lexical rules
- Amounts, quantities, rates are strings (
"333.33"). Bare numbers would be parsed as binary floats and are rejected (ADR 0003). idis unique per document (ULIDs recommended).- Dates are ISO 8601.
- Unknown fields are rejected (
extra="forbid"): the schema is the contract.
1.2 Event types (v0.1)
| Type | Meaning | Posting shape (native profile) |
|---|---|---|
Sale |
value delivered to a buyer | DR cash/AR total · CR revenue · CR tax payables |
Purchase |
value received from a vendor | DR expense/asset (+ non-recoverable tax) · DR recoverable taxes · CR cash/AP |
Refund |
reversal of a sale | mirror of Sale |
PaymentReceived |
settles a receivable | DR cash · CR AR · ± realized FX |
PaymentSent |
settles a payable | DR AP · CR cash · ± realized FX |
OwnerContribution |
equity injection | DR cash · CR owner capital |
LoanReceived |
debt proceeds | DR cash · CR loan payable |
Semantic notes:
- No account, no debit, no credit appears in AIR. Events are perspective-neutral (ADR 0002): the same event compiles to the seller's or buyer's books depending on the compiling entity's policy set.
tax.jurisdictionnames where the supply takes place; rates live only in ALSL policies.fx.rateis observed input data (e.g. a Bank of Canada Valet daily rate on the transaction date), never a constant of the system.
2. Provenance — accounting SSA
Every amount in a compilation is defined exactly once as a node in an
append-only DAG (core/provenance.py). Derivations record their operation:
prov_000000 event_subtotal subtotal:Sale 999.99 CAD <- evt_...
prov_000001 tax tax:GST@0.05~half_up 50.00 CAD <- prov_000000
prov_000002 tax tax:QST@0.09975~half_up 99.75 CAD <- prov_000000
prov_000005 journal_line post:credit:2320 99.75 CAD <- prov_000002From any posted line one can walk back to the source event, its document URI, its OCR/LLM confidence, and the exact policy and rounding mode applied.
3. The AIC pipeline
ValidationPass -> ClassificationPass -> TaxPass -> FxPass -> PostingPassAfter every pass the verifier checks (a) each entry balances per currency
and (b) the accounting equation
Assets = Liabilities + Equity + (Revenue − Expenses). Any violation aborts
with clang-style diagnostics (code, location, cause, help: fix), e.g.:
error[AIR-E400]: no tax policy in set 'ca-qc' matches jurisdiction 'CA-BC'
--> event evt_x, field tax.jurisdiction
pass: tax
help: add an ALSL tax policy for this jurisdiction or mark the event tax.exempt: trueDeterminism: same document + same policy set = byte-identical journal. No LLM, network, or clock inside the compiler (property-tested).
3.1 Diagnostic codes
| Code | Meaning |
|---|---|
| AIR-E100/E101/E102 | empty entry / unbalanced entry / accounting equation violated |
| AIR-E200..E203 | duplicate id / mixed currencies / no amount / negative subtotal |
| AIR-W200/W201 | taxable event without tax context / refund without related_event |
| AIR-W300 | multiple classification policies matched |
| AIR-E400/E401, AIR-W400 | no tax policy for jurisdiction / unsupported base / requested code not produced |
| AIR-E500..E502 | missing fx rate / settlement without related event / related event unusable |
| AIR-E600/E601, AIR-W600 | missing account role / no posting rule / stated gross differs from computed total |
4. ALSL v0.1
A YAML subset (full DSL later). A policy set carries: functional_currency,
rounding (mode + source), accounts (role → code/name/type), and
policies (kind tax or classification). Loader strictness: bare-float
rates are rejected; tax policies without a source citation are rejected.
See alsl/policies/ca-qc-2026.yaml for the reference set (GST 5%,
QST 9.975% on the pre-GST price, half-up per ETA s. 165.2(2) — sources in
docs/research/canada-gst-qst.md, verified 2026-08-05).
5. Backends
Common interface (backends/base.py): capabilities(),
compile(journal) -> TargetPayload, post(payload, idempotency_key),
reverse(receipt). Posted entries are never deleted — reversal appends
contra entries (or uses the target's native mechanism).
- native — AIR standalone: append-only, hash-chained JSONL ledger with idempotent posting and built-in statements (kernel/ledger.py, reporting.py). Managed by the AIR home (kernel/workspace.py): one directory holding meta, ledger, content-addressed document archive, and exports — the whole book of record is reproducible from it.
- generic_csv — universal flat-file journal export.
- quickbooks — QBO JournalEntry payloads (requestid idempotency,
minorversion=75, DocNumber ≤ 21 chars, contra-entry reversal). QuickBooks
is OPTIONAL: the backend is developed and tested entirely offline against
a mock transport that simulates QBO's documented behaviors (idempotent
replay, duplicate-DocNumber error 6140);
qbo-exportproduces QBO-shaped JSON with no account at all. The real HTTP transport activates only when the user supplies OAuth credentials. - Planned per docs/research/erp-apis.md: Xero (Idempotency-Key header), Odoo (account.move), beancount text export, SAP (deferred).
6. Ingestion — the only place an LLM appears
source text -> Extractor -> AIR schema validation -> confidence routing
| |
auto-approved human inbox
\\ /
deterministic compilerThe extractor (offline mock, or Claude with schema-constrained structured
output) emits AIR events only — never journal entries, accounts, or
debits/credits. Every extraction is validated against the AIR schema;
schema-invalid output ALWAYS routes to the human inbox, as does anything
below the confidence threshold (default 0.85). Approval stamps the approver
and timestamp into event meta (ingestion/approval.py); even approved
documents then go through the deterministic compiler. The inbox lives in the
AIR home (inbox/pending|approved|rejected/) — nothing is deleted.
7. Agent syscalls & the audit log
AI agents never touch the ledger, the compiler internals, or the files. Their
entire surface is the kernel syscall interface (sdk/syscalls.py):
| Syscall | Effect |
|---|---|
CreateEconomicEvent |
schema-validate + stage a draft in <home>/drafts/ |
Validate / Compile |
run the deterministic compiler on the drafts, no posting |
Post |
compile + append to the books (idempotent by draft content hash); refuses closed periods (AIR-E700); archives the drafts |
Reverse |
append the exact contra of a posted entry — never edits |
ClosePeriod |
close YYYY-MM; later posts into it are refused |
GenerateReport |
any statement, any format |
Merge |
post the drafts through the optimization passes (below) |
Reconcile |
match a bank statement against the books' cash movements |
Every syscall — successful or refused — appends one record to the
hash-chained audit log (<home>/audit.jsonl, kernel/audit.py): actor,
syscall, parameter digest, outcome, timestamp, prev_hash, hash. Editing,
deleting, or reordering any record breaks air audit verification. A refused
action is evidence too — denials are logged like successes.
8. Optimization passes & bank reconciliation
Opt-in (compile --optimize, or the Merge syscall); the invariant is still
verified after every pass, and optimized lines carry provenance nodes pointing
to every line they absorbed (aic/passes/optimize.py):
- DuplicateDetectionPass — identical economic content under different ids
→ warning
AIR-W800; nothing is dropped, a human decides. - NettingPass — a Refund referencing a Sale in the same compilation nets
against it: one net entry (or none when fully offset; note
AIR-N801). Refunds exceeding their sale are left alone. - FusionPass — same-day payments with an identical posting shape fuse into
one batch entry, amounts summed per line (note
AIR-N800). 50 payments → 1 entry — which then matches the bank's single batch deposit one-to-one.
Reconciliation (kernel/reconcile.py, formats per
docs/research/bank-statement-formats.md): camt.053 (namespace-agnostic,
sign from CdtDbtInd, reference from EndToEndId), MT940 (:61: lines,
comma decimals, D/C/RD/RC marks, opening±movements=closing integrity check),
and CSV, all normalized to signed-Decimal BankTransaction records. Matching
is exact on (signed amount, currency) within a configurable date-tolerance
window; unmatched items on both sides are reported, never dropped.
9. Versioning & incremental compilation
air_version is mandatory; schema migrations are explicit.
Changed source documents recompile as deltas (aic/incremental.py,
CLI recompile old.yaml new.yaml). Events are compared by content
fingerprint (SHA-256 over canonical JSON); the delta contains:
- an exact contra reversal entry (
rev_je_<event>,reverseslink) for every changed or removed event; - a replacement entry with a deterministic revision-suffixed id
(
je_<event>_r<fp8>) for every changed event; - a normal entry for every added event; nothing for unchanged events.
Posted history is never mutated. The delta itself must satisfy the
double-entry invariant, and posting v1 + delta yields byte-identical
balances to compiling v2 directly (tested in tests/test_incremental.py).