Frozen snapshot — Zanii Protocol 1.3 (2026-10-19). Kept verbatim so any receipt can be checked against the exact rules it was written under. The current specification is at
/protocol; what changed between versions is inCHANGELOG.md.
Zanii Protocol Specification
Version 1.3 · 2026-10-19 · describes "v": 1 objects ·
changelog · governance · formal model ·
frozen versions 1.0, 1.1, 1.2, 1.3
Verifiable identity and proof-of-action for AI agents. This document is normative for
@zanii/core and the zanii Python SDK, which are byte-identical on the published
conformance vectors (@zanii/conformance). Anything not specified here is implementation
detail.
1. Conventions
- Canonicalization: RFC 8785 (JSON Canonicalization Scheme). Every signature and every hash in this protocol is computed over the JCS form of a JSON object, UTF-8 encoded.
- Hash: SHA-256. Encoded as
sha256:<lowercase hex>. - Signature: Ed25519. Encoded as
ed25519:<lowercase hex>(64-byte signature). - Identity (DID):
did:keyfor Ed25519:did:key:z<base58btc(0xed 0x01 || publicKey32)>. The DID is the public key; no registry lookup is needed to verify a signature. - Timestamps: ISO 8601 UTC (
2026-07-02T16:00:00.000Z).
2. Delegation certificate
Proves an identity authorized another identity to act, within scopes, until expiry.
{
"v": 1,
"issuer": "did:key:z6Mk...",
"subject": "did:key:z6Mk...",
"scopes": ["stripe.*", "gmail.send"],
"exp": "2026-08-01T00:00:00.000Z",
"sig": "ed25519:..."
}
sig= Ed25519 by the issuer's key over JCS of the cert withoutsig.- Cert hash (used for revocation) =
sha256:hex of JCS of the full cert includingsig.
Scopes
A scope is either an exact string (gmail.send) or a wildcard prefix (stripe.*).
a.* covers a, a.b, a.b.c, and a.*. * covers everything.
Delegation chain
An ordered array of certs, root first: org → team → agent.
A chain is valid for agent A at time t against expected root owner O iff:
- Every cert's
sigverifies against itsissuerDID. chain[0].issuer === O(when the verifier pins an expected owner).chain[i+1].issuer === chain[i].subjectfor all links.chain[last].subject === A.- Every cert satisfies
t < exp. - No cert's hash appears in the verifier's revocation set.
Effective scopes = the last cert's scopes, filtered to those covered by every ancestor cert's scopes (a delegate can only narrow, never widen).
3. Action receipt
One signed record per agent action. The payload itself is never logged — only its hash — so proofs leak no data.
{
"v": 1,
"agent_id": "did:key:z6Mk...",
"delegation": [ /* cert chain, root first */ ],
"action": "tool_call",
"target": "stripe.charges.create",
"payload_hash": "sha256:...",
"ts": "2026-07-02T16:00:00.000Z",
"prev": "sha256:... | null",
"sig": "ed25519:..."
}
sig= Ed25519 by the agent's key (the key insideagent_id) over JCS of the receipt withoutsig.payloadis encoded to bytes with RFC 8785 JCS before hashing (payloadBytes/payload_bytes): object keys sorted, no insertion-order dependence, no whitespace, raw UTF-8 (never\uXXXXescapes), and a missing payload encoded asnull. (Since 1.3) Non-integer numbers are encoded exactly as RFC 8785 §3.2.2.3 specifies: ECMAScript'sNumber.prototype.toString, i.e. the shortest digits that round-trip, in plain form from 1e-6 up to 1e21 and exponent form (1e-7,1.5e-10) outside it;-0encodes as0. Before 1.3 they were rejected. Use integer minor units for money all the same. (Since 1.2) Whole numbers outside ±(2^53−1) are rejected, however they are written (9007199254740992,1e21): JavaScript cannot hold them exactly, so two honest implementations would hash different bytes. Pass such values as strings. Object keys are ordered by UTF-16 code units, as RFC 8785 §3.2.3 requires; this differs from code-point order only for keys containing characters beyond U+FFFF. A receipt written under this rule carries"payload_encoding": "jcs"; its absence means a pre-JCS receipt, whose bytes came from the writing SDK's own JSON serialiser and are therefore implementation-specific (verifyPayloadLegacy/verify_payload_legacy). This step was previously undefined and two SDKs chose differently, which is why it is spelled out here rather than left to the implementation.payload_hash=sha256(nonce ‖ payload)wherenonceis a fresh per-receipt 16-byte value kept operator-side (never in the receipt or on the log). Salting is the default: a bare hash of a low-entropy payload is dictionary-attackable from the public anchored log, and a linkable hash may itself be personal data. To prove a payload later, reveal(payload, nonce)and recompute (verifyPayload). A deterministic baresha256(payload)is an explicit opt-out for content-addressing.- Receipt hash =
sha256:hex of JCS of the full receipt includingsig. prev= hash of the agent's previous receipt, ornullfor the first. This hash-chains each agent's history: removing, reordering, or altering any receipt breaks the chain of everything after it.- Optional provenance (present only when set; covered by
sig):runtime_hash(the deployed runtime),model_id, andmanifest_hash(hash of the product's tool/scope manifest that governed the action — receipted per deploy and stamped on each action, so any receipt answers "which compliance rulebook governed this"). - Optional
risk(since 1.2) (present only when set; covered bysig): what is at stake in the action — exactly one oflow,medium,high,critical. Writers MUST NOT emit any other value. Verifiers MUST NOT reject a receipt because of itsrisk, including an unrecognised value; that judgement belongs to policy. The label is set by the recorder, so it is a control only where something independent of the agent enforces it (a policy engine, a human gate, a signing daemon). A rule that must hold against a misbehaving agent belongs on delegation scopes, which the log enforces. - Optional
subject_tag(present only when set; covered bysig): a pseudonymous, platform-scoped tag for the subject the action was about — a data subject (JCS-hash({v:1, type:'subject_tag', platform, subject_did})) or a physical item (JCS-hash({v:1, type:'item_tag', namespace, item}),@zanii/custody). On a2a and swarm receipts the tag sits inside the co-signed body. Enables per-subject auditability (@zanii/subject): the end user, holding their owndid:key, computes the same tag and pulls + offline-verifies exactly their slice viaGET /v1/subjects/{tag}. The same subject yields a different tag per platform (no cross-platform linkage), and a tag is not reversible to an identity. The slice is only as complete as the platform's stamping. - Two-phase actions (
@zanii/runtime, opt-in): anaction_intentreceipt (tool, args,action_hash) is written before the tool runs, and anaction_resultafter. A crash between them leaves the intent on record — so an unreported action is catchable (omission-evidence), and the actor is committed before it knows the outcome.
4. Receipt verification
A receipt R verifies at time t iff:
R.sigverifies against the public key inR.agent_id.R.delegationis a valid chain forR.agent_id(§2), atR.ts.R.targetis covered by the chain's effective scopes.- When verifying a sequence:
R.prevequals the hash of the preceding receipt.
Verification is pure — no network, no trusted party. Phase 1 adds a fifth check: Merkle inclusion proof against a signed (and later chain-anchored) tree head.
5. Transparency log (Phase 1)
An append-only Merkle tree over receipts, per RFC 6962 / RFC 9162:
- Leaf data = JCS bytes of the full receipt (including
sig). - Leaf hash =
SHA-256(0x00 || leaf_data). - Node hash =
SHA-256(0x01 || left || right). - Tree shape: the left subtree of an n-leaf tree holds the largest power of two strictly less than n.
Signed Tree Head (STH)
{
"v": 1,
"log_id": "did:key:z6Mk...",
"size": 12345,
"root": "sha256:...",
"ts": "2026-07-02T16:00:00.000Z",
"sig": "ed25519:..."
}
sig = Ed25519 by the log's key over JCS of the STH without sig. The log key's
DID is the log's identity; Phase 2 anchors root to a public chain.
Proofs
- Inclusion proof: audit path from a leaf to the root, verified with the RFC 9162 §2.1.3.2 algorithm. Proves a receipt is in the log at a given size.
- Consistency proof: RFC 9162 §2.1.4.2. Proves the log at size n is an append-only extension of the log at size m — history was never rewritten.
A receipt is fully proven when: it verifies per §4, its leaf has a valid inclusion proof against an STH, and the STH signature verifies against the log's DID.
Log API (v1)
POST /v1/receipts—{receipts: Receipt[]}; each is verified (§4, including per-agentprevlinkage against the log's view) before append. Idempotent by receipt hash.GET /v1/sth— current STH.GET /v1/proof/:receiptHash— receipt, leaf index, inclusion proof, STH.GET /v1/consistency?first=m&second=n— consistency proof.GET /v1/head/:agentDid— the agent's latest receipt hash (forprev).GET /verify/:receiptHash— human-readable verification page.
6. Revocation (Phase 2)
A revocation record kills a delegation certificate, in-log so the revocation time itself is provable:
{
"v": 1,
"type": "revocation",
"cert": { /* the full delegation cert being revoked */ },
"ts": "2026-07-02T16:00:00.000Z",
"sig": "ed25519:..."
}
sig= Ed25519 by the cert's issuer over JCS of the record withoutsig. Only the identity that granted authority can revoke it.- A record is valid iff the embedded cert's own signature verifies AND the record's signature verifies against the cert's issuer.
- The log appends valid revocation records as their own Merkle leaves (leaf data
= JCS bytes of the record) and adds
certHash(cert)to its revoked set. - Effect: any chain containing a revoked cert is invalid (§2 rule 6). The log rejects new receipts under it; verification pages flag existing ones.
- Key rotation is composition, not a primitive: issue a new cert to the new agent key, then revoke the old cert.
7. Chain anchoring (Phase 2)
Periodically, the log publishes its current STH outside its own control so that not even the log operator can rewrite history:
- Anchor record:
{ sth, backend, ref, anchored_at }wherebackendisevm(an EIP-1559 transaction on Base / Base Sepolia whose calldata is the JCS bytes of the STH;ref= tx hash + block number) orfile(dev-only append-only local file; explicitly not tamper-proof). - A receipt at leaf index i is anchored once any anchor exists with
sth.size > i: the anchored root commits to that leaf, and consistency proofs (§5) tie every later tree to the anchored one. - Third-party verification: fetch the tx calldata from the chain, decode the STH, verify its signature, then demand an inclusion proof against it (or a consistency proof from it to the current STH).
8. Audit bundles (Phase 3)
GET /v1/export/:agentDid produces a self-contained, offline-verifiable export
of one agent's complete history:
{
"v": 1,
"bundle_type": "zanii-audit-bundle",
"generated_at": "...",
"log_id": "did:key:...",
"agent_id": "did:key:...",
"sth": { /* STH at export time */ },
"receipts": [{ "receipt": {}, "hash": "", "index": 0, "proof": [] }],
"revocations": [{ "revocation": {}, "cert_hash": "", "index": 0, "proof": [] }],
"anchors": [{ /* anchor record */ , "consistency_proof": [] }]
}
anchors holds the anchors the bundle needs, not every anchor the log ever made:
for each receipt, the earliest anchor covering it (sth.size > index — the evidence that
the receipt existed by that anchored time), plus the latest anchor (the bundle tree is an
append-only extension of it). Every anchor record at a kept size is included, so dual-backend
anchors of one STH travel together. Verifiers check the anchors present; they do not require
completeness of the log's anchor history.
A bundle verifies iff, offline: the STH signature is valid; every receipt
verifies per §4, hashes to its hash, belongs to agent_id, and has a valid
inclusion proof against the STH; the prev chain is complete from null with
no gaps; every revocation record verifies per §6 with a valid inclusion proof;
and every anchor's STH verifies with a valid consistency proof to the bundle
STH (so the bundle tree is an append-only extension of everything anchored).
Reference implementation: verifyAuditBundle() in @zanii/core (pure).
9. Cross-org co-signed receipts (Phase 4)
When agent A (org X) interacts with agent B (org Y), neither org's word should be the record. An A2A receipt is one statement signed by both:
{
"v": 1,
"type": "a2a",
"action": "tool_call",
"target": "quotes.request",
"payload_hash": "sha256:...",
"ts": "2026-07-02T18:00:00.000Z",
"initiator": { "agent_id": "did:key:...", "delegation": [], "prev": null, "sig": "ed25519:..." },
"responder": { "agent_id": "did:key:...", "delegation": [], "prev": null, "sig": "ed25519:..." }
}
- Signing base = JCS of the receipt with both
sigfields removed. Both parties sign the identical base — order-independent, no countersigning of signatures. - Receipt hash = JCS hash of the full receipt (both sigs included).
- Solo receipts have no
typefield;type: "a2a"distinguishes the two.
Valid iff: both signatures verify against their agent_id keys; both
delegation chains are valid at ts and terminate at their respective agents;
target is within both parties' effective scopes; the parties are distinct.
Chains: the A2A receipt hash enters both agents' hash chains — each
party's prev is that agent's prior receipt hash, and each agent's next
receipt points back to the A2A hash. The log verifies both prevs against its
heads on ingest (POST /v1/interactions) and advances both heads atomically.
Exchange flow: initiator sends a draft (its half of the body); responder
completes the body with its identity/delegation/prev and returns its
signature; initiator verifies its half is unchanged, signs, and submits. The
log is the settlement point — both parties re-sync their chain heads from it.
10. Reputation & payments (Phase 4)
GET /v1/reputation/:agentDidsummarizes verifiable history only: receipt count, activity span, A2A interaction count, distinct counterparties, revocation status, anchored coverage. It is a convenience view — consumers wanting proof should pull the audit bundle (§8) and verify offline.- ERC-8004 alignment:
giveFeedback()on the ReputationRegistry withfeedbackURIpointing at the agent's audit-bundle export, so on-chain reputation is backed by verifiable history rather than assertion. - Payment receipts are a convention, not a rail: a solo or A2A receipt with
action: "payment"andtarget: "payment.<rail>"(e.g.payment.x402,payment.ap2), whose payload contains the rail's own settlement reference (tx hash, mandate id). Zanii proves who initiated/received; the rail settles.
11. Selective disclosure — payload field commitments (client convention)
An optional, client-side convention (@zanii/redact / zanii.redact) for
revealing individual payload fields without disclosing the rest. The log is
unaffected — it only ever sees the receipt payload, which here is a commitment
envelope. Format id: sd-v1.
Commit. Given fields {k: v}:
- Sort keys. For each key k, draw a random 16-byte salt_k (hex) and compute a
leaf L_k = LeafHash( JCS(["zr-field-v1", k, v, salt_k]) ) — the RFC 6962 leaf
hash (§5) of the RFC 8785 canonical bytes (§1) of that 4-element array.
- root = MerkleRoot([L_k for k in sorted keys]) (RFC 6962, §5).
- The receipt payload is the envelope {"_zr":"sd-v1", "root":"sha256:<hex>",
"keys":[sorted keys]}. It is hashed and recorded like any payload (§3), so the
ledger binds root immutably. keys discloses field names only, never values.
Disclose. To reveal field k at index i: publish {"_zr":"sd-disclosure-v1",
"key":k, "value":v, "salt":salt_k, "index":i, "size":n, "proof":[audit path],
"root"}, where proof is the RFC 6962 inclusion proof for leaf i (§5).
Verify. Recompute L_k from (k, v, salt_k); check keys[i] == k and
size == len(keys); verify the inclusion proof of L_k at (i, size) against
root; and confirm root is the payload root of a receipt whose payload hash is
in the log (§4). Undisclosed fields reveal nothing — each leaf is independently
salted.
This is a client convention, not a change to the log or receipt format (an
envelope is an ordinary payload). @zanii/retention uses the same salted-commitment
idea, SHA256(salt ‖ subject), for a single subject value.
12. Owner confirmation of irreversible actions (Article VI)
An action_confirmation is cryptographic evidence a human owner approved a
specific irreversible action — replacing an in-memory "yes" that proves nothing.
{
"v": 1,
"type": "action_confirmation",
"owner_id": "did:key:z... (the owner)",
"action_hash": "sha256:... (JCS hash of {tool, args})",
"confirmed_at": "2026-07-12T00:00:00Z",
"sig": "ed25519:... (owner over JCS without sig)"
}
- The owner signs the exact
action_hashwith theirdid:key. Only the owner's key can produce it;verifyConfirmationchecks the signature againstowner_id. - The deterministic runtime (
@zanii/runtime/zanii.runtime), when configured with anownerDid, refuses to fire an irreversible tool without a confirmation whoseowner_idandaction_hashmatch, and records the confirmation as its ownaction_confirmationreceipt — so a dispute ("did the client approve this payment?") has a verifiable answer. This is a signed artifact carried in a normal receipt; no server or receipt-schema change.
13. Infrastructure co-signatures — segregation of duties (Connect)
The agent that performs a task must not be the sole party reporting it. Connect —
the deterministic rails that are the only door to the outside world — holds its own
did:key and co-signs every receipt.
{
"v": 1,
"type": "receipt_cosign",
"cosigner_id": "did:key:z... (Connect / the rails)",
"receipt_hash": "sha256:... (the agent receipt)",
"agent_id": "did:key:z... (the doer)",
"cosigned_at": "2026-07-12T00:00:00Z",
"sig": "ed25519:... (Connect over JCS without sig)"
}
verifyCoSignaturerequires a valid signature bycosigner_idand thatcosigner_id ≠ agent_id— a second, independent party, not a self-attestation.- A receipt with no Connect co-signature is the red flag: an action that didn't go
through the rails. Reconciliation (
@zanii/compliancereconcile) is the backstop — it diffs provider records (Stripe, Meta, bank) against the ledger, surfacing anything that slipped around the rails; the reconciliation report is itself receipted. - This is a signed artifact carried in a normal receipt — no server or schema change. Distinct from §5's witness (co-signs whole-log STHs for append-only) and §9's A2A (two scoped agents): this co-signs a single action for segregation of duties.
14. N-party (swarm) receipts — agent teams (Phase 4)
A task performed by a team of 3+ agents is recorded as one swarm receipt: an
M-of-N threshold of the declared participants co-sign a shared body. This is
genuine threshold crypto — every counted signer signs the same canonical body — not a
chain of 2-party co-signatures.
Receipt shape.
{ "v": 1, "type": "swarm",
"body": { "v":1, "type":"swarm", "task", "target", "payload_hash"?, "ts",
"participants": [did …], // distinct, sorted; N ≥ 3
"threshold": M }, // 1 ≤ M ≤ N
"signers": [ { "agent_id", "delegation": [cert …], "prev", "sig" } … ] }
The signed bytes are JCS(body) — signers attest the shared statement and bring
their own authority proof in the envelope (delegation, prev), which is not part
of the signed bytes. sig is ed25519:<hex> over JCS(body).
Verification (a signature is not authority). For each listed signer, distinct and
∈ participants: (1) sig verifies against agent_id; (2) the delegation chain is
valid, owner-rooted, unexpired, and unrevoked at ts; (3) body.target is within the
chain's effective scopes; (4) prev equals the signer's current log head. Then the
count of valid signers MUST be ≥ threshold. By default the counted signers MUST root
at ≥ threshold distinct owners (segregation of duties — one owner's N agents
cannot satisfy an M-of-N); this may be relaxed per receipt.
Attribution. Only actual signers are attributed the action (indexed, chain-advanced, counted for reputation) — a declared but non-signing participant is bound by nothing.
Ingest. POST /v1/swarm { receipt }. Merkle inclusion, the STH, and on-chain
anchoring cover a swarm receipt exactly as any other leaf. The client primitive ships in
@zanii/swarm / zanii.swarm (offline sign/verify); server verification is
verify_swarm_receipt.
15. Session close (since 1.2)
An agent MAY record a session-close receipt when it ends a run deliberately: a solo
receipt with "action": "session.close" and "target": "zanii.session.close", whose
payload is {v: 1, type: "session.close", reason?}, committed like any other payload
(§3). The target is subject to scope checks exactly like any other action, so the
agent's delegation must cover it (zanii.session.* or *).
What it adds, stated narrowly. The per-agent prev chain already proves that no
receipt is missing between two receipts. A session close adds one fact: a chain whose
newest receipt is a session close was not cut off mid-session. It does not prove
that nothing came after — a bundle can still omit every receipt after a close, ending
the chain cleanly at a session boundary. Detecting that is the job of independent
monitoring and anchoring (§7), not of this marker.
Bundle verifiers report session_closed and closed_at. They note an open session only
for agents that have recorded at least one session close, so bundles from agents that
never use sessions read exactly as before. An open session is never a verification
failure: an agent mid-session is normal.
16. Explicitly out of scope so far
A pending-state protocol for interleaved A2A/swarm and solo traffic (currently: settle via the log, then re-sync heads). In-dashboard revocation (revocation requires the issuer's key, which the server must never hold).