@adastracomputing/ink 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CODE_OF_CONDUCT.md +42 -0
  3. package/LICENSE-APACHE +201 -0
  4. package/LICENSE-MIT +21 -0
  5. package/README.md +133 -0
  6. package/SECURITY.md +57 -0
  7. package/docs/key-rotation-rule.md +108 -0
  8. package/docs/logo.svg +8 -0
  9. package/docs/maturity.md +81 -0
  10. package/docs/threat-model.md +150 -0
  11. package/package.json +72 -0
  12. package/specs/ink-agent-containment-and-governance-extension-spec.md +508 -0
  13. package/specs/ink-auditability.md +652 -0
  14. package/specs/ink-authorization-chain.md +242 -0
  15. package/specs/ink-compatibility-policy.md +263 -0
  16. package/specs/ink-compliance-checklist.md +309 -0
  17. package/specs/ink-containment-phase1-implementation-spec.md +593 -0
  18. package/specs/ink-introduction-receipts-extension.md +501 -0
  19. package/specs/ink-key-rotation-spec.md +535 -0
  20. package/src/crypto/ink.ts +902 -0
  21. package/src/crypto/keys.ts +211 -0
  22. package/src/crypto/multi-key-verify.ts +170 -0
  23. package/src/crypto/sign.ts +155 -0
  24. package/src/crypto/verify.ts +1 -0
  25. package/src/discovery/agent-card.ts +508 -0
  26. package/src/index.ts +59 -0
  27. package/src/ink/checkpoint.ts +75 -0
  28. package/src/ink/discovery-gating.ts +147 -0
  29. package/src/ink/handshake-budget.ts +413 -0
  30. package/src/ink/receipts.ts +114 -0
  31. package/src/ink/transport-auth.ts +96 -0
  32. package/src/middleware/ink-auth.ts +263 -0
  33. package/src/models/agent-card.ts +63 -0
  34. package/src/models/ink-audit.ts +205 -0
  35. package/src/models/ink-handshake.ts +123 -0
  36. package/src/models/intent.ts +201 -0
  37. package/src/models/key-entry.ts +52 -0
  38. package/src/models/profile.ts +31 -0
  39. package/test-vectors/README.md +129 -0
  40. package/test-vectors/encryption.json +90 -0
  41. package/test-vectors/handshake.json +482 -0
  42. package/test-vectors/jcs.json +30 -0
  43. package/test-vectors/key-rotation.json +101 -0
  44. package/test-vectors/keys.json +32 -0
  45. package/test-vectors/receipts-and-audit.json +142 -0
  46. package/test-vectors/replay.json +88 -0
  47. package/test-vectors/signing.json +61 -0
  48. package/test-vectors/witness.json +394 -0
@@ -0,0 +1,593 @@
1
+ # INK Containment Phase 1, Implementation Spec
2
+
3
+ ## Status
4
+ Draft
5
+
6
+ ## Purpose
7
+
8
+ Narrow implementation spec for Phase 1 of the [Agent Containment and Governance Extension](ink-agent-containment-and-governance-extension-spec.md). Covers three slices:
9
+
10
+ 1. **Transport-bound authorization**, delegation tokens scoped to specific transports
11
+ 2. **Capability-gated Agent Card discovery**, redacted cards for unauthenticated peers
12
+ 3. **Handshake flood resistance**, per-correlation budgets, typed rejections, backoff hints
13
+
14
+ These three were chosen because they are independently shippable, require no fleet infrastructure, and harden the protocol surface that already exists.
15
+
16
+ ---
17
+
18
+ ## Slice 1: Transport-Bound Authorization
19
+
20
+ ### Problem
21
+
22
+ A delegation token issued for INK HTTP use can currently be replayed via extension callbacks, voice workflows, or other channels. The authorization chain spec defines `constraints` with `intentTypes`, `targetAgents`, `expiresAt`, and `maxMessages`, but not which transport the token is valid on.
23
+
24
+ ### Wire Changes
25
+
26
+ #### 1.1 Add `allowedTransports` to delegation hop constraints
27
+
28
+ Extend `DelegationHopSchema.constraints`:
29
+
30
+ ```typescript
31
+ constraints: z.object({
32
+ intentTypes: z.array(IntentTypeSchema).optional(),
33
+ targetAgents: z.array(z.string()).optional(),
34
+ expiresAt: z.string().datetime(),
35
+ maxMessages: z.number().int().positive().optional(),
36
+ // NEW
37
+ allowedTransports: z.array(InkTransportSchema).optional(),
38
+ }),
39
+ ```
40
+
41
+ Transport identifiers:
42
+
43
+ ```typescript
44
+ const InkTransportSchema = z.enum([
45
+ "ink_http", // Standard INK HTTP endpoints
46
+ "ink_ws", // WebSocket INK transport (future)
47
+ "extension_api", // Product API calls from extensions
48
+ "voice", // In-app voice (CF Calls / WebRTC)
49
+ "line_phone", // PSTN phone calls via Telnyx
50
+ "human_review_queue", // Escalation queue for human review
51
+ ]);
52
+
53
+ type InkTransport = z.infer<typeof InkTransportSchema>;
54
+ ```
55
+
56
+ #### 1.2 Default behavior and legacy migration
57
+
58
+ If `allowedTransports` is **omitted**, the effective default depends on a version gate:
59
+
60
+ - **Tokens issued after this spec ships (v0.3+):** omitted `allowedTransports` defaults to `["ink_http"]`. Implementations MUST NOT treat omission as "all transports allowed."
61
+ - **Legacy tokens (pre-v0.3, no `allowedTransports` field):** during the migration window (90 days from deploy), omitted `allowedTransports` defaults to `["ink_http", "extension_api", "voice", "line_phone"]`, matching the set of transports that existed before transport scoping was introduced. After the migration window closes, legacy tokens without `allowedTransports` fall back to `["ink_http"]` only.
62
+
63
+ **Version gate mechanism:** the delegation token issuance endpoint stamps a `tokenVersion` field (string, e.g. `"0.3"`) on newly issued tokens. Tokens without `tokenVersion` are legacy. The migration window end date is stored as a deployment config constant, not hardcoded in the protocol.
64
+
65
+ **Migration plan:**
66
+ 1. Deploy transport scoping with the legacy-permissive default
67
+ 2. Update all token issuance paths (extension install, delegation grant) to include `allowedTransports` explicitly
68
+ 3. Monitor audit logs for `transport_scope_violation` events from legacy tokens
69
+ 4. After 90 days (or when audit logs show zero legacy-token violations for 14 consecutive days), flip the default to `["ink_http"]` only
70
+
71
+ This avoids breaking existing extension, voice, and phone delegation flows on deploy day while providing a clear path to strict-by-default.
72
+
73
+ #### 1.3 Verification rule
74
+
75
+ When verifying a delegation chain, the verifier MUST:
76
+
77
+ 1. Determine the current invocation transport (from request context, not from the message itself)
78
+ 2. For each hop in the chain, check that the current transport is in that hop's `allowedTransports`
79
+ 3. If any hop does not include the current transport, reject with reason `transport_scope_violation`
80
+
81
+ Each hop's `allowedTransports` MUST be a subset of the previous hop's (same attenuation rule as permissions and autonomy tiers). A child delegation cannot add transports the parent didn't allow.
82
+
83
+ #### 1.4 Rejection reason
84
+
85
+ Add to the typed rejection enum:
86
+
87
+ ```typescript
88
+ "transport_scope_violation"
89
+ ```
90
+
91
+ #### 1.5 Audit event
92
+
93
+ Add audit event type:
94
+
95
+ ```typescript
96
+ "transport_scope_violation"
97
+ ```
98
+
99
+ Event payload:
100
+
101
+ ```typescript
102
+ {
103
+ messageId: string;
104
+ correlationId: string;
105
+ fromDid: string;
106
+ claimedTransport?: string;
107
+ actualTransport: string;
108
+ allowedTransports: string[];
109
+ }
110
+ ```
111
+
112
+ ### Implementation
113
+
114
+ #### Files modified
115
+
116
+ | File | Change |
117
+ |------|--------|
118
+ | `src/models/ink-handshake.ts` | Add `InkTransportSchema`, extend `constraints` |
119
+ | `src/crypto/ink.ts` | Chain validation: check transport attenuation |
120
+ | `src/middleware/ink-auth.ts` | Tag request context with transport identifier |
121
+ | `src/ink/chain-verifier.ts` | Add transport check to `verifyDelegationChain()` |
122
+ | `src/models/ink-audit.ts` | Add `transport_scope_violation` event type |
123
+ | Agent state/orchestration layer | Pass transport context through to chain verification |
124
+
125
+ #### Transport identification
126
+
127
+ The invocation transport is determined by the receiver, not claimed by the sender:
128
+
129
+ | Context | Transport value |
130
+ |---------|----------------|
131
+ | Request to `/ink/v1/*` endpoints | `ink_http` |
132
+ | Extension API call via product routes | `extension_api` |
133
+ | Voice session action | `voice` |
134
+ | PSTN call handler | `line_phone` |
135
+ | Human review queue resolution | `human_review_queue` |
136
+
137
+ This MUST be set by the routing layer before delegation chain verification runs. It is never parsed from the inbound message.
138
+
139
+ #### Backward compatibility
140
+
141
+ See §1.2 for the version-gated migration plan. During the 90-day migration window, legacy tokens without `allowedTransports` or `tokenVersion` are treated as `["ink_http", "extension_api", "voice", "line_phone"]` to avoid breaking existing flows. New tokens issued after deploy always include explicit `allowedTransports` and `tokenVersion: "0.3"`.
142
+
143
+ ### Tests
144
+
145
+ | Test | Description |
146
+ |------|-------------|
147
+ | Token with `allowedTransports: ["ink_http"]` accepted on INK HTTP | Happy path |
148
+ | Token with `allowedTransports: ["ink_http"]` rejected on `extension_api` | Transport mismatch |
149
+ | v0.3 token with omitted `allowedTransports` defaults to `["ink_http"]` | Strict default for new tokens |
150
+ | Legacy token (no `tokenVersion`) defaults to permissive set during migration window | Legacy compat |
151
+ | Legacy token defaults to `["ink_http"]` only after migration window closes | Migration complete |
152
+ | Token with `["ink_http", "voice"]` accepted on voice | Multi-transport grant |
153
+ | Child hop cannot add transport parent didn't allow | Attenuation check |
154
+ | Child hop can narrow parent's transport list | Subset OK |
155
+ | New tokens include `tokenVersion: "0.3"` | Version stamping |
156
+ | Rejection includes `transport_scope_violation` reason | Typed error |
157
+ | Audit event emitted on transport violation | Observability |
158
+
159
+ ---
160
+
161
+ ## Slice 2: Capability-Gated Agent Card Discovery
162
+
163
+ ### Problem
164
+
165
+ Agent Cards at `GET /ink/v1/{agentId}/agent.json` currently return full capability and endpoint details to any requester. This expands the probing surface: an attacker can enumerate capabilities, accepted intents, scheduling endpoints, delegation support, and extension hooks without authentication.
166
+
167
+ ### Wire Changes
168
+
169
+ #### 2.1 Agent Card `visibility` field
170
+
171
+ Add to `TulpaAgentCard`:
172
+
173
+ ```typescript
174
+ visibility: "public" | "network_only" | "capability_gated" | "private"
175
+ ```
176
+
177
+ This replaces the existing `"public" | "network_only" | "private"` enum by adding `capability_gated`.
178
+
179
+ #### 2.2 Redacted Agent Card
180
+
181
+ When visibility is `capability_gated`, unauthenticated requests to the Agent Card endpoint receive a redacted response:
182
+
183
+ ```typescript
184
+ interface RedactedAgentCard {
185
+ type: "tulpa.agent.card";
186
+ version: "1.0";
187
+ agentId: string;
188
+ displayName?: string;
189
+ visibility: "capability_gated";
190
+ supportsInk: true;
191
+ discoveryMode: "authenticate_for_details";
192
+ updatedAt: string;
193
+ }
194
+ ```
195
+
196
+ Fields NOT included in the redacted card:
197
+ - `capabilities`
198
+ - `openness`
199
+ - `communicationModes`
200
+ - `phoneCard`
201
+ - `voiceProfileSummary`
202
+ - `ownerLink` (if owner has restricted linkage visibility)
203
+ - Any governance fields from the containment spec
204
+
205
+ #### 2.3 Authenticated Agent Card query
206
+
207
+ New endpoint:
208
+
209
+ ```
210
+ POST /ink/v1/{agentId}/agent-card-query
211
+ ```
212
+
213
+ Request body:
214
+
215
+ ```typescript
216
+ {
217
+ protocol: "ink/0.1";
218
+ type: "network.tulpa.agent_card_query";
219
+ from: string; // requester DID
220
+ nonce: string; // replay protection
221
+ timestamp: string; // freshness
222
+ requestedFields?: string[]; // optional: specific fields requested
223
+ }
224
+ ```
225
+
226
+ Authentication: standard INK `Authorization: INK-Ed25519 <sig>` header. Signature base follows the same format as all INK requests (protocol + method + path + recipientDid + JCS(body) + timestamp).
227
+
228
+ Response (if authorized):
229
+
230
+ ```typescript
231
+ {
232
+ protocol: "ink/0.1";
233
+ type: "network.tulpa.agent_card_response";
234
+ card: TulpaAgentCard; // full or field-filtered
235
+ grantedFields: string[];
236
+ timestamp: string;
237
+ }
238
+ ```
239
+
240
+ Response (if denied):
241
+
242
+ ```typescript
243
+ {
244
+ protocol: "ink/0.1";
245
+ type: "network.tulpa.agent_card_denied";
246
+ reason: "unknown_requester" | "insufficient_trust" | "not_connected";
247
+ timestamp: string;
248
+ }
249
+ ```
250
+
251
+ #### 2.4 Access policy
252
+
253
+ The agent owner configures which peers see which fields. Recommended tiers:
254
+
255
+ | Requester relationship | Card detail level |
256
+ |----------------------|------------------|
257
+ | Not connected, unknown | Redacted card only |
258
+ | Known peer (has exchanged at least one message) | Capabilities and openness |
259
+ | Connected (mutual connection) | Full card |
260
+ | Fleet-managed same-org peer | Full card + governance fields |
261
+
262
+ The exact policy is implementation-defined. The protocol defines the query mechanism and response shapes; the access decision is local.
263
+
264
+ #### 2.5 Audit events
265
+
266
+ Add event types:
267
+
268
+ ```typescript
269
+ "discovery_query_received"
270
+ "discovery_query_granted"
271
+ "discovery_query_denied"
272
+ ```
273
+
274
+ Payload:
275
+
276
+ ```typescript
277
+ {
278
+ requesterDid: string;
279
+ grantedFields?: string[];
280
+ denyReason?: string;
281
+ }
282
+ ```
283
+
284
+ ### Implementation
285
+
286
+ #### Files modified
287
+
288
+ | File | Change |
289
+ |------|--------|
290
+ | `src/models/ink-handshake.ts` | Add `AgentCardQuerySchema`, `AgentCardResponseSchema`, `AgentCardDeniedSchema` |
291
+ | `src/models/ink-audit.ts` | Add discovery audit event types |
292
+ | INK route handlers | Add `POST /ink/v1/:agentId/agent-card-query` handler |
293
+ | INK route handlers | Modify `GET /ink/v1/:agentId/agent.json` to return redacted card when `capability_gated` |
294
+ | Agent state/orchestration layer | Store and serve visibility setting, access policy evaluation |
295
+
296
+ #### Agent Card endpoint behavior by visibility
297
+
298
+ Since `GET /ink/v1/{agentId}/agent.json` is unauthenticated, "INK peers only" is not enforceable at the GET layer. The visibility modes therefore define what the unauthenticated GET returns and whether authenticated query is available:
299
+
300
+ | Visibility | `GET /ink/v1/{agentId}/agent.json` | `POST /ink/v1/{agentId}/agent-card-query` |
301
+ |-----------|-----------------------------------|------------------------------------------|
302
+ | `public` | Full card | Not needed, but MAY respond with full card |
303
+ | `network_only` | **Redacted card** (identity + `supportsInk` + displayName only) | Full card for any authenticated INK peer with valid signature |
304
+ | `capability_gated` | Redacted card | Full or filtered card based on access policy (connection tier) |
305
+ | `private` | 404 | Responds only to connected peers |
306
+
307
+ The key difference between `network_only` and `capability_gated`:
308
+ - `network_only`: any peer that can produce a valid INK signature gets the full card. The gate is "are you a real INK agent?", authentication only, no authorization.
309
+ - `capability_gated`: authenticated peers get filtered results based on relationship tier (unknown → known → connected → same-org). The gate is "what is your relationship to this agent?", authentication plus authorization.
310
+
311
+ Both modes return the same redacted card on unauthenticated GET. The difference is in the authenticated query's access policy.
312
+
313
+ #### Recommended default
314
+
315
+ Per the governance spec:
316
+ - Self-sovereign / public-network deployments: `network_only`
317
+ - Enterprise / internal deployments: `capability_gated`
318
+
319
+ The default for new Tulpa agents: `network_only`. This is a **behavior change** from current (which returns a full card on unauthenticated GET). The change is safe because:
320
+ - No external INK peers exist yet, there are no consumers of the unauthenticated full card
321
+ - The authenticated query endpoint ships in the same deploy, so any future peer can get full details by signing the request
322
+ - The redacted card still confirms the agent exists and supports INK, which is sufficient for initial discovery
323
+
324
+ ### Tests
325
+
326
+ | Test | Description |
327
+ |------|-------------|
328
+ | `public` visibility returns full card on GET | Existing behavior preserved |
329
+ | `network_only` returns redacted card on unauthenticated GET | Probing surface closed |
330
+ | `network_only` returns full card on authenticated query from any INK peer | Auth-only gate |
331
+ | `capability_gated` returns redacted card on GET | Core redaction |
332
+ | `capability_gated` authenticated query from connected peer returns full card | Relationship-gated |
333
+ | `capability_gated` authenticated query from unknown peer returns denied | Access control |
334
+ | Redacted card includes only safe fields (no capabilities, openness, endpoints) | No capability/endpoint leak |
335
+ | Query with invalid/missing signature returns 401 | Auth enforcement |
336
+ | Query replay (same nonce) rejected | Replay protection |
337
+ | Audit events emitted for query granted/denied | Observability |
338
+ | `private` visibility returns 404 on GET | No discovery |
339
+
340
+ ---
341
+
342
+ ## Slice 3: Handshake Flood Resistance
343
+
344
+ ### Problem
345
+
346
+ The INK handshake is deterministic: intent → challenge → resolution (or rejection). An attacker can:
347
+ - Send many valid challenges to exhaust processing budget
348
+ - Loop challenge/resolution cycles on the same correlationId
349
+ - Flood rejections to suppress legitimate handshakes
350
+ - Exploit predictable retry behavior
351
+
352
+ ### Wire Changes
353
+
354
+ #### 3.1 Per-correlation handshake budgets
355
+
356
+ For each `correlationId`, recipients MUST enforce:
357
+
358
+ | Counter | Limit | Description |
359
+ |---------|-------|-------------|
360
+ | Challenges received | 3 | Max challenges from a single counterparty on one correlationId |
361
+ | Rejections received | 1 | Terminal, no further messages accepted |
362
+ | Resolutions received | 1 | Terminal, no further messages accepted |
363
+ | Total state transitions | 5 | Hard cap on all handshake messages per correlationId |
364
+ | Handshake TTL | Intent's `expiresAt` or 24h, whichever is shorter | After expiry, no further messages accepted |
365
+
366
+ When any limit is hit:
367
+ - **First violation** on a given correlationId/sender pair: return a typed rejection with the appropriate reason (`handshake_budget_exhausted`, `sender_rate_limited`) and an optional backoff hint. This tells a well-behaved sender what happened.
368
+ - **Subsequent violations** after a typed rejection has already been sent for that correlationId/sender pair: silent drop (no response). This prevents amplification from an attacker who keeps sending after being told to stop.
369
+
370
+ The budget tracker records whether a typed rejection has been sent per correlationId/sender pair. This is the canonical rule, there is no scenario where a first violation is silently dropped.
371
+
372
+ #### 3.2 Backoff hints
373
+
374
+ Add optional metadata to challenge and rejection responses:
375
+
376
+ ```typescript
377
+ const InkBackoffHintSchema = z.object({
378
+ retryAfterSeconds: z.number().int().positive().optional(),
379
+ cooldownUntil: z.string().datetime().optional(),
380
+ backoffClass: z.enum(["sender", "intent_ref", "counterparty"]).optional(),
381
+ });
382
+ ```
383
+
384
+ Semantics:
385
+ - `retryAfterSeconds`: sender SHOULD wait at least this many seconds before retrying
386
+ - `cooldownUntil`: absolute timestamp after which sender MAY retry
387
+ - `backoffClass`:
388
+ - `sender`: this sender is rate-limited (all intents)
389
+ - `intent_ref`: this specific intent/correlationId is rate-limited
390
+ - `counterparty`: the recipient is broadly rate-limiting (overloaded)
391
+
392
+ Backoff hints are advisory. A sender that ignores them risks harder rejection or silent drops.
393
+
394
+ #### 3.3 New typed rejection reasons
395
+
396
+ Add to the rejection reason enum:
397
+
398
+ ```typescript
399
+ "handshake_budget_exhausted" // per-correlation budget hit
400
+ "counterparty_cooldown" // recipient is rate-limiting broadly
401
+ "sender_rate_limited" // this sender is sending too much
402
+ "delegation_budget_exhausted" // delegation issuance limit hit
403
+ ```
404
+
405
+ These are in addition to existing rejection reasons. The `proof_of_work_required` reason from the governance spec is intentionally NOT implemented in Phase 1.
406
+
407
+ #### 3.4 Per-sender rate limits
408
+
409
+ Beyond per-correlation budgets, recipients SHOULD enforce per-sender limits:
410
+
411
+ | Window | Limit | Scope |
412
+ |--------|-------|-------|
413
+ | Per minute | 10 new intents | Per sender DID |
414
+ | Per hour | 60 new intents | Per sender DID |
415
+ | Per minute | 30 handshake messages (all types) | Per sender DID |
416
+
417
+ These are recommended defaults. Implementations MAY adjust based on trust tier (connected peers get higher limits).
418
+
419
+ #### 3.5 Audit events
420
+
421
+ Add event types:
422
+
423
+ ```typescript
424
+ "handshake_rate_limited"
425
+ "handshake_budget_exhausted"
426
+ ```
427
+
428
+ Payload:
429
+
430
+ ```typescript
431
+ {
432
+ correlationId: string;
433
+ fromDid: string;
434
+ messageType: string; // challenge, rejection, resolution
435
+ limitType: "per_correlation" | "per_sender_minute" | "per_sender_hour";
436
+ currentCount: number;
437
+ limit: number;
438
+ }
439
+ ```
440
+
441
+ ### Implementation
442
+
443
+ #### Files modified
444
+
445
+ | File | Change |
446
+ |------|--------|
447
+ | `src/models/ink-handshake.ts` | Add `InkBackoffHintSchema`, new rejection reasons, budget constants |
448
+ | `src/models/ink-audit.ts` | Add rate-limit audit event types |
449
+ | `src/ink/handshake-budget.ts` | Per-correlation and per-sender budget tracking |
450
+ | `src/middleware/ink-auth.ts` | Wire budget checks before handshake processing |
451
+ | Agent state/orchestration layer | Initialize budget tracker, connect to handshake pipeline |
452
+
453
+ #### Budget tracker design
454
+
455
+ ```typescript
456
+ interface HandshakeBudgetTracker {
457
+ /** Check and record a handshake message. Returns null if allowed, rejection reason if blocked. */
458
+ checkAndRecord(params: {
459
+ correlationId: string;
460
+ fromDid: string;
461
+ messageType: "intent" | "challenge" | "rejection" | "resolution";
462
+ intentExpiresAt?: string;
463
+ }): {
464
+ allowed: boolean;
465
+ reason?: string;
466
+ backoffHint?: InkBackoffHint;
467
+ };
468
+
469
+ /** Prune expired correlation state. Call from maintenance. */
470
+ pruneExpired(): void;
471
+ }
472
+ ```
473
+
474
+ Storage: in-memory within the per-agent state container. Correlation state is keyed by `correlationId` and tracks message counts and timestamps. Per-sender state is keyed by `fromDid` with sliding window counters.
475
+
476
+ Memory bounds: max 10,000 active correlations tracked. Oldest entries evicted on overflow (LRU). Per-sender windows use fixed-size circular buffers (60 slots for per-minute, 60 slots for per-hour).
477
+
478
+ #### Where budget checks run
479
+
480
+ Budget checks run **after** signature verification but **before** handshake processing:
481
+
482
+ 1. Verify INK auth signature (existing)
483
+ 2. Parse message type and correlationId
484
+ 3. `budgetTracker.checkAndRecord(...)`, if rejected, return typed rejection with backoff hint
485
+ 4. Proceed to handshake processing (existing)
486
+
487
+ This ordering ensures:
488
+ - Unsigned/forged messages never consume budget (no amplification)
489
+ - Valid but excessive messages get typed rejections
490
+ - Processing resources are protected
491
+
492
+ #### Silent drop vs. typed rejection
493
+
494
+ The canonical rule is defined in §3.1: first violation returns a typed rejection with backoff hint; subsequent violations are silent drops. The budget tracker maintains a `rejectionSent: Set<string>` keyed by `${correlationId}:${fromDid}` to distinguish first from subsequent violations.
495
+
496
+ ### Tests
497
+
498
+ | Test | Description |
499
+ |------|-------------|
500
+ | 3 challenges on same correlationId accepted | Within budget |
501
+ | 4th challenge on same correlationId rejected | Budget exhausted |
502
+ | Rejection is terminal, no further messages accepted | Terminal state |
503
+ | Resolution is terminal, no further messages accepted | Terminal state |
504
+ | Total state transitions capped at 5 | Hard cap |
505
+ | Expired handshake rejects new messages | TTL enforcement |
506
+ | Per-sender minute rate limit triggers after 10 intents | Sender limit |
507
+ | Per-sender hour rate limit triggers after 60 intents | Sender limit |
508
+ | Backoff hint included in rejection response | Advisory signaling |
509
+ | Second violation after rejection is silent drop | No amplification |
510
+ | Budget tracker prunes expired correlations | Memory management |
511
+ | LRU eviction at 10k correlations | Memory bounds |
512
+ | Audit event emitted on rate limit | Observability |
513
+ | Connected peers get higher limits (if configured) | Trust-tier awareness |
514
+
515
+ ---
516
+
517
+ ## Cross-Cutting: Agent Card Governance Fields
518
+
519
+ All three slices benefit from advertising governance capabilities in the Agent Card. Add to the Agent Card schema:
520
+
521
+ ```typescript
522
+ interface InkGovernanceCapabilities {
523
+ maxAcceptedDelegationDepth?: number;
524
+ supportedTransports?: InkTransport[];
525
+ supportsCapabilityGatedDiscovery?: boolean;
526
+ handshakeBudget?: {
527
+ maxChallengesPerCorrelation?: number;
528
+ maxIntentsPerMinute?: number;
529
+ };
530
+ }
531
+ ```
532
+
533
+ This is added as an optional `governance` field on `TulpaAgentCard`:
534
+
535
+ ```typescript
536
+ interface TulpaAgentCard {
537
+ // ... existing fields ...
538
+ governance?: InkGovernanceCapabilities;
539
+ }
540
+ ```
541
+
542
+ Senders can read the recipient's governance fields to pre-filter behavior (e.g., don't send a delegation chain deeper than `maxAcceptedDelegationDepth`, respect `supportedTransports`).
543
+
544
+ ---
545
+
546
+ ## Implementation Order
547
+
548
+ ```
549
+ Step 1: Schema changes, add all new types, enums, audit events
550
+ Step 2: Transport-bound authorization (tests first)
551
+ 2a. InkTransportSchema, constraints extension
552
+ 2b. Transport context tagging in middleware
553
+ 2c. Chain verifier transport check
554
+ 2d. Audit event emission
555
+ Step 3: Handshake flood resistance (tests first)
556
+ 3a. HandshakeBudgetTracker with per-correlation and per-sender limits
557
+ 3b. Wire into handshake pipeline (after auth, before processing)
558
+ 3c. Typed rejections with backoff hints
559
+ 3d. Silent drop on repeated violations
560
+ 3e. Audit event emission
561
+ Step 4: Capability-gated Agent Card discovery (tests first)
562
+ 4a. Redacted card generation
563
+ 4b. GET endpoint conditional response
564
+ 4c. POST agent-card-query endpoint
565
+ 4d. Access policy evaluation
566
+ 4e. Audit event emission
567
+ Step 5: Agent Card governance fields
568
+ Step 6: Integration tests, all three slices together
569
+ ```
570
+
571
+ Transport-bound authorization is first because it is the simplest wire change and immediately closes the confused-deputy gap. Handshake flood resistance is second because it protects the existing handshake surface. Discovery gating is third because it has more moving parts (new endpoint, access policy) but lower urgency.
572
+
573
+ ---
574
+
575
+ ## Verification
576
+
577
+ - `npx vitest run test/ink-transport-auth.test.ts`
578
+ - `npx vitest run test/ink-handshake-budget.test.ts`
579
+ - `npx vitest run test/ink-discovery-gating.test.ts`
580
+ - `npx vitest run`, all existing tests still pass
581
+ - `npx tsc --noEmit`, no new type errors
582
+ - Manual: issue delegation token with `allowedTransports: ["ink_http"]`, verify it is rejected on extension API call
583
+ - Manual: set agent visibility to `capability_gated`, verify GET returns redacted card, authenticated query returns full card
584
+ - Manual: send 4 challenges on same correlationId, verify 4th is rejected with `handshake_budget_exhausted`
585
+
586
+ ---
587
+
588
+ ## Dependencies
589
+
590
+ - No external dependencies required
591
+ - No new npm packages
592
+ - All crypto uses existing `@noble/ed25519` and JCS canonicalization
593
+ - Budget tracker is pure in-memory (per-agent state)