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, exactlyTurnOpened,RequestIntent/ResponseRecorded/ResponseFailed,ProposalAdmitted/ProposalRefused,AuthorizationConsumed/AuthorizationRefused, zero or moreEffectIntent/EffectObserved/EffectFailedpairs, and oneTransition/TerminalOutcome— withjournal::validatefail-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.invokeeffect boundary itself (model_invoke.rs):ModelInvocationRequest/ModelInvocationOutcome/ModelFailure(a closed failure domain),ProposalDecoder/ProposalOutcome,AuthorizationGate/AuthorizationGrant(the only mint site for authority), andInvocationBudgetHook(the seam #113/#179 attach cumulative budget policy behind). FixtureModelHandler/FixtureProposalDecoder/fixture_response(fixture.rs): deterministic, offline fixtures, includingFixtureModelHandler::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 closedModelFailure::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) andprovider_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 samereceipt_digest;audit_view::tests::redaction_never_changes_the_bound_receipt_digest).redacted_fields: oneRedactedField { name, commitment_digest }per withheld field, so a verifier holding the originalextrascan confirm what was actually withheld rather than trusting a silent omission.- The revealed fields' plaintext directly, for whichever fields the policy opted in.
PayloadPrivacyClaimfor 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:
- 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);
- a fully-redacted view is asserted not to contain that marker anywhere in its rendered text (not merely absent from one struct field);
- 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:
- Recomputes
commit_task_bytes(retained.task)andcommit_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." - If the receipt claims a settled response, requires retained response
bytes and recomputes
commit_response_bytesagainstreceipt.response_digest— this is the "reject wrong response" case, proven directly byreplay::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). - If the receipt claims a decode outcome, requires a
ProposalDecoder, rejects schema drift before ever callingdecode(ReplayError::SchemaDrift), then calls the realmodel_invoke::ProposalDecoder::decodeand recomputescommit_proposal_bytesover an admitted result, comparing againstreceipt.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:
| Outcome | Test |
|---|---|
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_echoas 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;
ReceiptPrivateExtrasis 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.