# Zanii Protocol Specification

**Version 1.3** · 2026-10-19 · describes `"v": 1` objects ·
[changelog](CHANGELOG.md) · [governance](GOVERNANCE.md) · [formal model](formal/README.md) ·
frozen versions [1.0](versions/1.0.md), [1.1](versions/1.1.md), [1.2](versions/1.2.md), [1.3](versions/1.3.md)

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:key` for 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.

```json
{
  "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 **without** `sig`.
- **Cert hash** (used for revocation) = `sha256:` hex of JCS of the **full** cert
  including `sig`.

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

1. Every cert's `sig` verifies against its `issuer` DID.
2. `chain[0].issuer === O` (when the verifier pins an expected owner).
3. `chain[i+1].issuer === chain[i].subject` for all links.
4. `chain[last].subject === A`.
5. Every cert satisfies `t < exp`.
6. 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.

```json
{
  "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 inside `agent_id`) over JCS of
  the receipt **without** `sig`.
- `payload` is 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 `\uXXXX` escapes), and a missing payload encoded as `null`.
  *(Since 1.3)* Non-integer numbers are encoded exactly as RFC 8785 §3.2.2.3 specifies:
  ECMAScript's `Number.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; `-0`
  encodes as `0`. 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)` where `nonce` is 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 bare `sha256(payload)` is an explicit opt-out for content-addressing.
- **Receipt hash** = `sha256:` hex of JCS of the **full** receipt including `sig`.
- `prev` = hash of the agent's previous receipt, or `null` for 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`, and `manifest_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 by `sig`): what is
  at stake in the action — exactly one of `low`, `medium`, `high`, `critical`. Writers
  MUST NOT emit any other value. Verifiers MUST NOT reject a receipt because of its
  `risk`, 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 by `sig`): 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 own `did:key`, computes
  the same tag and pulls + offline-verifies exactly their slice via
  `GET /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): an `action_intent` receipt (tool,
  args, `action_hash`) is written *before* the tool runs, and an `action_result` after.
  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:

1. `R.sig` verifies against the public key in `R.agent_id`.
2. `R.delegation` is a valid chain for `R.agent_id` (§2), at `R.ts`.
3. `R.target` is covered by the chain's effective scopes.
4. When verifying a sequence: `R.prev` equals 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)

```json
{
  "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-agent `prev` linkage 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 (for `prev`).
- `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:

```json
{
  "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 without `sig`.
  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 }` where `backend` is
  `evm` (an EIP-1559 transaction on Base / Base Sepolia whose calldata is the
  JCS bytes of the STH; `ref` = tx hash + block number) or `file` (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:

```json
{
  "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:

```json
{
  "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 `sig` fields 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 `type` field; `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 `prev`s 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/:agentDid` summarizes *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 with
  `feedbackURI` pointing 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"` and `target: "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.

```json
{
  "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_hash` with their `did:key`. Only the owner's
  key can produce it; `verifyConfirmation` checks the signature against `owner_id`.
- The deterministic runtime (`@zanii/runtime` / `zanii.runtime`), when configured with
  an `ownerDid`, **refuses to fire** an irreversible tool without a confirmation whose
  `owner_id` and `action_hash` match, and records the confirmation as its own
  `action_confirmation` receipt — 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.

```json
{
  "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)"
}
```

- `verifyCoSignature` requires a valid signature by `cosigner_id` **and** that
  `cosigner_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/compliance` `reconcile`) 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).
