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.
Two chains per site
Section titled “Two chains per site”Each site has two independent chains:
| Chain | Records on it |
|---|---|
discovery | decision: "observed" — agent visits identified by the install, and read-only skill calls such as browse_catalog or order_status. |
transactional | Mandate decisions, settlements and payment-link handoffs. |
Transactional decision values:
decision | Meaning |
|---|---|
approved | The mandate passed every guardrail. |
rejected | A guardrail rejected the mandate. |
escalated_approved / escalated_rejected | A guardrail held the mandate for review and it was then approved or rejected. |
verification_rejected | The mandate failed signature or shape checks before any guardrail ran. |
rejected_post_verify | A second submission of a mandate that already has a decision. |
settled_succeeded / settled_refunded | The payment for an approved mandate settled, or was refunded. |
handoff_pending | Sill created an unpaid order and returned a payment link for the buyer; no money moved through Sill. |
Record fields
Section titled “Record fields”{ "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"}| Field | Meaning |
|---|---|
record_id | ULID of the record. |
site_id | ULID of the site whose chain the record is on. |
mandate_id | Transactional records only. The mandate the record decides or settles. |
evaluated_at | ISO-8601 UTC time of the evaluation. |
policy_version | The guardrail policy version that was applied. |
rules_evaluated | One entry per rule checked: rule_id, passed, optional reason, and action_taken. |
decision | observed, or one of the transactional values above. |
operator_id, operator_decision_at | Escalated 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_summary | Guardrail 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_class | short_term_30d, standard_3y or long_term_7y. |
request_hash, response_hash | base64url 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_hash | Hash of the previous record on the same chain. See Chain. |
envelope_signature | base64url ed25519 signature over the record. See Signature. |
merkle_root | Always 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.
What the signed record binds
Section titled “What the signed record binds”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 inintent_summary. - The human reviewer. On escalated records resolved by a person,
operator_idandoperator_decision_atare signed. Records resolved by an expired review window carry neither field. - Settlements. A
settled_*record’srequest_hashisSHA-256(JCS({ mandate_id, decision_record_id, decision_envelope_signature })), which ties the settlement to the exact signed decision it settles. Itsresponse_hashcovers 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.
How a record is signed
Section titled “How a record is signed”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.
How records are chained
Section titled “How records are chained”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.
Daily Merkle roots
Section titled “Daily Merkle roots”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.
What an exported bundle contains
Section titled “What an exported bundle contains”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.
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.
Large days: bundle parts
Section titled “Large days: bundle parts”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:
- 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 islast_record − first_record + 1. - Hash each record to its leaf, as above. Start with those leaves at positions
lo = first_record − 1tohi = last_record − 1in a level ofn = leaf_countnodes. - While
n > 1: iflois odd, put the nextrange_proofdigest in front; ifhiis even, put the nextrange_proofdigest at the end whenhi + 1 < n, otherwise repeat the last node. Hash adjacent pairs, then setlo = floor(lo / 2),hi = floor(hi / 2),n = ceil(n / 2). - Every
range_proofdigest must be used exactly once, and the one remaining node must equalbatch_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.
Check a bundle end to end
Section titled “Check a bundle end to end”- Get the key. Fetch
https://sill.so/.well-known/sill-audit-envelope-v1.pub. Usepublic_keyif the bundle’ssigning_key_idequalscurrent_signing_key_id; otherwise use the matching entry inhistory. The id is an opaque string of the formsill-audit-ed25519-v1-<hex>; if a bundle’s id does not match any listed entry (e.g. a legacy bundle), match onpublic_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. - Bundle signature. Verify
envelope_signatureover the bundle signing input above. - Record signatures. Verify each record’s
envelope_signaturewith the same key. - Chain. Check each record’s
prev_record_hashagainst 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. - Merkle root. Recompute the root over all records and compare it with
batch_roots[0].merkle_root, and compareleaf_countwith the number of records. For a part, check its placement andrange_proofagainstbatch_root.merkle_rootinstead.
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.
Frequently asked
Section titled “Frequently asked”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.
See also
Section titled “See also”- Verify a signature — scripts that check bundles, agent cards and ARD catalogs.
- Hosted verifier — check a bundle in your browser.
- Audit log and export — where to export bundles in the dashboard.
- Transactional overview — the mandate decisions recorded on the transactional chain.
- RFC 8785 — JSON Canonicalization Scheme (JCS)
- RFC 8032 — Edwards-Curve Digital Signature Algorithm (EdDSA)