Skip to content

Receipt schema v1

This page specifies the behalf v1 action receipt: the signed payload written once per trust-boundary crossing. This is a v1 freeze: the fields below are the ones that must exist in the first receipt ever written, because receipts are immutable and hash-chained and none of this can be re-cut onto historical records. The machine-readable form is the JSON Schema at /schemas/receipt-v1.schema.json.

1. What freezes, and what deliberately does not

Section titled “1. What freezes, and what deliberately does not”

Two invariants govern everything here:

  • Stored bytes are the signed bytes are the hashed bytes — verbatim forever, never rewritten. Schema evolution lives entirely on the read path: reads project by schema_version, one export surface returns canonical bytes for the verifier, and a reconstruction spanning an upgrade legally mixes versions with each record labelled. A schema_version bump therefore does not rewrite or re-chain a single existing record.
  • Raw inputs are hashed evidence; computed values are recomputable and live on the read path. Anything a verifier can recompute from captured bytes — an attenuation delta, a rollup classification, an index column — is deliberately kept out of the hashed envelope, so a computation bug can never freeze into evidence. Anything that can only be observed at capture must be inside the envelope, because it cannot be backfilled.

The second invariant is the whole design tension. It is stated once here, applied field by field, and it is the test §9 runs to decide what must freeze now.

Not in this schema, by construction: log index (seq), inclusion proofs, checkpoint membership, checkpoint_id. These are ingest-assigned and live in the log structure and the index projection, never inside the signed receipt — which removes any circular dependency between a record and the checkpoint that later covers it. Arrival time and grouping provenance that ingest computes also live in the index projection, not the envelope. The hash provably covers what was captured, not what behalf later computed.

Decision: DSSE with pre-authentication encoding (PAE) is the envelope. RFC 8785 JCS is not adopted.

RFC 8785 JCS DSSE + PAE (chosen)
What is signed a canonicalised JSON document the exact submitted bytes, wrapped in PAE
Canonicalisation step required, and must agree byte-for-byte across writer and every verifier, forever none — there is no canonicalisation step to disagree about
Number/whitespace ambiguity a live failure surface structurally absent: PAE frames length-prefixed opaque bytes, so serialisation is never re-derived
Path to COSE/SCITT (RFC 9942) re-canonicalisation tax when COSE emission lands later COSE/SCITT emission wraps the same bytes

PAE’s structural sidestep is the entire argument: because the signed bytes are the stored bytes, no canonicalization step exists to disagree about. A tiled transparency log recomputes the leaf hash from bytes on every verification; a canonicalisation function that must produce identical output on a Go writer and a Rust verifier years apart, across schema_version bumps and storage-engine replacement, is exactly the kind of hidden coupling that “years-old receipts must still verify” cannot afford. DSSE removes the function.

Two artifacts result:

  1. The producer envelope — the DSSE-signed receipt payload: everything the capture surface asserts, and the subject of §4–§7. payloadType is application/vnd.behalf.receipt+json; payload is the receipt JSON; signatures[] carries the emitter key signature (§5).
  2. The log leaf — the hash of that complete DSSE envelope. The transparency log’s tree covers the leaf; log index, inclusion proofs and checkpoint membership stay external in the log structure.

The offline Rust verifier recomputes the leaf hash from the DSSE envelope bytes and checks the emitter signature and the delegation chain against published JWKS, with no call to behalf or the identity provider.

Everything evidentiary is a leaf in one global log; there is no separate control stream. kind is a closed vendor enum; customer vocabulary rides the verbatim, non-load-bearing kind_ext namespace.

kind What it records Anchoring rule when no action exists
action the default: one trust-boundary crossing
tool_call / resource_read / message operation sub-kinds of the above
delegation a minted hop / sub-agent invocation
delegation_failed a chain a customer tried to build and couldn’t anchors to the delegation token jti/par_hash + intent digest
approval / denial human consent / refusal, from the Claude Code PermissionRequest/PermissionDenied hook pair anchors to the delegation token jti + intent digest
revocation a revocation event, never a mutation links to affected actions by credential_ref (jti) match
erasure_notice the customer deleted their own payload blob references what it destroyed by digest
policy_change a retention/capture policy change, itself audited
orphan_intent intent flushed on crash recovery — the operation fired, the agent died carries the spooled intent digest
import a record brought in by behalf import; carries an asserted floor no later operation can raise
refusal rejection of unhashable/oversized bytes — even refusal leaves evidence carries the submission digest
loss_marker spool overflow: N records shed, signed, with count + digest range carries count + digest range
attestation late-arriving evidence linked to a sealed record; folds into a “current best” read view links to the target record

kind is a closed enum precisely so “we record everything” is falsifiable; widening is forward-only. Reclassification acts forward only.

The receipt payload. Legend: R required at write, O optional, R* required-if (condition given).

Field Req Type / values Why it is here
schema_version R const "behalf.sh/receipt/v1" Read-path projection key; verbatim bytes never rewritten.
otel_conventions_version R string The gen_ai.* conventions version in force at capture; the second projection key that lets old records be re-normalised when the still-Development OTel conventions move.
receipt_id R client-minted ULID The idempotency key, minted client-side at capture so a retried send can never occupy two immutable chain positions. Ingest dedups on it against a bounded window before append.
kind R enum (§3) Closed vendor enum.
kind_ext O string (namespaced) Verbatim, non-load-bearing customer vocabulary.
risk_class R enum Assigned by the proxy’s capture-time tool-policy config, not producer-self-reported.
risk_policy_digest R sha256 Digest of the policy that assigned risk_class, recorded on the receipt so the assignment is auditable rather than free-floating self-report. Capture-time-only (see §9).
captured_at R RFC 3339 Capture-surface timestamp (asserted; hardened clock trust is a later milestone).

behalf’s own records must not be caller-supplied strings — the exact self-asserted-metadata failure the product exists to correct — so the surface that produced the evidence and the actor it attributes to are separated first-class.

Field Req Type / values Why
emitter R object The capture surface. emitter.jkt = the surface’s own Ed25519 key thumbprint, generated at install, distinct from any human device key. This key signs the DSSE envelope and is bound under the leaf hash.
emitter.surface R enum mcp-proxy | claude-code-hook | cli The canonical v1 surface is the MCP proxy; Claude Code hooks are a companion surface. cli was widened forward-only for receipts the behalf CLI itself emits — the first being behalf login’s root receipt.
emitter.counter R integer, per-emitter monotonic Stamped before spooling so loss or reordering between capture and append is detectable — custody begins at capture-signature. Capture-time-only integrity primitive (see §9).
actor O object Who acted, if distinct from emitter.
actor.jkt R* key thumbprint The canonical actor identity is the hop’s key thumbprint — keys are what the cryptography proves. Required if actor present.
actor.labels O object clientInfo, hook agent_id/agent_type, MCP server name — stored verbatim as asserted labels, per MCP’s own warning that they are self-reported. Never used for security decisions.
actor.emitter_to_actor O const "asserted" The emitter-to-actor assertion is recorded but never enforced — recording who produced the evidence without becoming an authorization engine.

Human-readable identity is not in the receipt: it lives in the customer-held ID-token blob and a local, versioned alias map in the index; no identity-provider write-back in v1.

Field Req Type / values Why
operation R object The trust-boundary crossing: one JSON-RPC request/response pair for the v1 proxy.
operation.name R string Tool/operation name.
operation.target O string The resource acted on.
operation.outcome R object Result or failure; outcome covers failure of the attempted operation.
operation.idempotency_key O string The target operation’s idempotency key, if any — distinct from receipt_id.
attempt O object Intent is durably spooled by the proxy before forwarding, then merged into the single completion receipt in the common case; on crash it flushes as an orphan_intent receipt. One record in the common case.
attempt.intent_digest R* sha256 The spooled intent digest — also the anchor for denial/delegation_failed records. Required on orphan_intent. Capture-time-only: an operation that fired while the agent died cannot be backfilled.
run_id R string The reconstruction grouping key. Populated by normative precedence: caller/SDK-supplied key → Claude Code session/agent id (hooks) → root trace_id from carried traceparent → proxy-process session.
run_id_provenance R enum caller | hook-session | traceparent | proxy-session Which fallback produced run_id, so grouping is honest about its own provenance. Capture-time-only (see §9).
correlation O object The other five correlation keys — trace_id, session_id, txn, acti, conversation_id — as index columns; all six are indexed, none but run_id is required at ingest.
step_key O sha256 of (tool name, normalized argument schema, causal ordinal) Reserved and populated by the proxy from record one so behalf diff — structural comparison of two runs — works on day-one data; query-time sequence alignment is the fallback. Receipts written without it can never be aligned retroactively (see §9).

The chain is the attenuating-agent-token chain itself — a JWS chain per draft-niyikiza-oauth-attenuating-agent-tokens-01 (AAT) whose par_hash linkage is the DAG edge — embedded whole in the receipt (2,316–2,565 bytes at 3 hops, measured). Embedding wins at v1 depth because the offline verifier must verify a single receipt with no store to chase. The OCSF delegation{uid, parent_uid} envelope is populated in export mapping from par_hash.

authority.chain is an ordered array of hop objects. Per hop:

Field Req Source / values Why
del_depth, del_max_depth R* AAT draft, verbatim The draft’s field set is adopted as specified. Required if a chain is present.
par_hash R* AAT draft The linkage that is the DAG edge.
cnf.jwk R* AAT draft Hop key confirmation.
authorization_details R* RFC 9396, raw The raw per-hop grant, captured verbatim. The attenuation delta is computed at read/verify time from these raw inputs, stamped with a comparator version, so computation bugs never freeze into evidence.
exp R* AAT draft, verbatim Per-hop expiry. Half of the revocation-window join.
jti R* behalf extension Per-hop token id — a gap no current draft captures into durable, queryable evidence; proposed upstream as an extension rather than shipped as a quiet proprietary field. The other half of the revocation-window join. Unbackfillable.
credential R* object {issuer, kind, id, exp, jkt} The canonical credential reference — namespaced opaque ids, exp verbatim, never the token. Retrofitting a canonical key onto years of opaque per-issuer strings would be a rekeying migration against immutable data, so the canonical form is captured at write. Carries auth_time and amr where the exchange exposes them (e.g. an identity-assertion authorization grant).
root_principal_binding R* behalf extension At depth 0, the OIDC nonce-thumbprint binding (nonce == jkt(device_pubkey)) — the one genuinely verifiable thing in the stack.
trigger R* object {kind: schedule|webhook, descriptor_digest} For autonomous roots: depth-0 is the installing operator’s device key carrying a trigger claim, honestly asserted. Required if the root is autonomous.
verification R* object {status, method, evidence_ref} Per-hop three-state — verified | asserted | broken — carried per hop. See §8.
carriage_route O enum / string How the hop arrived (in-band signatures vs out-of-band params._meta sh.behalf/chain over MCP); recorded as metadata so an out-of-band hop is explicit, since verification comes from the signatures regardless.
attenuation_flag R* includes value unknown Vocabularies the AAT invariants cannot compare (proprietary role systems, wildcard grants) yield attenuation: unknown, recorded and flagged, never swallowed.

There is no bespoke normalization layer in v1; comparison is the AAT draft’s six invariants, adopted, not reinvented. The draft’s deliberate sub omission is preserved above the root — the token proves which key, not who.

Attribution is stored at write, never derived at query time. Two orthogonal axes:

Field Req Values Why
attribution.verification R verified | asserted | broken The receipt-level rollup = the weakest hop. Composition rule: any invalid signature or invariant violation → broken; else a chain whose root passes the root-binding checks is verified at the root and asserted above it. Three states, not two: collapsing the middle state into broken would misrepresent honestly-recorded-but-unverifiable assertions.
attribution.class R direct | delegated | autonomous | unattributed A second axis stored at write from chain shape. Derived strictly from token-path evidence; a linked approval receipt is recorded and joined but never reclassifies the stored attribution.

A fresh install with login renders as “verified root, asserted chain” — an honest description of what the cryptography covers on day one. Aggregate metrics over verification states are read-path computations over these stored fields and are not frozen here.

Payloads are customer-held, everywhere — in local-first v1, the customer’s own disk. behalf holds the index, digests, references, sizes, content types and custody-mode — never content. payload is an array of slots; per slot:

Field Req Type / values Why
digest R* plain SHA-256 over raw plaintext bytes Commitment and storage address are one value in v1: the content-addressed store is customer-side, so salting would break third-party verification and dedup while defending against nobody; the residual low-entropy re-identification risk is accepted and documented.
custody R* enum customer-held | dropped-with-digest | vendor-held (reserved) A verifier reading a receipt years later must distinguish “never here” from “deleted” from “no access” — three different findings. Unbackfillable (§9).
content_type, size O string / int Metadata behalf may hold.
ref R* content address The payload reference shape — immutable from record one; it determines whether a payload can ever be relocated without breaking the binding. Frozen as a content address.
field_digest_manifest O Merkle over canonicalized JSON fields Whole-blob digest plus a field-digest manifest captured at write for JSON payloads (non-JSON gets whole-blob only) — precisely what keeps verifiable redaction, per-field retention and selective disclosure possible for v1-era records. Unbackfillable (§9).
subjects[] O array, explicitly asserted Reserved so future erasure queries can enumerate scope; per-subject separation cannot be applied retroactively. A schema reservation, not a feature. Asserted, per the never-accept-self-asserted-identity rule.
state R* present | missing | deleted | unreadable | dropped-at-capture The reconstruction placeholder: a run full of placeholders is still verifiable evidence because the receipts carry digests regardless. This is the normal path, not the edge case.
cause_ref O link Reference to the policy_change/erasure_notice receipt that explains a non-present state.

model_call is a reserved context class — a customer-held payload artifact referenced by digest from the acting receipt, populated when the in-process SDK surface arrives; keeps receipts sparse while leaving full-fidelity model capture reachable without a schema break.

Identity in the receipt is pseudonymous only: key thumbprints for actors (§5) and issuer + sub-digest for the human principal. human_in_loop (approval_receipt_id, satisfied_by, binding_message digest) is marked asserted — a click is not cryptography.

Provenance and links. provenance.sourcenative | import; an import carries an asserted floor no later operation can raise, and the provenance class travels in every rendering and proof. links[] are typed references carrying the target’s log index + leaf hash. annex_iii_category is reserved as a forward provision only.

9. Capture-time fields — the freeze test

Section titled “9. Capture-time fields — the freeze test”

The v1 freeze is drawn by one question: what must be captured now, into the hashed envelope, that cannot be added later without splitting the evidence into a blind pre-field era and a sighted post-field era? Because a schema_version bump does not re-chain existing records, the risk is never that the format can’t grow — it is that a field’s absence permanently forecloses a capability for every record already written, because the information was only observable at capture and is gone.

A field freezes into v1 iff both:

  1. Hash-covered — it belongs inside the DSSE envelope (it is part of what the capture surface asserts), not the index projection; and
  2. Capture-only — it is derived from information available only at capture time and is not recomputable from other captured bytes later.

Fields failing (2) are safe on the read path, and the design deliberately puts them there: the attenuation delta (recomputable from raw authorization_details), the verification rollup and attribution class (stored at write for query stability, but recomputable from per-hop verification), aggregate metrics, OCSF export envelopes, seq/checkpoint membership, and every index column. This boundary is why the frozen list is finite.

The frozen capture-only fields, with the capability each protects:

  • Per-hop jti and exp (§7) — ephemeral token ids are captured nowhere else; without them the revocation-window finding (“last action under a revoked or expired credential”) is uncomputable for all v1-era data.
  • Per-hop verification three-state + attribution.class (§8) — verification evidence exists only at the moment of the exchange; attribution cannot be backfilled.
  • Payload custody enum (§9-adjacent) — only the capturing surface knows whether the blob was held, dropped, or never present; without it three distinct findings collapse into one.
  • provenance.source = import with the asserted floor (§8) — import context exists only at import; without it an imported record could later be passed off as write-time-signed evidence.
  • payload.field_digest_manifest — a whole-blob digest alone permanently forecloses verifiable redaction, per-field retention, and RFC 9942 selective-disclosure proofs for every v1-era record. Cheap at capture, impossible after.
  • step_key — the normalized argument schema and causal ordinal are capture-time observations; receipts written without it can never be structurally aligned for run comparison.
  • emitter.counter — without the counter stamped before spooling, anything suppressed in the capture-to-append window (which the spool can widen from milliseconds to hours) leaves no gap to find.
  • credential object with namespacing — the canonical, namespaced credential reference; storing opaque un-namespaced per-issuer strings would make cross-mechanism revocation joins a rekeying migration against immutable data. Includes auth_time/amr where exposed: strength-of-authentication evidence is capture-only.
  • attempt.intent_digest and the orphan_intent kind — an operation that fired while the agent died leaves no completion receipt, and that hole cannot be backfilled; intent is durably spooled before forwarding.
  • receipt_id minted client-side at capture — if ingest minted the id, a retried send could occupy two immutable chain positions and the duplicate would read as tampering, uncorrectable once chained. The mint location is the frozen decision.
  • run_id_provenance — which precedence rung fired is knowable only at capture; without it a caller-supplied run_id and a proxy-session guess are indistinguishable forever.
  • risk_policy_digest — the policy in force is a capture-time fact; without it risk_class is free-floating self-report.
  • Raw authorization_details retained verbatim — the delta is read-path, but the raw inputs must be hash-covered at capture or the delta can never be computed at all; non-comparable vocabularies are stamped attenuation: unknown rather than silently dropped.
  • otel_conventions_version (and optional raw_frame_ref) — old records can only be re-normalised when the conventions move if the version in force was stamped at write.
  • emitter.jkt distinct from actor.jkt — without an emitter key distinct from the actor identity from record one, it is impossible to retroactively prove who produced a historical record.
  • carriage_route and the reserved subjects[] tag — honesty and optionality markers; reserving the slot now is the unbackfillable part, populating it can lag.

Everything above is capture-only and hash-covered. The discipline — raw inputs hashed, computed values recomputed on read — is what keeps this list from being infinite: if a proposed field can be recomputed from what these receipts already carry, it does not belong in the v1 freeze. If it cannot, it does.