# 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. ```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). - `id` is 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.jurisdiction` names *where* the supply takes place; rates live only in ALSL policies. - `fx.rate` is **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_000002 ``` From 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 -> PostingPass ``` After **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: true ``` Determinism: 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-export` produces 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 compiler ``` The 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 `/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** (`/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_`, `reverses` link) for every changed or removed event; - a **replacement entry** with a deterministic revision-suffixed id (`je__r`) 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).