Model Call Receipt v1

September 13, 2026 · View on GitHub

Status: LOCAL bounded design + reference implementation, fixture-backed.

Audience: implementers of issue #180 ("Add model-call receipts, replay, redaction, and billing reconciliation") and reviewers of the receipt/audit boundary this document adds around Live Invocation Contract v1.

This document assumes the reader already knows Live Invocation Contract v1: the causal journal's record format and ordering rules (src/live_invocation/journal.rs), and the model.invoke effect boundary (src/live_invocation/model_invoke.rs). Everything below is additive to that contract, not a restatement or a second copy of it.

What already existed at the audit baseline

src/live_invocation/ (issues #108/#177) already owns the authoritative per-turn record of a model.invoke attempt:

  • A causal journal (journal::JournalEntry) recording, per turn, exactly TurnOpened, RequestIntent/ResponseRecorded/ResponseFailed, ProposalAdmitted/ProposalRefused, AuthorizationConsumed/AuthorizationRefused, zero or more EffectIntent/EffectObserved/EffectFailed pairs, and one Transition/TerminalOutcome — with journal::validate fail-closed on any reorder, omission, or post-terminal entry.
  • journal::receipt_projection, which that module's own docs already name as "the entire mechanism a receipt (owned downstream by #180) uses": a pure fold over an already-validated journal producing invocation id, turn count, model call/failure counts, effect call count, and terminal case.
  • LiveInvocationId/LiveInvocationSeed (identity.rs): a stable identity derived once from pre-dispatch bytes (ProgramRoot, deployment policy, task, budget, interaction schema digest, approved providers), never from a response.
  • The model.invoke effect boundary itself (model_invoke.rs): ModelInvocationRequest/ModelInvocationOutcome/ModelFailure (a closed failure domain), ProposalDecoder/ProposalOutcome, AuthorizationGate/ AuthorizationGrant (the only mint site for authority), and InvocationBudgetHook (the seam #113/#179 attach cumulative budget policy behind).
  • FixtureModelHandler/FixtureProposalDecoder/fixture_response (fixture.rs): deterministic, offline fixtures, including FixtureModelHandler::must_not_be_called() — a handler that panics if a replay path ever reaches it.

None of this is a receipt schema, a redaction boundary, an independent replay entry point, or a billing reconciler. journal::receipt_projection's aggregate counts are deliberately coarse (per-invocation totals, not a per-call shape with root bindings, model/provider/adapter identity, timing, cost, or provider-reported usage) — #180 asks for the richer per-call shape, which is what this module adds.

Journal integration

The Model Call Journal Receipt v1 projection now folds actual generic-kernel journals and authenticated source checkpoints. It uses a separate schema because historical journals lack the required timestamps and cost estimates of the enriched v1 receipt below. Unknown facts remain absent; no fabricated host metadata is used to populate this older contract. Both schemas can be retained as Audit Capsule objects.

Schema: ModelCallReceipt v1

src/model_call_receipt/receipt.rs. Canonical schema tag semaprax.model-call-receipt.v1. Fields, grouped:

  • Root associations: agent_id, program_root, deployment_root, instance_root, invocation_id, turn, attempt (1-based ordinal within (invocation_id, turn)).
  • Call shape: model_class, provider_class, adapter_identity, proposal_grammar_digest, deployment_policy_digest.
  • Request commitment: task_digest, observation_digest, request_digest, request_bytes_len, reserved_budget.
  • Response commitment (absent until settlement): response_digest, response_bytes_len, private_payload_reference (an authenticated reference into a separately retained, encrypted payload store — never the encrypted bytes or a key).
  • Outcome: terminal_stage (closed vocabulary below), failure (the closed ModelFailure::as_str() tag, if any), proposal_digest, proposal_refusal_reason.
  • Timing: reserved_at_ms, dispatched_at_ms, first_byte_at_ms, completed_at_ms — milliseconds since a caller-defined, invocation-scoped epoch; this module asserts no wall-clock authority of its own.
  • Local usage/cost: local_request_bytes, local_response_bytes, cost_estimate_micros.
  • Provider evidence: provider_call_reference (a locally generated idempotency-key-shaped token this attempt expects a provider invoice to cite back verbatim) and provider_reported (usage a provider has already reported for this exact call, distinct from a later imported invoice row).

A receipt never carries raw prompt bytes, raw response bytes, a credential, or an authorization header — only domain-separated digests (commit_task_bytes/commit_observation_bytes/commit_response_bytes/ commit_proposal_bytes, each with its own domain separator) and plain counters. That is what makes a bare ModelCallReceipt safe to hand to a reviewer or an audit capsule by default.

Attempt lifecycle (ReceiptStage)

The closed vocabulary #180 asks for: Reserved, IntentPersisted, Dispatched, FirstByte, Completed, Decoded, Accepted, Rejected, Cancelled, Uncertain, Reconciled. ReceiptStage::is_settled() is the subset (Completed/Decoded/Accepted/Rejected) billing reconciliation requires before it will run at all.

Canonical rendering and digest

ModelCallReceipt::render() builds a fixed-field-order, JSON-escaped canonical string (the same format! + quote_json hand-rolled convention live_invocation::identity/journal/model_invoke already use — never a reserialized serde_json::Value, so redaction or round-tripping can never silently reorder or reinterpret a field). ModelCallReceipt::digest() is a domain-separated SHA-256 over that rendering. A single mutated byte in the rendering changes the digest (receipt::tests::single_byte_mutation_of_the_rendered_receipt_changes_the_digest).

Root binding (verify_root_binding)

RootBindingContext carries the caller's own trusted agent/root/policy/ grammar identities and the previous attempt ordinal. verify_root_binding rejects a receipt whose agent_id, program_root, deployment_root, instance_root, invocation_id, proposal_grammar_digest, deployment_policy_digest, or attempt (out-of-order relative to previous_attempt) disagrees with that trusted context — each as its own named BindingError variant (receipt::tests::verify_root_binding_rejects_every_wrong_root_and_policy_field_individually). It does not check request/response digests; that is replay's job (below), which recomputes rather than trusts.

Low-entropy payload policy (PayloadPrivacyClaim)

A bare commitment digest over a short payload (below LOW_ENTROPY_BYTE_THRESHOLD = 32 bytes) can still be recovered by brute force even though the plaintext was never transmitted — a two-byte prompt's digest is not meaningfully private. Receipt and audit projections always use DigestOnlyLowEntropyCaveat for short payloads and DigestOnly otherwise. An opaque private-reference string does not authenticate private retention and cannot promote either projection to Withheld. The low-level PayloadPrivacyClaim::classify(..., true) remains available only for callers that separately possess authenticated proof; this module does not produce it. Neither length nor a hash is an encryption guarantee.

Redaction: ModelCallAuditView v1

src/model_call_receipt/audit_view.rs. Canonical schema tag semaprax.model-call-audit-view.v1.

Because ModelCallReceipt itself never carries raw payloads, redaction needs something real to withhold. ReceiptPrivateExtras models the raw material a real deployment might still retain alongside a receipt (for debugging, provider-support escalation, or human review): task, observation, response, private_payload_reference_material, plus two free-text fields a careless handler or adapter might attach — adapter_diagnostic_hint and provider_error_detail — and authorization_header_echo. These last three are exactly the shape model_invoke.rs's own docs warn never belongs in the journal ("nothing provider-shaped"); modeling them here lets this module prove its redaction boundary actually stops them from reaching a reviewer, rather than merely asserting it never records them in the first place.

RedactionPolicy is seven independent reveal flags, all false by default (fully_redacted()). checked_redact(receipt, extras, policy) first bounds inputs and verifies task/observation/response association with the receipt, then produces a ModelCallAuditView carrying:

  • receipt_digest, binding the view to one exact receipt (never mutating or reconstructing the receipt itself — two views built from the same receipt under different policies always carry the same receipt_digest; audit_view::tests::redaction_never_changes_the_bound_receipt_digest).
  • redacted_fields: one RedactedField { name, commitment_digest } per withheld field, so a verifier holding the original extras can confirm what was actually withheld rather than trusting a silent omission.
  • The revealed fields' plaintext directly, for whichever fields the policy opted in.
  • PayloadPrivacyClaim for each payload.

verify_audit_view(view, receipt, extras) independently recomputes every redacted field's commitment from extras and rejects a forged commitment (AuditViewError::CommitmentMismatch), and independently checks every revealed field's plaintext against extras and rejects a tampered plaintext (AuditViewError::RevealedFieldTampered). Every present field must appear exactly once as either disclosure or ordered redaction commitment; omissions, duplicate entries, double disclosure and forged privacy labels fail closed. Receipt payload commitments must agree with the independently retained extras. The legacy redact constructor remains available, but it does not perform these checks itself.

audit_view::canonical renders sorted canonical JSON with a terminal LF and hex-encoded arbitrary disclosed bytes. It caps the document at 1 MiB and replays exact submitted bytes against the original receipt, extras, and disclosure policy, without a provider or storage callback. Unknown keys, changed encoding, and any single-byte mutation fail exact replay. The human review renderer is display-only and is not the wire codec.

Per-field redaction proof

audit_view::tests::redaction_hides_each_of_six_secret_bearing_fields_individually mirrors std.auth.tests.audit_event_safety's structure exactly: a distinct marker string is placed in exactly one of the six secret-bearing fields at a time against an otherwise-clean baseline, and:

  1. a fully-revealed view is asserted to contain that marker (the positive control — proves the marker really would surface if not redacted, so the test cannot pass merely because "decoded" and "raw" happened to already be identical);
  2. a fully-redacted view is asserted not to contain that marker anywhere in its rendered text (not merely absent from one struct field);
  3. the redacted view still carries a verifiable commitment naming that field, and verifies against the real extras.

A clean baseline (no marker in any of the six fields) is also asserted to contain none of the six markers, ruling out a redaction that "passes" only because the marker was never there to begin with.

Replay: independent, zero-dispatch

src/model_call_receipt/replay.rs. replay_receipt(receipt, retained, decoder) has no parameter of type model_invoke::ModelHandler anywhere in its signature — a caller cannot wire a live handler in even by mistake, so replay structurally cannot dispatch a new provider call. It:

  1. Recomputes commit_task_bytes(retained.task) and commit_observation_bytes(retained.observation) and compares against the receipt's embedded digests — never trusts the embedded field, matching AGENTS.md's "digests identify bytes; recomputation must be independent."
  2. If the receipt claims a settled response, requires retained response bytes and recomputes commit_response_bytes against receipt.response_digest — this is the "reject wrong response" case, proven directly by replay::tests::replay_rejects_a_wrong_response_by_recomputing_its_digest_independently (a different, still-well-formed response for the same turn; the embedded digest field is untouched, only the bytes checked against it differ).
  3. If the receipt claims a decode outcome, requires a ProposalDecoder, rejects schema drift before ever calling decode (ReplayError::SchemaDrift), then calls the real model_invoke::ProposalDecoder::decode and recomputes commit_proposal_bytes over an admitted result, comparing against receipt.proposal_digest — reproducing the decode, never trusting the recorded outcome blindly.

Zero-dispatch proof

replay::tests::replay_makes_zero_additional_dispatches_against_the_shared_handler builds a receipt through one real dispatch against a shared FixtureModelHandler (asserting handler.calls == 1 immediately after), then calls replay_receipt five times and asserts handler.calls is still exactly 1 — replay never increments the shared dispatch counter, even though the handler stayed in scope. replay_receipt's signature having no handler parameter at all is the structural guarantee; the test additionally proves it holds in practice by keeping a real, counting handler alive throughout and confirming it after every replay call. A second check constructs FixtureModelHandler::must_not_be_called() and confirms .calls == 0.

Billing/usage reconciliation

src/model_call_receipt/reconciliation.rs. ProviderInvoiceRow is untrusted external evidence — a caller parses this from an actual provider invoice or usage export; this module performs no adapter-specific parsing itself and never treats a row as proof of semantic correctness. Because a real per-provider tokenizer is out of scope here, "usage units" is a byte proxy (local_request_bytes + local_response_bytes vs. tokens_in + tokens_out) — an explicit, declared approximation, not a claim of real token accounting.

BillingReconciler::reconcile(receipt, row) returns one of the closed ReconciliationOutcome variants, each proven by its own test in reconciliation.rs:

OutcomeTest
Reconciled (exact match)exact_match_reconciles
ProviderOverReported { local_units, provider_units }provider_over_report_is_detected_with_the_specific_units
ProviderUnderReported { local_units, provider_units }provider_under_report_is_detected_with_the_specific_units
DuplicateInvoiceRow { provider_call_id }a_duplicate_invoice_row_is_rejected_on_its_second_submission
UnknownCall { provider_call_id }a_row_naming_a_different_call_is_unknown
WrongAccount { expected, found }a_row_for_the_wrong_account_is_rejected
Uncertain { reason } (unsettled receipt, or no row yet)reconciliation_is_uncertain_before_the_receipt_settles_or_before_a_row_arrives

A discrepancy is always reported with the specific figures or identifiers that disagreed, never as a bare boolean mismatch, and reconciliation never mutates the receipt or widens any budget/authority.

A receipt is evidence, not authority

Nothing in this module imports or references model_invoke::AuthorizationGate; no function here mints, decodes, or reconstructs an AuthorizationGrant. ModelCallReceipt carries a compile_fail doc test proving a receipt cannot be passed where a grant is required — a compile-time type error, not a runtime check:

fn wants_grant(_: semaprax::live_invocation::AuthorizationGrant) {}

fn feed(receipt: semaprax::model_call_receipt::ModelCallReceipt) {
    wants_grant(receipt); // does not type-check: wrong type entirely
}

Run via cargo test --locked -p semaprax --doc model_call_receipt.

No live network call, no real provider, no key

Tests use offline provider fixtures, actual compiled Proposal schemas, live kernel runs and recovered source checkpoints. Invoice verification accepts explicit host-supplied verifier implementations; the built-in content-digest verifier is local retained-byte evidence, not remote-provider authentication. No provider network transport, credentials or invoice-fetching authority are created by a receipt or verification result.

Focused gate

cargo test --locked -p semaprax --lib model_call_receipt
cargo test --locked -p semaprax --lib provider_adapter_sdk
cargo test --locked -p semaprax --doc model_call_receipt

Explicitly out of scope (unchanged from the issue)

  • Storing raw API keys or authorization headers (this module only ever models authorization_header_echo as a hypothetical leak vector to prove redaction stops it — it never itself attaches one).
  • Making prompts public by default (a bare receipt carries commitments only; ReceiptPrivateExtras is a caller-owned sidecar this module never constructs on its own).
  • Treating provider invoices as proof of semantic correctness.
  • Allowing receipt replay to call the provider (structurally impossible — see "Replay" above).
  • Claiming exact remote execution internals the provider does not attest.
  • Real per-provider token accounting (billing reconciliation's "units" are an explicit byte-count proxy).

Adapter observations and provider usage comparison

Model Call Adapter Evidence v1 binds settled generic attempts to captured transport events without inventing missing rich-receipt metadata. BillingReconciler::reconcile_provider_reported compares actual input tokens, output tokens, and monetary cost separately against an invoice. Exact call identity and account matching precede duplicate consumption; negative costs are invalid and absent usage remains uncertain. Mixed discrepancy directions are explicit. The older byte-proxy reconciliation API is unchanged.

Bounded canonical decoding and live enrichment

receipt_decode::decode_receipt parses at most 65,536 bytes and bounds each string to 4096 UTF-8 bytes. Commitment fields require canonical lowercase SHA-256 digests; historical root and policy labels remain opaque bindings. It validates numeric ranges, positive attempts, known timestamp ordering, response/proposal lifecycle consistency and embedded provider call identity. The parsed object must render to exactly the submitted bytes, rejecting unknown/duplicate/reordered fields and alternate JSON encodings. Parsing alone neither authenticates root labels nor recomputes retained payload commitments.

generic_enrichment::enrich_generic_call reconstructs rich v1 receipts from validated live journals, the independently retained invocation seed and exact logical request, the checked interaction schema and independent host bindings. The host must supply required reservation time, cost estimate, model/provider identity and invoice reference; the function does not fill absent facts with zero. ProgramRoot, policy, invocation and grammar additionally match the seed. Agent, DeploymentRoot and InstanceRoot are the host's independently retained binding and are compared again during exact-byte replay.

Accepted and refused decode outcomes are independently reproduced with the compiled decoder. Responses, attempted-byte counts, reserved units and failures come from journal entries. Unresolved intents remain uncertain. A known provider failure retains its closed failure reason and the furthest known dispatched or intent_persisted stage; cancelled stays distinct. A refused reservation preserves the actual zero reservation alongside the original request commitment. ModelInvocationRequest::canonical_json exposes the same logical bytes its existing digest already commits; receipt request length is that document's length. Generic local input counters count task and observation bytes; the observed-adapter variant instead records actual transport input length. These are distinct quantities, not interchangeable token estimates.

enrich_observed_generic_call also checks the immutable adapter observation, exact transport request, declared provider/adapter identity and retained events. Captured timestamps are used only when supplied by an explicit clock, and must agree with any retained host values. Only complete, settled, explicitly provider-reported usage enters the complete-only v1 field. The observed_usage helpers preserve independently missing dimensions for billing reconciliation and never promote local estimates to provider observations. Both enrichment variants have exact-byte replay APIs without live dispatch.

source_enrichment::enrich_source_calls reconstructs rich receipts from an opaque authenticated source checkpoint. It checks ProgramRoot and invocation, rechecks the original source/task/policy/grammar binding, and requires the exact retained canonical observation and provider-prompt preimages. Decoded outcomes are reproduced with the compiler-derived Agent Proposal schema; lifecycle-only refusal tags retain their journal meaning. Source retry ordinals are zero-based in the journal and become one-based in rich receipts. At most 1024 attempts, 65,536 bytes per receipt and 4 MiB total receipt bytes are emitted. Exact replay compares their canonical documents concatenated in causal order. A checkpoint without a bound ProgramRoot remains eligible for the journal projection, but cannot manufacture a root-bound rich receipt.

Source usage consistency and lifecycle evidence

Source enrichment indexes checkpoint observations, outcomes and usage by (turn, attempt). Known journal input/output counters must match supplied host provider usage. Unknown counters remain unknown; total, reasoning and cache counters are distinct dimensions. Source monetary charges retain their currency and minor-unit scale in journal evidence and are not silently converted to a currency-free cost_micros field. Pending attempts with a known first-byte time emit first_byte; those with only dispatch known emit dispatched. LiveKernelRun::rich_model_call_receipt and RecoveredSourceCheckpoint::rich_model_call_receipts expose the same checked constructors directly on actual run evidence. Mandatory host facts stay explicit.

Verified invoice import and reconciliation records

Model Call Invoice Import v1 owns the bounded external import boundary, explicit adapter verification, closed offline replay, and immutable reconciliation-result evidence. The existing raw typed-row API remains available for legacy hosts and does not imply verified import.