Audit envelope
The audit envelope is Sill’s canonical record of every governed interaction it observes. Each record is canonicalized with RFC 8785 (JCS), signed with an ed25519 envelope signature, linked to the previous record by a chain hash, and folded into a per-(site, decision-class, UTC-day) Merkle batch. The envelope is append-only by construction: any byte-level change to a stored record breaks the signature, the chain link, and the Merkle root that contains it.
This page documents the on-the-wire shape of an audit record, how the chain and the Merkle batch are constructed, and the export formats Sill ships today. An exported bundle can be checked in one drop at the hosted verifier or against the manual recipe; the JWKS that lets a third party verify Sill’s other signed surfaces with off-the-shelf tooling is documented at Public JWKS.
Two decision classes, two chains per site
Section titled “Two decision classes, two chains per site”Each site keeps two independent audit chains, one per decision class:
- Discovery —
decision: 'observed'records emitted by the embed beacon when an AI-agent visit is identified. - Transactional —
decision: 'approved' | 'rejected' | 'escalated_approved' | 'escalated_rejected' | 'verification_rejected' | 'rejected_post_verify' | 'settled_succeeded' | 'settled_refunded'records produced by the transactional mandate / policy / authorize / settle pipeline. The twosettled_*values ride on settlement records appended after the authorizing decision record — same site, same transactional chain, distinct chain slot; see “What the signed record binds” below.
The two chains are domain-separated at genesis (see “Chain linkage” below), so a discovery record can never be substituted for a transactional record on the same site, and vice versa.
Record shape
Section titled “Record shape”Every audit record carries a fixed set of fields. The signing-input canonicaliser fails closed on any field outside the allow-list, so the envelope’s signed scope cannot widen silently.
{ "record_id": "rec_01KT5ZQX2J5K7H8N4M6P7Q8R9S", "site_id": "site_01EXAMPLE00000000000000000", "evaluated_at": "2026-06-22T14:03:11.482Z", "policy_version": "pol_v_01KT0Z8C8N2K7H3X4Y5Z6A7B8C", "rules_evaluated": [ { "rule_id": "r02", "outcome": "pass" }, { "rule_id": "r10", "outcome": "pass" } ], "decision": "observed", "retention_class": "standard", "request_hash": "Yw3K8x_p2nC4rT9k1aZ0eF7Hg6JdBqLm9oN5sUvXwYI", "response_hash": "Hd7R2c_l9mA1zPq4xK8nE0sUbWfTjMyLcNkVoPxIeQk", "prev_record_hash": "C7n8X0qVfJk3M2pLrT4aB1dHe5gK9oNzQ6sUyW8VxYI", "envelope_signature": "kx9z3Q…detached-ed25519-signature-base64url…", "merkle_root": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}Field-by-field:
| Field | Meaning |
|---|---|
record_id | Prefixed ULID. The record’s stable identifier. |
site_id | Prefixed ULID. The site whose chain this record belongs to. |
evaluated_at | ISO-8601 UTC timestamp the policy engine evaluated the interaction. |
policy_version | The exact policy ruleset version applied. |
rules_evaluated | Per-rule outcomes (`pass |
decision | One of observed (discovery) or the eight transactional decisions above. |
retention_class | Per-record retention bucket. |
request_hash / response_hash | base64url SHA-256 of the canonical-JSON request / response bytes captured for this evaluation, or the empty-payload sentinel (43 zero-base64url chars) when no payload was captured (e.g. a discovery beacon). |
prev_record_hash | The chain link — see below. |
envelope_signature | The ed25519 envelope signature over the canonical signing input. |
merkle_root | The Merkle batch root for the per-(site, decision-class, day) batch this record belongs to, or the pending sentinel until the batch closes. |
Transactional records additionally carry mandate_id (the signed-mandate identifier they decided on); HITL-resolved records also carry operator_id and operator_decision_at inside the signed scope — see “What the signed record binds” below. The signing-input canonicaliser’s allow-list is the same across every record class; whether a given field participates in a specific record’s canonical form is dictated by whether that record carries it (JCS drops absent keys).
The pending Merkle-root sentinel
Section titled “The pending Merkle-root sentinel”Between sign-and-link time and end-of-day batch close, every record carries
merkle_root = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"(32 zero bytes → 43 base64url chars.) It has the same shape as a real root, so type checks and string-length checks behave uniformly, but the all-zero SHA-256 output is computationally unreachable as a real digest — a verifier cannot accidentally accept the sentinel as valid. The same sentinel shape covers request_hash / response_hash on records with no captured payload.
What the signed record binds
Section titled “What the signed record binds”The record fields listed above are the fixed on-the-wire allow-list; request_hash and response_hash are what let the envelope signature attest specific facts about the interaction, not just its shape. This section documents what the crypto seals on each record class. All three bindings below are on the live transactional chain today.
Intent — approved and human-resolved records
Section titled “Intent — approved and human-resolved records”For every record with decision = 'approved' | 'escalated_approved' | 'escalated_rejected' and a real verified intent, request_hash is SHA-256(JCS(intent_projection)) over the buyer-stripped intent_summary block — action, merchant, requested SKU or line_items[], the buyer-authorized max_amount, currency — plus the envelope-identity fields (mandate_id, agent_id, policy_id, policy_version). The projection is drawn from the same shape persisted in audit_record.intent_summary (a JSONB column), so a verifier reconstitutes the hash input from the row without a second lookup.
response_hash on the same records is SHA-256(JCS(mandate_outcome)) — the mandate-engine outcome served to the agent (decision, policy_version, evaluated_at, plus a bounded rules_evaluated_summary).
A tamper of any bound intent value — action, merchant, SKU, line items, max_amount, currency — invalidates request_hash, which invalidates the envelope signature. The buyer’s PII (name, email, phone, shipping address) is not part of the signed projection: buyer plaintext rides only in a separate AEAD-encrypted vault, retention-controlled and independently erasable, and is never on the audit chain.
The two sanitized-rejection classes — verification_rejected (edge shape or signature failure) and rejected_post_verify (idempotency-collision shadow row) — carry the empty-payload sentinel in both hash fields by design. On a verification_rejected there is no verified intent; on a rejected_post_verify the intent is already bound by the winning row.
Settlement — rail-neutral
Section titled “Settlement — rail-neutral”When an approved (or HITL-approved) mandate settles on the rail, a separate signed settlement record is appended to the same transactional chain. Its decision is settled_succeeded or settled_refunded. The record shares the site’s transactional-chain genesis with the decision record it settles — chain linkage carries ordering; request_hash carries correspondence to a specific decision.
response_hashbinds the rail-neutral settlement outcome:rail(a bounded enum label —stripe,shopify, additional rails additively as they ship), signedamount_minor,currency,settled_at(the rail’s terminal money-move time), an opaquerail_state_refhandle,ledger_id, and astatusofsettledorrefunded. Every field maps 1:1 to a column on Sill’s rail-neutral settlement ledger (settlement_ledger); there is no PaymentIntent-typed field, no Shopify GID-typed field, no rail-specific branching. A future rail’s settlement records are byte-parity in shape with today’s, differing only in the enum values.request_hashbinds the decision-record linkage:{mandate_id, decision_record_id, decision_envelope_signature}. The decision record’senvelope_signatureis a bounded-length commit to the decision’s full canonical form; binding it here proves this settlement corresponds to that authorized decision, not merely to another record with the samemandate_id.
intent_summary, operator_id, and any buyer plaintext are structurally absent from a settlement record — the decision record already binds intent, no human resolved the settle, and buyer data has never been on the signed scope. A refund settle rides the same shape with decision = 'settled_refunded', a negative amount_minor, and status = 'refunded'.
Human-overseer identity — HITL records
Section titled “Human-overseer identity — HITL records”Records with decision = 'escalated_approved' | 'escalated_rejected' and a real human resolver additionally carry two fields inside the signed envelope:
operator_id— a Sill-minted opaque handle (a ULID that refers to a dashboard user), never a name, an email, or an IP. It is a stable pseudonymous binding: a specific reviewer’s decisions can be correlated across audit rows, but the human-readable identity is not on the chain.operator_decision_at— ISO-8601 UTC millisecond-precision, captured server-side at the moment the resolver claimed the escalation and threaded verbatim through the signed projection so read-back byte-equality holds.
A tamper of either value invalidates the envelope signature. The human-readable identity — name, email — lives on the erasable user row; a subsequent user-lifecycle purge leaves the pseudonymous handle on the chain intact. Accountability of which reviewer resolved a specific over-cap or destructive escalation is bound cryptographically; erasability of the human-readable identity is preserved on the separately-controlled user row.
Records resolved by the escalation timeout cron or an auto-refund policy carry no operator_id and no operator_decision_at — the two fields are absent from the record; JCS drops absent keys; the canonical form is unchanged from the pre-widening shape. Non-HITL record classes (approved, rejected, settled_*, observed, sanitized rejections) never carry an operator claim.
Deployers of a high-risk AI system that document human oversight under Article 14 or 26 of the EU AI Act can draw on this cryptographic evidence toward record-keeping obligations. Sill asserts framework mappings only, never conformity or certification claims — see Compliance.
Signed scope, by record class
Section titled “Signed scope, by record class”| Record class | request_hash binds | response_hash binds | operator_id / operator_decision_at |
|---|---|---|---|
observed (Discovery) | empty-payload sentinel | empty-payload sentinel | absent |
approved | verified intent projection | mandate-engine outcome | absent |
rejected | empty-payload sentinel | mandate-engine outcome | absent |
escalated_approved (human resolver) | verified intent projection | mandate-engine outcome | signed |
escalated_rejected (human resolver) | verified intent projection | mandate-engine outcome | signed |
escalated_rejected (timeout / auto-refund) | verified intent projection | mandate-engine outcome | absent |
verification_rejected | empty-payload sentinel | empty-payload sentinel | absent |
rejected_post_verify | empty-payload sentinel | empty-payload sentinel | absent |
settled_succeeded / settled_refunded | decision-record linkage | rail-neutral settlement outcome | absent |
Canonical form
Section titled “Canonical form”Every signing input, chain-hash input, and Merkle-leaf-hash input is canonicalized with RFC 8785 (JCS) before being hashed or signed. JCS is the load-bearing primitive — without it, two semantically equal JSON objects could produce different bytes and break verification across implementations.
The signed scope is the record with envelope_signature and merkle_root stripped, JCS-canonicalized, UTF-8-encoded. The prev_record_hash field is inside the signed envelope, which is what makes the chain link tamper-evident from the record alone.
Chain linkage
Section titled “Chain linkage”Each record’s prev_record_hash is SHA-256(JCS(prev_record)) with the previous record’s merkle_root normalized to the pending sentinel before canonifying. Normalizing this one field is what lets the chain link stay valid after the per-day batch closer writes the real root back into every record at end-of-day.
The first record on a chain has no predecessor; instead, its prev_record_hash is the genesis hash for that (site, decision-class) pair:
genesis = SHA-256("sill-audit-genesis-v1|" + site_id + "|" + decision_class)The sill-audit-genesis-v1 prefix is a versioned domain separator: two chains on the same site cannot collide because their decision class differs, and two sites cannot collide because their site_id differs. A future -v2 genesis can land without breaking deployed chains.
Merkle batch
Section titled “Merkle batch”At end of each UTC day, Sill closes a Merkle batch per (site_id, decision_class, utc_date). The leaf hash for each record is identical to its chain-link input (SHA-256(JCS(record_with_pending_merkle_root))), so the chain hash and the Merkle leaf hash are byte-identical by construction. Internal nodes are SHA-256(left || right) over raw 32-byte digests; odd leaves are duplicated, RFC 6962 / Bitcoin convention.
flowchart TB
subgraph chain["Per-(site, decision-class) chain"]
direction LR
G([genesis<br/>SHA-256 domain-separated]) --> R1[Record 1<br/>prev_record_hash = genesis]
R1 --> R2[Record 2<br/>prev_record_hash = H(R1)]
R2 --> R3[Record 3<br/>prev_record_hash = H(R2)]
R3 --> R4[Record 4<br/>prev_record_hash = H(R3)]
end
subgraph batch["Per-day Merkle batch (UTC) — leaf = H(record)"]
direction TB
L1[L1 = H(R1)] --> N12[N12 = H(L1 ‖ L2)]
L2[L2 = H(R2)] --> N12
L3[L3 = H(R3)] --> N34[N34 = H(L3 ‖ L4)]
L4[L4 = H(R4)] --> N34
N12 --> ROOT[merkle_root]
N34 --> ROOT
end
After the batch closes, the computed merkle_root is written back into every record in the batch. The chain link and the envelope signature both still verify because both derivations canonicalize the record with merkle_root normalized to the pending sentinel — the real root value is excluded from those hash inputs by construction.
A day with no records has no batch and no root; the chain skips that day and the verifier treats consecutive prev_record_hash links across day boundaries as continuous.
Sign-and-link sequence
Section titled “Sign-and-link sequence”sequenceDiagram autonumber participant E as Edge / origin writer participant A as @sill/audit participant K as Signing key (ed25519) participant S as Storage (audit_record) participant B as Batch closer (end of UTC day) E->>A: createObservedRecord / createTransactionalRecord<br/>(unsigned shape) E->>A: signAndLink(unsigned, prev_record, kms) A->>A: prev_record_hash = chainHashOf(prev) or genesisPrevRecordHash(site, class) A->>A: signing_input = utf8(JCS(record_with_prev_hash)) A->>K: kms.sign(signing_input) K-->>A: envelope_signature A-->>E: AuditRecord (merkle_root = pending sentinel) E->>S: persist row (immutable) Note over S,B: end of UTC day B->>S: load all records for (site, class, day) B->>A: computeMerkleBatch(records) A-->>B: merkle_root + per-record map B->>S: write merkle_root back into each row
The two-step design — signed and chain-linked at write time, Merkle-rooted at end of day — is the load-bearing decision that lets the edge emit a verifiable record synchronously while origin attaches the batch root asynchronously.
Export formats
Section titled “Export formats”Sill ships two merchant-downloadable export formats today: signed JSON and server-rendered HTML. NDJSON (toNdjson()) exists as an internal library primitive in the audit package but is not exposed as a downloadable bundle format — no route serves it and the dashboard does not offer it. See Audit log and export for what the dashboard surfaces.
JSON — signed audit bundle
Section titled “JSON — signed audit bundle”A signed envelope around a list of AuditRecord rows plus the batch roots that cover them. The signing input is
SHA-256( utf8("sill-audit-bundle-v1") || 0x00 || JCS(body) )The sill-audit-bundle-v1 prefix and the 0x00 separator structurally distinguish a bundle signature from a per-record signature, so the two signed shapes cannot be confused. The bundle envelope looks like this:
{ "bundle_id": "bndl_01KT5ZQX2J5K7H8N4M6P7Q8R9S", "site_id": "site_01EXAMPLE00000000000000000", "decision_class": "discovery", "exported_at": "2026-06-22T15:00:00Z", "record_count": 247, "records": [ /* AuditRecord[] */ ], "batch_roots": [ { "utc_date": "2026-06-21", "merkle_root": "Lq3R…base64url-32-byte-root…", "leaf_count": 247 } ], "envelope_signature": "…detached-ed25519-signature-base64url…", "signing_key_id": "…identifier of the audit-bundle signing key…"}Verifying a bundle proves the envelope was signed by the holder of the bundle key, but it does not by itself prove the chain or the Merkle batch — a complete verifier also runs the per-record verifier on each row, and recomputes the Merkle root from the leaves in each covered batch.
HTML — server-rendered audit bundle
Section titled “HTML — server-rendered audit bundle”The dashboard renders a slice of the envelope as a server-rendered HTML document — recipient-friendly, printable, share-by-link. Each record’s signature and chain link are still present in the rendered output as JCS-canonical JSON, so a recipient can verify the bundle without trusting the HTML rendering. See Audit log and export for the dashboard view.
Dashboard audit log view with the “Export bundle” action.
Verifying an envelope end-to-end
Section titled “Verifying an envelope end-to-end”A complete third-party verification of an exported bundle does the following:
- Fetch the public key.
GET https://edge.sill.so/.well-known/jwks.json. See Public JWKS. - Verify the bundle envelope. Recompute
SHA-256(utf8("sill-audit-bundle-v1") || 0x00 || JCS(body))and ed25519-verifyenvelope_signatureagainst the JWKS key whose id matchessigning_key_id. - Verify each record’s envelope signature. For every
recordinrecords, stripenvelope_signatureandmerkle_root, JCS-canonicalize, ed25519-verify against the JWKS key. - Verify each chain link. For each record except the first on its (site, decision-class) chain, recompute
SHA-256(JCS(prev_with_pending_merkle_root))and compare torecord.prev_record_hash. For the chain’s first record, compare toSHA-256("sill-audit-genesis-v1|" + site_id + "|" + decision_class). - Verify each batch root. For each batch covered by
batch_roots, recompute the Merkle root over the leaves (SHA-256(JCS(record_with_pending_merkle_root))) and compare. - (Optional) Re-derive
request_hashandresponse_hashfrom the persisted projection. For a record whose class binds a real projection — an approved or HITL-resolved decision record, or a settlement record — canonicalise the row’sintent_summary/ settlement-outcome / linkage projection and compareSHA-256(JCS(projection))to the value inside the signed envelope. Any mismatch means the persisted projection has been tampered with since the record was signed. See “What the signed record binds” for the exact shape per class.
The full step-by-step verifier — including a minimal JavaScript sketch and a tampering smoke test — is documented at Verify a signature.
Frequently asked
Section titled “Frequently asked”Are records ever mutated after signing?
No. The only field that changes after a record is signed is merkle_root, which is excluded from both the chain-hash input and the envelope-signature input by construction (it is normalized to the pending sentinel before either derivation). Every other field is immutable; any change breaks the envelope signature.
Why two hash sentinels?
PENDING_MERKLE_ROOT and EMPTY_PAYLOAD_HASH are both 43-character all-zero base64url strings — same shape as a real SHA-256 digest, but the all-zero bit pattern is computationally unreachable as a real digest (no preimage). They keep the canonical form uniform across records that don’t yet have a batch root, or that never had a captured payload to hash, without introducing a different type at the canonical-form boundary.
Does the per-record signature attest the request and response payloads?
Yes, when the record carries them. request_hash and response_hash are base64url SHA-256 of the JCS-canonical projection bytes; the raw projection is stored on the audit row (or re-derivable from its persisted columns) and is not itself part of the signed envelope. A verifier re-canonicalises the projection from the row and compares to the hash in the signed envelope. See “What the signed record binds” for the exact projection on each record class — an approved or HITL-resolved record binds the verified intent (action, merchant, SKU or line items, max_amount, currency) via request_hash and the mandate-engine outcome via response_hash; a settlement record binds the rail-neutral settlement outcome and the decision-record linkage.
What about key rotation?
The bundle envelope records its own signing_key_id, and each verifier picks the matching public key from the JWKS by kid. Multi-key support is in place; rotation is operational mechanism, out of scope for this page.
How big can a bundle get? Bundle bodies are bounded by SHA-256 pre-hashing — the signing input is the 32-byte digest of the canonical body, not the canonical body itself — so the format imposes no practical size ceiling. Dashboard exports are typically scoped by date range and site.
See also
Section titled “See also”- Verify a signature — third-party verifier recipe.
- Public JWKS — the published ed25519 public key.
- Audit log and export — the dashboard view and the HTML bundle export.
- What is Sill — Identity / Intent / Proof, and how the audit envelope is the “Proof” layer.
- Quickstart — install Discovery and start writing audit records.
- Transactional overview — the mandate / policy / authorize pipeline whose decisions are written into the transactional chain.
External references:
- RFC 8785 — JSON Canonicalization Scheme (JCS)
- RFC 8032 — Edwards-Curve Digital Signature Algorithm (EdDSA)
- RFC 7515 — JSON Web Signature (JWS) and RFC 8037 — CFRG ECDH and EdDSA for JOSE
- RFC 6962 — Certificate Transparency — Merkle-tree construction Sill follows for batches.
- NIST AI RMF 1.0 — the audit envelope + bundle export support the Measure and Manage functions.