Witness Service
The witness service is an independent intermediary, like a notary, that records audit events in a Merkle tree. Neither party in an INK handshake can retroactively rewrite their audit history without the witness detecting the inconsistency.
The witness sees tree hashes and event metadata but never message content. Ad Astra Computing operates two public instances: production at witness.tulpa.network (did:web:witness.tulpa.network) for real Tulpa-network agent traffic, and a public verify-against lane at witness-demo.tulpa.network (did:web:witness-demo.tulpa.network) on a resettable Merkle tree for tutorials and integration testing. The Quickstart below uses the demo so throwaway events stay out of the production log.
Non-normative reference implementation: Ad-Astra-Computing/witness. Live reference deployments operated by Ad Astra Computing: witness.tulpa.network (production) and witness-demo.tulpa.network (public verify-against lane). The protocol text on this page is authoritative; both the repository and the deployments are provided so implementers have source to inspect and a concrete endpoint to test against, neither is required for conformance.
For protocol-level details on third-party audit architecture, see Third-Party Audit.
Overview
Each INK agent maintains its own hash-chained audit log. These local chains are tamper-evident in isolation, but a malicious agent can maintain two different chains and show each counterparty a different history, a split-view attack.
The witness solves this by providing a single append-only Merkle tree that both parties submit to. Because the tree only grows and its checkpoints are public, any attempt to present divergent histories is detectable:
- If Agent A claims it sent a message but the witness has no corresponding event, the claim is unsupported.
- If Agent B’s view of the tree differs from Agent A’s at the same tree size, the witness has been compromised or one party is lying.
- If the tree shrinks or its root hash changes for a given size, the witness itself is misbehaving.
Architecture
Endpoints
The witness exposes six endpoints. Authenticated routes use INK-Ed25519 transport auth per S3.3.
These are distinct from the bilateral Audit Trail endpoint POST /ink/v1/audit that two agents use to exchange audit records directly. Witness submission and query are a separate /audit/submit and /audit/query namespace because a witness is a third party with different auth, deduplication and Merkle semantics.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /ink/v1/audit/submit | INK-Ed25519 | Submit an audit event |
POST | /ink/v1/audit/query | INK-Ed25519 | Query events by messageId |
GET | /ink/v1/checkpoint | Public | Current tree size and root hash |
GET | /ink/v1/leaves | Public | Enumerate leaf hashes for tree verification |
GET | /ink/v1/consistency | Public | RFC 6962 consistency proof between two tree sizes |
GET | /.well-known/did.json | Public | Witness DID document |
GET | /health | Public | Health check |
POST /ink/v1/audit/submit
Submit a signed audit event for inclusion in the Merkle tree.
Request:
{ "protocol": "ink/0.1", "type": "network.tulpa.audit_submit", "from": "tulpa:z6Mk...", "to": "did:web:witness.tulpa.network", "event": { "id": "01JEXAMPLE0001", "version": "ink-audit/1", "agentId": "tulpa:z6Mk...", "agentSignature": "<base64url Ed25519 signature>", "sequence": 42, "previousEventHash": "a1b2c3...", "eventType": "message.sent", "timestamp": "2026-03-19T12:00:00Z", "messageId": "msg-abc-123", "counterpartyId": "tulpa:z6Mk..." }, "nonce": "<base64url random>", "timestamp": "2026-03-19T12:00:00Z"}Response (200):
{ "protocol": "ink/0.1", "type": "network.tulpa.audit_inclusion", "eventId": "01JEXAMPLE0001", "treeSize": 48291, "leafIndex": 48290, "rootHash": "e3b0c44298fc1c149afbf4c8996fb924...", "inclusionProof": ["<hex-sibling-hash>", "<hex-sibling-hash>"], "timestamp": "2026-03-19T12:00:01Z", "serviceSignature": "<base64url Ed25519, see canonical format below>"}Canonical signature format. serviceSignature is an Ed25519 signature over the bytes:
"ink/audit-inclusion/v1\n" || JCS({eventId, leafIndex, treeSize, rootHash, timestamp})where JCS is the RFC 8785 canonical JSON serialization of the inclusion-receipt object with all top-level fields except serviceSignature and inclusionProof. Verifiers reconstruct the signed bytes from the receipt and verify against the witness’s published Ed25519 public key (e.g. from /.well-known/did.json).
See Quickstart below for an end-to-end walkthrough that submits an event and verifies the receipt with both the CLI and the library API.
Error responses:
| Status | Error | Cause |
|---|---|---|
| 400 | Invalid JSON | Malformed request body |
| 400 | Invalid submit body | Schema validation failure |
| 400 | Invalid agent ID format | Cannot extract public key from agentId |
| 400 | Invalid agent signature | Event signature does not verify |
| 401 | missing_authorization | No Authorization header |
| 401 | invalid_auth_scheme | Not INK-Ed25519 scheme |
| 401 | nonce_replay | Nonce already used (10-minute window) |
| 401 | Transport sender does not match body.from | Auth identity mismatch |
| 409 | Duplicate event ID | Event with this ID already recorded |
POST /ink/v1/audit/query
Query witnessed events for a specific messageId. Only direct parties to the message (an event’s own agentId or counterpartyId) can query.
Request:
{ "protocol": "ink/0.1", "type": "network.tulpa.audit_query", "from": "tulpa:z6Mk...", "to": "did:web:witness.tulpa.network", "messageId": "msg-abc-123", "nonce": "<base64url random>", "timestamp": "2026-03-19T12:05:00Z"}Response (200): the witness returns a signed network.tulpa.audit_query_response envelope. Every visible event is paired with a per-event Merkle inclusion proof against the witness’s (treeSize, rootHash) at response time. requester and serviceDid are bound inside the signature so a response signed for Alice cannot be replayed to Bob.
{ "protocol": "ink/0.1", "type": "network.tulpa.audit_query_response", "serviceDid": "did:web:witness.tulpa.network", "messageId": "msg-abc-123", "requester": "tulpa:z6Mk...", "events": [ { "id": "01JEXAMPLE0001", "version": "ink-audit/1", "agentId": "tulpa:z6Mk...", "agentSignature": "<base64url>", "sequence": 42, "previousEventHash": "a1b2c3...", "eventType": "message.sent", "timestamp": "2026-03-19T12:00:00Z", "messageId": "msg-abc-123", "counterpartyId": "tulpa:z6Mk..." } ], "proofs": [ { "eventId": "01JEXAMPLE0001", "leafIndex": 48290, "inclusionProof": ["<hash>", "<hash>", "..."] } ], "treeSize": 48291, "rootHash": "<64 hex>", "timestamp": "2026-03-19T12:05:01Z", "serviceSignature": "<base64url Ed25519>"}serviceSignature is Ed25519 over "ink/audit-query-response/v1\n" + JCS(payload-without-serviceSignature). Use @adastracomputing/ink ≥ 0.1.0-alpha.3:
import { verifyAuditQueryResponse, verifyAuditEventSignature, fetchAgentCard, extractCandidateKeys, hexToBytes,} from "@adastracomputing/ink";
const { valid, steps } = await verifyAuditQueryResponse({ response, witnessPublicKey, // 32-byte Ed25519, resolved from /.well-known/did.json expectedRequester: localRequesterDid, expectedMessageId: "msg-abc-123", expectedServiceDid: "did:web:witness.tulpa.network", verifyEventSignature: async (event) => { const card = await fetchAgentCard(event.agentId); const candidates = extractCandidateKeys(card); for (const c of candidates) { if (await verifyAuditEventSignature(event, hexToBytes(c.publicKeyHex))) return true; } return false; },});verifyEventSignature is REQUIRED. Without it the verifier refuses to return valid, because Merkle inclusion alone does not prove an agent produced the event (see Trust Model). The verifier also enforces envelope shape, requester binding (cross-requester replay defense), events/proofs strict one-to-one alignment, per-event scope (each event.messageId must equal envelope messageId; requester must be event.agentId or event.counterpartyId), walks every Merkle proof via computeAuditMerkleLeafHash(event) up to rootHash, and supports an optional laterCheckpoint cross-check.
Per-event Merkle leaves are hashed per RFC 6962 §2.1: SHA-256(0x00 || JCS(event-without-agentSignature)). The library exposes this as computeAuditMerkleLeafHash. It is distinct from computeEventHash, which omits the 0x00 prefix and is used only for previousEventHash chain linkage.
Truncation. If the requester’s visible event set for a messageId exceeds the witness’s cap, the witness fails closed with HTTP 413 rather than silently sign a partial response. A signed response is, by definition, a complete enumeration of the requester’s visible events at (treeSize, rootHash).
Empty-log responses. A fresh witness with no submissions reports treeSize: 0 and rootHash equal to SHA-256 of the empty string (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855), with empty events and proofs. Verifiers reject any treeSize: 0 response that deviates from this shape.
Error responses:
| Status | Error | Cause |
|---|---|---|
| 400 | messageId is required | Missing messageId field |
| 400 | nonce is required | Missing nonce field |
| 401 | nonce_replay | Nonce already used |
| 403 | Forbidden | Requester is not a party to this messageId |
| 413 | Query result exceeds maximum allowed events; refine messageId scope | Visible set above cap, fail closed |
| 500 | Integrity error | Storage corruption detected (column/event_json drift, hash mismatch, missing Merkle node). Never silently signs. |
GET /ink/v1/checkpoint
Returns the current tree state as a signed checkpoint in plaintext. No authentication required.
Response (200, text/plain):
witness.tulpa.network48291e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
-- witness.tulpa.network <base64url Ed25519 signature>Format: origin on line 1, tree size on line 2, root hash (SHA-256 hex) on line 3, then a blank line and one or more -- <origin> <signature> lines. The Ed25519 signature covers the three body lines (origin\ntreeSize\nrootHash, no trailing newline).
Verify the signature before trusting the values. The library helper verifyCheckpoint(signed, witnessPublicKey, expectedOrigin) checks the witness signature and binds the log origin, returning the parsed { origin, treeSize, rootHash } or null. An unverified checkpoint body is attacker-controllable, so any rollback or fork cross-check built on it provides no security.
GET /ink/v1/leaves
Enumerate leaf hashes for independent tree verification. No authentication required. Returns only the SHA-256 hash and index for each leaf, no event content is exposed.
Query parameters:
| Parameter | Default | Description |
|---|---|---|
start | 0 | First leaf index to return |
count | 100 | Number of leaves to return (max 1000) |
Response (200):
{ "treeSize": 48291, "start": 0, "count": 100, "leaves": [ { "index": 0, "hash": "a1b2c3d4..." }, { "index": 1, "hash": "e5f6a7b8..." } ]}Each leaf hash is SHA-256(0x00 || JCS(event)) per RFC 6962 domain separation. An auditor can enumerate all leaves, rebuild the Merkle tree locally and verify the computed root matches the public checkpoint.
GET /ink/v1/consistency
Returns an RFC 6962 Section 2.1.2 consistency proof between two tree sizes. No authentication required.
Query parameters:
| Parameter | Description |
|---|---|
first | Earlier tree size. Non-negative integer, <= second. |
second | Later tree size. Non-negative integer, <= current tree size. |
Response (200, application/json):
{ "first": 10, "second": 20, "proof": ["<sha256-hex>", "..."]}The proof is the list of subtree hashes a verifier needs to confirm the tree of first leaves is an append-only prefix of the tree of second leaves. Pair it with first and second roots taken from signature-verified checkpoints and check it with verifyConsistencyProof. The witness returns 400 for non-integer or out-of-range sizes. This lets a holder of an earlier checkpoint verify the later checkpoint is append-only with respect to the earlier one, not merely that the tree size grew.
GET /ink/v1/agents/:agentId/audit-summary
Public reputation read for transparency-log consumers. Returns coarse aggregated counts only — no event content, no relationship details, no proofs. No authentication required, but rate-limited per-IP plus a global cap to push back on bulk scraping.
Path parameters:
| Parameter | Description |
|---|---|
agentId | URL-encoded agent identifier. Max 256 chars, character set [A-Za-z0-9_:.\-]. |
Response (200):
{ "schemaVersion": "ink.witness.agent-summary.v1", "agentId": "did:web:example.com", "highRiskCount": 0, "acceptedCount": 12, "totalEvents": 12, "knownSince": "2026-03-14T08:42:11.000Z"}knownSince is the timestamp of the earliest known event for the agent, or null if the agent is unknown. highRiskCount aggregates events typed as risk indicators by submitters; acceptedCount aggregates successful exchanges. Counts are eventually consistent with the underlying log.
Response is Cache-Control: public, max-age=60, s-maxage=60 on success so repeat lookups for the same agent absorb at the CDN edge rather than incrementing the rate limit. On rate-limit (429) the Retry-After: 60 header is set.
Errors:
| Status | Code | When |
|---|---|---|
| 400 | invalid_agent_id | agentId missing, too long, or contains disallowed characters |
| 429 | rate_limit_exceeded | Per-IP or global per-minute cap hit |
GET /.well-known/did.json
Returns the witness DID document. No authentication required.
Response (200):
{ "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/ed25519-2020/v1" ], "id": "did:web:witness.tulpa.network", "verificationMethod": [ { "id": "did:web:witness.tulpa.network#witness-key", "type": "Ed25519VerificationKey2020", "controller": "did:web:witness.tulpa.network", "publicKeyMultibase": "z6Mk..." } ], "authentication": ["did:web:witness.tulpa.network#witness-key"], "assertionMethod": ["did:web:witness.tulpa.network#witness-key"]}GET /health
Response (200):
{ "status": "ok", "service": "did:web:witness.tulpa.network", "time": "2026-05-31T03:21:58.328Z", "log": { "treeSize": 48291, "rootHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }}service is the witness DID. log.treeSize and log.rootHash mirror the signed checkpoint so an operator can sanity-check the witness without hitting a separate endpoint.
Quickstart
End-to-end: generate a keypair, submit one signed audit event, save the response as receipt.json and verify it. Five minutes from npm install to a verifiable receipt.
Prerequisites: Node 24+ and @adastracomputing/ink installed.
mkdir witness-quickstart && cd witness-quickstartnpm init -y && npm install @adastracomputing/inkNote: the examples below submit to
https://witness-demo.tulpa.network, the public demo lane operated by Ad Astra Computing. It runs the same code as the production witness (Ad-Astra-Computing/witness) on a separate Durable Object with its own Merkle tree. Throwaway quickstart traffic stays out of the production tree atwitness.tulpa.network. The demo tree may be reset periodically.
1. Submit an event
Save as submit.mjs and run with node submit.mjs > receipt.json:
import { generateKeypair, deriveAgentId, signAuditEvent, signInkMessage, buildAuthHeader,} from "@adastracomputing/ink";
const WITNESS = "https://witness-demo.tulpa.network";const WITNESS_DID = "did:web:witness-demo.tulpa.network";
// Throwaway identity for this run. Real agents persist their keypair.const me = await generateKeypair();const myId = deriveAgentId(me.publicKey);
// Counterparty: derive a second throwaway agent so the event has a// well-formed counterpartyId (the witness validates this field).const peer = await generateKeypair();const peerId = deriveAgentId(peer.publicKey);
// Build the audit event. First event for a brand-new agent has// sequence: 1 and previousEventHash: null.const event = { id: crypto.randomUUID(), version: "ink-audit/1", agentId: myId, agentSignature: "", // filled in below sequence: 1, previousEventHash: null, eventType: "message.sent", timestamp: new Date().toISOString(), messageId: "test-" + crypto.randomUUID(), counterpartyId: peerId,};event.agentSignature = await signAuditEvent(event, me.privateKey);
// Wrap in the INK transport envelope.const body = { protocol: "ink/0.1", type: "network.tulpa.audit_submit", from: myId, to: WITNESS_DID, event, nonce: crypto.randomUUID().replace(/-/g, ""), timestamp: new Date().toISOString(),};
// Sign the transport. Note this is a distinct signature from// event.agentSignature: it authenticates the HTTP request itself.const transportSig = await signInkMessage({ method: "POST", path: "/ink/v1/audit/submit", // path, not full URL recipientDid: WITNESS_DID, body, timestamp: body.timestamp,}, me.privateKey);
const res = await fetch(`${WITNESS}/ink/v1/audit/submit`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: buildAuthHeader(transportSig), }, body: JSON.stringify(body),});if (!res.ok) { console.error(res.status, await res.text()); process.exit(1);}process.stdout.write(JSON.stringify(await res.json(), null, 2));The witness’s response is the receipt. It commits the witness to a specific (leafIndex, treeSize, rootHash) for your event, signed under the witness’s published Ed25519 key.
2. Verify the receipt
Verify with the bundled CLI:
npx @adastracomputing/ink verify-inclusion \ --file receipt.json \ --witness https://witness-demo.tulpa.network# Exit 0 = valid, 1 = invalid, 2 = usage / network errorThe CLI fetches the witness’s DID document and verifies the witness signature on the receipt. Pass --event-hash <hex> to also walk the inclusion proof up to the claimed root. Pass --origin <witness-origin> to enable the checkpoint cross-check: the CLI fetches the current checkpoint, verifies its Ed25519 signature against the witness key, binds it to that origin and then confirms the tree only grew (no rewind, no fork at the same treeSize). When the checkpoint is newer than the receipt, the CLI also fetches an RFC 6962 consistency proof and verifies the receipt’s tree is an append-only prefix of the checkpoint, catching a witness that forked its history. A witness that serves no consistency proof has that step reported as skipped, not passed. Without --origin the cross-check is skipped rather than trusting an unverified checkpoint body.
In code, the same checks are available as library helpers: verifyCheckpoint (checkpoint signature + origin), verifyReceipt (binds a delivery receipt to the message it acknowledges: issuer key, from/to/messageId, recomputed message hash, optional disposition), verifyInclusionReceipt, which accepts an event option that recomputes the leaf hash and binds it to receipt.eventId (prefer it over the legacy unbound eventHash), and verifyConsistencyProof(first, firstRoot, second, secondRoot, proof), which verifies the tree of first leaves is an append-only prefix of the tree of second leaves.
No npm project? You can run the same check through Nix without installing the package:
nix run github:Ad-Astra-Computing/ink -- verify-inclusion \ --file receipt.json \ --witness https://witness-demo.tulpa.networkOr call the library API directly (useful inside an integrator’s own verification pipeline):
import { verifyInclusionReceipt } from "@adastracomputing/ink";
const result = await verifyInclusionReceipt({ receipt, witnessPublicKey, // Uint8Array, 32 bytes; fetch from /.well-known/did.json eventHash, // optional, re-walks the Merkle proof laterCheckpoint, // optional, cross-checks tree-grew + no-fork});if (!result.valid) { for (const step of result.steps) console.log(step.name, step.pass, step.detail);}Gotchas
- The
/audit/submitresponse is the receipt. Save it directly. - Two signatures:
event.agentSignatureproves the agent authored the event; theAuthorization: INK-Ed25519 ...header authenticates the HTTP transport envelope. They sign different bytes. - The transport signature uses the path (
/ink/v1/audit/submit), not the full URL. body.frommust equal the agentId derived from the signing key. The witness rejects mismatches.- Timestamps must be current. Replay protection allows roughly a five-minute window (see §3.5).
- The
noncemust be unique at the witness during the replay window (the witness caches each nonce it has seen and rejects duplicates regardless of which agent submitted them). The UUID-hex form in the quickstart is fine for demos; production submitters should use 32 random bytes base64url-encoded. - The first event for a brand-new agent must be
sequence: 1withpreviousEventHash: null. Subsequent events continue the per-agent hash chain (usecomputeEventHashon the previous event). - Reusing an
event.idreturns409 Duplicate event ID. - Don’t mint a fresh keypair per submission in a loop. The witness enforces per-IP and per-CIDR rate limits in addition to the per-agent cap; looping with new keypairs from the same client will trip the IP/CIDR ceiling fast. Real agents persist a single keypair across runs and continue the chain at the next
sequence(usecomputeEventHashon the previous event to derive the nextpreviousEventHash). For a one-shot demo, generating a fresh keypair once is fine.
Submit Flow
Per-agent chain continuity
The witness enforces hash-chain continuity per agent. Each submitted event must either:
- be the agent’s first event (
sequence: 1,previousEventHash: null), or - match the agent’s current chain head (
sequence = head.sequence + 1ANDpreviousEventHash == head.event_hash).
A non-contiguous sequence or a mismatched previousEventHash returns 409 Conflict. A first-event submission with the wrong shape returns 400 Bad Request. This prevents an agent from injecting events into a counterparty’s chain or rewriting its own history past the head.
Verify-then-commit nonce ordering
The nonce is peeked (read-only check) before signature verification, then committed (atomic check-and-store) only after every signature on the submission has verified. This prevents a holder of valid transport credentials from burning chosen nonces by submitting garbage event payloads.
Verification
Inclusion Proofs
The witness Merkle tree follows the RFC 6962 algorithm. For non-power-of-two tree sizes, the tree splits at the largest power of 2 less than the current size.
To verify an inclusion proof from a submit-time receipt:
- Fetch the current checkpoint from
GET /ink/v1/checkpoint. - Take the
leafIndexandtreeSizefrom your stored inclusion receipt. - Recompute the root hash by walking the proof path from your leaf hash up to the root.
- Compare the computed root against the checkpoint’s root hash, they must match.
To verify an inclusion proof from an /audit/query response, call verifyAuditQueryResponse from @adastracomputing/ink (see the POST /ink/v1/audit/query section). It walks every event’s proof against the response’s own (treeSize, rootHash) and runs your verifyEventSignature callback against each event’s agentSignature so witness Merkle validity does not stand in for agent provenance.
The Merkle tree supports static verification: given a leaf hash, the proof path and the expected root hash, any party can independently verify inclusion without contacting the witness.
Consistency Between Checkpoints
The tree is append-only. If you observe two checkpoints at different times:
treeSizemust never decrease.- The root hash at
treeSize=Nmust remain stable, if the tree grows totreeSize=M, the subtree covering the first N leaves must still hash to the same value.
A checkpoint that violates either property indicates witness misbehavior.
Full Tree Verification
Any party can independently reconstruct the entire Merkle tree and verify the checkpoint:
- Fetch the current checkpoint from
GET /ink/v1/checkpointto get the tree size and expected root hash. - Enumerate all leaf hashes via
GET /ink/v1/leaves?start=0&count=1000, paginating until all leaves are retrieved. - Rebuild the Merkle tree locally using the RFC 6962 algorithm (split at largest power of 2 less than size).
- Compare the locally computed root hash against the checkpoint, they must match.
This allows third-party auditors to verify the witness is not omitting or reordering events without needing access to event content. The leaf hashes are domain-separated (SHA-256(0x00 || event)) so they cannot be confused with internal node hashes.
Cross-Agent Verification
For a given messageId, both parties’ events should appear in the witness:
- Agent A queries
POST /ink/v1/audit/querywith the messageId. - The response includes events from both Agent A and Agent B (assuming both submitted).
- If only one side’s events appear, the other party either did not submit or submitted with a different messageId.
This is the core split-view detection mechanism: both agents can independently verify that the witness holds a consistent view of the interaction.
Trust Model
The witness is a semi-trusted intermediary. Understanding what it can and cannot do is critical.
What the witness CAN do
- Prove an event existed at a point in time. The signed inclusion receipt ties an event ID to a specific tree position and timestamp.
- Detect split-view attacks. Both parties submit to the same tree, so divergent histories become visible.
- Provide public checkpoints. Anyone can verify the tree is append-only without authentication.
- Enable full tree auditing. The public leaf hash endpoint lets any third party reconstruct the Merkle tree and verify its integrity without accessing event content.
What the witness CANNOT do
- Read message content. Events contain metadata (type, messageId, agentId) but never message bodies or payloads.
- Forge events that verifiers accept. A witness could in principle commit a fabricated event_json into its Merkle tree and produce a valid inclusion proof, but verifiers re-check
event.agentSignatureagainst the agent’s published keys (Auditability §7.3 / §7.5).verifyAuditQueryResponserequires the caller to pass averifyEventSignaturecallback for exactly this reason, and refuses to return valid without it. Verifiers that walk Merkle proofs without checkingagentSignaturelose this guarantee. - Selectively omit events without detection. Once an event is included and a signed receipt is returned, removing it would change the root hash. Any party holding a prior checkpoint can detect the inconsistency. The
/audit/queryresponse bindsrequesterand the witness fails closed with HTTP 413 on truncation, so a partial response cannot masquerade as a complete one.
Compromise scenario
A compromised witness can refuse service (availability failure) but cannot forge history. If it stops accepting events, agents fall back to bilateral audit exchange. If it attempts to rewrite the tree, any client holding a prior checkpoint will detect the root hash mismatch.
For high-stakes interactions, agents MAY submit to multiple independent witness services.
Privacy boundary: public read endpoints leak relationship metadata
GET /ink/v1/checkpoint and GET /ink/v1/leaves are deliberately unauthenticated. Public read access is what makes the log independently verifiable, any third party can reconstruct the Merkle tree, walk inclusion proofs, and confirm consistency across checkpoints without trusting the operator.
The cost of that property is that everything visible in those endpoints is visible to everyone:
- Leaves expose agent identifiers. Each leaf is the hash of an event whose preimage includes
agentIdandcounterpartyId. The leaf hashes themselves don’t reveal those values, but the public/leavesenumeration combined with any side-channel knowledge of an event preimage (e.g. an agent publishing its own audit records) lets an observer link leaves to identities. - Submission timing is observable. New leaves appear in
/leavesin order of submission. An observer can tell when a given agent submitted events, and combined with public submission patterns, infer who is talking to whom and how often. - Counterparty graphs are reconstructable. If an agent publishes its own audit log (a common pattern for auditable services), the corresponding witness leaves reveal the agent’s full counterparty graph over time.
Agents that need traffic-pattern privacy should:
- Pad submissions to constant cadence rather than per-event.
- Submit to multiple witnesses with random selection per event (graph fragmentation).
- Treat the audit log as semi-public metadata and not depend on the witness for any confidentiality property. Witness gives you non-repudiation, not unlinkability.
This is fundamental to the design and applies to any append-only transparency log. It is recorded here so integrators don’t assume the witness provides privacy it cannot.
Security Properties
Transport Authentication
All mutating endpoints require INK-Ed25519 transport auth (S3.3). The signature base covers:
ink/0.1\nMETHOD\nPATH\nrecipientDid\nJCS(body)\ntimestampThe witness verifies the transport signature, then confirms the from field in the body matches the authenticated sender identity.
Key Rotation Support
The witness resolves signing keys through a two-tier strategy:
- Agent card fetch. Queries the agent’s published key set via the resolver configured in the witness deployment (typically the agent’s Agent Card endpoint). Both active and retired keys are tried per the authority rule.
- Bootstrap key. Extracts the Ed25519 public key embedded in the
tulpa:z6Mk...agent ID. Only used when no key set exists (agent has never rotated keys).
If an agent has rotated keys (a key set is found), the bootstrap key is not trusted — a compromised bootstrap key cannot bypass rotation.
Agent card responses are cached in memory with a short TTL to balance freshness and performance.
Nonce Replay Protection
Both submit and query require a fresh nonce. Nonces are tracked with a 10-minute TTL. Expired nonces are pruned periodically.
Timestamp freshness is also enforced: messages must be within 5 minutes of the witness clock (30 seconds of future drift allowed).
Private Key Protection
The witness Ed25519 private key is encrypted at rest using AES-256-GCM. Legacy plaintext keys are automatically re-encrypted on first access.
Event Deduplication
Submitting an event with a previously-seen id returns 409 Duplicate event ID. This prevents phantom Merkle leaves, each event ID maps to exactly one leaf in the tree.
Merkle Tree Implementation
The tree uses SHA-256 with RFC 6962 domain separation. Leaf hashes are SHA-256(0x00 || event_data) and internal node hashes are SHA-256(0x01 || left || right). This prevents second preimage attacks where a crafted leaf could be confused with an internal node.
For non-power-of-two sizes, the tree uses the RFC 6962 algorithm: recursively split at the largest power of 2 less than the current size. Complete power-of-two subtrees are cached since they are stable in an append-only tree.
The tree is backed by persistent storage with single-writer semantics and strong consistency.