Skip to content

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. For the end-to-end verifier recipe see Verify a signature; for the JWKS that lets a third party verify Sill’s signatures with off-the-shelf tooling see Public JWKS.

Each site keeps two independent audit chains, one per decision class:

  • Discoverydecision: 'observed' records emitted by the embed beacon when an AI-agent visit is identified.
  • Transactionaldecision: 'approved' | 'rejected' | 'escalated_approved' | 'escalated_rejected' | 'verification_rejected' | 'rejected_post_verify' records produced by the mandate / policy / authorize pipeline (Phase 2).

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.

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:

FieldMeaning
record_idPrefixed ULID. The record’s stable identifier.
site_idPrefixed ULID. The site whose chain this record belongs to.
evaluated_atISO-8601 UTC timestamp the policy engine evaluated the interaction.
policy_versionThe exact policy ruleset version applied.
rules_evaluatedPer-rule outcomes (`pass
decisionOne of observed (discovery) or the six transactional decisions above.
retention_classPer-record retention bucket.
request_hash / response_hashbase64url 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_hashThe chain link — see below.
envelope_signatureThe ed25519 envelope signature over the canonical signing input.
merkle_rootThe 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); escalation-resolution records additionally carry operator_id (the reviewing dashboard user) and operator_decision_at. The signed scope is the same allow-list either way.

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.

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.

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.

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.

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.

Sill ships three export shapes today. The cryptographic primitive is the JSON bundle; NDJSON is the line-oriented variant; HTML is a server-rendered presentation of the same underlying records.

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": "foyer/origin/audit-bundle-v1"
}

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.

toNdjson(records) emits one JCS-canonical record per line, separated by \n. NDJSON is unsigned at the line level — each line’s envelope_signature field is the line-level proof — but the file is typically wrapped in a signed bundle envelope when downloaded from the dashboard.

{"decision":"observed","envelope_signature":"…","evaluated_at":"…",…}
{"decision":"observed","envelope_signature":"…","evaluated_at":"…",…}
{"decision":"observed","envelope_signature":"…","evaluated_at":"…",…}

NDJSON is the right shape for piping into downstream log stores (SIEM, data warehouse, etc.).

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.

A complete third-party verification of an exported bundle does the following:

  1. Fetch the public key. GET https://edge.sill.so/.well-known/jwks.json. See Public JWKS.
  2. Verify the bundle envelope. Recompute SHA-256(utf8("sill-audit-bundle-v1") || 0x00 || JCS(body)) and ed25519-verify envelope_signature against the JWKS key whose id matches signing_key_id.
  3. Verify each record’s envelope signature. For every record in records, strip envelope_signature and merkle_root, JCS-canonicalize, ed25519-verify against the JWKS key.
  4. 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 to record.prev_record_hash. For the chain’s first record, compare to SHA-256("sill-audit-genesis-v1|" + site_id + "|" + decision_class).
  5. 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.

The full step-by-step verifier — including a minimal JavaScript sketch and a tampering smoke test — is documented at Verify a signature.

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? It attests request_hash and response_hash — base64url SHA-256 of the JCS-canonical request and response bytes — when those payloads were captured. The raw payloads themselves are stored separately on the audit row and are not part of the signed envelope. A verifier can rehash a stored payload and compare to the value in the signed envelope to bind the two.

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.

  • 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: