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. It runs at witness.tulpa.network and identifies as did:web:witness.tulpa.network.
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.
| 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 | /.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...", "timestamp": "2026-03-19T12:00:01Z", "serviceSignature": "<base64url Ed25519 over eventId:treeSize:rootHash:timestamp>"}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 parties to the message (agent or counterparty) 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):
{ "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..." } ]}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 |
GET /ink/v1/checkpoint
Returns the current tree state in plaintext. No authentication required.
Response (200, text/plain):
witness.tulpa.network48291e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Format: origin on line 1, tree size on line 2, root hash (SHA-256 hex) on line 3.
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 /.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": "tulpa-witness"}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:
- 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.
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. Each event carries the agent’s Ed25519 signature. The witness cannot produce valid signatures for agents it does not control.
- 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.
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 three-tier strategy:
- Agent card fetch — queries the agent’s published key set from
api.tulpa.network, supporting active and retired keys. - 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.