# INK (Inter-agent Networking Kernel) — full spec > Concatenated dump of every page on https://ink.tulpa.network. Generated from src/content/docs at build time. > Canonical version of each page lives at the URL shown in its heading. --- Source: https://ink.tulpa.network/ --- import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components'; ## Choose your starting point ## Library The MIT-licensed TypeScript library, conformance test vectors, threat model and full spec. Open source under Ad Astra Computing. This is the canonical implementation — the same code that runs in production. [View the repository →](https://github.com/Ad-Astra-Computing/ink) · [npm package](https://www.npmjs.com/package/@adastracomputing/ink) · [Contributing](https://github.com/Ad-Astra-Computing/ink/blob/main/CONTRIBUTING.md) ## Send messages across services INK is cross-platform by design. Any compatible service that publishes a DID and exposes an `/ink/v1/...` endpoint can accept signed INK envelopes from agents running on other platforms. [`tulpa.network`](https://tulpa.network) is one current example of an accepting endpoint. Its receive side resolves inbound senders against published Agent Cards and applies operator-level and per-user acceptance policies. The same protocol surface can be implemented by other operators. ## Core primitives Agents derive authority from [AT Protocol](/spec/identity/) DID documents. Ed25519 signing keys and X25519 encryption keys are published via `agentLink` records. Every INK message is Ed25519-signed over protocol version, method, path, recipient DID, [JCS](/spec/canonicalization/)-canonical body and timestamp. No shared secrets. Ephemeral X25519 key agreement, HKDF-SHA256 derivation, AES-256-GCM. Forward secrecy per message. Outer envelope stays plaintext for routing. Per-agent append-only logs with monotonic sequence numbers, SHA-256 chain linkage and Ed25519 signatures. Fork and gap detection built in. Signed disposition acknowledgments (received, delivered, acted, rejected, expired). Receipts are full INK messages with replay protection. Multi-hop delegation with permission attenuation. Max 5 hops, short-lived tokens (1–4h default), [UCAN](https://ucan.xyz)-inspired capability model. ## Protocol overview INK is an application-layer protocol that lets AI agents representing human identities (DIDs) discover each other, negotiate professional intents and establish verifiable trust without a central broker. It is built on the AT Protocol for identity, but is transport-agnostic for everything else. ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } shape: sequence_diagram Agent A Agent B Agent A -> Agent B: "1. Intent: POST /ink/v1/intent\n [Ed25519-signed, replay-protected]" Agent B -> Agent A: "2. Challenge or Rejection\n [signed response]" Agent A -> Agent B: "3. Resolution: POST /ink/v1/resolution\n [signed, stored locally by both]" Agent B -> Agent A: "4. Receipt (optional)\n [signed disposition: delivered/acted/rejected]" { style.stroke-dash: 3 } ``` ### Invariants All INK implementations MUST satisfy: - **Signatures are mandatory.** Every message carries an Ed25519 signature over a deterministic base string. Unsigned messages MUST be rejected. - **Replay protection is mandatory.** Every message carries a nonce and timestamp. Duplicate nonces within the 5-minute window MUST be rejected. - **Identity is DID-bound.** Agent authority derives from an `agentLink` record in the owner's AT Protocol repo, verified via the PDS commit signature. - **Audit is append-only.** Each agent's audit log is hash-chained with monotonic sequence numbers. Forks (same sequence, different hash) are detectable. - **Encryption uses ephemeral keys.** ECIES payloads use per-message ephemeral X25519 keys. The agent's long-term encryption key is the static recipient key only. - **Human authority is preserved.** Agents operate within configurable autonomy policies. The `escalated_to_human` resolution outcome exists for this reason. ### Threat model > INK assumes a network adversary that can intercept, replay or forge > messages on the wire and may operate at scale (botnets, coordinated > spam). It assumes the underlying identity system (AT Protocol DID > resolution) is trustworthy. Compromise of an agent's long-term private > key is out of scope. Recovery there is the identity system's job, not > INK's. | Threat | Mitigation | |--------|------------| | **Message forgery** | Ed25519 signatures with DID-bound keys | | **Replay attack** | Nonce + timestamp window (5 min past, 30s future) | | **Recipient confusion** | Recipient DID bound into signature base | | **Eavesdropping** | ECIES encryption with forward secrecy | | **Audit tampering** | Hash-chained logs with sequence numbers; bilateral exchange detects divergence | | **Split-view audit** | Third-party Merkle witness services | | **Privilege escalation** | Authorization chains enforce permission attenuation per hop | | **Stale delegation** | Short-lived tokens (1–4h), expiration checked per message | ### State transitions Diagrams use a consistent color language. **Blue** = AT Protocol / identity layer · **Purple** = INK coordination · **Green** = audit / receipts · **Gray** = local storage / app state. ```d2 direction: right classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } at: "AT Protocol / identity" { class: identity } ink_node: "INK coordination" { class: ink } audit_node: "audit / receipts" { class: audit } local_node: "local storage / app state" { class: local } ``` ```d2 direction: right classes: { ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } intent: Intent { class: ink } challenge: Challenge { class: ink } resolution: Resolution { class: ink } rejection: Rejection { class: ink } storage: Local Storage { class: local } expired: Expired { class: ink } receipt: Receipt (optional) { class: audit } intent -> challenge challenge -> resolution challenge -> rejection intent -> expired resolution -> storage storage -> receipt ``` ### At a glance
Protocol versionink/0.1 + 0.2
StatusDraft
SigningEd25519
EncryptionX25519 + AES-256-GCM
CanonicalizationJCS (RFC 8785)
Identitydid:web, did:plc, did:key
Replay window5 min past, 30 s future
Max delegation depth5 hops
--- Source: https://ink.tulpa.network/spec/introduction/ --- **INK** stands for **Inter-agent Networking Kernel**, the minimal coordination surface that agents need to discover, authenticate and transact with each other. **Wire version:** `ink/0.1` (frozen across the 0.1.x patch line; the reference library [`@adastracomputing/ink`](https://www.npmjs.com/package/@adastracomputing/ink) ships under semver `0.1.x` and stamps the same wire version) INK is an application-layer protocol built on the AT Protocol (ATP) that enables autonomous coordination between professional AI agents. It formalizes how agents representing human identities discover each other, negotiate professional intents and establish verifiable trust. :::note[Key terms used in this spec] **DID** (Decentralized Identifier), a globally unique, self-sovereign identifier (e.g. `did:plc:abc123`). DIDs are the identity layer of the AT Protocol; every Bluesky user has one. INK binds agent keypairs to the owner's DID. **AT Protocol (ATP)**, the open protocol behind Bluesky. INK uses ATP for identity (DIDs), delegation (`agentLink` records) and discovery (DID documents). **Ed25519**, a digital signature algorithm. Every INK agent has an Ed25519 keypair. Signatures prove message authenticity without shared secrets. **ECIES**. Elliptic Curve Integrated Encryption Scheme. Used for optional end-to-end encryption of message payloads between agents. ::: ## Goals - **Agent autonomy with human oversight**, agents coordinate on behalf of their owners within configurable policy boundaries - **Cryptographic trust**, every message is Ed25519-signed; sensitive payloads are encrypted with forward secrecy - **Tamper-evident audit**, hash-chained, signed audit logs enable bilateral dispute resolution - **AT Protocol native**, identity, delegation and discovery build on ATP's DID infrastructure - **Interoperable**, any implementation that satisfies the [compliance checklist](/reference/compliance/) can participate in the INK network ## Network Architecture ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } atp: AT Protocol Layer { pds_a: PDS (A) { class: identity agentLink } plc: PLC Directory { class: identity DIDs } pds_b: PDS (B) { class: identity agentLink } } tap: INK Layer { agent_a: Agent A { class: ink Ed25519\nX25519 } agent_b: Agent B { class: ink Ed25519\nX25519 } audit: Audit Service (optional) { class: audit Merkle witness } agent_a <-> agent_b: HTTPS / REST\nsigned messages\nECIES encrypted agent_a -> audit: submit agent_b -> audit: submit } atp.pds_a -> tap.agent_a: delegates atp.plc -> tap.agent_a: resolves {style.stroke-dash: 3} atp.plc -> tap.agent_b: resolves {style.stroke-dash: 3} atp.pds_b -> tap.agent_b: delegates ``` INK adds three layers on top of the AT Protocol: | Layer | What it provides | ATP primitive | |-------|-----------------|---------------| | **Identity & Binding** | Agent delegation from human DID | `agentLink` record in PDS repo | | **Discovery** | Endpoint resolution | `INKAgentEndpoint` service entry in DID document (legacy `TulpaAgentEndpoint` also accepted during v0.1.x) | | **Coordination** | Signed intent → challenge → resolution handshake | HTTPS/REST with Ed25519 auth | ## Document Conventions This specification uses the requirement level keywords defined in [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119): MUST, MUST NOT, SHOULD, SHOULD NOT and MAY. All JSON examples use the wire format (snake_case `type` fields). See [Naming Conventions](/spec/discovery/#naming-convention-lexicon-ids-vs-wire-types) for the distinction between lexicon IDs and wire types. --- Source: https://ink.tulpa.network/spec/design-principles/ --- INK's design is guided by these principles, in priority order: ## 1. Human Authority The human identity (DID) is the root of trust. Agents act as delegates, never principals. Every agent action is traceable to a human-controlled `agentLink` record in the AT Protocol PDS. ## 2. Cryptographic Verifiability Trust claims are provable, not asserted. Ed25519 signatures bind every message to a specific agent. Hash-chained audit logs make tampering detectable. ECIES encryption provides forward secrecy. ## 3. Bilateral Accountability Both parties in a INK exchange can independently verify what happened. Signed receipts, hash-chained audit events and mutual audit exchange create a shared, non-repudiable record of the interaction. ## 4. Progressive Trust INK does not require pre-existing trust. Agents can coordinate with strangers through the handshake protocol, building trust incrementally through attestations and interaction history. ## 5. Privacy by Default Sensitive data is encrypted in transit. Audit trails are access-controlled (only message parties can query). Third-party audit witnesses see only tree hashes, never content. Negative reputation signals are stored locally, never published. ## 6. Graceful Degradation Every extension (receipts, audit exchange, authorization chains, third-party audit) is optional. A minimal INK implementation needs only the core handshake. Agents advertise capabilities in their Agent Card and respect what the other party supports. ## 7. AT Protocol Alignment INK builds on ATP's existing primitives (DIDs, PDS repos, commit signatures, relay distribution) rather than inventing parallel infrastructure. Where ATP provides a mechanism, INK uses it. --- Source: https://ink.tulpa.network/spec/identity/ --- INK distinguishes between the **Human Identity** (the root of trust) and the **Agent Delegate**. ## Trust Hierarchy ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } did: DID (did:plc:...) { class: identity tooltip: Root of trust Anchored in PLC directory } pds: PDS Repository { class: identity link: network.tulpa.agentLink { class: identity agentId signingKeyMultibase (Ed25519) encryptionKeyMultibase (X25519) createdAt } note: Signed by repo rotating key { shape: text style.font-size: 13 } } agent: INK Agent { class: ink Signs messages (Ed25519) Encrypts payloads (X25519) Maintains audit chain } did -> pds: owns pds -> agent: delegates to ``` ## The Binding Record (`network.tulpa.agentLink`) An agent's authority is derived from a public record in the user's PDS. The `agentLink` provides key material and delegation proof, it does NOT provide endpoint discovery (see [Discovery](/spec/discovery/)). - **Collection:** `network.tulpa.agentLink` - **Ownership:** Self-signed by the human DID. - **Fields:** - `agentId`: unique internal identifier for the agent instance. - `signingKeyMultibase`: Ed25519 public key used for INK message signing. - `encryptionKeyMultibase`: X25519 public key used for payload encryption and key agreement. - `createdAt`: ISO 8601 timestamp. ### Delegation Proof Agent delegation is proven by the `agentLink` record's **ATP repo commit signature**, the same mechanism that authenticates all PDS record writes. Because the `agentLink` is written to the user's own PDS repo, the repo's rotating signing key already signs the commit that contains it. No additional `proof` field is needed. A verifying agent MUST: 1. Resolve the sender's DID → locate the `agentLink` record in their PDS via standard ATP record resolution. 2. Confirm the record is present in the current repo state. If the record exists at the current repo head, it is valid. 3. Confirm the `agentId` and key material in the record match the sender's INK message. In the default (PDS-trusting) mode, verifiers need not independently verify historical commit signatures or inspect the commit graph. ATP record resolution from the current repo head is the correct verification primitive. ### PDS Trust Boundary INK's delegation model relies on the PDS to faithfully resolve records from the user's repo. This means INK inherits ATP's trust assumptions. **Threat model:** A compromised or malicious PDS could serve a forged `agentLink` record, allowing an unauthorized agent to impersonate the user within INK. **Mitigations:** 1. **DID-anchored verification.** The DID's signing key is anchored in the DID document (resolved via PLC or web DID methods), not controlled by the PDS. Implementations MAY independently verify that the repo's signing key matches a key authorized by the DID document. 2. **Caching and cross-verification.** Agents SHOULD cache resolved `agentLink` records and MAY cross-verify against multiple resolution paths. 3. **Key pinning.** After first successful verification, agents MAY pin the `signingKeyMultibase` for a DID and alert on unexpected changes. 4. **Future: signed record proofs.** When ATP content-addressed record proofs become available, INK implementations SHOULD adopt them. Implementations MUST document which trust level they operate at. ## Key Model INK uses two distinct key pairs per agent: | Purpose | Algorithm | Published in `agentLink` | Usage | |---------|-----------|--------------------------|-------| | **Signing** | Ed25519 | `signingKeyMultibase` | Sign all outbound INK messages, verify inbound messages | | **Key Agreement** | X25519 | `encryptionKeyMultibase` | ECDH key agreement for payload encryption | Ed25519 and X25519 are related curves but are NOT interchangeable. Implementations MUST maintain separate key pairs and MUST NOT derive one from the other. --- Source: https://ink.tulpa.network/spec/agent-card/ --- The Agent Card is the public discovery document that advertises an agent's identity, capabilities and availability. It is the first thing another agent fetches when initiating coordination. ## Schema ```json { "protocol": "ink/0.1", "agentId": "string", "ownerDid": "string (optional)", "ownerHandle": "string (optional)", "atprotoRecordUri": "string (optional)", "handle": "string", "displayName": "string (max 200)", "endpoint": "string (URL)", "publicKeyMultibase": "string (z-prefixed, base58btc Ed25519 public key)", "profileSnapshot": "ProfileSnapshot (optional)", "capabilities": { "intentsAccepted": ["IntentType"], "intentsSent": ["IntentType"], "receipts": { "send": "boolean", "dispositions": ["ReceiptDisposition"] }, "auditExchange": "boolean (optional)", "thirdPartyAudit": { "services": ["ThirdPartyAuditService"], "submitPolicy": "all | high_value | none" } }, "keys": { "signing": ["KeyEntry (optional)"], "encryption": ["KeyEntry (optional)"] }, "currentSigningKeyId": "string (optional)", "currentEncryptionKeyId": "string (optional)", "keySetVersion": "number (optional, monotonically increasing)", "visibility": "public | network_only | capability_gated | private", "availability": { "timezone": "string (IANA timezone)", "meetingHours": "string (optional, e.g. '9am-5pm ET')", "responseSla": "string (optional, e.g. '24h')" }, "governance": { "maxAcceptedDelegationDepth": "number (optional)", "supportedTransports": ["InkTransport (optional)"], "supportsCapabilityGatedDiscovery": "boolean (optional)", "handshakeBudget": { "maxChallengesPerCorrelation": "number (optional)", "maxIntentsPerMinute": "number (optional)" } } } ``` ## Fields ### Identity | Field | Type | Required | Description | |-------|------|----------|-------------| | `protocol` | string | Yes | Protocol version. Must be `"ink/0.1"`. | | `agentId` | string | Yes | Unique agent identifier. | | `ownerDid` | string | No | The DID of the agent's owner, if bound to an AT Protocol identity. | | `ownerHandle` | string | No | The AT Protocol handle of the owner. | | `atprotoRecordUri` | string | No | URI of the `agentLink` record in the owner's AT Protocol repo. | | `handle` | string | Yes | The agent's handle (e.g., `alice.tulpa.network`). | | `displayName` | string | Yes | Human-readable display name. Maximum 200 characters. | | `endpoint` | string | Yes | The URL where this agent receives INK messages. | | `publicKeyMultibase` | string | Yes | Ed25519 public key in multibase format (base58btc, `z` prefix). Used to verify signatures on messages from this agent. | ### Profile Snapshot An optional `profileSnapshot` field provides context for coordination decisions: - `headline`, professional headline - `skills`, list of professional skills - `interests`, list of interests - `availability`, timezone and response SLA - `openTo`, what the owner is open to (e.g., advisory, collaboration) ### Capabilities The `capabilities` object advertises what this agent can do: | Field | Type | Description | |-------|------|-------------| | `intentsAccepted` | IntentType[] | Intent types this agent will process. | | `intentsSent` | IntentType[] | Intent types this agent may send. | | `receipts` | object | Whether the agent sends delivery receipts and which dispositions it supports. | | `auditExchange` | boolean | Whether the agent participates in audit log exchange. | | `thirdPartyAudit` | object | Third-party audit configuration: which services and the submission policy. | ### Third-Party Audit Service ```json { "endpoint": "string (URL)", "did": "string", "publicKey": "string" } ``` ### Availability The `availability` object helps other agents make scheduling and urgency decisions: | Field | Type | Required | Description | |-------|------|----------|-------------| | `timezone` | string | Yes | IANA timezone identifier (e.g., `America/New_York`). | | `meetingHours` | string | No | Human-readable meeting hours (e.g., `9am-5pm ET`). | | `responseSla` | string | No | Expected response time (e.g., `24h`, `4h`). | ### Visibility The `visibility` field controls how the card is served on unauthenticated requests: | Visibility | Unauthenticated GET | Authenticated Query | |------------|--------------------|--------------------| | `public` | Full card | Full card | | `network_only` | Redacted card | Full card (any authenticated INK peer) | | `capability_gated` | Redacted card | Full card (relationship-tier filtered) | | `private` | 404 | Denied unless explicitly connected | ### Governance The optional `governance` block advertises containment and authorization constraints: | Field | Type | Description | |-------|------|-------------| | `maxAcceptedDelegationDepth` | number | Maximum delegation chain depth this agent will accept | | `supportedTransports` | InkTransport[] | Transport channels this agent supports | | `supportsCapabilityGatedDiscovery` | boolean | Whether authenticated card queries are supported | | `handshakeBudget` | object | Per-correlation handshake budget limits | **Standard transport identifiers:** `ink_http`, `ink_ws`, `extension_api`, `voice`, `line_phone`, `human_review_queue`. ### Key Management The optional `keys` block advertises the agent's signing and encryption key sets, enabling key rotation without changing the agent's identity. | Field | Type | Description | |-------|------|-------------| | `keys.signing` | KeyEntry[] | Signing keys (active and retired) | | `keys.encryption` | KeyEntry[] | Encryption keys (active and retired) | | `currentSigningKeyId` | string | `keyId` of the current active signing key | | `currentEncryptionKeyId` | string | `keyId` of the current active encryption key | | `keySetVersion` | number | Monotonically increasing version; incremented on every rotation | Each `KeyEntry` includes `keyId`, `algorithm`, `publicKeyMultibase`, `status` (`active` / `retired` / `revoked`), `validFrom` and optional `validUntil`. Agents without a `keys` block use the top-level `publicKeyMultibase` field as the sole signing key. See [Key Rotation](/extensions/key-rotation/) for full details on key lifecycle and verification rules. ## Retrieval Agent Cards are served at: ``` GET /ink/v1/:agentId/agent.json ``` For `public` visibility, this returns the full card with no authentication required. For `network_only` and `capability_gated` visibility, unauthenticated GET returns a **redacted card**: ```json { "type": "ink.agent.card", "version": "1.0", "agentId": "agent:abc123", "displayName": "Alice's Tulpa", "visibility": "capability_gated", "supportsInk": true, "discoveryMode": "authenticate_for_details", "updatedAt": "2026-03-18T12:00:00Z" } ``` The `type` value `ink.agent.card` is the protocol-generic name introduced in v0.1.1. The legacy value `tulpa.agent.card` MUST also be accepted by consumers during v0.1.x; publishers SHOULD emit `ink.agent.card`. The legacy synonym is removed at the next wire-version bump. The redacted card confirms the agent exists and supports INK but strips capabilities, endpoints, keys, availability and profile data. For `private` visibility, unauthenticated GET returns HTTP 404. ### Authenticated Card Query Authenticated peers can request the full card via: ``` POST /ink/v1/:agentId/agent-card-query Authorization: INK-Ed25519 ``` ```json { "protocol": "ink/0.1", "type": "network.tulpa.agent_card_query", "from": "did:plc:requester", "nonce": "", "timestamp": "2026-03-18T12:00:00Z", "requestedFields": ["capabilities", "availability"] } ``` The response is either a full card (`network.tulpa.agent_card_response`) or a denial (`network.tulpa.agent_card_denied`) with reason `unknown_requester`, `insufficient_trust` or `not_connected`. ## Usage in Discovery 1. Agent A resolves Agent B's DID to find the `INKAgentEndpoint` service entry (legacy `TulpaAgentEndpoint` is also accepted during v0.1.x). 2. Agent A fetches Agent B's Agent Card from `serviceEndpoint`. Per the [Discovery spec](/spec/discovery/), the consumer MUST bind the card's `ownerDid` (when present) to Agent B's DID, bind the card's `agentId` to the agent identifier being addressed, refresh on `keyId` miss or `keySetVersion` bump, and apply the SSRF floor on the fetch. 3. Agent A inspects `capabilities.intentsAccepted` to determine if the desired intent type is supported. 4. Agent A uses `publicKeyMultibase` (or `keys.signing`) to verify signatures on future messages from Agent B. 5. Agent A uses `availability` to make urgency and scheduling decisions. 6. Agent A POSTs signed envelopes to the inbound URL returned by `resolveAgentInbox(card)` — typically `card.endpoint`, with `inboxEndpoint` accepted as a forward-compat alias that MUST equal `endpoint` when both are present (v0.1.1). ## Validation Implementations MUST validate the following: - `protocol` is a recognized version string - `publicKeyMultibase` starts with `z` and decodes to a valid Ed25519 public key - `endpoint` is a valid HTTPS URL - `intentsAccepted` and `intentsSent` contain only recognized intent types --- Source: https://ink.tulpa.network/spec/discovery/ --- ## Discovery Flow ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } shape: sequence_diagram sender: Sending Agent plc: PLC / DID Resolver pds: Recipient PDS sender -> plc: 1. Resolve DID plc -> sender: 2. DID Document (service entries) sender -> sender: 3. Find INKAgentEndpoint\nservice entry in DID doc sender -> pds: 4. Fetch Agent Card from serviceEndpoint URL pds -> sender: 5. Agent Card (incl. key material + inbound endpoint) sender -> pds: 6. POST signed envelope to Agent Card's `endpoint`\n(Ed25519 signed) ``` ## Endpoint Discovery **The DID document is the sole authoritative source for endpoint discovery.** Agents locate a target's Agent Card by resolving the target DID and finding the `INKAgentEndpoint` service entry. The `serviceEndpoint` value is the **URL of the Agent Card**, not the inbound message endpoint: ```json { "id": "#inkAgent", "type": "INKAgentEndpoint", "serviceEndpoint": "https://agent.example.com/ink/v1/alice/agent.json" } ``` The Agent Card itself carries the inbound message URL in its `endpoint` field. A consumer fetches the card from `serviceEndpoint`, then POSTs signed envelopes to the card's `endpoint`. If no `INKAgentEndpoint` service entry exists in the DID document, the DID is not INK-reachable. The `agentLink` record provides supplementary key material and delegation proof but does NOT contain an endpoint field. ### Legacy service entry name For deployments published before this release, `type: "TulpaAgentEndpoint"` MUST also be accepted as a synonym for `INKAgentEndpoint` while the `ink/0.x` wire line is current. New publishers SHOULD emit `INKAgentEndpoint`; consumers MUST accept either. The vendor-named legacy synonym is slated for removal only at a future incompatible wire-version bump, not at `ink/0.2` (which differs from `ink/0.1` solely in the body-signature domain). When a DID document contains both, `INKAgentEndpoint` takes precedence. ### Agent Card identity binding (MUST) The Agent Card fetched from `serviceEndpoint` carries two identifiers that consumers MUST bind to the resolution context: - The card's `ownerDid` (when present) MUST equal the **DID being resolved** — i.e. the human DID whose DID Document contained the `INKAgentEndpoint` service entry. A card whose `ownerDid` does not match the resolved DID MUST be rejected. - The card's `agentId` is the agent's own identifier (a `tulpa:`/`did:`/`agent:` shape depending on the platform). The consumer MUST bind it to whatever value it intends to send to (URL path param, address-book entry, etc.). For inbound deliveries the routing layer MUST refuse to publish a card under a path whose `agentId` does not match the card's `agentId` field. - When the DID resolution chain produced a delegated `agentLink` record, the card's published key material MUST be consistent with the keys delegated by that record. This is the structural defense against a host that legitimately publishes one DID claiming to publish another at the same URL: the `ownerDid` field binds the card to the resolved human, the `agentId` field binds it to a routable agent identifier, and either mismatch is a hard reject. ## Discovery fetch security (MUST) Discovery follows attacker-controllable URLs. The following requirements apply to every HTTP fetch a consumer issues while resolving a DID, fetching a DID Document, or fetching an Agent Card: - **HTTPS only.** Plaintext `http://` MUST be refused (TLS 1.2+). - **No private or reserved hosts.** The destination hostname MUST NOT resolve to a loopback (127.0.0.0/8, ::1), link-local (169.254.0.0/16, fe80::/10, cloud-metadata 169.254.169.254), unique-local (fc00::/7), private (10/8, 172.16/12, 192.168/16) or multicast/reserved range. IP-literal hosts SHOULD be rejected entirely. - **Bounded redirects.** Consumers MUST cap redirect depth (recommended: 3) and MUST re-apply the host check on every redirect target. Cross-host redirects on `did:web:` document fetches SHOULD be refused. - **Size and time caps.** Consumers MUST cap response body size (recommended: 64 KB for an Agent Card or DID Document) and request timeout (recommended: 5 seconds). Truncated or timed-out responses MUST be treated as a failed fetch, not a partial card. - **`Cache-Control` MUST be honored.** Consumers that cache cards MUST observe `max-age` and `no-store` from the response. Detailed SSRF hardening (DNS pinning, egress allow-lists, runtime egress controls) is the implementer's responsibility. The list above is the normative floor; any implementation that violates one of these requirements is not INK-conformant. ## Agent Card cache and refresh (MUST) A consumer MAY cache Agent Cards subject to the rules above. Beyond `Cache-Control`, a consumer MUST refetch a card when any of the following occur: - **Signature verification miss.** An inbound message verifies against no key in the cached card. - **Unknown `keyId` hint.** An inbound message carries a `keyId` header that the cached card does not list. - **`keySetVersion` increase observed.** The card carries `keySetVersion: N`; any subsequent fetch returning `keySetVersion > N` MUST replace the cached card immediately. A consumer MUST NOT fall back to a bootstrap key (one derived from the DID itself) after it has observed a valid published key set for that DID, even if every key in the cached card has since rotated to revoked. Bootstrap keys are only valid before any card has been observed. ## Transport - **Protocol:** HTTPS / REST. All INK endpoints MUST be served over TLS 1.2+. - **Content-Type:** `application/json`. - **Protocol version on the wire:** every INK message MUST include `protocol` at the top level, either `ink/0.1` or `ink/0.2`. This is the wire version, not the spec/package release version. `ink/0.2` differs from `ink/0.1` only in the body-signature domain separator (`ink/sign\n` instead of the legacy `tulpa/sign\n`); see [Authentication](/spec/authentication/). A receiver MUST verify the body signature under the domain selected by the message's `protocol` and MUST reject an unknown version. Senders emit `ink/0.1` by default and emit `ink/0.2` only to a receiver that advertises support for it. A receiver advertises the versions it verifies in its Agent Card `supportedProtocolVersions` array; when that field is absent, assume `ink/0.1` only. ## Naming Convention: Lexicon IDs vs. Wire Types INK uses two distinct identifier formats for message types. Implementations MUST distinguish between them: | Purpose | Format | Example | |---------|--------|---------| | **AT Protocol Lexicon ID** (schema registry) | camelCase, includes `ink` namespace segment | `network.tulpa.ink.auditQuery` | | **Wire `type` field** (JSON message body) | snake_case, omits `ink` segment | `network.tulpa.audit_query` | The lexicon ID follows AT Protocol conventions (camelCase NSID with hierarchical namespacing). The wire type is the value in the `"type"` field of every INK message, it is what implementations match on when routing messages. Implementations MUST key off the wire `type` field, not the lexicon ID. For single-word types the distinction is invisible (`network.tulpa.intent` maps to lexicon `network.tulpa.ink.intent`). For multi-word types it is significant: `network.tulpa.audit_query` (wire) vs. `network.tulpa.ink.auditQuery` (lexicon). The `network.tulpa.*` wire namespace is **frozen** for v0.x per the [INK compatibility policy](https://github.com/Ad-Astra-Computing/ink/blob/main/specs/ink-compatibility-policy.md). Renaming the wire discriminator would break every deployed router; the vendor-leaked name is paid debt that will be settled only at a future incompatible wire-version bump. The `ink/0.2` bump does not touch it, since `ink/0.2` differs from `ink/0.1` solely in the body-signature domain. See [Wire Types Reference](/reference/wire-types/) for the complete mapping. --- Source: https://ink.tulpa.network/spec/authentication/ --- INK uses **Ed25519 signature-based authentication**, not HMAC. There is no shared secret. ## Authorization Header Every request includes an `Authorization` header: ``` Authorization: INK-Ed25519 ``` When the sender has multiple keys (see [Key Rotation](/extensions/key-rotation/)), an optional `keyId` parameter identifies which signing key produced the signature: ``` Authorization: INK-Ed25519 keyId=sig-2026-03 ``` This lets the receiver verify against the correct key directly instead of trial-verifying across all candidate keys. If `keyId` is omitted or unknown, receivers fall back to trying active keys then retired keys in order. ## Signature Base The signature covers a concatenation of the protocol version, HTTP method, request path, recipient DID, JCS-canonicalized request body and timestamp: ``` signatureBase = PROTOCOL + "\n" + METHOD + "\n" + PATH + "\n" + recipientDid + "\n" + JCS(body) + "\n" + timestamp signature = Ed25519.sign(privateKey, signatureBase) ``` For example, an intent sent via `POST /ink/v1/intent` to `did:plc:recipient`: ``` signatureBase = "ink/0.1\nPOST\n/ink/v1/intent\ndid:plc:recipient\n{...canonical body...}\n2026-03-18T12:00:00Z" ``` ## Why This Signature Base Including the protocol version, HTTP method, path and recipient DID prevents: - **Cross-version replay:** A signature from one protocol version cannot be replayed against a different version. - **Cross-endpoint replay:** A signed intent cannot be replayed against a different endpoint (e.g., `/ink/v1/challenge`). - **Recipient confusion:** A message intended for Agent B cannot be forwarded to Agent C and appear valid. - **Method confusion:** A POST body cannot be replayed as a PUT or vice versa. ## Verification Steps ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } step1: |md **1. Extract Authorization header** INK-Ed25519 {signature} | { class: ink } step2: |md **2. Resolve sender DID** Find agentLink record Extract signingKeyMultibase | { class: identity } plc: PLC Dir / PDS { class: identity } step3: |md **3. Resolve sender's key set** (integrator-side: DID resolution, agentLink lookup, repo commit check) | { class: identity } step4: |md **4. Reconstruct signature base** PROTOCOL + METHOD + PATH \+ recipientDid + JCS(body) + timestamp | { class: ink } step5: |md **5. Ed25519.verify** Valid = process message Invalid = reject (401) | { class: ink } step1 -> step2 plc -> step2 step2 -> step3 step3 -> step4 step4 -> step5 ``` The receiving agent: 1. Resolves the sender's DID, finds the `agentLink` (or equivalent identity record), and obtains the current `signingKeyMultibase`. This identity resolution and any delegation verification (e.g. ATP repo commit signature) is the integrator's responsibility; the INK reference middleware (`verifyInkAuth`) expects the public key to be supplied via a caller-provided resolver and then applies the key-rotation authority rule. 2. Reconstructs the signature base from the received request and verifies the Ed25519 signature against the resolved key set per the [authority rule](/extensions/key-rotation/#authority-rule-normative). ## Signature Base Anatomy The signature base is a newline-delimited string. Every field is mandatory, omitting any field invalidates the signature. ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } base: "Signature Base (concatenated, newline-delimited)" { fields: |||md | Value | Field | |-------|-------| | `"ink/0.1"` | protocol version | | `"POST"` | HTTP method | | `"/ink/v1/intent"` | request path | | `"did:plc:recipient"` | recipient DID | | `'{"from":"did:plc:sender"…}'` | JCS(body) | | `"2026-03-18T12:00:00Z"` | timestamp | ||| { shape: text; style.font-size: 13 } } sign: "Ed25519.sign(agent_private_key, base)\n→ base64url encoded → Authorization header" { shape: rectangle } base -> sign ``` Each field prevents a specific attack class: | Field | Prevents | |-------|----------| | Protocol version | Cross-version replay | | HTTP method | Method confusion (POST replayed as PUT) | | Request path | Cross-endpoint replay | | Recipient DID | Recipient confusion (forward to wrong agent) | | JCS(body) | Body tampering | | Timestamp | Replay outside window | --- Source: https://ink.tulpa.network/spec/encryption/ --- For sensitive payloads (e.g., scheduling details, personal context), INK supports ECIES encryption. ## Encryption Flow ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } shape: sequence_diagram sender: Sender recipient: Recipient sender -> sender: "1. Generate ephemeral X25519 key pair\n(provides forward secrecy)" sender -> sender: "2. ECDH(ephemeral_priv, recipient_pub)\n= shared secret (32 bytes)" sender -> sender: "3. HKDF-SHA256(shared_secret,\nsalt=\"ink/0.1\",\ninfo=\"ink/0.1/encrypt\")\n= symmetric key (32 bytes)" sender -> sender: "4. AES-256-GCM(key, nonce, plaintext)\n= ciphertext + auth tag" sender -> recipient: "5. POST /ink/v1/intent\nOuter envelope:\ntype, from, ephemeralKey (plaintext)\nciphertext (encrypted)\nmessageNonce (replay protection)" recipient -> recipient: "6. Verify signature\n7. Check replay protection\n8. ECDH(recipient_priv, ephemeral_pub)\n9. HKDF = symmetric key\n10. AES-GCM decrypt\n11. Verify from/to match envelope" ``` ## Encryption Procedure 1. Sender generates an **ephemeral X25519 key pair** for this message. The sender's long-term `encryptionKeyMultibase` is NOT used for ECDH, ephemeral keys provide forward secrecy. 2. Sender performs ECDH using the ephemeral private key and the recipient's `encryptionKeyMultibase` (from their `agentLink`). 3. Derives a symmetric key via HKDF-SHA256: - **IKM:** The raw ECDH shared secret (32 bytes). - **Salt:** `"ink/0.1"` (UTF-8 encoded, 6 bytes). - **Info:** `"ink/0.1/encrypt"` (UTF-8 encoded). - **Output length:** 32 bytes. 4. Encrypts the plaintext envelope with AES-256-GCM using a random 12-byte nonce. 5. Wraps the result in a `InkEncryptedPayload` outer envelope. ## Outer Envelope ```json { "protocol": "ink/0.1", "type": "network.tulpa.encrypted", "from": "did:plc:sender", "ephemeralKey": "", "nonce": "", "ciphertext": "", "timestamp": "2026-03-18T12:00:00Z", "messageNonce": "" } ``` The outer envelope is **not encrypted**. `from`, `ephemeralKey`, `timestamp` and `messageNonce` are plaintext. This is necessary so the recipient can identify the sender and apply replay protection before decryption. ## Plaintext Envelope The ciphertext, when decrypted, yields a JSON object identical to an unencrypted INK message: ```json { "protocol": "ink/0.1", "type": "network.tulpa.intent", "from": "did:plc:sender", "to": "did:plc:recipient", "intent": "schedule_meeting", "purpose": "Discuss partnership opportunity", "urgency": "normal", "nonce": "", "timestamp": "2026-03-18T12:00:00Z" } ``` The recipient MUST verify that `from` and `to` in the plaintext envelope match the outer envelope's `from` and the recipient's own DID. A mismatch indicates tampering and MUST be rejected. ## Decryption Procedure 1. Parse the outer envelope. Verify the `Authorization` header signature. **Reject before decryption if invalid.** 2. Check `timestamp` and `messageNonce` against replay protection rules. **Reject before decryption if replayed.** 3. Perform ECDH using the recipient's own X25519 private key and the `ephemeralKey`. 4. Derive the symmetric key via HKDF-SHA256. 5. Decrypt `ciphertext` using AES-256-GCM. 6. Parse the plaintext envelope. Verify `from` matches outer `from` and `to` matches recipient's DID. 7. Process the inner message normally. ## Envelope Anatomy: Plaintext vs Encrypted A side-by-side comparison of what is visible on the wire in each mode. ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } plaintext: "Plaintext Message" { class: ink auth: "Authorization: INK-Ed25519 " { shape: text; style.font-size: 13 } body: |md ```json { "protocol": "ink/0.1", "type": "network.tulpa.intent", "from": "did:plc:sender", "to": "did:plc:recipient", "intent": "schedule_meeting", "purpose": "Discuss Q3 plans", "urgency": "normal", "nonce": "", "timestamp": "2026-03-18T…" } ``` **All fields visible to network** | { shape: text; style.font-size: 13 } } encrypted: "Encrypted Message" { class: ink auth: "Authorization: INK-Ed25519 " { shape: text; style.font-size: 13 } body: |md ```json { "protocol": "ink/0.1", "type": "network.tulpa.encrypted", "from": "did:plc:sender", "ephemeralKey": "", "nonce": "<12-byte AES-GCM>", "ciphertext": "", "timestamp": "2026-03-18T…", "messageNonce": "" } ``` **to, intent, purpose, urgency** **are inside ciphertext, not on wire** | { shape: text; style.font-size: 13 } } ``` **Key differences:** - Encrypted envelopes expose only: `from`, `ephemeralKey`, `nonce`, `timestamp`, `messageNonce` - The `to` field, intent type, purpose and all payload data are inside the ciphertext - Both modes carry Ed25519 signatures and replay protection - The outer `nonce` is the AES-GCM IV (12 bytes, base64url-encoded); `messageNonce` is the replay-protection nonce. Both fields live on the outer envelope. ## AAD Construction AES-GCM Additional Authenticated Data binds the ciphertext to every security-relevant outer-envelope field. Without this binding, an attacker could replay the same ciphertext under a different sender, timestamp or message type. The library constructs AAD as follows: 1. Build an object with exactly these fields, in this order: `protocol`, `type`, `from`, `ephemeralKey`, `nonce`, `timestamp`, `messageNonce`. All values must equal the outer-envelope values byte-for-byte. 2. JCS-canonicalize the object (sorts keys lexicographically). 3. Prepend the domain separator `ink/0.1:envelope\n`. 4. UTF-8-encode the result. The resulting bytes are passed as `additionalData` to `AES-GCM.encrypt` / `AES-GCM.decrypt`. Implementations MUST construct AAD identically on both sides. Any mismatch causes the GCM tag to fail and decryption returns an error — there is no version negotiation for AAD. ## Encryption Requirements by Intent Type | Intent Type | Sender | Receiver | Rationale | |-------------|--------|----------|-----------| | `schedule_meeting` | MUST encrypt | MUST reject plaintext | Contains availability windows, calendar data | | `context_share` | MUST encrypt | MUST reject plaintext | Contains personal/professional context | | `multi_party_sync` | MUST encrypt | MUST reject plaintext | Contains scheduling coordination data for multi-party enclaves | | `intro_request` | MAY encrypt | MUST accept both | Low sensitivity | | `opportunity` | MAY encrypt | MUST accept both | Low sensitivity | | `follow_up` | SHOULD encrypt | MUST accept both | May reference prior conversations | | `ask` | MAY encrypt | MUST accept both | General-purpose | --- Source: https://ink.tulpa.network/spec/replay-protection/ --- Every INK message MUST include: - **`nonce`:** A base64url-encoded random value, 16 to 256 characters. 22 characters (128 bits) is the recommended minimum; conforming receivers MUST accept any length in that range. - **`timestamp`:** ISO 8601 UTC timestamp. ## Validation Rules Receiving agents MUST: 1. Reject messages with timestamps older than **5 minutes** from the receiver's clock. 2. Reject messages with timestamps in the future by more than **30 seconds**. 3. Track seen `(sender, nonce)` pairs and reject duplicates for at least the timestamp window. A **10-minute** retention window is recommended so a nonce stays tracked after its timestamp expires. Nonce storage is the integrator's responsibility. The library exposes a `checkReplay()` primitive that decides whether a single `(timestamp, nonce)` pair is fresh given a caller-supplied seen-nonce set; the caller owns the cache and its TTL. ## Rationale The 5-minute window accommodates reasonable clock skew between agents while limiting the replay attack surface. The 30-second future tolerance prevents rejection of messages from slightly fast clocks. The recommended 10-minute nonce-retention window is intentionally larger than the timestamp window so a nonce is still tracked after the timestamp becomes invalid, preventing an edge case where a message's timestamp expires but its nonce is purged from tracking, potentially allowing re-acceptance with a fresh timestamp. --- Source: https://ink.tulpa.network/spec/canonicalization/ --- All INK messages are canonicalized using **JSON Canonicalization Scheme (JCS, RFC 8785)** before signing. This ensures deterministic byte-level representation regardless of JSON serialization order. ## Procedure Implementations MUST: 1. Serialize the message body (excluding the `signature` field itself) using JCS. 2. Construct the signature base by joining six fields with `\n`: `PROTOCOL + "\n" + METHOD + "\n" + PATH + "\n" + recipientDid + "\n" + JCS(body) + "\n" + timestamp`, where `PROTOCOL` is the literal version string `ink/0.1`. 3. Sign the resulting byte string with the sender's Ed25519 private key. Omitting the `PROTOCOL` line is the most common implementation bug: signatures from a five-field base will not verify against a six-field one. See [Authentication](/spec/authentication/) for the full anatomy. ## Why JCS SSB's original design used `JSON.stringify` with specific key ordering, which caused interoperability bugs across implementations. JCS (RFC 8785) is a proper IETF standard that defines canonical JSON serialization unambiguously. Key JCS rules: - Object keys sorted lexicographically by code point - No whitespace between tokens - Numbers serialized per ECMAScript `Number.toString()` - Strings escaped per JSON spec with no unnecessary escaping --- Source: https://ink.tulpa.network/spec/handshake/ --- Coordination follows a three-stage signed exchange. ## Sequence Overview ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } shape: sequence_diagram Agent A Agent B Agent A -> Agent B: "Stage 1: Intent\nPOST /ink/v1/intent\ntype: network.tulpa.intent\nEd25519 signed, optionally ECIES encrypted" Agent B -> Agent A: "Stage 2: Challenge or Rejection\ntype: network.tulpa.challenge | rejection\nChallenge: request proof, verify identity\nRejection: policy_violation, capacity" Agent A -> Agent B: "Stage 3: Resolution\ntype: network.tulpa.resolution\noutcome: accepted | declined | escalated_to_human" Agent B -> Agent A: "Optional: Delivery Receipt\ntype: network.tulpa.receipt\ndisposition: delivered | acted | rejected" { style.stroke-dash: 3 } ``` ## Stage 1: Intent (`network.tulpa.intent`) Agent A sends an Intent to Agent B's INK endpoint via `POST /ink/v1/intent`. ```json { "protocol": "ink/0.1", "type": "network.tulpa.intent", "from": "did:plc:sender", "to": "did:plc:recipient", "intent": "schedule_meeting", "purpose": "Discuss partnership opportunity", "urgency": "normal", "expiresAt": "2026-03-25T00:00:00Z", "nonce": "", "timestamp": "2026-03-18T12:00:00Z" } ``` **Intent types:** `schedule_meeting`, `schedule_meeting_response`, `intro_request`, `intro_response`, `opportunity`, `opportunity_response`, `follow_up`, `ask`, `ask_response`, `connection_request`, `connection_response`, `context_share`, `ping`, `retract`, `multi_party_sync`. ## Stage 2: Context Challenge Agent B responds with one of: ### Accept, request additional context ```json { "protocol": "ink/0.1", "type": "network.tulpa.challenge", "intentRef": "", "challengeType": "mutual_connection_proof", "fields": ["mutualDid", "attestationUri"], "availableWindows": ["2026-03-20T14:00:00Z/PT1H"], "nonce": "", "timestamp": "..." } ``` **Challenge types:** | Type | Description | Required fields | |------|-------------|-----------------| | `mutual_connection_proof` | Prove a shared connection | `mutualDid`, `attestationUri` | | `identity_verification` | Verify professional identity | `linkedInUrl` or `verifiedDomain` | | `availability_query` | Propose time windows | `availableWindows` | | `context_request` | Request more detail about intent | `contextFields` | | `none` | No challenge, proceed directly | _(empty)_ | ### Reject ```json { "protocol": "ink/0.1", "type": "network.tulpa.rejection", "intentRef": "", "reason": "policy_violation", "detail": "Intent type 'scheduling' requires mutual connection", "retryAfter": null, "nonce": "", "timestamp": "..." } ``` **Rejection reasons:** | Reason | Description | |--------|-------------| | `policy_violation` | Sender does not meet autonomy policy | | `trust_threshold` | Insufficient trust score or attestations | | `capacity` | Agent or user at capacity | | `unsupported_intent` | Intent type not supported | | `rate_limited` | Too many recent requests | | `expired` | Intent has already expired | | `handshake_budget_exhausted` | Per-correlation handshake budget exceeded | | `counterparty_cooldown` | Recipient is broadly rate-limiting inbound handshakes | | `sender_rate_limited` | Per-sender sliding window rate limit exceeded | | `delegation_budget_exhausted` | Delegation issuance limit hit | | `transport_scope_violation` | Invocation transport not permitted by delegation token | Rejections MAY include an optional `backoffHint` to guide retry behavior: ```json { "retryAfterSeconds": 60, "cooldownUntil": "2026-03-18T12:05:00Z", "backoffClass": "sender" } ``` `backoffClass` indicates the scope of the rate limit: `sender` (this sender only), `intent_ref` (this correlation only) or `counterparty` (all inbound traffic). Rejections are final. Agents SHOULD NOT retry without material change. The first budget violation returns a typed rejection with backoff hint; subsequent violations from the same sender are silently dropped to prevent amplification. ## Stage 3: Resolution A final agreement or escalation to Human-in-the-Loop (HITL). ```json { "protocol": "ink/0.1", "type": "network.tulpa.resolution", "intentRef": "", "outcome": "accepted", "details": { "scheduledAt": "2026-03-20T14:00:00Z", "duration": "PT30M" }, "nonce": "", "timestamp": "..." } ``` **Outcomes:** `accepted`, `declined`, `escalated_to_human`, `expired`. ### Resolution Storage Resolutions are **local application data**, not ATP repo records. Both parties store a copy containing the same `intentRef` and a cross-reference `counterpartyDid`. The Ed25519 signatures on resolution messages serve as cryptographic receipts. Agents MUST support exporting resolutions in a portable JSON format on user request. ## Complete Message Lifecycle The full lifecycle from intent to receipt, showing what is signed, what is stored and where state lives. ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } intent: "1. INTENT\ntype: network.tulpa.intent\n[Ed25519 SIGNED]\n[replay nonce + ts]\n[optionally ENCRYPTED]" { class: ink } verify: "Agent B verifies:\nVerify signature\nCheck replay nonce\nCheck autonomy policy\nLog: message.received" { class: ink } intent -> verify: POST /ink/v1/intent challenge: "2a. CHALLENGE\ntype: network.tulpa.challenge\n[Ed25519 SIGNED]\nRequest proof/context" { class: ink } rejection: "2b. REJECTION\ntype: network.tulpa.rejection\n[Ed25519 SIGNED]\nreason: policy_violation" { class: ink } verify -> challenge: if more info needed verify -> rejection: if policy fails resolution: "3. RESOLUTION\ntype: network.tulpa.resolution\noutcome: accepted / declined / escalated\n[Ed25519 SIGNED]" { class: ink } challenge -> resolution: POST /ink/v1/resolution storage: "Local Storage\nBoth agents store resolution locally (not in ATP repo)\nCross-referenced by intentRef + counterpartyDid\nEd25519 signatures serve as cryptographic receipts" { class: local } resolution -> storage receipt: "4. RECEIPT (optional)\ntype: network.tulpa.receipt\n[Ed25519 SIGNED]\ndisposition: delivered / acted / rejected" { class: audit } storage -> receipt audit: "Audit\nEach agent logs every step to their hash-chained audit log\nEvents: message.sent → message.received → receipt.sent → …\nOptional: submit events to third-party Merkle witness" { shape: text; style.font-size: 13 } ``` --- Source: https://ink.tulpa.network/spec/focus-signals/ --- INK uses ATP's event stream architecture for real-time status propagation. ## Focus Signals (`network.tulpa.focusSignal`) ```json { "text": "shipping MVP", "visibility": "public", "expiresAt": "2026-03-19T00:00:00Z" } ``` ## Connection Records (`network.tulpa.connection`) A connection is a mutual, bidirectional relationship between two DIDs. ```json { "subjectDid": "did:plc:other_party", "createdAt": "2026-03-18T00:00:00Z" } ``` A connection is **established** when both parties have a `network.tulpa.connection` record referencing each other. A unilateral record is a **pending** connection. To verify a connection between DID A and DID B: 1. DID A's PDS contains a `network.tulpa.connection` record with `subjectDid` = DID B. 2. DID B's PDS contains a `network.tulpa.connection` record with `subjectDid` = DID A. **Revocation:** Either party deletes their record. The connection is immediately downgraded. ## Visibility Levels | Level | Who can see | |-------|-------------| | `public` | Anyone who resolves the DID | | `connections` | First-degree connections only | | `private` | Only the user and their agent | ## Propagation Focus signals are published as ATP records and propagated via the standard relay/Jetstream infrastructure. Consuming agents filter a shared relay stream for `network.tulpa.focusSignal` records from DIDs in their active connection set. --- Source: https://ink.tulpa.network/spec/autonomy/ --- Policy is governed by an **Autonomy Policy** record stored in the PDS. ## Autonomy Policy (`network.tulpa.autonomyPolicy`) - **Collection:** `network.tulpa.autonomyPolicy` - **Fields:** - `maxAutonomyLevel`: `none` | `draft_only` | `auto_respond` | `full` - `trustedDids`: List of DIDs allowed to bypass HITL for specific intent types - `budgetLimit`: Optional. `{ "unit": "meetings_per_week" | "hours_per_week", "max": number }`. When exhausted, the agent drops to `draft_only` mode. - `maxInboundRate`: Optional. Maximum inbound intents per hour from a single DID. Default: 10. ## Autonomy Levels | Level | Behavior | |-------|----------| | `none` | Agent receives but does not act. All actions require human approval. | | `draft_only` | Agent drafts responses but does not send them without human approval. | | `auto_respond` | Agent can automatically respond to intents from trusted DIDs and within budget. | | `full` | Agent acts autonomously within configured policy boundaries. | --- Source: https://ink.tulpa.network/spec/reputation/ --- INK v0.1 uses **Signed Attestations**. Zero-Knowledge Proofs are deferred to a future version. ## Trust Attestation (`network.tulpa.trustAttestation`) ```json { "subjectDid": "did:plc:target", "issuerDid": "did:plc:issuer", "category": "professional_competence", "signal": "positive", "context": "Collaborated on Project X, Q1 2026", "issuedAt": "2026-03-18T00:00:00Z", "expiresAt": "2027-03-18T00:00:00Z" } ``` ## Attestation Semantics - **Issuer:** The DID that creates the attestation. Verified via ATP commit signature. - **Categories:** `professional_competence`, `reliability`, `domain_expertise`, `collaboration`. Extensible. - **Signal:** `positive` or `negative`. - **Expiry:** Attestations expire automatically. Agents MUST NOT consider expired attestations. ## Negative Attestation Storage Negative attestations are **local application data**, not ATP repo records. They are never published to the PDS, relay or firehose. An agent MAY share a negative attestation with another agent via an encrypted INK message if both parties have an established connection. The receiving agent stores it as local data and attributes it to the original issuer. ## Trust Scoring Agents use attestations as weighted inputs to their local prioritization engine. The scoring algorithm is implementation-defined. INK mandates the attestation format and verification rules, not the scoring formula. ### Positive Attestation Verification 1. The attestation record exists in the issuer's PDS. 2. The issuer's DID is not the same as the subject's DID (no self-attestation). 3. The attestation has not expired. ### Negative Attestation Verification 1. The INK message carrying the attestation has a valid Ed25519 signature. 2. The issuer's `agentLink` is valid. 3. The issuer's DID is not the same as the subject's DID. 4. The attestation has not expired. Agents SHOULD weight privately-shared negative attestations lower than PDS-published positive attestations. --- Source: https://ink.tulpa.network/extensions/receipts/ --- Receipts are an INK message type (`network.tulpa.receipt`) that provide signed delivery and disposition acknowledgments. They are delivered via `POST /ink/v1/receipt`. ## Receipt Envelope ```json { "protocol": "ink/0.1", "type": "network.tulpa.receipt", "from": "did:plc:recipient", "to": "did:plc:sender", "messageId": "original-message-id", "disposition": "received", "dispositionAt": "2026-03-19T12:00:00Z", "note": "optional detail", "messageHash": "", "nonce": "", "timestamp": "2026-03-19T12:00:01Z" } ``` ## Disposition Types | Disposition | Meaning | |-------------|---------| | `received` | Envelope accepted, queued for processing | | `delivered` | Message shown to owner or processed by rule | | `acted` | Owner/agent took action | | `rejected` | Message rejected by pipeline | | `expired` | Message expired before processing | ## Receipt Flow ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } shape: sequence_diagram Sender Recipient Sender -> Recipient: "POST /ink/v1/intent" Recipient -> Sender: "HTTP 200 { accepted }" Recipient -> Recipient: "(processes message)" Recipient -> Sender: "POST /ink/v1/receipt\n(type: network.tulpa.receipt)" Sender -> Recipient: "HTTP 200" ``` ## Receipt Lifecycle Dispositions follow a progression. Each transition generates a separate signed receipt. ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } arrive: Message arrives { class: ink } received: received { class: audit } delivered: delivered { class: audit } acted: acted (signed, final) { class: audit } rejected: rejected (signed, final) { class: audit } expired: expired (signed, final) { class: audit } arrive -> received received -> rejected: Pipeline rejects received -> delivered: "Queued, shown to owner\nor processed by rule" delivered -> expired: Message window closes delivered -> acted: Owner/agent takes action note: |md **Properties of each receipt:** - Ed25519-signed (full INK message, not just an ack) - Carries nonce + timestamp (replay-protected) - from/to reversed relative to original message - messageHash binds receipt to specific message content - Receipts for receipts are NOT sent (loop prevention) | { shape: text style.font-size: 13 } ``` ## Properties - Receipts are full INK messages: signed per [S3.3](/spec/authentication/#signature-base), with nonce and timestamp for replay protection - Receipts for receipts are NOT sent (loop prevention) - Receipts are **opt-in** per agent, advertised in Agent Card capabilities - The `from`/`to` fields are reversed relative to the original message ## `messageHash` Scope Always SHA-256 of the **JCS-canonicalized plaintext message body**, regardless of transport encryption. For **encrypted messages**, the hash is computed over the decrypted intent body, not the outer `InkEncryptedPayload` envelope. Both sender and recipient possess the plaintext after decryption, so the hash is verifiable by both parties and binds the receipt to the semantic content rather than the transport encoding. ## Agent Card Capability ```json { "capabilities": { "receipts": { "send": true, "dispositions": ["received", "delivered", "acted", "rejected"] } } } ``` ## Prior Art | Protocol | Lesson for INK | |----------|---------------| | **MDN (RFC 8098)** | Advisory receipts are unreliable. INK uses signed protocol-level messages | | **XMPP (XEP-0184/0333)** | Disposition escalation pattern: received → delivered → acted | | **Matrix** | Receipts as ephemeral vs persistent. INK persists for evidence | | **DIDComm v2** | No built-in receipts due to multi-transport. INK standardizes on HTTP | --- Source: https://ink.tulpa.network/extensions/audit/ --- Each agent maintains a per-agent hash-chained audit log with Ed25519 signatures per event. ## Audit Event Envelope ```typescript InkAuditEvent = { id: string, // ULID version: "ink-audit/1", agentId: string, agentSignature: string, // Ed25519 over event (minus this field) // Chain position (SSB-inspired) sequence: number, // monotonically increasing from 1 previousEventHash: string | null, // SHA-256 of prior event; null for seq=1 // What happened eventType: InkAuditEventType, timestamp: string, // ISO 8601 // References messageId?: string, correlationId?: string, counterpartyId?: string, signingKeyId?: string, // Which key signed `agentSignature` (for rotation) data?: Record, } ``` ## Event Types ### Message Lifecycle `message.sent` · `message.received` · `message.queued` · `message.delivered` · `message.acted` · `message.rejected` · `message.expired` · `message.retracted` ### Receipt Lifecycle `receipt.sent` · `receipt.received` ### Delegation `delegation.granted` · `delegation.used` · `delegation.revoked` · `delegation.expired` ### Connection `connection.requested` · `connection.accepted` · `connection.declined` ### Verification `signature.verified` · `signature.verified_retired` · `signature.failed` · `signature.revoked_rejected` · `replay.detected` ### Key Lifecycle `key.rotated` · `key.revoked` ### Introduction Lifecycle `introduction.requested` · `introduction.approved` · `introduction.declined` · `introduction.forwarded` · `introduction.completed` · `introduction.expired` · `introduction.receipt_sent` · `introduction.receipt_received` ### Enclave Lifecycle `enclave.requested` · `enclave.authorized` · `enclave.opened` · `enclave.operation_submitted` · `enclave.resolved` · `enclave.expired` · `enclave.aborted` · `enclave.receipt_sent` · `enclave.receipt_received` ### Containment `transport_scope_violation` · `handshake_rate_limited` · `handshake_budget_exhausted` · `discovery_query_received` · `discovery_query_granted` · `discovery_query_denied` ## Hash Chain Structure ```d2 direction: right classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } ev1: Event seq=1 { class: audit previousHash: null eventType: message.sent agentSignature: Ed25519(...) } ev2: Event seq=2 { class: audit previousHash: SHA-256(ev1) eventType: message.received agentSignature: Ed25519(...) } ev3: Event seq=3 { class: audit previousHash: SHA-256(ev2) eventType: receipt.sent agentSignature: Ed25519(...) } ev1 -> ev2: hash chain ev2 -> ev3: hash chain fork: Fork detection { shape: text style.font-size: 13 Two events with seq=3 but different hashes\n= chain forked, untrusted } gap: Gap detection { shape: text style.font-size: 13 seq jumps from 3 to 5\n= event 4 suppressed, flag for review } ``` ## Tamper Evidence The audit chain uses **both** a hash chain and monotonic sequence numbers: - **`sequence`:** Monotonically increasing integer starting at 1. Gaps indicate deleted or suppressed events. - **`previousEventHash`:** SHA-256 of the JCS-canonicalized prior event (excluding `agentSignature`). Null for sequence=1. - **`agentSignature`:** Ed25519 signature over the event (excluding this field). Proves the agent attested to this event at this chain position. **Fork detection:** If an agent presents two different events with the same `sequence` number, the chain is forked and SHOULD be treated as untrusted. ## Audit Exchange Protocol Agents exchange audit records via `POST /ink/v1/audit`. ### Request ```json { "protocol": "ink/0.1", "type": "network.tulpa.audit_query", "from": "did:plc:alice", "to": "did:plc:bob", "messageId": "msg-123", "nonce": "", "timestamp": "2026-03-19T12:00:00Z" } ``` ### Response ```json { "protocol": "ink/0.1", "type": "network.tulpa.audit_response", "messageId": "msg-123", "events": [ /* InkAuditEvent[] */ ], "responseSignature": "" } ``` The `responseSignature` allows the requester to prove the responder attested to this specific audit history. ### Access Control The responder MUST verify that the requester's DID is either the sender or recipient of the referenced `messageId`. If not, return `access_denied`. ## Dispute Resolution ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } shape: sequence_diagram Alice Bob Alice -> Bob: "POST /ink/v1/audit\n(messageId=123)" Bob -> Alice: "{ events: [...],\nresponseSignature }" Alice -> Alice: "Compare sequence numbers and hashes:\n- Matching hashes = agreement\n- Divergent hashes = flag for review\n- Sequence gaps = events suppressed\n- Fork (same seq, different hash)\n = chain is untrusted" ``` ## Reconciliation: Agreement vs Divergence When agents exchange audit records, four outcomes are possible. Implementations MUST handle all four. ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } agreement: "1. AGREEMENT" { style.font-size: 14 desc: |md Hashes match at every overlapping sequence number. Alice seq=1 →hash→ Alice seq=2 Bob seq=1 →hash→ Bob seq=2 H(Alice.1) == H(Bob.1), H(Alice.2) == H(Bob.2) **Result:** consistent history, no action needed | { shape: text; style.font-size: 13 } } gap: "2. GAP" { style.font-size: 14 desc: |md Sequence numbers jump (event suppressed). Bob's chain: seq=1, seq=2, seq=5 Missing: seq=3, seq=4 **Result:** flag for review, events may have been deleted or withheld | { shape: text; style.font-size: 13 } } fork: "3. FORK" { style.font-size: 14 desc: |md Same sequence, different hash (split-view). Bob shows Alice: seq=3, hash=abc Bob shows Carol: seq=3, hash=xyz **Result:** chain is UNTRUSTED. Bob maintains two different histories | { shape: text; style.font-size: 13 } } divergence: "4. DIVERGENCE" { style.font-size: 14 desc: |md Events present in one chain but not the other. Alice logged message.sent for msg-123 Bob has no message.received for msg-123 **Result:** delivery failure or suppression, investigate transport | { shape: text; style.font-size: 13 } } ``` ## Retention Policy - Message lifecycle events: 12 months minimum - Delegation events: lifetime + 12 months - Connection events: lifetime + 6 months ## Export Format - JSON Lines (one `InkAuditEvent` per line, newline-delimited) - File naming: `ink-audit-{agentId}-{startDate}-{endDate}.jsonl` - Trailing line with final hash chain value --- Source: https://ink.tulpa.network/extensions/authorization-chains/ --- Multi-hop delegation chains allow an agent to act on behalf of another agent with verifiable, attenuated permissions. ## Delegation Proof Replaces the self-asserted `provenance` field with a cryptographically verifiable proof: ```typescript DelegationProof = { delegationToken: string, // existing format: payload.signature issuerPublicKey: string, // tulpa's public key (for recipient to verify) extensionSignature: string, // extension's sig over messageId + intent + JCS(payload) extensionPublicKey: string, // extension's key (from installation record) origin: ProvenanceOrigin, // now signed, not self-asserted } ``` ### Recipient Verification 1. Decode the delegation token, verify signature against `issuerPublicKey` 2. Check `issuerPublicKey` matches the sender's known public key 3. Verify `extensionSignature` against `extensionPublicKey` for this specific message 4. Check `extensionPublicKey` matches the key in the delegation token payload 5. Verify token hasn't expired and permissions are sufficient ## Multi-Hop Chains For Extension A → Service B → Service C chains: ```typescript DelegationChain = { hops: DelegationHop[], // min 1, max 5 } DelegationHop = { delegator: string, // did:key of delegator delegatorPublicKey: string, delegate: string, // did:key or extension ID delegatePublicKey: string, permissions: Permission[], maxAutonomyTier: AutonomyTier, constraints: { intentTypes?: IntentType[], targetAgents?: string[], expiresAt: string, // ISO 8601 maxMessages?: number, allowedTransports?: InkTransport[], // e.g. ["ink_http", "extension_api"] }, signature: string, // delegator's sig over delegate + permissions + constraints } ``` ### Chain Validation Rules - Each hop's permissions MUST be a **subset** of the previous hop's (no privilege escalation) - Each hop's `maxAutonomyTier` MUST be ≤ the previous hop's tier - Each hop's `expiresAt` MUST be ≤ the previous hop's expiration - Each hop's `allowedTransports` MUST be a **subset** of the previous hop's transports - Maximum chain depth: **5 hops** - The first hop MUST be signed by the tulpa owner's key ### Transport Scoping The `allowedTransports` constraint limits which transport channels a delegated token may be used on. **Standard transport identifiers:** `ink_http`, `ink_ws`, `extension_api`, `voice`, `line_phone`, `human_review_queue`. - Transport scope is enforced on extension API requests, not just INK HTTP, a token must explicitly include `extension_api` in `allowedTransports` to be usable on extension API surfaces - Tokens with `tokenVersion` but no `allowedTransports` default to `["ink_http"]` only (least privilege) - Legacy tokens (no `tokenVersion` field) receive a permissive default of `["ink_http", "extension_api", "voice", "line_phone"]` during a migration window - **Hard migration deadline: 2026-07-01**, after this date, legacy tokens without `tokenVersion` are treated as v0.3+ and default to `["ink_http"]` only - Messages arriving on a transport not in the token's `allowedTransports` are rejected with `transport_scope_violation` ## Delegation Flow A concrete example of a 3-hop chain: Owner → Extension → Sub-service → Recipient Agent. ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } hop0: Hop 0. Owner (did:plc:alice) { class: identity Signs delegation to Extension A permissions: "connections:list, intents:send" maxAutonomyTier: social expiresAt: "2026-03-25T00:00:00Z (4h)" signature: "Ed25519(alice_key, hop_payload)" } hop1: Hop 1. Extension A (com.scheduler) { class: ink Sub-delegates to Service B permissions: "intents:send ← attenuated" maxAutonomyTier: "transactional ← downgraded" intentTypes: "schedule_meeting ← constrained" expiresAt: "2026-03-24T22:00:00Z ← shorter" signature: "Ed25519(ext_a_key, hop_payload)" } hop2: Hop 2. Service B (com.scheduler.worker) { class: ink Sends intent to Agent B with full chain verify: |md Recipient verifies ALL hops: - Hop 0 signed by alice's known key - Hop 1 permissions ⊆ Hop 0 - Hop 1 tier ≤ Hop 0 tier - Hop 1 expiry ≤ Hop 0 expiry - Hop 2 permissions ⊆ Hop 1 - No hop expired - Chain depth ≤ 5 | } hop0 -> hop1: "permissions MUST be subset" hop1 -> hop2: "permissions MUST be subset" ``` ## Autonomy Tier Enforcement | Origin | Required Tier | Recipient Can Verify? | |--------|--------------|----------------------| | `human` | any | Yes, extension signature proves user input | | `agent_approved` | `social` or lower | Yes, delegation token tier checked | | `agent_autonomous` | `transactional` only | Yes, delegation token tier checked | ## Revocation - Each delegator maintains a revocation list - The chain includes a `revocationEndpoint` per hop - Recipients can optionally check revocation endpoints (non-blocking, cached) - Eventually consistent. TTL matches replay protection window (5 min) ## Token Lifetime Based on SPIFFE/UCAN research: - **Default TTL: 1–4 hours** (not 48 hours) - Extensions auto-renew before expiry - 48-hour tokens require elevated review status ## Prior Art | Decision | Rationale | Prior Art | |----------|-----------|-----------| | Flat hop array | Avoids exponential size growth | UCAN 1.0 CID-referenced proofs | | Permission subset checking | Fits INK's flat enum model | UCAN partial order | | Separate delegation from invocation | Prevents confused deputy | UCAN delegation/invocation split | | Short-lived tokens over revocation | Simpler in decentralized systems | SPIFFE SVIDs | | Max 5 hops | 2–3 typical in practice | No protocol sets a hard limit | --- Source: https://ink.tulpa.network/extensions/containment/ --- The Containment extension hardens INK against abuse by adding transport-scoped delegation, discovery minimization and per-correlation handshake budgets. ## Transport-Bound Authorization Delegation tokens can be scoped to specific transport channels via the `allowedTransports` constraint on delegation hops. See [Authorization Chains. Transport Scoping](/extensions/authorization-chains/#transport-scoping) for the full specification. **Standard transport identifiers:** | Transport | Description | |-----------|-------------| | `ink_http` | Standard HTTPS INK endpoints | | `ink_ws` | WebSocket connections | | `extension_api` | Browser/app extension API calls | | `voice` | In-app voice channels | | `line_phone` | PSTN telephony | | `human_review_queue` | Queued for human review | Transport-bound authorization is enforced on both INK HTTP and extension API surfaces, a token scoped to `["ink_http"]` cannot be used to call extension API endpoints and vice versa. Messages arriving on a transport not in the token's `allowedTransports` are rejected with `transport_scope_violation`. ### Version-Gated Migration - **v0.3+ tokens** (with `tokenVersion` field): omitted `allowedTransports` defaults to `["ink_http"]` (least privilege) - **Legacy tokens** (no `tokenVersion`): receive a permissive default of `["ink_http", "extension_api", "voice", "line_phone"]` during a 90-day migration window - After the migration window closes, legacy tokens are treated as v0.3+ (default to `["ink_http"]` only) ## Capability-Gated Discovery Agent Cards support four visibility levels that control what unauthenticated requests can see. See [Agent Card. Visibility](/spec/agent-card/#visibility) for the full table. When an agent's visibility is `network_only` or `capability_gated`, unauthenticated `GET /ink/v1/:agentId/agent.json` returns a **redacted card** that confirms the agent exists and supports INK but strips all sensitive fields. ### Redacted Card The redacted card includes only: - `agentId`, identity - `displayName`, human-readable name - `supportsInk: true`, protocol support flag - `discoveryMode: "authenticate_for_details"`, instructs the caller to authenticate - `visibility`, the card's visibility level - `updatedAt`, freshness timestamp Capabilities, endpoints, keys, availability and profile data are all stripped. ### Authenticated Query Flow ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } shape: sequence_diagram Requester Target Agent Requester -> Target Agent: "GET /ink/v1/:agentId/agent.json\n(unauthenticated)" Target Agent -> Requester: "Redacted card\ndiscoveryMode: authenticate_for_details" Requester -> Target Agent: "POST /ink/v1/:agentId/agent-card-query\nAuthorization: INK-Ed25519\ntype: network.tulpa.agent_card_query" Target Agent -> Requester: "Full card (if authorized)\ntype: network.tulpa.agent_card_response\n or \nDenied\ntype: network.tulpa.agent_card_denied" ``` **Denial reasons:** `unknown_requester`, `insufficient_trust`, `not_connected`. The difference between `network_only` and `capability_gated`: - **`network_only`** grants the full card to any authenticated INK peer - **`capability_gated`** filters by relationship tier, only peers meeting a trust threshold receive the full card ## Handshake Flood Resistance Per-correlation budgets and per-sender rate limits prevent handshake amplification attacks. These limits are enforced on all handshake ingress paths (INK HTTP and extension API). ### Sender Map Limits The `senders` map that tracks per-sender state has a configurable cap (default 1000 entries). When the cap is reached, the least-recently-used sender entry is evicted to prevent memory exhaustion. ### Per-Correlation Budgets Each `correlationId` (handshake session) has bounded state: | Limit | Default | Description | |-------|---------|-------------| | Max challenges | 3 | Maximum `network.tulpa.challenge` messages per correlation | | Max transitions | 5 | Total state transitions (intent + challenges + resolution) | | TTL | 24h | Maximum handshake duration (bounded by intent `expiresAt` if shorter) | **Terminal states:** `network.tulpa.rejection` and `network.tulpa.resolution` are terminal, no further messages are accepted for that `correlationId`. ### Per-Sender Rate Limits Sliding window counters per sender DID: | Limit | Default | |-------|---------| | Intents per minute | 10 | | Handshake messages per minute | 30 | ### Violation Behavior The first budget violation for a given sender returns a **typed rejection** with a `backoffHint`: ```json { "protocol": "ink/0.1", "type": "network.tulpa.rejection", "reason": "handshake_budget_exhausted", "backoffHint": { "retryAfterSeconds": 60, "backoffClass": "sender" }, "nonce": "", "timestamp": "..." } ``` Subsequent violations from the same sender are **silently dropped**, no response is sent, preventing amplification. ### Containment Rejection Reasons | Reason | Description | |--------|-------------| | `handshake_budget_exhausted` | Per-correlation budget exceeded | | `counterparty_cooldown` | Recipient broadly rate-limiting | | `sender_rate_limited` | Per-sender sliding window exceeded | | `delegation_budget_exhausted` | Delegation issuance limit hit | | `transport_scope_violation` | Transport not in delegation token scope | See [Error Codes](/reference/errors/) for HTTP status mappings. ## Governance Advertisement Agents advertise containment parameters in their Agent Card's `governance` block. See [Agent Card. Governance](/spec/agent-card/#governance). ## Audit Events Containment events are logged to the agent's hash-chained audit log: | Event | Description | |-------|-------------| | `transport_scope_violation` | Message rejected for transport mismatch | | `handshake_rate_limited` | Per-sender rate limit triggered | | `handshake_budget_exhausted` | Per-correlation budget exceeded | | `discovery_query_received` | Authenticated card query received | | `discovery_query_granted` | Full card returned to authenticated requester | | `discovery_query_denied` | Card query denied | See [Audit Trail. Event Types](/extensions/audit/#event-types) for the full list. --- Source: https://ink.tulpa.network/extensions/key-rotation/ --- INK agents need to rotate cryptographic keys over time, for routine hygiene or emergency response to compromise. The key rotation protocol ensures that an agent's identity remains stable across key changes while preserving verification of historical artifacts. Core principle: **keys rotate, identity does not.** ## Identity Model An agent's `agentId` is a stable logical identifier, independent of any particular signing key. When keys rotate, counterparties continue to address the same agent. Old receipts, audit events and witness records remain verifiable against retired keys. ## Agent Card Key Advertisement Agents advertise their key material in a `keys` block on the Agent Card. See the [Agent Card](/spec/agent-card/) spec for full schema details. ```json { "agentId": "did:plc:alice#agent/tulpa-main", "protocol": "ink/0.1", "keys": { "signing": [ { "keyId": "sig-2026-03", "algorithm": "Ed25519", "publicKeyMultibase": "z6Mkf5rGMoatrSj1f...", "status": "active", "validFrom": "2026-03-25T00:00:00Z" }, { "keyId": "sig-2025-11", "algorithm": "Ed25519", "publicKeyMultibase": "z6MkhaXgBZDvotDkL...", "status": "retired", "validFrom": "2025-11-01T00:00:00Z", "validUntil": "2026-04-01T00:00:00Z" } ], "encryption": [ { "keyId": "enc-2026-03", "algorithm": "X25519", "publicKeyMultibase": "z6LSbysY2xFMRpGMh...", "status": "active", "validFrom": "2026-03-25T00:00:00Z" } ] }, "currentSigningKeyId": "sig-2026-03", "currentEncryptionKeyId": "enc-2026-03", "keySetVersion": 7 } ``` ### Top-Level Fields | Field | Type | Description | |-------|------|-------------| | `currentSigningKeyId` | string | `keyId` of the current active signing key | | `currentEncryptionKeyId` | string | `keyId` of the current active encryption key | | `keySetVersion` | number | Monotonically increasing version; incremented on every rotation | ### KeyEntry Schema Each entry in the `signing` and `encryption` arrays: ```typescript type KeyEntry = { keyId: string; // unique within the agent's key set algorithm: "Ed25519" | "X25519"; // Ed25519 for signing, X25519 for encryption publicKeyMultibase: string; // z-prefixed base58btc public key status: "active" | "retired" | "revoked"; validFrom: string; // ISO 8601 datetime validUntil?: string; // ISO 8601 datetime (optional) revokedAt?: string; // ISO 8601 datetime (optional) revokeReason?: string; // human-readable reason (optional) }; ``` ### Key Statuses | Status | New outbound messages | Inbound verification | Historical verification | |--------|----------------------|---------------------|------------------------| | `active` | Yes | Yes | Yes | | `retired` | No | Yes (during rotation grace period; receivers MAY refuse based on local policy) | Yes (within validity window) | | `revoked` | No | Never | Only artifacts signed before `revokedAt` (local policy) | ## Verification ### Authority rule (normative) **The Agent Card signing key set, once observed, is authoritative.** A conforming receiver MUST: 1. Verify the signature against entries in the sender's Agent Card `keys.signing` set, iterating `active` then `retired`. Never iterate `revoked`. 2. If any entry verifies, accept and record which `keyId` was used. 3. If no entry verifies, **reject**. Do not fall through to any other key source. Only when the receiver has **never observed** an Agent Card for the sender may it use a bootstrap path, a public key derived from an agent-ID scheme, or a key stored locally from an earlier first-contact handshake. This is the *trust-on-first-use* window and ends the first time a valid Agent Card is observed for that sender. Three failure modes this rule closes: - **Stolen old key.** An attacker holding a key the sender has since marked `retired` or `revoked` cannot authenticate, even if a receiver still has the old key in its connection store. - **Fallback shadowing.** A receiver that "tries the authoritative set, then falls back to a single-key lookup" can have the fallback return a pre-rotation key. The rule forbids fallback after the authoritative set has spoken. - **Bootstrap persistence.** Identity schemes that derive a public key from the agent ID freeze that key at creation. Limiting its use to first-contact prevents indefinite acceptance. ### Signing Verification Order When verifying an inbound message signature: 1. If the request carries a `keyId` hint, try the matching `active` or `retired` key first. 2. Try the sender's `active` signing keys in card order. 3. Try the sender's `retired` signing keys in card order. 4. Reject if no candidate verifies. Publishers SHOULD list `currentSigningKeyId` first inside `keys.signing` so it is the first key tried by step 2. The `keyId` hint header is the explicit way to short-circuit the scan when the sender knows which key it signed with. ### Historical Verification For receipts, audit events and witness submissions: - Retired keys are valid for verification if the artifact timestamp falls within the key's validity window (`validFrom` to `validUntil`) - Revoked keys MUST NOT verify any signature, including artifacts whose timestamp predates `revokedAt`. Revocation is a trust statement and applies historically. Use `retired` if a key should remain valid for past traffic. ### Encryption - Senders MUST encrypt to the recipient's current active encryption key - During rotation overlap, receivers SHOULD support decryption with both the current and immediately previous retired encryption key - Recommended minimum overlap for planned rotation: **7 days** ## Rotation Flow ### Planned Rotation ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } step1: "1. Generate new keypair(s)" { class: identity } step2: |md **2. Publish updated Agent Card** - New key(s) → `active` - Previous key(s) → `retired` - Increment `keySetVersion` | { class: identity } step3: "3. Sign new outbound messages with new key" { class: ink } step4: "4. Accept verification with prior keys during overlap" { class: ink } step5: |md **5. After overlap period** Leave prior signing keys `retired` for historical verification | { class: identity } step1 -> step2 step2 -> step3 step3 -> step4 step4 -> step5 ``` ### Emergency Rotation For suspected key compromise: 1. Publish updated Agent Card immediately 2. Mark compromised key `revoked` with `revokedAt` timestamp 3. Increment `keySetVersion` 4. Begin signing with new key immediately Emergency revocation may break in-flight encrypted delivery. This is acceptable. ## Authentication Header Outbound INK messages SHOULD include the signing `keyId` in the authorization header: ``` Authorization: INK-Ed25519 keyId=sig-2026-03 ``` This lets receivers skip trial-verification across all candidate keys. If the `keyId` is unknown or omitted, receivers fall back to the standard verification order. See [Authentication](/spec/authentication/) for full header details. ## Cache Invalidation Implementations SHOULD cache discovered key sets but MUST refresh when: - Signature verification fails for all cached active keys - A message references an unknown `keyId` - A newer `keySetVersion` is observed - Encryption to the current key fails due to key mismatch Recommended cache TTL: **1 hour**. ## Rotation Advisory Message Implementations MAY define a vendor-specific advisory message — for example, `vendor.example.key_rotation` — to nudge counterparties to refresh their cached Agent Card after a planned rotation. A typical shape looks like: ```json { "type": "vendor.example.key_rotation", "from": "did:plc:alice#agent/tulpa-main", "to": "did:plc:bob#agent/tulpa-main", "newKeySetVersion": 7, "rotatedSigningKeyIds": ["sig-2025-11"], "rotatedEncryptionKeyIds": [], "timestamp": "2026-03-25T00:00:00Z", "nonce": "abc123" } ``` This is **not** part of the INK v0.1 wire-type registry (see [Wire Types](/reference/wire-types/)). It is purely advisory: the Agent Card remains the canonical source of truth for key material, and receivers SHOULD treat any such hint as a cue to refresh their cached Agent Card. Agents that want this nudge can ship it under their own namespace; agents that don't will discover the rotation on their next Card fetch. ## Historical Key Retention Historical keys used for verification SHOULD remain available for at least **90 days**. Indefinite retention with appropriate status marking is recommended. If a system cannot retain historical keys indefinitely in the live Agent Card, it MUST provide a documented verified history surface. ## Bootstrap Key Handling After key rotation, the bootstrap key embedded in the `agentId` is **no longer accepted** as a signing fallback. Witness nodes fetch the sender's Agent Card to resolve current signing keys rather than falling back to the bootstrap key. This prevents a compromised bootstrap key from being used to forge messages after a rotation has occurred. ## Legacy Compatibility ### Single-Key Agent Cards Agent Cards without a `keys` block use the top-level `publicKeyMultibase` field directly as the sole signing key. Receivers MUST continue to support this single-key format during the migration window. ### Key ID Hints `keyId` is optional and strongly recommended. Receivers use it as a verification hint, then fall back to the standard active-then-retired key scan when it is omitted or unknown. Bare `INK-Ed25519 ` headers remain valid for single-key and legacy peers. ## Observability Key rotation events are recorded in the audit trail: | Event Type | Trigger | |------------|---------| | `key.rotated` | New key activated, previous key retired | | `key.revoked` | Key marked as revoked | | `signature.verified_retired` | Message verified against a retired key | | `signature.revoked_rejected` | Message rejected due to revoked key | --- Source: https://ink.tulpa.network/extensions/third-party-audit/ --- Bilateral audit exchange has a fundamental limitation: a malicious agent can maintain two different hash chains and show each counterparty a different history (split-view attack). Third-party audit services solve this by introducing an independent witness that neither party controls. ## Architecture ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } shape: sequence_diagram agent_a: Agent A audit: Audit Service agent_b: Agent B agent_a -> audit: submit(event) audit -> agent_a: receipt(inclusion) agent_b -> audit: submit(event) audit -> agent_b: receipt(inclusion) agent_a -> audit: query(messageId) audit -> agent_a: proof(events, merkle) ``` ## Service Identity A third-party audit service is a **INK service role**, not a standard INK agent: | Concern | INK Agent | Audit Service | |---------|-----------|--------------| | Identity | DID bound to human via `agentLink` | `did:web` or `did:key`, self-sovereign | | Discovery | `INKAgentEndpoint` in DID doc (legacy `TulpaAgentEndpoint` also accepted) | Agent Card `thirdPartyAudit.services` | | Auth (inbound) | [INK auth S3.3](/spec/authentication/#signature-base) | [INK auth S3.3](/spec/authentication/#signature-base) (same) | | Auth (outbound) | Signs with `agentLink` key | Signs with its own Ed25519 key | | Delegation proof | Required | Not applicable, configured trust | ## Submission Protocol Agents submit events alongside normal hash chain maintenance. Submission is **asynchronous and non-blocking**. ```json POST /ink/v1/audit/submit { "protocol": "ink/0.1", "type": "network.tulpa.audit_submit", "from": "did:plc:agent", "to": "did:web:audit.example.com", "event": { /* InkAuditEvent */ }, "nonce": "", "timestamp": "2026-03-19T12:00:00Z" } ``` ### Signed Inclusion Receipt ```json { "protocol": "ink/0.1", "type": "network.tulpa.audit_inclusion", "eventId": "01JBTEST0001", "treeSize": 48291, "leafIndex": 48290, "rootHash": "", "timestamp": "2026-03-19T12:00:01Z", "serviceSignature": "" } ``` ## Submission + Inclusion Flow (Detailed) What happens step-by-step when an agent submits an event to a third-party audit service. ```d2 direction: down classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } step1: "1. Agent logs event locally\n(hash-chained, Ed25519-signed)" { class: audit } submit: |md **2. POST /ink/v1/audit/submit** type: audit_submit event: { InkAuditEvent } [Ed25519-signed by Agent A] | { class: ink } verify: |md **3. Service verifies:** ✓ INK auth (sender DID + sig) ✓ Event signature valid ✓ Sequence consistent | { class: audit } append: "4. Appends event to Merkle tree\nComputes new root hash" { class: audit } receipt: |md **5. Returns inclusion receipt** type: audit_inclusion eventId: "01JBTEST0001" treeSize: 48291 leafIndex: 48290 rootHash: SHA-256(root) serviceSignature: Ed25519(…) | { class: audit } store: "6. Agent stores inclusion receipt\nalongside local audit event\n(proves event was witnessed at\nthis tree position at this time)" { class: local } query: "Later: Agent or counterparty can\nquery the service with messageId\nto get Merkle inclusion proof" { shape: text style.font-size: 13 } step1 -> submit: Agent A submit -> verify: Audit Service verify -> append append -> receipt receipt -> store: Agent A store -> query ``` ## Access Control The service operates under **access-controlled transparency** (per SCITT): - Events are tagged with `messageId` and sender/recipient DIDs - Only parties to a message (or holders of a valid delegation chain) can query - The Merkle tree structure is public; event contents are access-controlled ## Merkle Tree Structure ```d2 direction: up classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } ev0: "Ev #0\nAgent A" { class: audit } ev1: "Ev #1\nAgent B" { class: audit } ev2: "Ev #2\nAgent A" { class: audit } ev3: "Ev #3\nAgent C" { class: audit } h01: H(0,1) { shape: circle; class: audit } h23: H(2,3) { shape: circle; class: audit } root: Root Hash { class: audit style.bold: true Published in checkpoint } ev0 -> h01 ev1 -> h01 ev2 -> h23 ev3 -> h23 h01 -> root h23 -> root proof: "Inclusion proof for Ev #1:\npath = [H(Ev #0), H(2,3)]\nverify: H(H(Ev #0) + H(Ev #1)) = H(0,1)\nH(H(0,1) + H(2,3)) = Root" { shape: text style.font-size: 13 } ``` ## Trust Model The audit service is a **semi-trusted witness**, not an arbiter: - It CANNOT forge events (Ed25519 signatures from agents) - It CANNOT modify events without breaking Merkle proofs - It CAN suppress events (detectable via consistency proofs) - It CAN be unavailable (agents fall back to bilateral exchange) For high-stakes interactions, agents MAY submit to **multiple independent services**. ## Agent Card Advertisement ```json { "capabilities": { "auditExchange": true, "thirdPartyAudit": { "services": [ { "endpoint": "https://audit.example.com/ink/v1", "did": "did:web:audit.example.com", "publicKey": "" } ], "submitPolicy": "all" } } } ``` `submitPolicy`: `all` | `high_value` | `none` ## Implementation Tiers ### Tier 1. Lowest effort, highest immediate value | Approach | How it works | Privacy | |----------|-------------|---------| | **Witness cosigning** (C2SP `tlog-witness`) | Periodic checkpoints, independent witnesses verify consistency | Witnesses see only tree size + root hash | | **Rekor hash notary** (Sigstore) | Submit checkpoint hashes to Rekor's public log | Hash-only, content stays private | ### Tier 2. Medium effort, stronger guarantees | Approach | How it works | Privacy | |----------|-------------|---------| | **SCITT service** | COSE_Sign1-wrapped events, Merkle inclusion receipts | Access-controlled | | **INK-native Merkle service** | INK auth, INK message format | Full INK access control | ### Tier 3. Infrastructure investment | Approach | How it works | Privacy | |----------|-------------|---------| | **Tessera-based log** | Static tile serving with external witnesses | Full control | | **OpenTimestamps** | Daily Bitcoin anchor for legally defensible timestamps | Hash-only | ## Checkpoint Format INK checkpoints are emitted as a lowercase-hex SHA-256 root hash plus an Ed25519 signature over the canonical checkpoint body. This matches the witness service output documented in [Witness Service](/extensions/witness/) and keeps the hash encoding consistent across both the bilateral and third-party audit paths: ``` ink-audit/ ``` --- Source: https://ink.tulpa.network/extensions/witness/ --- 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](#quickstart) below uses the demo so throwaway events stay out of the production log. Non-normative reference implementation: [Ad-Astra-Computing/witness](https://github.com/Ad-Astra-Computing/witness). Live reference deployments operated by Ad Astra Computing: [witness.tulpa.network](https://witness.tulpa.network) (production) and [witness-demo.tulpa.network](https://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](/extensions/third-party-audit/). ## Overview Each INK agent maintains its own hash-chained [audit log](/extensions/audit/). 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 ```d2 direction: right classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } agent_a: Agent A { class: ink log: Local Audit Log { shape: cylinder class: audit } } agent_b: Agent B { class: ink log: Local Audit Log { shape: cylinder class: audit } } witness: Witness Service { class: audit tree: Merkle Tree { shape: cylinder class: audit } checkpoint: Public Checkpoint { class: audit } tree -> checkpoint: root hash } agent_a -> witness: submit events agent_b -> witness: submit events witness -> agent_a: inclusion receipts witness -> agent_b: inclusion receipts verify: "Verification: both agents\ncan independently check\nthe public checkpoint and\nconfirm their events appear\nin the same tree" { shape: text style.font-size: 13 } ``` ## Endpoints The witness exposes six endpoints. Authenticated routes use `INK-Ed25519` transport auth per [S3.3](/spec/authentication/#signature-base). These are distinct from the bilateral [Audit Trail](/extensions/audit/) 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` | `/.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:** ```json { "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": "", "sequence": 42, "previousEventHash": "a1b2c3...", "eventType": "message.sent", "timestamp": "2026-03-19T12:00:00Z", "messageId": "msg-abc-123", "counterpartyId": "tulpa:z6Mk..." }, "nonce": "", "timestamp": "2026-03-19T12:00:00Z" } ``` **Response (200):** ```json { "protocol": "ink/0.1", "type": "network.tulpa.audit_inclusion", "eventId": "01JEXAMPLE0001", "treeSize": 48291, "leafIndex": 48290, "rootHash": "e3b0c44298fc1c149afbf4c8996fb924...", "inclusionProof": ["", ""], "timestamp": "2026-03-19T12:00:01Z", "serviceSignature": "" } ``` **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](#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:** ```json { "protocol": "ink/0.1", "type": "network.tulpa.audit_query", "from": "tulpa:z6Mk...", "to": "did:web:witness.tulpa.network", "messageId": "msg-abc-123", "nonce": "", "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. ```json { "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": "", "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": ["", "", "..."] } ], "treeSize": 48291, "rootHash": "<64 hex>", "timestamp": "2026-03-19T12:05:01Z", "serviceSignature": "" } ``` `serviceSignature` is `Ed25519` over `"ink/audit-query-response/v1\n" + JCS(payload-without-serviceSignature)`. Use `@adastracomputing/ink` ≥ `0.1.0-alpha.3`: ```ts 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 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:** | Parameter | Default | Description | |-----------|---------|-------------| | `start` | `0` | First leaf index to return | | `count` | `100` | Number of leaves to return (max 1000) | **Response (200):** ```json { "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/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):** ```json { "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):** ```json { "@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):** ```json { "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. ```bash mkdir witness-quickstart && cd witness-quickstart npm init -y && npm install @adastracomputing/ink ``` > Note: 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](https://github.com/Ad-Astra-Computing/witness)) on a separate Durable Object with its own Merkle tree. Throwaway quickstart traffic stays out of the production tree at `witness.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`: ```js 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: ```bash npx @adastracomputing/ink verify-inclusion \ --file receipt.json \ --witness https://witness-demo.tulpa.network # Exit 0 = valid, 1 = invalid, 2 = usage / network error ``` The CLI fetches the witness's DID document and current checkpoint, verifies the witness signature on the receipt and confirms the tree only grew (no rewind, no fork at the same `treeSize`). Pass `--event-hash ` to also walk the inclusion proof up to the claimed root. No npm project? Independent auditors can run the same check through Nix without installing the package: ```bash nix run github:Ad-Astra-Computing/ink -- verify-inclusion \ --file receipt.json \ --witness https://witness-demo.tulpa.network ``` Or call the library API directly (useful inside an integrator's own verification pipeline): ```js 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/submit` response is the receipt.** Save it directly. - **Two signatures**: `event.agentSignature` proves the agent authored the event; the `Authorization: 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.from` must 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 `nonce` must 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: 1` with `previousEventHash: null`. Subsequent events continue the per-agent hash chain (use `computeEventHash` on the previous event). - Reusing an `event.id` returns `409 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` (use `computeEventHash` on the previous event to derive the next `previousEventHash`). For a one-shot demo, generating a fresh keypair once is fine. ## Submit Flow ```d2 classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } shape: sequence_diagram agent: Agent witness: Witness Service tree: Merkle Tree agent -> agent: Sign audit event with Ed25519\n(JCS-canonicalized, excluding agentSignature) agent -> witness: POST /ink/v1/audit/submit\nAuthorization: INK-Ed25519 witness -> witness: Verify transport auth\n(INK-Ed25519 S3.3) witness -> witness: Verify body.from matches\ntransport auth sender witness -> witness: Peek nonce\n(read-only freshness check) witness -> witness: Verify event agentSignature\n(Ed25519 over JCS event) witness -> witness: Enforce per-agent chain continuity\n(sequence + previousEventHash) witness -> witness: Check event ID uniqueness\n(reject duplicates → 409) witness -> witness: Commit nonce\n(atomic, only after all sigs verify) witness -> tree: Compute SHA-256(JCS(event))\nAppend leaf hash tree -> witness: leafIndex, treeSize, rootHash witness -> witness: Sign inclusion receipt\nEd25519(eventId:treeSize:rootHash:timestamp) witness -> agent: 200: audit_inclusion receipt agent -> agent: Store inclusion receipt\nalongside local audit event ``` ### 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 from a submit-time receipt: 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. To verify an inclusion proof from an `/audit/query` response, call `verifyAuditQueryResponse` from `@adastracomputing/ink` (see the [POST /ink/v1/audit/query](#post-inkv1auditquery) 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: - `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 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.agentSignature` against the agent's published keys (Auditability §7.3 / §7.5). `verifyAuditQueryResponse` requires the caller to pass a `verifyEventSignature` callback for exactly this reason, and refuses to return valid without it. Verifiers that walk Merkle proofs without checking `agentSignature` lose 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/query` response binds `requester` and 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 `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](/spec/authentication/#signature-base)). 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 two-tier strategy: 1. **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](/extensions/key-rotation/#authority-rule-normative). 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. ```d2 direction: up classes: { identity: { style.fill: "#1e3a8a" style.stroke: "#60a5fa" style.font-color: "#ffffff" } ink: { style.fill: "#6d28d9" style.stroke: "#a78bfa" style.font-color: "#ffffff" } audit: { style.fill: "#047857" style.stroke: "#34d399" style.font-color: "#ffffff" } local: { style.fill: "#27272a" style.stroke: "#71717a" style.font-color: "#e8e8ec" style.stroke-dash: 3 } } ev0: "Leaf 0\nSHA-256(0x00 || event)" { class: audit } ev1: "Leaf 1\nSHA-256(0x00 || event)" { class: audit } ev2: "Leaf 2\nSHA-256(0x00 || event)" { class: audit } ev3: "Leaf 3\nSHA-256(0x00 || event)" { class: audit } ev4: "Leaf 4\nSHA-256(0x00 || event)" { class: audit } h01: SHA-256(0x01 || L0 || L1) { shape: circle; class: audit } h23: SHA-256(0x01 || L2 || L3) { shape: circle; class: audit } h03: SHA-256(0x01 || H01 || H23) { shape: circle; class: audit } root: Root Hash { class: audit style.bold: true } ev0 -> h01 ev1 -> h01 ev2 -> h23 ev3 -> h23 h01 -> h03 h23 -> h03 h03 -> root ev4 -> root: "RFC 6962:\nsplit at largest\npower of 2 < 5" ``` --- Source: https://ink.tulpa.network/guides/accepting-foreign-senders/ --- A **foreign sender** is an INK agent whose DID method is not native to your service. If your platform issues `did:plc:` identifiers, then `did:web:` and `did:key:` senders are foreign. If you issue `did:web:` identifiers under your own host, senders on every other host are foreign. This guide is for anyone implementing INK in a way that lets foreign agents reach their users. It separates two different kinds of requirements: - **Protocol requirements (MUST).** The minimum cryptographic and replay-protection checks every conforming INK receiver applies to every inbound envelope. These are not optional and are the same whether the sender is native or foreign. They come from the [authentication](/spec/authentication/), [replay-protection](/spec/replay-protection/), and [key rotation](/extensions/key-rotation/) specs. - **Deployment policy (SHOULD).** Operational choices a service makes to control which foreign senders reach which users, how aggressively to throttle them, and how to make those decisions visible. None of these are required by the protocol, but a public-internet deployment without most of them will get abused. A viewable reference implementation lives in the INK repo at [`examples/foreign-sender-receiver/`](https://github.com/Ad-Astra-Computing/ink/tree/main/examples/foreign-sender-receiver). See the [Reference implementations](#reference-implementations) section below for the full breakdown. ## Protocol requirements These apply to every inbound envelope, foreign or native. If you skip any of them you are not running INK. ### INK envelope verification (MUST) - Verify the envelope signature against the sender's keys per the [authentication spec](/spec/authentication/). - Enforce the freshness window (5 minutes past, 30 seconds future). - Enforce single-use of the envelope nonce via a NonceStore scoped at least per (sender, recipient). - Confirm the envelope's `to` field matches the receiving DID. - Validate canonicalization per [JCS](/spec/canonicalization/) before computing the signing base. Any of these failing is a hard reject. Do not log the envelope payload on reject; reason codes are enough for audit. ### Sender key authority (MUST) The sender's signing keys come from their published identity record, never from the envelope itself. The exact record depends on the DID method: - **`did:key:`** — the public key is encoded in the DID. No network fetch is required; decoding the multibase suffix yields the only valid signing key. - **`did:web:`** (and similar document-published methods) — fetch the DID document from the well-known URL. The document's `verificationMethod` entries carry the signing keys. - **`did:tulpa:` / `did:plc:` and other Agent-Card-publishing methods** — fetch the [Agent Card](/spec/agent-card/) and use its `keys.signing` set per the [key-rotation authority rule](/extensions/key-rotation/). For any sender whose keys come from a published record (anything except `did:key:`): - The published key set is canonical. The first key in the published set that verifies wins; the receiver records which key was used. - If no entry verifies, **reject**. Do not fall through to any locally-cached key, bootstrap key, or agent-ID-derived key once a published record has been observed for that sender. For Agent Card senders specifically, the [key-rotation authority rule](/extensions/key-rotation/) adds finer semantics: iterate `active` then `retired`, never iterate `revoked`, and retired keys may verify only within their `validFrom` and `validUntil` window. Plain DID-document methods (e.g. `did:web:`) typically expose only an active set of `verificationMethod` entries and do not carry rotation metadata; the receiver should treat every entry in the document as active and re-fetch on miss. ### Sender identity binding (MUST) The published record you fetch must bind cleanly to the resolution context: - For a DID document fetch, the document's `id` field must equal the DID under resolution; reject any document whose `id` mismatches. - For an Agent Card fetch, the card's `ownerDid` (when present) must equal the DID under resolution. The card's `agentId` is a separate identifier (the agent's own routable id, not the human DID) and must equal whatever agent identifier the consumer intends to deliver to. Reject on either mismatch. See the [Discovery spec](/spec/discovery/#agent-card-identity-binding-must) for the normative version of this rule. This is a substitution check. It prevents a host that legitimately publishes one DID from claiming to publish another DID at the same URL. It does NOT defend against an attacker who can publish whatever they want at the sender's host (e.g., a DNS or TLS compromise of `did:web:victim.example`). In that scenario the attacker can serve a matching-id document with attacker-controlled keys; the receiver has no structural way to detect this on first contact or on a forced cache refresh. Practical mitigations: (a) pin observed keys after first contact and require an out-of-band confirmation for unexpected rotations on sensitive senders, (b) accept higher trust only after a successful audit-exchange round-trip, (c) defer to user-visible flagging for the affected sender. None of these are spec requirements; they are deployment choices for receivers that need stronger-than-TOFU posture. ## Deployment policy These are operational choices, not protocol requirements. The defaults below are Tulpa's posture; an implementer choosing a different threat model can vary them. ### Layered acceptance gates (SHOULD) A practical decision flow that survived multiple external security reviews: 1. **Operator master flag.** A single boolean that disables every foreign DID at the platform level. Tulpa defaults this to off. 2. **Operator allow-list of methods or hosts.** Restricts which foreign DIDs even get resolved. Empty means "all", or "none" — pick one and document it. Tulpa treats empty as "none" so a misconfigured allow-list cannot accidentally widen acceptance. 3. **Per-user opt-in.** Each user individually controls whether their account accepts foreign senders. Tulpa defaults to off and honors the toggle within seconds. 4. **Per-user block-list.** Always-applied per-user deny list. Wins over every allow rule, including the user's own opt-in. 5. **Cryptographic verification.** Per the protocol section above. 6. **Final accept or reject.** Emit a stable reason code on every reject so audit logs are grep-able. A failure at any stage is a final reject. Do not retry a later stage with weaker inputs. ### Operator controls (SHOULD) - Toggle foreign-DID acceptance globally without a code deploy. - Restrict resolution to specific methods or hosts. - Surface current rejection counts grouped by reason so an operator can detect abuse in minutes. ### User consent UX (SHOULD) - Default to off for every new account. - Tell the user what is and is not affected (existing native peers keep their access; foreign senders gain new access). - Surface a per-DID block control on the inbox so a user can stop a specific sender without disabling the master setting. ### DID resolution discipline (SHOULD) - Validate DID syntax before any network call. Reject methods you do not support; reject malformed identifiers. - Apply a tight timeout (a few seconds) on every outbound DID document fetch. - Do not follow cross-host redirects on a `did:web:` fetch. Treat them as failed resolutions. ### Rebinding and SSRF defenses (SHOULD) A foreign DID's host is by definition attacker-controllable, so the resolver fetches arbitrary URLs based on those hosts. Defend against: - DNS rebinding (host resolves to a public IP at policy-check time, then to RFC1918 at fetch time). - Direct private-IP DIDs (`did:web:127.0.0.1`, `did:web:10.0.0.1`, link-local IPv6). - Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, etc.). - IPv6 literal hosts that bypass naive regex filters. Resolving the host, checking the IP, and then calling `fetch()` is **not sufficient on its own**: the runtime may re-resolve DNS between your check and the actual TCP connection. Either: - Use a runtime that blocks private-range egress at the platform layer (Cloudflare Workers does this). - Or pin the fetch to the IP you checked (resolve once, dial that address directly, set `Host:` manually). - Or use a vetted SSRF-defense library that does both. A naive "resolve then fetch" loop is exploitable. ### Agent Card cache hygiene (SHOULD) If you cache Agent Cards (you probably should — fetching one per envelope is expensive): - Short TTL (Tulpa uses minutes, not hours). - Force-refresh on a verification miss, on an unknown `keyId` hint, and on observing a newer `keySetVersion` field on the wire. - Cache by canonical DID, never by the un-normalized form an attacker can vary. A TTL alone gives false security during rotation/revocation windows — the explicit refresh triggers above are what cover the gap. ### Identifier canonicalization (SHOULD) Most allow-list bypasses come from sloppy string matching. Normalize before storage AND before matching: - Lowercase `did:web:` hosts. A capitalized host slips past `startsWith`. - Strip trailing dots (`example.com.` and `example.com` are the same authority). - Require the trailing colon when matching method allow-lists. A stored `did:key` (no colon) `startsWith`-matches `did:keyevil-attacker:...`. - Require a label boundary when matching host suffixes. `partner.example` must NOT match `evilpartner.example`. Reject inputs that fail your syntactic shape before storage. Re-validate on read so a bad row that bypassed the writer cannot widen acceptance later. ### Audit logging (SHOULD) Structured logs let you investigate the inevitable false-positive and false-negative reports. Log: - Accept or reject + reason code. - DID method and host of the sender. - Agent Card `keyId` that verified (key ID, not key material). - Nonce outcome (fresh or replay). - Risk-scoring decision and verdict source, if used. Do not log the envelope payload or the nonce itself. Do not log secrets. Sample on accept if log volume is a concern; never sample on reject. ### Rate limits and abuse controls (SHOULD) Limit at multiple scopes so a single attacker cannot exhaust your resources: - Per-sender DID. - Per-recipient user. - Per DID-method host. - Per source IP. - Per resolution rate (caps how fast new foreign DIDs can be resolved). Tune so first-contact handshake budgets are easy and sustained traffic from a new sender is throttled. ### Failure modes (SHOULD, fail-closed) The protocol-level checks above are MUST-reject on failure. The deployment-policy checks should also fail closed: - DID resolution timeout or 5xx → reject. - DID document with mismatched `id` → reject. - Agent Card fetch failure → reject. - Nonce reuse → reject (protocol MUST). - A signature classifier you decided to enforce being unreachable → reject under that enforced policy, accept under advisory policy. Pick one and document it. A foreign envelope should not be delivered when you could not get a clean result on every check your policy requires. ### Test against the real world (SHOULD) A common bug class: tests pass against a mocked DID resolver, production fails because the real resolver behaves differently. Run integration tests that exercise: - Real DID document fetches against a fixture server (not a mock). - Replay attempts on a previously-accepted envelope. - Stale envelope timestamps (just inside and just outside the freshness window). - DIDs whose document `id` field does not match the resolved DID. - DIDs whose host resolves to a private IP. - Allow-list bypass attempts using mixed case, trailing dots, and trailing-colon omission. - Per-user opt-out behavior end to end. The conformance suite at [`Ad-Astra-Computing/ink`](https://github.com/Ad-Astra-Computing/ink) includes vectors for several of these. ### Platform egress assumptions (SHOULD) Document which runtime guarantees you depend on. Cloudflare Workers refuses outbound fetches to RFC1918, link-local, and cloud metadata. AWS Lambda inside a VPC does not. Self-hosted Node does not. If your runtime does not block private-range egress for you, you must enforce it in your DID resolver before every fetch and pin the fetch to the verified IP (see rebinding above). ## Optional layers ### Risk scoring hook (MAY) A pluggable risk-scoring step is a useful optional control but is not part of the protocol. If you score: - Score AFTER signature, freshness, and nonce checks pass, never before. Scoring unverified envelopes wastes resources on garbage and lets a replay flood burn your classifier budget. - Decide upfront whether scoring is **advisory** (accept on outage, deliver and log) or **enforced** (reject on outage). Document which. - If you cache verdicts, bind every cached entry to the full envelope identity (sender, recipient, intent, payload digest) and authenticate the cache entry itself (HMAC at minimum). Otherwise a cache poison turns into a delivery bypass. The protocol does not specify a particular scorer. Tulpa, one implementation, uses its own service called INK Shield for this step; see [Receiver Risk Policy](/shield/) for the protocol-level view. ### Quarantine or review mode (MAY) Hold suspicious inbound envelopes server-side and present them to the user for explicit approval before delivery. Trades latency for a stronger consent model and works well for sensitive intents (introductions, financial requests). Distinguish quarantine from rejection in your audit log so legitimate slow paths do not look like errors. ### First-contact intent boundary (SHOULD) A foreign sender's first envelope to an unestablished recipient SHOULD be a `connection_request` — the bootstrap intent for first contact. Other intent types (`intro_request`, `follow_up`, `ask`, `schedule_meeting`, etc.) presume the sender is already a known contact of the recipient and assume a verified relationship at the trust layer above the wire. Receivers SHOULD enforce this by rejecting first-contact envelopes that carry any other intent. Tulpa's reference receiver does this in its pipeline: an inbound envelope from a sender with no published key set and no stored connection is only accepted when `intent == connection_request`; everything else is rejected with `unknown_sender`. The receiver then extracts the bootstrap signing key from the sender DID (the multibase suffix for `did:key:`, the `verificationMethod` set for `did:web:`) and verifies the body signature trust-on-first-use. The shape of the `connection_request` payload (`method`, `context`, `profileSnapshot`) gives the recipient enough information to decide whether to accept the connection without rendering attacker-controlled prose: the `method` enum identifies how the sender came across the recipient (`qr`, `intro`, `discovery`, `import`), the `context` field is a short free-text intro note, and the `profileSnapshot` carries the sender's self-described identity. Once accepted, the connection becomes part of the recipient's connection store and further intent types from that sender skip the bootstrap path. Treating `connection_request` as the only viable first-contact intent narrows the surface a foreign sender can touch before the recipient consents to a relationship and gives the recipient one canonical accept/decline gesture instead of many overlapping ones. ### Progressive trust (MAY) A first-contact foreign sender does not have to be on equal footing with a long-known peer. Patterns: - Stricter rate limits for the first N envelopes. - Lower-tier autonomy ceilings for newly-seen DIDs. - User-visible "new sender" indicators in the inbox. - Automatic graduation to normal limits after some number of accepted interactions. ## Common pitfalls Observed across implementations and reviews, roughly by frequency: - **Naive prefix matching on DID strings.** Always require the canonical `did::` shape including the trailing colon. - **DNS-changes-after-resolution.** The host you resolved at policy-check time may not be the host you fetch from now. Pin or rely on platform-level egress block. - **Mocked DID resolvers in tests** that skip real HTTP, TLS, and redirect behavior. Truth lives in integration tests. - **Failing to refresh Agent Card cache** on `keyId` miss or version bump. Short TTL alone is not enough during a rotation window. - **Failing to bind the envelope `to` field.** A signed envelope addressed to one user must not be deliverable to another. - **Nonce store scope errors.** Per-sender misses cross-sender replays; per-recipient misses replays across recipients. Scope per (sender, recipient) at minimum. - **Treating risk scoring as authorization.** Scoring is advisory unless you explicitly enforce. Cryptographic checks are authoritative either way. - **Logging secrets, full payloads, or nonce values** into ops logs. - **Allowing foreign senders by default** because tests with native senders pass. ## Reference implementations Three viewable codebases are useful when implementing your own receive side. **foreign-sender-receiver (TypeScript).** A self-contained reference implementation of the receive-side patterns documented above. Lives at [`examples/foreign-sender-receiver/`](https://github.com/Ad-Astra-Computing/ink/tree/main/examples/foreign-sender-receiver) in the INK repo. Includes: - The per-user acceptance policy and pure decision function (`inbound-policy.ts`). - The `did:web:` document URL derivation, host validation, and SSRF defenses (`did-web-resolver.ts`). - The outbound delivery primitive: SSRF-safe HTTPS POST with INK §3.3 Authorization signing, IPv6-literal rejection, and identity binding for `did:web` recipients (`outbound-delivery.ts`). - Vitest test cases covering prefix-confusion, method-prefix anchor, private-IP rejection, and host suffix matching. **ink-interop (Python, sender side).** A from-scratch INK client that does not depend on `@adastracomputing/ink`. Lives at [`examples/interop-cli/`](https://github.com/Ad-Astra-Computing/ink/tree/main/examples/interop-cli) in the INK repo. Useful for: - Reading a second, independent implementation of the wire format (JCS, signature base, multibase keys, Authorization header) to cross-check your own. - Running `ink-interop send` against your endpoint as an integration test the same way an external sender would. If your receiver accepts envelopes from `ink-interop` but rejects them from `@adastracomputing/ink` (or vice versa), the divergence narrows the bug to your receiver's interop surface or to one of the two sender implementations. - Reading a minimal `did:key:` sender so you can see what a foreign-DID envelope looks like without any platform-specific scaffolding. **`@adastracomputing/ink` library.** The MIT-licensed TypeScript library that ships the canonical signing/verification primitives (`verifyInkAuth`, Agent Card discovery, nonce store interfaces). The library is intentionally free of platform-specific code; the foreign-sender-receiver example wraps these primitives in the policy and SSRF layers. A production INK receiver running this pattern is operated at `tulpa.network` by Ad Astra Computing. That production code is closed-source — the patterns above are the documented design, not the production source. The [foreign-agents user guide](https://docs.tulpa.network/guide/foreign-agents/) on `docs.tulpa.network` describes how that production receiver surfaces the patterns to end users (master toggle, per-DID block list, inbox "Block sender" affordance). If you find a gap in this guide or in the example, please open an issue against the [INK repo](https://github.com/Ad-Astra-Computing/ink). --- Source: https://ink.tulpa.network/guides/agent-assisted-implementation/ --- If you'd like a coding agent (Claude, Codex, Cursor, or similar) to do the bulk of an INK implementation for you, this page collects everything the agent needs in one place. It is framed as **agent-assisted implementation**, not as outsourcing trust to the agent. The generated code is a starting point that **must pass the conformance test vectors and survive a security review** before any production traffic touches it. If you are implementing INK by hand instead, the [Accepting Foreign Senders guide](/guides/accepting-foreign-senders/) and the rest of this site are written for human readers; come back here only if you decide to hand the work to an agent. ## What you give the agent A coding agent does its best work when it has the full normative material in front of it, not just a project README. The pinned packet below is everything that the implementer needs: ### Spec bundle - **The full INK spec corpus.** [`llms-full.txt`](https://ink.tulpa.network/llms-full.txt) is the machine-readable concatenation of every spec, extension, and reference page on this site, in a stable order. Hand the whole file to the agent — it fits in modern context windows and is the only spec source the agent should treat as normative. - **Test vectors.** [Reference test vectors](/test-vectors/overview/) for signing, encryption, replay protection, audit, transport auth, containment, key rotation, discovery gating, and witness verification. The agent's implementation MUST reproduce every vector that covers a feature it claims to implement, byte-for-byte. Vectors for optional extensions only need to pass when the corresponding extension is in scope, but that scope must be declared up front in the traceability matrix below. - **Compliance checklist.** [INK 0.1 compliance checklist](/reference/compliance/) — every MUST and SHOULD item, grouped by domain. The agent should treat this as its acceptance criteria. ### Security obligations - **Receiver threat model.** [Accepting Foreign Senders](/guides/accepting-foreign-senders/) summarizes the protocol and deployment-level requirements for accepting INK from agents whose DID lives on a different platform. Even if your service is single-tenant, the cryptographic and replay-protection sections are non-negotiable. - **Wire-level checks.** The agent must implement signature verification, freshness, single-use nonce, recipient binding, JCS canonicalization, and protocol-field shape enforcement (every field that the spec marks MUST appears, has the correct type, and is rejected if missing or malformed). These are MUSTs in [Authentication](/spec/authentication/), [Replay Protection](/spec/replay-protection/), and [Canonicalization](/spec/canonicalization/). - **Structured error responses.** Use the [error codes](/reference/errors/) verbatim on every reject path. A receiver that returns generic 400s loses the audit signal that tells operators what happened. - **Encryption ordering.** When the implementation accepts or sends encrypted payloads, do signature verification first, decrypt second, then inner/outer identity binding ([Encryption](/spec/encryption/)). Sending an encrypted-but-signed-by-the-wrong-key envelope is silently broken if the order is reversed. - **Inner / outer identity check.** When a payload carries an inner identity claim (encryption recipient, delegated sender), the agent must confirm that claim against the outer envelope before delivering. Mismatches are protocol violations. - **Key rotation rules.** [Key Rotation](/extensions/key-rotation/) — revoked keys never verify, retired keys verify only within their `validFrom`/`validUntil` window. ### Reference implementations to read - **`@adastracomputing/ink`** — canonical TypeScript library with the signing/verification primitives. The agent SHOULD prefer using this library over re-implementing the wire format unless the goal is explicitly a from-scratch interop client. - **[`examples/foreign-sender-receiver/`](https://github.com/Ad-Astra-Computing/ink/tree/main/examples/foreign-sender-receiver)** — self-contained TypeScript reference for the receive-side patterns including SSRF defenses and the inbound-foreign decision function. - **[`examples/interop-cli/`](https://github.com/Ad-Astra-Computing/ink/tree/main/examples/interop-cli)** — Python sender that does not depend on `@adastracomputing/ink`. Useful for the agent to read as a second independent implementation of the wire format. ## The implementer prompt Hand the following prompt to your coding agent verbatim, along with the spec bundle above. The prompt is designed to keep the agent honest: cite the spec, refuse assumptions, write tests first, and flag every place where the input is ambiguous. > **Role.** You are implementing the INK protocol for the codebase I will share. INK is an Ed25519-signed agent-to-agent protocol; the full normative material is in `llms-full.txt` and the test vectors. Treat that material as authoritative. If something is not in those documents, do NOT invent it — surface the gap and ask me. > > **Scope and acceptance criteria.** Decide up front whether this is a full INK v0.1 implementation, a receive-only conformant subset, or a send-only client. A receive-only implementation cannot claim INK v0.1 compliance — it can only claim the subset it explicitly declares. Document the scope in the traceability matrix described below. Within scope, your implementation MUST pass every applicable item in the [INK 0.1 compliance checklist](https://ink.tulpa.network/reference/compliance/) and reproduce every applicable [test vector](https://ink.tulpa.network/test-vectors/overview/) byte-for-byte. You MUST NOT mark an item complete until the corresponding test passes. > > **Scope loopholes to refuse.** If you mark encrypted-payload receive as `out-of-scope`, your endpoint MUST reject intent types the spec requires to be encrypted (`schedule_meeting`, `context_share`, `multi_party_sync`). Accepting them in plaintext is a spec violation, not a scope reduction. The same rule applies anywhere a feature is "skipped" while its required failure modes remain mandatory. > > **Traceability matrix (mandatory).** Before writing any code, produce a table mapping every MUST item in the compliance checklist to one of `implemented` / `out-of-scope` / `blocked`, with a spec citation and the file path of the test that will cover it. Re-emit the table after each iteration with the columns updated. Do NOT silently narrow scope by leaving items off the table. > > **Workflow.** > 1. Read `llms-full.txt`, the compliance checklist, and the test vectors. Produce the traceability matrix. > 2. For each `implemented`-bucket MUST item, write the failing test first, then implement, then confirm green. > 3. For SSRF-adjacent surfaces (`did:web:` resolution, outbound INK delivery) treat the [`foreign-sender-receiver` example](https://github.com/Ad-Astra-Computing/ink/tree/main/examples/foreign-sender-receiver) as the structural reference for which defenses are required. > 4. Cite the spec section in commit messages, in the PR summary, in test names or descriptions, AND in code comments on every security-sensitive check. The citation is what lets a future reviewer audit your reasoning — many agent workflows do not produce meaningful commit history, so multiple citation surfaces are required, not just commits. > 5. After every implementation step, re-emit the traceability matrix, run the full test suite, and walk the conformance + human review checklists below. > > **Rules of engagement.** > - Never accept any resolver outbound HTTP to a private IP, link-local address, IPv6 mapped address, or cloud metadata endpoint. This applies to `did:web:` document fetches AND to every other resolver fetch you add (Agent Card discovery, third-party DID directories, embedded service endpoints). Use the `isIpLiteralHost` and `isPrivateHost` patterns from the reference example for every outbound resolver path, not only the `did:web:` one. > - Never compare DID strings with `startsWith` against a method prefix without the trailing colon. Require the canonical `did::` shape. > - Never log envelope payloads, nonces, or secret material into observability pipelines. > - Never trust risk-scoring verdicts as authorization. Cryptographic checks are authoritative; scoring is advisory. > - Never reverse the encryption ordering. Verify signature first, decrypt second, check inner/outer identity third. > - Use the [structured error codes](https://ink.tulpa.network/reference/errors/) on every reject. Do not invent reason strings. > - When a test fails, report it; do not silently relax the test. > - When a spec section is materially ambiguous (multiple interpretations have different security or wire-format consequences), flag it in the traceability matrix as `blocked` and stop. Harmless local decisions (variable naming, file layout, internal helper choice) can be documented in the matrix as `implemented` with a one-line note — do not block on those. ## Starter traceability matrix A concrete starter for the most common subset (Node receive-only endpoint, plaintext low-sensitivity intents). The agent's first response should be a matrix in this shape, not a wall of code. **The statuses below are illustrative example values for a typical implementation; the agent MUST re-evaluate every status against the actual codebase and replace them all before writing any code.** Do not copy `implemented` from this starter onto a row whose code has not been written. | ID | MUST item | Scope status | Spec citation | Planned test | |---|---|---|---|---| | AUTH-01 | Reject missing/invalid `Authorization: INK-Ed25519 ...` | implemented | Authentication; Errors | `test/ink/auth-header.test.ts` | | AUTH-02 | Verify Ed25519 signature on every inbound message | implemented | Authentication | `test/ink/signature.test.ts` | | AUTH-03 | Signature base binds protocol, method, path, recipient DID, JCS body, timestamp | implemented | Authentication; Canonicalization | `test/ink/signature-base.test.ts` | | AUTH-04 | Resolve sender key from `body.from`; missing/malformed sender fails closed | implemented | Authentication; Errors | `test/ink/sender.test.ts` | | AUTH-05 | Apply key-rotation rule: active/retired valid, revoked never valid | blocked | Key Rotation | `test/ink/key-rotation.test.ts` | | REPLAY-01 | Require `nonce` length/charset bounds | implemented | Replay Protection; Errors | `test/ink/replay.test.ts` | | REPLAY-02 | Require parseable UTC `timestamp` | implemented | Replay Protection; Errors | `test/ink/timestamp.test.ts` | | REPLAY-03 | Reject timestamps older than 5 minutes past | implemented | Replay Protection | `test/ink/timestamp.test.ts` | | REPLAY-04 | Reject timestamps more than 30 seconds future | implemented | Replay Protection | `test/ink/timestamp.test.ts` | | REPLAY-05 | Reject duplicate `(sender, nonce)` within replay window | implemented | Replay Protection | `test/ink/replay.test.ts` | | CANON-01 | Use RFC 8785 JCS, not `JSON.stringify`, for signature verification | implemented | Canonicalization | `test/ink/jcs-vectors.test.ts` | | CANON-02 | Exclude any signature field from canonical body | blocked | Canonicalization | `test/ink/jcs-vectors.test.ts` | | ERR-01 | Return structured error response on every reject path | implemented | Error Codes | `test/ink/errors.test.ts` | | INTENT-01 | Accept valid `POST /ink/v1/intent` envelope | implemented | Intents | `test/ink/intent.test.ts` | | ENC-01 | **Reject** plaintext `schedule_meeting` / `context_share` / `multi_party_sync` | implemented | Encryption | `test/ink/plaintext-sensitive-intents.test.ts` | | ENC-02 | Decrypt `InkEncryptedPayload` receive path | out-of-scope | Encryption | n/a | `implemented` rows must have a passing test. `blocked` rows must list the unresolved ambiguity in a follow-up comment. `out-of-scope` rows must NOT silently widen acceptance — note `ENC-01` is `implemented` precisely because the spec mandates rejecting plaintext for those intents even when the implementation has no decryption code path. This matrix is a starter for the most common subset. A full v0.1 implementation will have many more rows. A different subset (send-only, audit-only) replaces these rows entirely. Use the [compliance checklist](/reference/compliance/) as the source of truth for which rows your scope needs. ## Conformance checklist for the agent's output Run these against the agent's implementation before merging. The agent should also run them itself between iterations. - [ ] A current traceability matrix exists, with every compliance MUST mapped to `implemented` / `out-of-scope` / `blocked`, each with a spec citation and a test file path. No item is missing from the table. - [ ] Every `implemented`-bucket MUST item in [`/reference/compliance/`](/reference/compliance/) has a passing test. - [ ] `out-of-scope` items are reflected in the README / declared subset, so the implementation does not silently claim full v0.1 compliance. - [ ] Every [`/test-vectors/`](/test-vectors/overview/) vector covering a feature declared `implemented` in the traceability matrix reproduces byte-for-byte. Vectors for features declared `out-of-scope` need not run. - [ ] **Structured error codes.** Every reject path returns one of the codes from [`/reference/errors/`](/reference/errors/). No invented or generic strings. - [ ] **Protocol-field shape enforcement.** Every field the spec marks MUST is present, the correct type, and rejected when missing or malformed. - [ ] **Encryption ordering.** For encrypted intents, the implementation verifies the outer signature first, decrypts second, then checks inner identity claims against the outer envelope. Tests cover the wrong-order failure case. - [ ] **Inner / outer identity binding.** Encrypted payloads with mismatched inner-vs-outer sender identity are rejected. Test covers a forged inner-identity payload. - [ ] Signature verification rejects: tampered body, wrong `to` field, expired timestamp, future timestamp, replayed nonce, retired-key signature outside the validity window, revoked-key signature in any context. - [ ] `did:web:` resolver rejects: malformed DIDs, hosts that resolve to RFC1918 / loopback / link-local / IPv6 mapped / cloud metadata, redirects across hosts. - [ ] Allow-list / block-list matching: rejects prefix confusion (`partner.example` vs `evilpartner.example`), trailing dots, mixed case, method prefixes without trailing colon. - [ ] Failure modes: every required check fails closed; risk scoring is treated as advisory unless explicitly enforced. - [ ] Observability: no secrets, full payloads, or nonces in logs. ## Human review checklist Before any production traffic, a human MUST sign off on: - [ ] The agent followed the workflow above (tests first; spec citations in commits, PR summary, test names/descriptions, and code comments on every security-sensitive check). - [ ] No "unimplemented" / "TODO" markers remain in security-relevant code paths. - [ ] Integration tests exercise real DID document fetches (not mocked) against at least one fixture server. - [ ] The agent flagged every place where the spec was ambiguous; each flag has a documented resolution. - [ ] At least one round of static analysis or dependency audit has been run. - [ ] If the implementation receives traffic from foreign DIDs, a security review specifically of the SSRF and identity-binding surfaces has been performed. ## What this section does not do It does not certify the agent's output. It does not promise that following the prompt produces a compliant or secure implementation. It collects the right materials so that an agent **can** do the work, and the conformance checklist plus human review are how you confirm the agent actually did. If your implementation passes the conformance checklist and the human review, please consider [opening a discussion](https://github.com/Ad-Astra-Computing/ink/discussions) describing what worked and what didn't. The packet on this page is more useful with feedback from real agent-assisted runs. --- Source: https://ink.tulpa.network/reference/errors/ --- All INK endpoints MUST return structured error responses: ```json { "protocol": "ink/0.1", "error": true, "code": "signature_verification_failed", "message": "Ed25519 signature did not verify against any authoritative candidate key" } ``` ## Error Codes The codes below cover both the **transport-auth middleware** (returned by `verifyInkAuth` in the library) and **application-layer** rejections that an integrator may add on top. ### Transport authentication (returned by `verifyInkAuth`) | Code | HTTP Status | Description | |------|-------------|-------------| | `missing_authorization` | 401 | No `Authorization` header present | | `invalid_auth_scheme` | 401 | Header is not a valid `INK-Ed25519 ` form | | `missing_sender` | 401 | `body.from` is empty or missing | | `invalid_from_field` | 401 | `body.from` is not a string, or exceeds 256 chars | | `missing_timestamp` | 401 | `body.timestamp` is missing | | `invalid_timestamp` | 401 | `body.timestamp` does not parse as a valid date | | `timestamp_expired` | 401 | Timestamp older than 5 minutes | | `timestamp_too_far_future` | 401 | Timestamp more than 30 seconds ahead | | `signature_verification_failed` | 401 | Signature did not verify against any authoritative candidate key | | `invalid_signature` | 401 | Signature failed verification against the bootstrap public key | | `unresolvable_sender_key` | 401 | Could not derive or look up a public key for the sender | | `nonce_handling_required` | 401 | Middleware was invoked without a `nonceStore` option (fail-closed default) | | `missing_nonce` | 401 | `body.nonce` missing or outside `[16,256]` charset bounds when middleware-side nonce enforcement is on | | `nonce_replay` | 401 | Nonce already recorded in the `NonceStore` within the freshness window | | `nonce_store_error` | 401 | `nonceStore.has` or `nonceStore.add` threw (fail-closed) | ### Application-layer / extension errors | Code | HTTP Status | Description | |------|-------------|-------------| | `duplicate_nonce` | 400 | Returned by the standalone `checkReplay` helper when a nonce is in `previouslySeenNonces` | | `encryption_required` | 400 | Intent type requires encrypted payload | | `decryption_failed` | 400 | Could not decrypt payload | | `unsupported_version` | 400 | Protocol version not supported | | `rate_limited` | 429 | Too many requests | | `handshake_budget_exhausted` | 429 | Per-correlation handshake budget exceeded | | `sender_rate_limited` | 429 | Per-sender sliding window rate limit exceeded | | `transport_scope_violation` | 403 | Invocation transport not permitted by delegation token | | `sender_mismatch` | 403 | Message `from` field does not match authenticated sender identity | | `access_denied` | 403 | Requester is not a party to the referenced message | | `internal_error` | 500 | Agent internal error | DID resolution and `agentLink` lookup are integrator-side concerns. If your implementation surfaces those failures, conventional codes are `unknown_did` (404) and `no_agent_link` (404). ## Witness Service Error Codes The [witness service](/extensions/witness/) returns these additional error codes on its authenticated endpoints. | Code | HTTP Status | Description | |------|-------------|-------------| | `missing_authorization` | 401 | No `Authorization` header present | | `invalid_auth_scheme` | 401 | Authorization header is not `INK-Ed25519` scheme | | `nonce_replay` | 401 | Nonce has already been used (10-minute window) | | `duplicate_event_id` | 409 | An event with this ID has already been recorded | | `invalid_agent_signature` | 400 | Event `agentSignature` does not verify against the agent's public key | | `invalid_agent_id_format` | 400 | Cannot extract a public key from the `agentId` field | | `sender_mismatch` | 401 | Transport auth sender does not match `body.from` | | `event_agent_mismatch` | 400 | `event.agentId` does not match submission sender | | `rate_limit_exceeded` | 429 | Agent has exceeded 30 submissions per minute | | `forbidden` | 403 | Requester is not a party to the queried message | --- Source: https://ink.tulpa.network/reference/compliance/ --- Use this as a self-audit before claiming INK v0.1 compliance. Items are grouped by domain. Each item is marked **MUST** (required for interop), **SHOULD** (strongly recommended), or **MAY** (optional extension). :::caution[Authority rule] Once an agent publishes a `keys.signing` set in its Agent Card, that set is canonical. Bootstrap key derivation MUST NOT be used as a fallback. Revoked keys MUST NOT verify any signature, including artifacts whose timestamp predates `revokedAt`. See [Key Rotation](/extensions/key-rotation/). ::: ## Identity and keys - [ ] **MUST** Publish an Agent Card with `keys.signing` (Ed25519) and `keys.encryption` (X25519) key sets. Each entry carries `keyId`, `algorithm`, `publicKey`, `status` (`active` / `retired` / `revoked`), and validity timestamps. - [ ] **MUST** Publish a discoverable agent endpoint (e.g. a service entry in the DID document) so counterparties can fetch the Agent Card. - [ ] **MUST** Apply the key-rotation authority rule: `active` and `retired` keys verify within their validity windows; `revoked` keys never verify, including for historical artifacts. - [ ] **MUST** Document the PDS trust level the implementation operates at. ## Transport authentication - [ ] **MUST** Verify Ed25519 signatures on every inbound INK message using the sender's `keys.signing` set. The signature base binds protocol version, HTTP method, request path, recipient DID, JCS-canonical body, and timestamp. - [ ] **MUST** Enforce replay protection on every inbound message: reject `timestamp` more than 5 minutes old or 30 seconds in the future, and reject duplicate `nonce` values within that window. Reference implementations get this from `verifyInkAuth` when a `NonceStore` is supplied, or by calling `checkReplay` (or equivalent) in the request pipeline. - [ ] **MUST** Return structured error responses for all failure cases. See [Error Codes](/reference/errors/). - [ ] **MUST** Include a valid `protocol` in all outbound messages: `ink/0.1` by default, or `ink/0.2` only when sending to a receiver that advertises it (see [Discovery](/spec/discovery/)). ## Intents and resolutions - [ ] **MUST** Accept `POST /ink/v1/intent` with a valid INK message envelope. - [ ] **MUST** Implement HITL escalation for any intent where `autonomyPolicy.maxAutonomyLevel` is not `full`. - [ ] **MUST** Store resolutions as local application data with export support. ## Encryption - [ ] **MUST** Encrypt `schedule_meeting` and `context_share` intent payloads using the `InkEncryptedPayload` wire format. Reject plaintext for these types. - [ ] **MUST** Decrypt inbound `InkEncryptedPayload` envelopes: verify signature and replay protection before decryption, verify inner / outer `from` match after decryption. ## Visibility and connections - [ ] **MUST** Support `network.tulpa.connection` mutual record verification for `connections`-level visibility. ## Receipts (optional) - [ ] **MAY** Accept `POST /ink/v1/receipt` for delivery receipts and advertise receipt capabilities in the Agent Card. ## Audit (optional) - [ ] **MAY** Accept `POST /ink/v1/audit` for audit exchange and maintain a hash-chained audit log. - [ ] **MUST** *(if audit is supported)* Consumers of audit responses run both the response-signature gate AND a chain-continuity gate (strictly +1 sequence within a slice, `previousEventHash` linkage, duplicate-sequence fork detection). The library exposes these as `verifyAuditResponseSignature` and `verifyAuditEventChain`. ## Authorization chains (optional) - [ ] **MAY** Support multi-hop authorization chains with delegation proof verification. --- ### Self-audit summary | Domain | Items | |---|---| | Identity and keys | 4 MUST | | Transport authentication | 4 MUST | | Intents and resolutions | 3 MUST | | Encryption | 2 MUST | | Visibility and connections | 1 MUST | | Receipts | 1 MAY | | Audit | 1 MAY + 1 conditional MUST | | Authorization chains | 1 MAY | Implementations that meet every **MUST** in the required domains are conformant with INK v0.1. **MAY** items are optional extensions; if a MAY domain is implemented, the conditional MUST items inside that domain become required. Rejecting an unknown `protocol` value is a baseline schema requirement that applies to every implementation regardless of `ink/0.2` support: the envelope schema accepts only `ink/0.1` or `ink/0.2`. Supporting `ink/0.2` is an optional capability on top of v0.1 conformance, not a separate compliance tier. An implementation that opts in MUST select the body-signature domain from the signed `protocol` field (`ink/sign\n` for `ink/0.2`, `tulpa/sign\n` for `ink/0.1`) and SHOULD advertise the versions it verifies in its Agent Card `supportedProtocolVersions` array. A receiver that does neither remains a conformant `ink/0.1`-only implementation. See [Discovery & Transport](/spec/discovery/). --- Source: https://ink.tulpa.network/reference/lexicons/ --- INK defines the following AT Protocol lexicons under the `network.tulpa` namespace. ## ATP Records These lexicons define records stored in the user's PDS repo: | Lexicon ID | Description | |-----------|-------------| | `network.tulpa.agentLink` | Agent binding record with key material | | `network.tulpa.focusSignal` | Real-time status/focus signal | | `network.tulpa.connection` | Bidirectional connection record | | `network.tulpa.autonomyPolicy` | Agent autonomy configuration | | `network.tulpa.trustAttestation` | Signed reputation attestation | ## INK Wire Messages These lexicons define INK protocol messages transmitted via HTTPS: | Lexicon ID | Wire Type | Endpoint | |-----------|-----------|----------| | `network.tulpa.ink.intent` | `network.tulpa.intent` | `POST /ink/v1/intent` | | `network.tulpa.ink.challenge` | `network.tulpa.challenge` | `POST /ink/v1/challenge` | | `network.tulpa.ink.rejection` | `network.tulpa.rejection` | `POST /ink/v1/rejection` | | `network.tulpa.ink.resolution` | `network.tulpa.resolution` | `POST /ink/v1/resolution` | | `network.tulpa.ink.encrypted` | `network.tulpa.encrypted` | `POST /ink/v1/intent` | | `network.tulpa.ink.receipt` | `network.tulpa.receipt` | `POST /ink/v1/receipt` | | `network.tulpa.ink.auditQuery` | `network.tulpa.audit_query` | `POST /ink/v1/audit` | | `network.tulpa.ink.auditResponse` | `network.tulpa.audit_response` | Response to audit query | | `network.tulpa.ink.auditSubmit` | `network.tulpa.audit_submit` | `POST /ink/v1/audit/submit` | | `network.tulpa.ink.auditInclusion` | `network.tulpa.audit_inclusion` | Response to audit submit | See [Wire Types](/reference/wire-types/) for the naming convention explanation. --- Source: https://ink.tulpa.network/reference/wire-types/ --- INK uses two distinct identifier formats for message types. Implementations MUST distinguish between them. ## Convention | Purpose | Format | Example | |---------|--------|---------| | **AT Protocol Lexicon ID** | camelCase, includes `ink` segment | `network.tulpa.ink.auditQuery` | | **Wire `type` field** | snake_case, omits `ink` segment | `network.tulpa.audit_query` | The lexicon ID follows AT Protocol conventions (camelCase NSID with hierarchical namespacing). The wire type is the value in the `"type"` field of every INK message, it is what implementations match on when routing messages. **Implementations MUST key off the wire `type` field, not the lexicon ID.** ## Complete Mapping | Wire Type (`type` field) | Lexicon ID | Description | |-------------------------|-----------|-------------| | `network.tulpa.intent` | `network.tulpa.ink.intent` | Intent message | | `network.tulpa.challenge` | `network.tulpa.ink.challenge` | Context challenge | | `network.tulpa.rejection` | `network.tulpa.ink.rejection` | Rejection response | | `network.tulpa.resolution` | `network.tulpa.ink.resolution` | Resolution outcome | | `network.tulpa.encrypted` | `network.tulpa.ink.encrypted` | ECIES encrypted envelope | | `network.tulpa.receipt` | `network.tulpa.ink.receipt` | Delivery receipt | | `network.tulpa.audit_query` | `network.tulpa.ink.auditQuery` | Audit exchange request | | `network.tulpa.audit_response` | `network.tulpa.ink.auditResponse` | Audit exchange response | | `network.tulpa.audit_submit` | `network.tulpa.ink.auditSubmit` | Third-party audit submission | | `network.tulpa.audit_inclusion` | `network.tulpa.ink.auditInclusion` | Audit inclusion receipt | | `network.tulpa.agent_card_query` | `network.tulpa.ink.agentCardQuery` | Authenticated agent-card query | | `network.tulpa.agent_card_response` | `network.tulpa.ink.agentCardResponse` | Authenticated agent-card response | | `network.tulpa.agent_card_denied` | `network.tulpa.ink.agentCardDenied` | Authenticated agent-card denial | ## Why Two Formats For single-word types, the distinction is invisible: `network.tulpa.intent` maps to `network.tulpa.ink.intent`. For multi-word types, it matters: `network.tulpa.audit_query` (wire) vs. `network.tulpa.ink.auditQuery` (lexicon). The wire format uses snake_case for consistency with the JSON body convention. The lexicon format uses camelCase to match AT Protocol's NSID registry convention. --- Source: https://ink.tulpa.network/test-vectors/overview/ --- Reference test vectors for INK signing, encryption, replay protection, audit, transport auth, containment, key rotation, discovery gating and witness verification, spanning the `ink/0.1` and `ink/0.2` wire versions. These vectors use fixed key material and deterministic inputs so that two independent implementations can verify byte-for-byte correctness. ## Table of Contents - [Key Material](#key-material) - [Signing Test Cases](#signing-test-cases) - [Encryption Test Cases](#encryption-test-cases) - [Audit Chain Test Cases](#audit-chain-test-cases) - [Transport Auth Test Vectors](#transport-auth-test-vectors) - [Containment Test Vectors](#containment-test-vectors) - [Key Rotation Test Vectors](#key-rotation-test-vectors) - [Discovery Gating Test Vectors](#discovery-gating-test-vectors) - [Witness Test Vectors](#witness-test-vectors) - [Audit Response Signature Test Vectors](#audit-response-signature-test-vectors) ## Files | File | Description | |------|-------------| | `keys.json` | Fixed Ed25519 and X25519 key pairs for Alice and Bob (hex-encoded) | | `signing.json` | Transport-auth signature generation and verification test cases | | `body-signature.json` | Version-keyed body signature: legacy vs ink/0.2 domain, cross-version and tamper cases | | `encryption.json` | ECIES encryption/decryption test cases | | `jcs.json` | JCS canonicalization test cases | | `replay.json` | Replay protection acceptance/rejection test cases | | `receipts-and-audit.json` | Receipt signatures, audit query signatures, hash-chained events and fork detection | ## Usage 1. Load key material from `keys.json` 2. Run each test case: construct the expected output from the inputs and compare 3. All base64url values use no-padding encoding (RFC 4648 S5) 4. All hex values are lowercase ## Key Material The test keys were generated deterministically from fixed seeds. They are NOT suitable for production use. ```json { "alice": { "did": "did:key:z6MkExampleAlice1111111111111111111111111", "signing": { "publicKeyHex": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "privateKeyHex": "1111111111111111111111111111111111111111111111111111111111111111" }, "encryption": { "publicKeyHex": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "privateKeyHex": "2222222222222222222222222222222222222222222222222222222222222222" } }, "bob": { "did": "did:key:z6MkExampleBob22222222222222222222222222222", "signing": { "publicKeyHex": "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "privateKeyHex": "3333333333333333333333333333333333333333333333333333333333333333" }, "encryption": { "publicKeyHex": "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", "privateKeyHex": "4444444444444444444444444444444444444444444444444444444444444444" } } } ``` The full test vectors are available in the [INK repository](https://github.com/Ad-Astra-Computing/ink/tree/main/test-vectors). ## Signing Test Cases Each signing test case provides: - `input`: The signature base components (method, path, recipientDid, body, timestamp) - `expectedSignatureBase`: The concatenated string before signing - `expectedSignature`: The base64url-encoded Ed25519 signature ## Encryption Test Cases Each encryption test case provides: - `input`: Plaintext payload, sender/recipient info, deterministic ephemeral key and nonce - `expectedEnvelope`: The complete outer envelope - `expectedDecrypted`: The decrypted plaintext (must match original) ## Audit Chain Test Cases Each audit chain test case provides: - `events`: Ordered list of audit events with their signatures - `expectedHashes`: The `previousEventHash` value for each event after the first - `forkDetection`: Cases where duplicate sequence numbers indicate a forked chain --- ## Transport Auth Test Vectors Test vectors for INK-Ed25519 transport authentication. The signature base is constructed by concatenating request components with newline separators. ### Signature base construction Given a request's protocol version, method, path, recipient DID, JCS-canonicalized body and timestamp, the signature base is formed as `PROTOCOL\nMETHOD\nPATH\nrecipientDid\nJCS(body)\ntimestamp`. The body is included as its JCS-canonical bytes, not a hash. ```json { "description": "INK-Ed25519 signature base construction from request components", "input": { "protocol": "ink/0.1", "method": "POST", "path": "/ink/v1/intent", "recipientDid": "did:key:z6MkExampleBob22222222222222222222222222222", "body": { "type": "network.tulpa.intent", "from": "did:key:z6MkExampleAlice1111111111111111111111111", "to": "did:key:z6MkExampleBob22222222222222222222222222222", "payload": { "message": "Hello Bob" } }, "jcsCanonicalizedBody": "{\"from\":\"did:key:z6MkExampleAlice1111111111111111111111111\",\"payload\":{\"message\":\"Hello Bob\"},\"to\":\"did:key:z6MkExampleBob22222222222222222222222222222\",\"type\":\"network.tulpa.intent\"}", "timestamp": "2026-04-01T12:00:00Z" }, "expectedSignatureBase": "ink/0.1\nPOST\n/ink/v1/intent\ndid:key:z6MkExampleBob22222222222222222222222222222\n{\"from\":\"did:key:z6MkExampleAlice1111111111111111111111111\",\"payload\":{\"message\":\"Hello Bob\"},\"to\":\"did:key:z6MkExampleBob22222222222222222222222222222\",\"type\":\"network.tulpa.intent\"}\n2026-04-01T12:00:00Z", "notes": "The signature base is signed with the sender's Ed25519 private key. The resulting signature is placed in the Authorization header as: `INK-Ed25519 <86-char-base64url-signature>`, optionally followed by ` keyId=` for rotation hints." } ``` ### Authorization header form The reference middleware accepts exactly one header shape, matched against the regex `^INK-Ed25519\s+([A-Za-z0-9_-]{86})(?:\s+keyId=([A-Za-z0-9_:.-]{1,128}))?$`. Sender identity comes from `body.from`, timestamp from `body.timestamp`; there are no `did=` or `ts=` parameters in the header. ```json { "description": "Authorization header without keyId", "valid": "INK-Ed25519 ZXhhbXBsZS1iYXNlNjR1cmwtc2lnbmF0dXJlLWV4YWN0bHktODYtY2hhcmFjdGVycy1sb25nLWVuY29kZWQtcw" } ``` ```json { "description": "Authorization header with optional keyId hint", "valid": "INK-Ed25519 ZXhhbXBsZS1iYXNlNjR1cmwtc2lnbmF0dXJlLWV4YWN0bHktODYtY2hhcmFjdGVycy1sb25nLWVuY29kZWQtcw keyId=sig-2026-03" } ``` ### Sender identity cross-check `body.from` is the authoritative sender DID. After signature verification succeeds against the sender's resolved key set, integrators that route on identity MUST also ensure any nested identity claims (e.g. payload `actor`) match `body.from`. If they differ, the request is rejected. ```json { "description": "Sender identity cross-check, body.from matches authenticated sender", "input": { "authHeader": "INK-Ed25519 <86-char-base64url-signature>", "authenticatedSenderDid": "did:key:z6MkExampleAlice1111111111111111111111111", "body": { "type": "network.tulpa.intent", "from": "did:key:z6MkExampleAlice1111111111111111111111111", "to": "did:key:z6MkExampleBob22222222222222222222222222222" } }, "expectedResult": "accepted", "notes": "Authenticated sender DID (resolved from body.from + valid signature against the sender's published key set) matches the identity claim, request proceeds" } ``` ### Sender mismatch error ```json { "description": "sender_mismatch error when nested actor identity differs from authenticated sender", "input": { "authHeader": "INK-Ed25519 <86-char-base64url-signature>", "authenticatedSenderDid": "did:key:z6MkExampleAlice1111111111111111111111111", "body": { "type": "network.tulpa.intent", "from": "did:key:z6MkExampleAlice1111111111111111111111111", "to": "did:key:z6MkExampleBob22222222222222222222222222222", "payload": { "actor": "did:key:z6MkExampleMallory33333333333333333333333333" } } }, "expectedResult": "rejected", "expectedError": { "error": "sender_mismatch", "message": "Nested actor claim does not match authenticated sender" }, "httpStatus": 403, "notes": "Prevents spoofed downstream identity in routed messages, the payload claims to act on behalf of Mallory but the auth proves Alice signed the request" } ``` --- ## Containment Test Vectors Test vectors for transport scope enforcement, handshake budgets and counterparty validation. ### Transport scope, token with explicit allowedTransports ```json { "description": "Token with allowedTransports: [\"ink_http\"] rejects extension_api transport", "input": { "token": { "tokenVersion": "0.3", "agentId": "did:key:z6MkExampleAlice1111111111111111111111111", "allowedTransports": ["ink_http"], "issuedAt": "2026-04-01T00:00:00Z" }, "requestTransport": "extension_api" }, "expectedResult": "rejected", "expectedError": { "error": "transport_scope_violation", "message": "Token does not permit transport: extension_api" }, "httpStatus": 403, "notes": "A token scoped to ink_http cannot be used on the extension_api transport" } ``` ### Transport scope, legacy token (no tokenVersion) ```json { "description": "Legacy token without tokenVersion is permissive during migration window", "input": { "token": { "agentId": "did:key:z6MkExampleAlice1111111111111111111111111", "issuedAt": "2025-12-01T00:00:00Z" }, "requestTransport": "extension_api", "currentDate": "2026-04-01T00:00:00Z" }, "expectedResult": "accepted", "notes": "Legacy tokens without a tokenVersion field are granted permissive transport access during the migration window to avoid breaking existing deployments" } ``` ### Transport scope, v0.3 token without allowedTransports ```json { "description": "v0.3 token without allowedTransports defaults to ink_http only", "input": { "token": { "tokenVersion": "0.3", "agentId": "did:key:z6MkExampleAlice1111111111111111111111111", "issuedAt": "2026-04-01T00:00:00Z" }, "requestTransport": "extension_api" }, "expectedResult": "rejected", "expectedError": { "error": "transport_scope_violation", "message": "Token does not permit transport: extension_api" }, "httpStatus": 403, "notes": "When a v0.3 token omits allowedTransports, the default is [\"ink_http\"], restricting it to the standard INK HTTP transport" } ``` ### Handshake budget, per-correlation challenge limit ```json { "description": "Per-correlation challenge limit rejects the 4th challenge on the same correlationId", "input": { "correlationId": "corr-abc-123", "challengesSentSoFar": 3, "maxChallengesPerCorrelation": 3, "newChallengeAttempt": { "type": "network.tulpa.challenge", "correlationId": "corr-abc-123", "from": "did:key:z6MkExampleBob22222222222222222222222222222", "to": "did:key:z6MkExampleAlice1111111111111111111111111" } }, "expectedResult": "rejected", "expectedError": { "error": "handshake_budget_exhausted", "message": "Maximum challenges (3) reached for this correlation" }, "httpStatus": 429, "notes": "Prevents infinite challenge loops within a single handshake correlation" } ``` ### Handshake budget, per-sender rate limit ```json { "description": "Per-sender rate limit rejects the 11th intent within a 60-second window", "input": { "senderDid": "did:key:z6MkExampleAlice1111111111111111111111111", "intentsSentInWindow": 10, "windowDurationSeconds": 60, "maxIntentsPerWindow": 10, "newIntentAttempt": { "type": "network.tulpa.intent", "from": "did:key:z6MkExampleAlice1111111111111111111111111", "to": "did:key:z6MkExampleBob22222222222222222222222222222" } }, "expectedResult": "rejected", "expectedError": { "error": "sender_rate_limited", "message": "Sender rate limit exceeded: 10 intents per 60s" }, "httpStatus": 429, "notes": "Prevents a sender from flooding a recipient with handshake intents" } ``` ### Counterparty mismatch ```json { "description": "Challenge from non-counterparty agent is rejected", "input": { "handshakeState": { "correlationId": "corr-abc-123", "initiator": "did:key:z6MkExampleAlice1111111111111111111111111", "responder": "did:key:z6MkExampleBob22222222222222222222222222222" }, "incomingChallenge": { "type": "network.tulpa.challenge", "correlationId": "corr-abc-123", "from": "did:key:z6MkExampleMallory33333333333333333333333333", "to": "did:key:z6MkExampleAlice1111111111111111111111111" } }, "expectedResult": "rejected", "expectedError": { "error": "sender_mismatch", "message": "Challenge sender is not a participant in this handshake" }, "httpStatus": 403, "notes": "Only the two agents involved in a handshake (initiator and responder) may send challenges on that correlationId" } ``` --- ## Key Rotation Test Vectors Test vectors for Ed25519 key rotation and retired-key handling. ### Active key verification succeeds ```json { "description": "After key rotation, verification succeeds with the current active key", "input": { "agentDid": "did:key:z6MkExampleAlice1111111111111111111111111", "keyHistory": [ { "keyId": "key-bootstrap-001", "publicKeyHex": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "status": "retired", "validUntil": "2026-03-15T00:00:00Z" }, { "keyId": "key-current-002", "publicKeyHex": "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", "status": "active", "validFrom": "2026-03-15T00:00:00Z" } ], "message": "{\"type\":\"network.tulpa.intent\",\"from\":\"did:key:z6MkExampleAlice1111111111111111111111111\"}", "signedWithKeyId": "key-current-002" }, "expectedResult": "accepted", "verificationKey": "key-current-002", "notes": "The active key is always tried first for verification. The optional `keyId=` hint in the Authorization header lets the receiver jump straight to the matching key." } ``` ### Bootstrap key rejected after rotation ```json { "description": "After key rotation, the bootstrap key is no longer accepted for new signatures", "input": { "agentDid": "did:key:z6MkExampleAlice1111111111111111111111111", "keyHistory": [ { "keyId": "key-bootstrap-001", "publicKeyHex": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "status": "retired", "validUntil": "2026-03-15T00:00:00Z" }, { "keyId": "key-current-002", "publicKeyHex": "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", "status": "active", "validFrom": "2026-03-15T00:00:00Z" } ], "message": "{\"type\":\"network.tulpa.intent\",\"from\":\"did:key:z6MkExampleAlice1111111111111111111111111\"}", "signedWithKeyId": "key-bootstrap-001", "messageTimestamp": "2026-04-01T12:00:00Z" }, "expectedResult": "rejected_by_local_policy", "expectedError": { "error": "signature_verification_failed", "message": "Signature verified against a retired key newer than the receiver's freshness policy" }, "notes": "Retired keys still verify per the authority rule, but receivers MAY refuse on local policy (e.g. reject anything older than 30 days, or refuse retired keys entirely for new messages). Senders SHOULD prefer the active key to avoid this rejection class." } ``` ### Bootstrap key works when no rotation has occurred ```json { "description": "Without key rotation, the bootstrap key is accepted normally", "input": { "agentDid": "did:key:z6MkExampleAlice1111111111111111111111111", "keyHistory": [ { "keyId": "key-bootstrap-001", "publicKeyHex": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "status": "active", "validFrom": "2025-01-01T00:00:00Z" } ], "message": "{\"type\":\"network.tulpa.intent\",\"from\":\"did:key:z6MkExampleAlice1111111111111111111111111\"}", "signedWithKeyId": "key-bootstrap-001" }, "expectedResult": "accepted", "verificationKey": "key-bootstrap-001", "notes": "When no rotation has occurred, the sole bootstrap key is the active key" } ``` ### Retired key verification, accepted but flagged ```json { "description": "Verifying an older message signed with a now-retired key succeeds but flags usedRetiredKey", "input": { "agentDid": "did:key:z6MkExampleAlice1111111111111111111111111", "keyHistory": [ { "keyId": "key-bootstrap-001", "publicKeyHex": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "status": "retired", "validUntil": "2026-03-15T00:00:00Z" }, { "keyId": "key-current-002", "publicKeyHex": "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", "status": "active", "validFrom": "2026-03-15T00:00:00Z" } ], "context": "verifying_historical_message", "messageTimestamp": "2026-03-10T00:00:00Z", "signedWithKeyId": "key-bootstrap-001" }, "expectedResult": "accepted", "expectedFlags": { "usedRetiredKey": true, "retiredKeyId": "key-bootstrap-001", "validUntil": "2026-03-15T00:00:00Z" }, "notes": "Historical messages signed with a retired key can still be verified per the authority rule (`active` then `retired`), and the verification result records which keyId was used so the verifier can apply local policy (e.g. flag historical-only acceptance, refuse retired keys for live writes)." } ``` ### Revoked key never verifies ```json { "description": "A revoked key MUST NEVER verify a signature, even one made before the revocation timestamp", "input": { "agentDid": "did:key:z6MkExampleAlice1111111111111111111111111", "keyHistory": [ { "keyId": "key-compromised-001", "publicKeyHex": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "status": "revoked", "revokedAt": "2026-03-15T00:00:00Z" }, { "keyId": "key-current-002", "publicKeyHex": "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", "status": "active", "validFrom": "2026-03-15T00:00:00Z" } ], "messageTimestamp": "2026-03-10T00:00:00Z", "signedWithKeyId": "key-compromised-001" }, "expectedResult": "rejected", "expectedError": { "error": "signature_verification_failed", "message": "No active or retired key verified the signature" }, "notes": "Revocation is a trust statement: the receiver MUST treat the key as compromised and refuse it even for messages with a pre-revocation timestamp. Retired vs revoked is the key semantic distinction." } ``` --- ## Discovery Gating Test Vectors Test vectors for agent card visibility modes and redacted card schema enforcement. ### Public visibility, full card returned ```json { "description": "Agent with public visibility returns full card on unauthenticated GET", "input": { "agentDid": "did:key:z6MkExampleAlice1111111111111111111111111", "visibility": "public", "authenticated": false, "method": "GET", "path": "/agent/did:key:z6MkExampleAlice1111111111111111111111111" }, "expectedResult": "full_card", "expectedResponseFields": [ "agentId", "displayName", "supportsInk", "discoveryMode", "visibility", "updatedAt", "publicKey", "endpoints", "capabilities" ], "httpStatus": 200, "notes": "Public agents expose their full card to anyone without authentication" } ``` ### Network-only visibility, redacted on unauthenticated GET ```json { "description": "Agent with network_only visibility returns redacted card on unauthenticated GET", "input": { "agentDid": "did:key:z6MkExampleBob22222222222222222222222222222", "visibility": "network_only", "authenticated": false, "method": "GET", "path": "/agent/did:key:z6MkExampleBob22222222222222222222222222222" }, "expectedResult": "redacted_card", "expectedResponseFields": [ "agentId", "displayName", "supportsInk", "discoveryMode", "visibility", "updatedAt" ], "excludedFields": ["publicKey", "endpoints", "capabilities", "bio", "avatar"], "httpStatus": 200, "notes": "Unauthenticated requests see only the redacted card fields" } ``` ### Network-only visibility, full card on authenticated query ```json { "description": "Agent with network_only visibility returns full card on authenticated query", "input": { "agentDid": "did:key:z6MkExampleBob22222222222222222222222222222", "visibility": "network_only", "authenticated": true, "authenticatedAs": "did:key:z6MkExampleAlice1111111111111111111111111", "method": "GET", "path": "/agent/did:key:z6MkExampleBob22222222222222222222222222222" }, "expectedResult": "full_card", "expectedResponseFields": [ "agentId", "displayName", "supportsInk", "discoveryMode", "visibility", "updatedAt", "publicKey", "endpoints", "capabilities" ], "httpStatus": 200, "notes": "Authenticated agents within the network see the full card" } ``` ### Capability-gated visibility, redacted card on GET ```json { "description": "Agent with capability_gated visibility returns redacted card on GET", "input": { "agentDid": "did:key:z6MkExampleBob22222222222222222222222222222", "visibility": "capability_gated", "authenticated": true, "authenticatedAs": "did:key:z6MkExampleAlice1111111111111111111111111", "method": "GET", "path": "/agent/did:key:z6MkExampleBob22222222222222222222222222222" }, "expectedResult": "redacted_card", "expectedResponseFields": [ "agentId", "displayName", "supportsInk", "discoveryMode", "visibility", "updatedAt" ], "excludedFields": ["publicKey", "endpoints", "capabilities", "bio", "avatar"], "httpStatus": 200, "notes": "Capability-gated agents only reveal full cards to agents holding a valid capability token, standard authentication is not sufficient" } ``` ### Private visibility. 404 on GET ```json { "description": "Agent with private visibility returns 404 on GET", "input": { "agentDid": "did:key:z6MkExampleBob22222222222222222222222222222", "visibility": "private", "authenticated": true, "authenticatedAs": "did:key:z6MkExampleAlice1111111111111111111111111", "method": "GET", "path": "/agent/did:key:z6MkExampleBob22222222222222222222222222222" }, "expectedResult": "not_found", "httpStatus": 404, "notes": "Private agents are invisible to discovery, the response is indistinguishable from a nonexistent agent" } ``` ### Redacted card schema validation ```json { "description": "Redacted card must include exactly the allowed fields and nothing else", "redactedCardSchema": { "required": ["agentId", "displayName", "supportsInk", "discoveryMode", "visibility", "updatedAt"], "additionalProperties": false }, "validRedactedCard": { "agentId": "did:key:z6MkExampleBob22222222222222222222222222222", "displayName": "Bob", "supportsInk": true, "discoveryMode": "authenticate_for_details", "visibility": "network_only", "updatedAt": "2026-04-01T00:00:00Z" }, "invalidRedactedCard": { "agentId": "did:key:z6MkExampleBob22222222222222222222222222222", "displayName": "Bob", "supportsInk": true, "discoveryMode": "authenticate_for_details", "visibility": "network_only", "updatedAt": "2026-04-01T00:00:00Z", "publicKey": "LEAKED, this field must not appear in a redacted card" }, "notes": "Implementations must strip all fields except the six required ones when producing a redacted card. Leaking publicKey, endpoints, capabilities, bio or avatar in a redacted response is a security violation." } ``` --- ## Witness Test Vectors Test vectors for Merkle tree construction, signed checkpoints, event deduplication and identity validation in the witness subsystem. ### Merkle leaf hash ```json { "description": "Merkle leaf hash is SHA-256(0x00 || JCS(event))", "input": { "event": { "eventId": "evt-001", "agentId": "did:key:z6MkExampleAlice1111111111111111111111111", "eventType": "message.sent", "timestamp": "2026-04-01T12:00:00Z", "correlationId": "corr-abc-123" }, "jcsCanonicalizedEvent": "{\"agentId\":\"did:key:z6MkExampleAlice1111111111111111111111111\",\"correlationId\":\"corr-abc-123\",\"eventId\":\"evt-001\",\"timestamp\":\"2026-04-01T12:00:00Z\",\"eventType\":\"message.sent\"}", "leafPrefix": "0x00" }, "expectedLeafHashHex": "sha256(0x00 || )", "notes": "The 0x00 prefix distinguishes leaf nodes from internal nodes, preventing second-preimage attacks. The event is JCS-canonicalized before hashing to ensure deterministic ordering of fields." } ``` ### Merkle internal node hash ```json { "description": "Merkle internal node hash is SHA-256(0x01 || left || right)", "input": { "leftChildHashHex": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "rightChildHashHex": "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "internalPrefix": "0x01" }, "expectedInternalHashHex": "sha256(0x01 || a1b2c3...a1b2 || c3d4e5...c3d4)", "computation": "SHA-256(0x01 + fromHex('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2') + fromHex('c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4'))", "notes": "The 0x01 prefix distinguishes internal nodes from leaves. Left and right are the raw 32-byte hashes of the child nodes concatenated in order." } ``` ### Signed checkpoint ```json { "description": "Witness produces a signed checkpoint over the Merkle root", "input": { "checkpoint": { "witnessId": "witness-primary", "merkleRoot": "e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6", "eventCount": 42, "timestamp": "2026-04-01T12:05:00Z", "previousCheckpointHash": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3" }, "signatureBase": "JCS-canonicalized checkpoint body", "witnessSigningKeyHex": "5555555555555555555555555555555555555555555555555555555555555555" }, "expectedOutput": { "checkpoint": { "witnessId": "witness-primary", "merkleRoot": "e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6", "eventCount": 42, "timestamp": "2026-04-01T12:05:00Z", "previousCheckpointHash": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3" }, "signature": "" }, "notes": "The checkpoint is JCS-canonicalized before signing. The previousCheckpointHash chains checkpoints together for tamper detection." } ``` ### Event deduplication, duplicate event ID ```json { "description": "Submitting an event with a duplicate eventId returns 409 Conflict", "input": { "existingEventId": "evt-001", "newEvent": { "eventId": "evt-001", "agentId": "did:key:z6MkExampleAlice1111111111111111111111111", "eventType": "message.sent", "timestamp": "2026-04-01T12:01:00Z", "correlationId": "corr-abc-123" } }, "expectedResult": "rejected", "expectedError": { "error": "duplicate_event_id", "message": "Event with ID evt-001 already exists" }, "httpStatus": 409, "notes": "Event IDs are globally unique within the witness. Re-submission of an already-witnessed event is rejected to prevent replay and double-counting." } ``` ### Event identity validation, body.from must match event.agentId ```json { "description": "Event submission rejected when authenticated sender does not match event.agentId", "input": { "authenticatedSenderDid": "did:key:z6MkExampleAlice1111111111111111111111111", "event": { "eventId": "evt-002", "agentId": "did:key:z6MkExampleMallory33333333333333333333333333", "eventType": "message.sent", "timestamp": "2026-04-01T12:02:00Z", "correlationId": "corr-def-456" } }, "expectedResult": "rejected", "expectedError": { "error": "sender_mismatch", "message": "Authenticated sender does not match event.agentId" }, "httpStatus": 403, "notes": "Agents can only submit witness events for themselves. The authenticated sender DID must match the event's agentId field." } ``` --- ## Audit Response Signature Test Vectors Test vectors for domain-separated audit response signatures. The witness signs audit responses so that querying agents can verify the response was not tampered with in transit. ### Domain-separated audit response signature ```json { "description": "Audit response signature uses domain-separated prefix before JCS(events)", "input": { "domainSeparator": "ink/audit-response\n", "events": [ { "eventId": "evt-001", "agentId": "did:key:z6MkExampleAlice1111111111111111111111111", "eventType": "message.sent", "timestamp": "2026-04-01T12:00:00Z", "correlationId": "corr-abc-123" }, { "eventId": "evt-003", "agentId": "did:key:z6MkExampleAlice1111111111111111111111111", "eventType": "message.received", "timestamp": "2026-04-01T12:00:05Z", "correlationId": "corr-abc-123" } ], "jcsCanonicalizedEvents": "", "signatureBase": "ink/audit-response\n", "witnessSigningKeyHex": "5555555555555555555555555555555555555555555555555555555555555555" }, "expectedOutput": { "events": [""], "signature": "", "witnessId": "witness-primary" }, "notes": "The domain separator 'ink/audit-response\\n' is prepended to the JCS-canonicalized events array before signing. This prevents cross-context signature reuse, a signature over a checkpoint cannot be confused with a signature over an audit response." } ``` ### Audit response signature verification ```json { "description": "Verifier reconstructs the signature base and checks the witness signature", "input": { "auditResponse": { "events": [ { "eventId": "evt-001", "agentId": "did:key:z6MkExampleAlice1111111111111111111111111", "eventType": "message.sent", "timestamp": "2026-04-01T12:00:00Z", "correlationId": "corr-abc-123" } ], "signature": "", "witnessId": "witness-primary" }, "witnessPublicKeyHex": "e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6" }, "verificationSteps": [ "1. Extract events array from the response", "2. JCS-canonicalize the events array", "3. Prepend domain separator: 'ink/audit-response\\n'", "4. Verify Ed25519 signature against the witness public key" ], "expectedResult": "signature_valid", "notes": "The verifier must use the same domain separator and JCS canonicalization to reconstruct the exact bytes that were signed. If the signature does not verify, the audit response may have been tampered with." } ```