Batch receipts and shared accounting

src/decision/batch-receipts/ defines the versioned decision-batch-receipt/v1 record.

Batch receipts and shared accounting

`src/decision/batch-receipts/` defines the versioned `decision-batch-receipt/v1` record. A receipt is the sole owner of a shared provider request's identity, attempt chronology, usage, and cost evidence. Evaluation results link through `decision-batch-result-ref/v1`; they do not copy shared tokens or cost.

Receipts bind the deterministic context plan digest and partition ID from `context-plan.ts`, plus an optional native group ID from `batch.ts`. A split plan therefore emits one receipt per partition under one plan digest. Attempt history is append-only and includes failures, retries, uncertain execution, and fallback lineage.

Accounting rules

  • Provider usage lives on each attempt exactly once. `batchAccountingTotals` adds all attempts, including failed requests that consumed usage. Any unknown token field makes that aggregate field unknown rather than zero.
  • Provider-authoritative cost is preserved. Client-derived cost uses integer USD micros and a price-catalog ID, version, and effective timestamp. Unknown cost stays `unknown`, or `bounded-unknown` when `batchReceipts.unknownCostBound` supplies a reviewed per-attempt upper bound and policy ID/version. `batchEnforcementCostMicros` sums known and bounded attempts and returns null if any attempt remains unbounded; callers must fail closed on null before authorizing spend. If `batchReceipts.maxCostMicros` is configured, the evaluator requires a valid unknown-cost bound, fails closed before dispatch when the first request exceeds the ceiling, and uses cumulative receipt-level enforcement cost plus the bound to gate further retries and partitions. The bound must conservatively cover actual cost of each dispatched request; an already dispatched request cannot be unspent.
  • Optional per-answer values are reporting-only `estimated` allocations. Algorithm `largest-remainder-weighted` version `1` floors each weighted share and awards remaining integer tokens by descending remainder, breaking ties by question ID. The result reconciles exactly to the request total. These allocations are not spend-admission evidence.

Persistence, replay, and exports

Receipt stores expose acquisition plus compare-and-swap. File revisions are immutable and published with an exclusive hard-link, so concurrent owners cannot both acquire or publish the same revision. Reads validate every contiguous transition; retrying acquisition returns the existing identity and timestamps. A durable native batch also requires a result store: without one the evaluator fails before dispatch. The result store publishes successful value snapshots before the completed receipt revision becomes visible. A failed value publication leaves the receipt execution-uncertain; a failed receipt commit never exposes a completed receipt pointing to missing values. Orphaned snapshots are not replayable until the receipt commits. Store receipt and result directories together on durable private storage; both directories use mode 0700 and files use mode 0600. Reads reject nonregular or group/world-accessible files; reads and writes reject symlinked or group/world-accessible directories. Results contain private answer values, so do not export or put this directory in telemetry. Result snapshots cannot contain provider request IDs or shared usage/cost. Protect storage access and backups at the same tenant/project boundary as the receipt store.

Integrity, encryption, and lifecycle

Both file stores follow the D03 receipt integrity model and the D10 lifecycle contract (data-lifecycle.md). Their constructors fail closed unless the host supplies every required option:

const lifecycle = hostLifecyclePolicy; // decision-lifecycle/v1; both stores use surfaces.receipt
const results = new FileBatchResultStore(resultDirectory, {
  integrityKey,                            // >= 32 bytes, host secret
  lifecycle,
  encryptionKeyReference: 'batch-results-2026', // logical name, never key material
  resolveEncryptionKey: reference => kms.resolve(reference), // must return exactly 32 bytes
});
const receipts = new FileBatchReceiptStore(receiptDirectory, {
  integrityKey, lifecycle,
  results,                                 // cascade target for erase and sweep
  isTombstoned: reference => journalHasTombstone(reference), // optional independent D10 authority
});
  • Integrity. Every receipt revision is stored as a `decision-batch-receipt-envelope/v1` whose HMAC-SHA256 covers the canonical receipt. Every result snapshot is a `decision-batch-result-envelope/v1` whose HMAC covers the key reference, creation time, nonce, tag and ciphertext. Reads require the file bytes to equal the canonical serialization, so changing any byte, including whitespace, is detected. Verification failures throw `BatchStoreIntegrityError` with the fixed message `Batch store integrity check failed`. `readMany` throws rather than returning a partial map. The evaluator maps the error to `persistence-error` and does not retry the read or re-dispatch. File names are keyed HMACs, so a directory listing reveals no tenant, project, batch, question or answer identifiers.
  • Encryption at rest. The whole result snapshot, including the answer `observation`, is encrypted with AES-256-GCM under a fresh 96-bit nonce. The AAD binds tenant, project, batch, question, answer, receipt revision, key reference and creation time. A snapshot copied to another tenant, project, batch or answer passes the envelope MAC but fails GCM authentication. The envelope records the key reference, so hosts can rotate keys by changing `encryptionKeyReference` while their resolver continues to return older keys for existing snapshots. The store zeroes its copy of the key after each use.
  • Retention. A receipt is unavailable once `surfaces.receipt.retentionMs` has elapsed since its revision-1 `createdAtEpochMs`. A result snapshot is unavailable once that time has elapsed since its own creation or its receipt's creation. Expired records read as missing: `read` returns `null` and `readMany` returns an empty map. Acquisition of an expired or erased batch throws `BatchRecordUnavailableError` instead of taking ownership, so the shared request is never dispatched again. The evaluator reports each affected evaluation with the reason `batch-record-unavailable`. That reason exists only in `decision.aiwg.io/v1alpha2`, which durable batch results always use because they carry `attempts[].batch` evidence. The released v1alpha1 schemas reject it. `sweepExpired(isHeld)` erases expired receipts and their results. It skips any reference that the host hold authority reports as held, or for which that authority is unavailable.
  • Erase and legal hold. `lifecycleReference(batchId, tenantId, projectId)` returns an opaque `receipt` reference (`batch-receipt-<hmac>`) for host link registration. `erase(opaqueId)` is the D10 content eraser: register it with `FileDecisionLifecycleStore` and call it through `eraseDecisionSubject`. That function refuses to erase while an active hold covers the subject, and it records the journal tombstone before any content is removed. The eraser writes a body-free local tombstone, erases the result snapshots of the batch (which leaves a body-free result tombstone), and then removes every receipt revision. Later reads return the same result as a batch that was never written. Later writes and acquisitions are refused with `BatchRecordUnavailableError`.
  • Backup and restore. `restoreFrom(backupDirectory, tombstones)` on the receipt store and `restoreFrom(backupDirectory)` on the result store copy only verified sealed files. Both refuse a batch that has a local tombstone or is past retention. The receipt store also refuses a batch that the `isTombstoned` authority reports as erased or that has a D10 tombstone in the supplied list (`mayRestoreDecisionReference`). A restored result is served only through `readMany` with a live receipt, so it cannot outlive an erased receipt. A lifecycle rule with `backup: 'not-persisted'` refuses every restore. If backup files are copied into a store directory outside this API, the local tombstone and retention checks still stop them from being read. If the local tombstone is also lost, the host's `isTombstoned` authority takes its place. Hosts must still expire backups together with the primary store. Purging deployed backups is host infrastructure.
  • Migration. Stores written before this protection used unkeyed file names (`<sha256>.r<n>.json` for receipts and `<sha256>.json` for results) and plaintext bodies. The protected stores never accept these records silently. `read`, `acquire`, `readMany` and `writeMany` throw `BatchStoreMigrationRequiredError` for a legacy record. `migrateLegacy(authorize)` validates every legacy record, and refuses the whole run if any record is corrupt or misnamed, before it asks for authorization. It writes sealed or encrypted replacements only after `authorize` returns `true`, and then removes the legacy files. If the authorizer denies the request or throws, nothing is written. Migrate before running any lifecycle operation on a legacy store: erase and sweep act only on sealed records.

The in-memory stores are test and single-process fixtures. They do not persist, and they have no lifecycle API.

Sanitized exports retain hashes, plan/partition lineage, chronology, answer references, and accounting evidence. Raw state and response bodies are never accepted by the receipt type. Provider request IDs are bounded opaque internal values; public exports replace them with deployment-salted hashes. They must not be metric labels, authorization inputs, or cross-provider correlation keys.

Runtime ownership

Set `batchReceipts` on a decision evaluation request to enable durable native batching alongside invocation receipts. The supplied context plan must contain a partition whose question IDs exactly match the opaque IDs in the native batch. The evaluator acquires that batch receipt and publishes its `running` revision before calling the adapter. A concurrent or restarted evaluator that does not own the receipt never repeats the transport request.

After a terminal provider response, the receipt stores the shared request ID, usage, and cost once. Each emitted `DecisionResult.spec.batchResult` is a reference-only link to its correlated answer. Per-result attempts deliberately contain null usage and no provider request ID. Terminal, retriable provider failures may be retried up to the target retry limit and invocation attempt budget. Every failed attempt commits an append-only running revision before a new request; the completed receipt reconciles all attempts, including unknown failed-attempt usage. Unknown dispatch outcomes become `execution-uncertain`, remain replay-safe, and are not automatically retried. A compatible second target (same adapter/version and shared transport envelope for every question) may run as a batch fallback only when every question authorizes the terminal failure reason in `fallbackOn`. The second target is capability-checked and separately egress-projected before transport. Fallback attempts link to the preceding attempt ordinal; unknown dispatch never triggers fallback. Incompatible or additional target configurations use the ordinary single-evaluation path.