Skip to content

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

Diagram

Endpoints

The witness exposes six endpoints. Authenticated routes use INK-Ed25519 transport auth per S3.3.

MethodPathAuthPurpose
POST/ink/v1/audit/submitINK-Ed25519Submit an audit event
POST/ink/v1/audit/queryINK-Ed25519Query events by messageId
GET/ink/v1/checkpointPublicCurrent tree size and root hash
GET/ink/v1/leavesPublicEnumerate leaf hashes for tree verification
GET/.well-known/did.jsonPublicWitness DID document
GET/healthPublicHealth 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:

StatusErrorCause
400Invalid JSONMalformed request body
400Invalid submit bodySchema validation failure
400Invalid agent ID formatCannot extract public key from agentId
400Invalid agent signatureEvent signature does not verify
401missing_authorizationNo Authorization header
401invalid_auth_schemeNot INK-Ed25519 scheme
401nonce_replayNonce already used (10-minute window)
401Transport sender does not match body.fromAuth identity mismatch
409Duplicate event IDEvent 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:

StatusErrorCause
400messageId is requiredMissing messageId field
400nonce is requiredMissing nonce field
401nonce_replayNonce already used
403ForbiddenRequester 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.network
48291
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

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

ParameterDefaultDescription
start0First leaf index to return
count100Number 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

Diagram

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 + 1 AND previousEventHash == 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:

  1. Fetch the current checkpoint from GET /ink/v1/checkpoint.
  2. Take the leafIndex and treeSize from your stored inclusion receipt.
  3. Recompute the root hash by walking the proof path from your leaf hash up to the root.
  4. 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:

  • treeSize must never decrease.
  • The root hash at treeSize=N must remain stable — if the tree grows to treeSize=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:

  1. Fetch the current checkpoint from GET /ink/v1/checkpoint to get the tree size and expected root hash.
  2. Enumerate all leaf hashes via GET /ink/v1/leaves?start=0&count=1000, paginating until all leaves are retrieved.
  3. Rebuild the Merkle tree locally using the RFC 6962 algorithm (split at largest power of 2 less than size).
  4. 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:

  1. Agent A queries POST /ink/v1/audit/query with the messageId.
  2. The response includes events from both Agent A and Agent B (assuming both submitted).
  3. 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 agentId and counterpartyId. The leaf hashes themselves don’t reveal those values, but the public /leaves enumeration 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 /leaves in 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)\ntimestamp

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

  1. Agent card fetch — queries the agent’s published key set from api.tulpa.network, supporting active and retired keys.
  2. 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.

Diagram