Skip to content

Audit envelope: signed, chained audit records

Every interaction Sill governs leaves an audit record: a JSON object signed with ed25519, linked by hash to the record before it, and covered by a daily Merkle root. Together these form the audit envelope. Changing any byte of a record after it was written breaks its signature and the link from the next record.

This page describes the record format so an auditor or developer can check records independently. To check an exported bundle without writing code, drop it on the hosted verifier. To check one with your own code, follow Verify a signature.

Audit records are signed with Sill’s audit key, published at https://sill.so/.well-known/sill-audit-envelope-v1.pub. This is a different key from the one in the public JWKS, which signs agent cards and ARD catalogs.

Each site has two independent chains:

ChainRecords on it
discoverydecision: "observed" — agent visits identified by the install, and read-only skill calls such as browse_catalog or order_status.
transactionalMandate decisions, settlements and payment-link handoffs.

Transactional decision values:

decisionMeaning
approvedThe mandate passed every guardrail.
rejectedA guardrail rejected the mandate.
escalated_approved / escalated_rejectedA guardrail held the mandate for review and it was then approved or rejected.
verification_rejectedThe mandate failed signature or shape checks before any guardrail ran.
rejected_post_verifyA second submission of a mandate that already has a decision.
settled_succeeded / settled_refundedThe payment for an approved mandate settled, or was refunded.
handoff_pendingSill created an unpaid order and returned a payment link for the buyer; no money moved through Sill.
{
"record_id": "01JDEM0REC0006000000000000",
"site_id": "01JDEMOSITE00000000000MAIN",
"mandate_id": "mnd_01JDEMOMANDATE00000000006",
"evaluated_at": "2026-09-30T11:38:00.000Z",
"policy_version": "v7",
"rules_evaluated": [
{ "rule_id": "r03", "passed": true, "action_taken": "allow" },
{ "rule_id": "r05", "passed": false, "reason": "amount_exceeds_cap", "action_taken": "escalate" }
],
"decision": "escalated_approved",
"operator_id": "01JDEMOUSER0000000000MARCO",
"operator_decision_at": "2026-09-30T11:52:00.000Z",
"intent_summary": {
"action": "place_order",
"merchant": "northwind-coffee.example",
"sku": "NW-GRINDER-01",
"quantity": 5,
"max_amount": 645,
"currency": "USD"
},
"retention_class": "standard_3y",
"request_hash": "u3Q0m…43 base64url characters…",
"response_hash": "f9Lx2…43 base64url characters…",
"prev_record_hash": "8kQe1…43 base64url characters…",
"envelope_signature": "Zt0v…86 base64url characters…",
"merkle_root": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
FieldMeaning
record_idULID of the record.
site_idULID of the site whose chain the record is on.
mandate_idTransactional records only. The mandate the record decides or settles.
evaluated_atISO-8601 UTC time of the evaluation.
policy_versionThe guardrail policy version that was applied.
rules_evaluatedOne entry per rule checked: rule_id, passed, optional reason, and action_taken.
decisionobserved, or one of the transactional values above.
operator_id, operator_decision_atEscalated records resolved by a person only. operator_id is the reviewer’s Sill user ID (never a name or email); operator_decision_at is when they decided.
intent_summaryGuardrail decisions only (approved, rejected, escalated_*). What the agent was authorized to do: action, merchant, SKU or line_items, max_amount, currency. Buyer name, email, phone and address are never included.
retention_classshort_term_30d, standard_3y or long_term_7y.
request_hash, response_hashbase64url SHA-256 of the request and of the outcome Sill recorded for this interaction. AAAA… (43 A characters, 32 zero bytes) means nothing was captured.
prev_record_hashHash of the previous record on the same chain. See Chain.
envelope_signaturebase64url ed25519 signature over the record. See Signature.
merkle_rootAlways AAAA… (43 A characters) on the record itself. The day’s Merkle root is reported in the bundle’s batch_roots.

Optional fields appear only when they apply; an absent field is left out, not set to null.

Every field except envelope_signature and merkle_root is signed, so a change to any of them makes verification fail. In particular:

  • The authorized intent. On guardrail decision records (approved, rejected, escalated_*), the action, merchant, SKU or line items, amount cap and currency are signed in intent_summary.
  • The human reviewer. On escalated records resolved by a person, operator_id and operator_decision_at are signed. Records resolved by an expired review window carry neither field.
  • Settlements. A settled_* record’s request_hash is SHA-256(JCS({ mandate_id, decision_record_id, decision_envelope_signature })), which ties the settlement to the exact signed decision it settles. Its response_hash covers the settled outcome: rail, signed amount in minor units (negative for a refund), currency, settle time, and the rail’s reference.

If you document human oversight for a high-risk AI system under the EU AI Act, these signed fields can support your record-keeping. Sill provides framework mappings only, not conformity or certification; see Compliance.

The signing input is the record with envelope_signature and merkle_root removed, canonicalized with RFC 8785 (JCS), as UTF-8 bytes. The signature is a raw 64-byte ed25519 signature, base64url-encoded.

Records written before request and response hashing was introduced carry neither request_hash nor response_hash. If either is missing, leave both out of the canonical form.

prev_record_hash is the hash of the previous record on the same chain:

prev_record_hash = base64url( SHA-256( JCS(previous record, with merkle_root set to "AAAA…") ) )

The previous record is hashed whole, including its envelope_signature. The first record on a chain links to a genesis value instead:

genesis = base64url( SHA-256( "sill-audit-genesis-v1|" + site_id + "|" + chain ) )

where chain is discovery or transactional.

flowchart LR
  G["Genesis hash"] --> R1["Record 1"] --> R2["Record 2"] --> R3["Record 3"] --> R4["…"]

Each record’s prev_record_hash is the hash of the record before it; the first record’s is the genesis hash.

Records are grouped by site, chain and UTC day. The leaf for each record is the same hash used for chain links. Each level of the tree is built by hashing pairs of raw 32-byte digests, SHA-256(left || right); when a level has an odd number of nodes, the last one is paired with itself. The single remaining digest, base64url-encoded, is the day’s root.

flowchart BT
  L1["Leaf 1: hash of record 1"] --> N12["SHA-256(leaf 1 + leaf 2)"]
  L2["Leaf 2: hash of record 2"] --> N12
  L3["Leaf 3: hash of record 3"] --> N34["SHA-256(leaf 3 + leaf 4)"]
  L4["Leaf 4: hash of record 4"] --> N34
  N12 --> ROOT["Day's Merkle root"]
  N34 --> ROOT

A day with four records. Each leaf is the same hash the next record carries in its prev_record_hash.

The root lets you confirm that a bundle contains every record for the day, with none removed, added or reordered. While a UTC day is open, Sill updates its root as new records arrive, and a bundle only carries a root that matches exactly the records in it. After the day ends, the root is sealed and never changes again. For a root that covers the whole day, export the bundle after the day has ended.

A record that reaches Sill after its day was sealed, for example after a delivery delay, is still signed and chained, but the sealed root does not cover it. It is exported with any other late records for that day, and its membership shows as pending.

A bundle holds every record on one chain for one site and UTC day: the day of the record you exported it from. It is offered as an HTML document and as signed JSON; see Audit log and export for how to download each. The HTML document embeds the same signed JSON in a <script type="application/sill+json" id="sill-bundle"> element. Inside that element, every </ is written as <\/; undo that before parsing.

Exported audit bundle for northwind-coffee.example on 2026-09-30: 14 mandates, 10 approved, 4 rejected, 2 human-reviewed The HTML bundle opens with a readable summary of the day. The signed JSON is embedded at the end of the file.

{
"bundle_id": "01M3VZNFC0EPACZTHZE5AYYXYR",
"site_id": "01JDEMOSITE00000000000MAIN",
"decision_class": "transactional",
"exported_at": "2026-10-01T15:00:00.000Z",
"record_count": 14,
"records": [ "… the day's records, oldest first …" ],
"batch_roots": [
{ "utc_date": "2026-09-30", "merkle_root": "v6LN6D…", "leaf_count": 14 }
],
"envelope_signature": "…base64url ed25519 signature…",
"signing_key_id": "…id of the audit key that signed the bundle…"
}

The bundle itself is signed. Its signing input is:

SHA-256( "sill-audit-bundle-v1" || 0x00 || JCS(bundle without envelope_signature and signing_key_id) )

that is, the UTF-8 bytes of sill-audit-bundle-v1, one zero byte, then the canonical body, hashed with SHA-256. The 32-byte digest is what is signed. If batch_roots[0].merkle_root is AAAA…, no day root covered the bundle’s records when it was exported: either they were written after the root was last updated, and exporting again later includes it, or they reached Sill after the day was sealed.

A day with more than 1,000 records on one chain is exported in parts of 1,000 records, in the same order as the day’s Merkle leaves. Each part is a separately signed file that can be checked without the others. A day with 1,000 records or fewer is always exported as one bundle, in the format above.

{
"format": "sill-audit-bundle-part-v1",
"bundle_id": "01M3W1A7Q2C9D8E6F5G4H3J2K1",
"site_id": "01JDEMOSITE00000000000MAIN",
"decision_class": "discovery",
"exported_at": "2026-10-05T09:00:00.000Z",
"part": { "index": 2, "count": 3, "size": 1000, "first_record": 1001, "last_record": 2000 },
"batch_root": { "utc_date": "2026-10-04", "merkle_root": "Qm4x…", "leaf_count": 2501 },
"record_count": 1000,
"records": [ "… records 1,001 to 2,000 of the day …" ],
"range_proof": [ "…base64url digest…", "…" ],
"envelope_signature": "…",
"signing_key_id": "…"
}

A part is signed under its own prefix, so a part can never pass as a whole-day bundle, or the reverse:

SHA-256( "sill-audit-bundle-part-v1" || 0x00 || JCS(part without envelope_signature and signing_key_id) )

Instead of every record of the day, a part carries range_proof: the digests from the rest of the day’s tree that are needed to rebuild the day’s root from this part’s records alone. To check it:

  1. Check the part is placed where it says: count = ceil(leaf_count / size), first_record = (index − 1) × size + 1, last_record = min(index × size, leaf_count), and the number of records is last_record − first_record + 1.
  2. Hash each record to its leaf, as above. Start with those leaves at positions lo = first_record − 1 to hi = last_record − 1 in a level of n = leaf_count nodes.
  3. While n > 1: if lo is odd, put the next range_proof digest in front; if hi is even, put the next range_proof digest at the end when hi + 1 < n, otherwise repeat the last node. Hash adjacent pairs, then set lo = floor(lo / 2), hi = floor(hi / 2), n = ceil(n / 2).
  4. Every range_proof digest must be used exactly once, and the one remaining node must equal batch_root.merkle_root.

A moved, missing or edited record fails step 4. If batch_root.merkle_root is AAAA…, no day root covered the part’s records when it was exported, so membership cannot be checked from that file. The signatures can still be checked. Exporting the part again later includes the proof, unless the records reached Sill after their day was sealed.

  1. Get the key. Fetch https://sill.so/.well-known/sill-audit-envelope-v1.pub. Use public_key if the bundle’s signing_key_id equals current_signing_key_id; otherwise use the matching entry in history. The id is an opaque string of the form sill-audit-ed25519-v1-<hex>; if a bundle’s id does not match any listed entry (e.g. a legacy bundle), match on public_key_fingerprint — the base64url SHA-256 of the raw 32 public-key bytes — or try each published public key in turn and use whichever verifies.
  2. Bundle signature. Verify envelope_signature over the bundle signing input above.
  3. Record signatures. Verify each record’s envelope_signature with the same key.
  4. Chain. Check each record’s prev_record_hash against the record before it in the bundle. The first record normally links to the last record of the previous day, which is not in the file. It matches the genesis value only on a chain’s very first day.
  5. Merkle root. Recompute the root over all records and compare it with batch_roots[0].merkle_root, and compare leaf_count with the number of records. For a part, check its placement and range_proof against batch_root.merkle_root instead.

Verify a signature has a script that runs all five checks. The hosted verifier runs checks 1 to 4, and check 5 for a part.

Can a record change after it is written? No. Records are append-only, and the day’s Merkle root is stored with the day’s batch rather than written back into the records.

Does a record include the raw request or response? No. It carries their SHA-256 hashes, and on guardrail decision records the signed intent_summary. Buyer personal data is never on the chain.

What if Sill rotates the audit key? Each bundle names its key in signing_key_id. Earlier keys stay listed in history in the published key document, so older bundles still verify.