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. Aschema_versionbump 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.
2. The envelope — DSSE, decided
Section titled “2. The envelope — DSSE, decided”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:
- The producer envelope — the DSSE-signed receipt payload: everything the capture surface
asserts, and the subject of §4–§7.
payloadTypeisapplication/vnd.behalf.receipt+json;payloadis the receipt JSON;signatures[]carries the emitter key signature (§5). - 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.
3. Record kinds — the receipt family
Section titled “3. Record kinds — the receipt family”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.
4. Envelope core
Section titled “4. Envelope core”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). |
5. Identity — emitter vs actor
Section titled “5. Identity — emitter vs actor”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.
6. Operation, attempt, run
Section titled “6. Operation, attempt, run”| 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). |
7. Delegation chain — per hop
Section titled “7. Delegation chain — per hop”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.
8. Attribution — two orthogonal axes
Section titled “8. Attribution — two orthogonal axes”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.
9-adjacent. Payload custody
Section titled “9-adjacent. Payload custody”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.source ∈ native | 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:
- Hash-covered — it belongs inside the DSSE envelope (it is part of what the capture surface asserts), not the index projection; and
- 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
jtiandexp(§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
verificationthree-state +attribution.class(§8) — verification evidence exists only at the moment of the exchange; attribution cannot be backfilled. - Payload
custodyenum (§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=importwith 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.credentialobject 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. Includesauth_time/amrwhere exposed: strength-of-authentication evidence is capture-only.attempt.intent_digestand theorphan_intentkind — 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_idminted 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-suppliedrun_idand a proxy-session guess are indistinguishable forever.risk_policy_digest— the policy in force is a capture-time fact; without itrisk_classis free-floating self-report.- Raw
authorization_detailsretained 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 stampedattenuation: unknownrather than silently dropped. otel_conventions_version(and optionalraw_frame_ref) — old records can only be re-normalised when the conventions move if the version in force was stamped at write.emitter.jktdistinct fromactor.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_routeand the reservedsubjects[]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.