@agenticprimitives/fabric 0.0.0-alpha.39 → 0.0.0-alpha.40

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/CLAUDE.md +14 -22
  2. package/dist/cloudflare/exchange-store.d.ts +20 -1
  3. package/dist/cloudflare/exchange-store.d.ts.map +1 -1
  4. package/dist/cloudflare/exchange-store.js +26 -4
  5. package/dist/cloudflare/exchange-store.js.map +1 -1
  6. package/dist/cloudflare/index.d.ts +4 -4
  7. package/dist/cloudflare/index.d.ts.map +1 -1
  8. package/dist/cloudflare/index.js +2 -2
  9. package/dist/cloudflare/index.js.map +1 -1
  10. package/dist/cloudflare/interaction-store.d.ts +26 -0
  11. package/dist/cloudflare/interaction-store.d.ts.map +1 -1
  12. package/dist/cloudflare/interaction-store.js +52 -1
  13. package/dist/cloudflare/interaction-store.js.map +1 -1
  14. package/dist/cloudflare/orchestration-runtime.d.ts +19 -5
  15. package/dist/cloudflare/orchestration-runtime.d.ts.map +1 -1
  16. package/dist/cloudflare/orchestration-runtime.js +11 -6
  17. package/dist/cloudflare/orchestration-runtime.js.map +1 -1
  18. package/dist/cloudflare/principal-gateway-do.d.ts +227 -31
  19. package/dist/cloudflare/principal-gateway-do.d.ts.map +1 -1
  20. package/dist/cloudflare/principal-gateway-do.js +398 -58
  21. package/dist/cloudflare/principal-gateway-do.js.map +1 -1
  22. package/dist/cloudflare/receipt-signer.d.ts.map +1 -1
  23. package/dist/cloudflare/receipt-signer.js +6 -3
  24. package/dist/cloudflare/receipt-signer.js.map +1 -1
  25. package/dist/cloudflare/vault-exchange-store.d.ts.map +1 -1
  26. package/dist/cloudflare/vault-exchange-store.js +17 -0
  27. package/dist/cloudflare/vault-exchange-store.js.map +1 -1
  28. package/dist/cloudflare/vault-interaction-store.d.ts +1 -1
  29. package/dist/cloudflare/vault-interaction-store.d.ts.map +1 -1
  30. package/dist/cloudflare/vault-interaction-store.js +14 -0
  31. package/dist/cloudflare/vault-interaction-store.js.map +1 -1
  32. package/dist/email/index.d.ts +4 -0
  33. package/dist/email/index.d.ts.map +1 -1
  34. package/dist/email/index.js +19 -5
  35. package/dist/email/index.js.map +1 -1
  36. package/dist/messaging/board.d.ts +1 -1
  37. package/dist/messaging/consult-routing.d.ts +92 -19
  38. package/dist/messaging/consult-routing.d.ts.map +1 -1
  39. package/dist/messaging/consult-routing.js +136 -35
  40. package/dist/messaging/consult-routing.js.map +1 -1
  41. package/dist/messaging/index.d.ts +1 -1
  42. package/dist/messaging/index.d.ts.map +1 -1
  43. package/dist/messaging/index.js +1 -1
  44. package/dist/messaging/index.js.map +1 -1
  45. package/dist/messaging/vault-store.d.ts.map +1 -1
  46. package/dist/messaging/vault-store.js +8 -0
  47. package/dist/messaging/vault-store.js.map +1 -1
  48. package/package.json +9 -9
@@ -1,3 +1,5 @@
1
+ import { canonicalizeJson } from '@agenticprimitives/types';
2
+ import { TERMINAL_INTERACTION_STATES } from '../interaction-machines.js';
1
3
  import { admitDeliveryEnvelope, deliveryToExchangeRecord, } from '../delivery-envelope.js';
2
4
  import { createDurableObjectSqlVault } from './sql-vault.js';
3
5
  import { createDurableObjectOutboxStore } from './outbox-store.js';
@@ -8,13 +10,55 @@ import { TOPIC_PROFILE } from '../interaction-profile.js';
8
10
  import { createDurableObjectAuthorityVerdictCache, } from './authority-verdict-cache.js';
9
11
  import { createDurableObjectRequestIdLedger } from './request-id-ledger.js';
10
12
  import { createDurableObjectReceiptSigner, } from './receipt-signer.js';
11
- import { runGatewayOrchestration } from './orchestration-runtime.js';
13
+ import { runGatewayOrchestration, gatewayToolSpecs } from './orchestration-runtime.js';
12
14
  import { projectExchangeProvenance, projectRunProvenance, } from '@agenticprimitives/provenance';
13
15
  import { createDurableObjectProvenanceStore } from './provenance-store.js';
14
16
  import { createDurableObjectChannelStore, projectionAuthorizes } from './channel-store.js';
17
+ /** An ingress was refused before anything was read or written (spec 445 §3.4, DATA-3). */
18
+ export class GatewayAuthError extends Error {
19
+ reason;
20
+ constructor(reason) {
21
+ super(`PrincipalGatewayDO: ${reason}`);
22
+ this.reason = reason;
23
+ this.name = 'GatewayAuthError';
24
+ }
25
+ }
26
+ /** `openInteraction` was asked to open an interaction that already exists as something else — a
27
+ * different shape, or one that has ended (spec 445 §3.4, DATB-2). Nothing was written. */
28
+ export class InteractionExistsError extends Error {
29
+ interactionId;
30
+ state;
31
+ reason = 'interaction-exists';
32
+ constructor(interactionId, state) {
33
+ super(`PrincipalGatewayDO: interaction ${interactionId} already exists (${state}); open never overwrites`);
34
+ this.interactionId = interactionId;
35
+ this.state = state;
36
+ this.name = 'InteractionExistsError';
37
+ }
38
+ }
15
39
  function json(body, status = 200) {
16
40
  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
17
41
  }
42
+ /**
43
+ * Is `offered` the SAME signed envelope the stored exchange was seeded with?
44
+ *
45
+ * The identity is the envelope's full canonical bytes — `canonicalizeJson` over the whole envelope,
46
+ * `signature` INCLUDED. That is strictly stronger than any of the three weaker identities and implies
47
+ * them all: same `id`, same `bodyHash`, same signed payload (`hashExchangeEnvelope` covers everything
48
+ * but the signature) and the same proof over it. An envelope that matches on id and body hash but
49
+ * carries a different signature, recipient list or timestamp is a DIFFERENT envelope and is refused.
50
+ * Compared against `envelopes[0]`: later envelopes are responses the exchange accrued, and a
51
+ * redelivery is of the one that opened it.
52
+ */
53
+ function isSameSignedEnvelope(stored, offered) {
54
+ const seed = stored.envelopes[0];
55
+ return seed !== undefined && canonicalizeJson(seed) === canonicalizeJson(offered);
56
+ }
57
+ const sameAgent = (a, b) => a.toLowerCase() === b.toLowerCase();
58
+ /** The account part of an id (`eip155:1:0xabc` → `0xabc`), lowercased — a verdict names its principal
59
+ * as a bare address while a gateway is configured with a CAIP-10. */
60
+ const accountOf = (id) => (id.split(':').pop() ?? '').toLowerCase();
61
+ const sameAccount = (a, b) => accountOf(a) !== '' && accountOf(a) === accountOf(b);
18
62
  export class PrincipalGatewayDO {
19
63
  state;
20
64
  env;
@@ -28,6 +72,8 @@ export class PrincipalGatewayDO {
28
72
  outbox;
29
73
  provenance;
30
74
  channels;
75
+ /** The callers `admitCaller` minted. Membership IS the proof of minting — nothing else is. */
76
+ minted = new WeakSet();
31
77
  constructor(state, env, deps) {
32
78
  this.state = state;
33
79
  this.env = env;
@@ -63,7 +109,12 @@ export class PrincipalGatewayDO {
63
109
  */
64
110
  async createChannel(channel) {
65
111
  await this.channels.createChannel(channel);
66
- // Idempotent on rename: the derived id is stable, so this refreshes rather than duplicates.
112
+ // A RENAME CHANGES THE NAME AND NOTHING ELSE (spec 445 §3.4, audit DATB-2/DATB-6). The topic's
113
+ // interaction already exists under its derived id; whatever state it is in — active, suspended,
114
+ // concluded — and whatever `rev`/`createdAt` it carries are the lifecycle's, moved only by
115
+ // `transitionInteraction`. So an existing interaction is left exactly as it is, in EVERY state.
116
+ if (await this.interactionStore.getInteraction(topicInteractionId(channel.channelId)))
117
+ return;
67
118
  await this.openInteraction({
68
119
  interactionId: topicInteractionId(channel.channelId),
69
120
  profileVersion: TOPIC_PROFILE.profileVersion,
@@ -77,9 +128,19 @@ export class PrincipalGatewayDO {
77
128
  * satisfies it via the injected `verifyEntitlement` (fail-closed — no credential / no verifier / invalid ⇒
78
129
  * rejected). An ungated channel joins openly.
79
130
  */
80
- async joinChannel(channelId, member, opts = {}) {
131
+ async joinChannel(channelId, member, caller, opts = {}) {
132
+ // WHO is being seated is not the caller's to assert (DATA-3): an agent seats ITSELF, or this
133
+ // gateway's own principal (the organization) seats a member.
134
+ if (!this.isMinted(caller))
135
+ return { ok: false, reason: 'unauthenticated' };
136
+ if (!this.selfOrPrincipal(caller, member))
137
+ return { ok: false, reason: 'actor_mismatch' };
81
138
  const channel = await this.channels.getChannel(channelId);
82
- if (channel?.joinGate) {
139
+ // A channel that does not exist has no gate to check, so a join before creation would seat an
140
+ // agent ahead of the gate it is later created with (audit DATA-10). Refused.
141
+ if (!channel)
142
+ return { ok: false, reason: 'unknown_channel' };
143
+ if (channel.joinGate) {
83
144
  const verify = this.deps?.verifyEntitlement;
84
145
  if (!verify)
85
146
  return { ok: false, reason: 'gate_unverifiable' };
@@ -94,8 +155,13 @@ export class PrincipalGatewayDO {
94
155
  return { ok: true };
95
156
  }
96
157
  /** Remove a member — **kick = the org revokes** its role delegation (C2). */
97
- async leaveChannel(channelId, member) {
98
- return this.channels.leave(channelId, member);
158
+ async leaveChannel(channelId, member, caller) {
159
+ if (!this.isMinted(caller))
160
+ return { ok: false, reason: 'unauthenticated' };
161
+ if (!this.selfOrPrincipal(caller, member))
162
+ return { ok: false, reason: 'actor_mismatch' };
163
+ await this.channels.leave(channelId, member);
164
+ return { ok: true };
99
165
  }
100
166
  async channelMembers(channelId) {
101
167
  return this.channels.members(channelId);
@@ -110,38 +176,52 @@ export class PrincipalGatewayDO {
110
176
  async projectConversationRole(channelId, projection) {
111
177
  await this.channels.putRoleProjection({ ...projection, conversationId: channelId });
112
178
  }
113
- /** MANAGE-gated (spec 324 §8): project a member's conversation role. `by` must hold a cached projection
179
+ /** MANAGE-gated (spec 324 §8): project a member's conversation role. The AUTHENTICATED actor (never a
180
+ * caller-named `by`, DATA-3) must hold a cached projection
114
181
  * authorizing `manage`. This CACHES an org-issued RoleAssignment + its materializing delegation — it does
115
182
  * not itself grant authority (ADR-0048 #4); the org issues the delegation, then the projection mirrors it. */
116
- async putConversationRoleProjection(channelId, projection, by, at) {
117
- if (!projectionAuthorizes(await this.channels.roleProjection(channelId, by), 'manage', at))
183
+ async putConversationRoleProjection(channelId, projection, caller, at) {
184
+ if (!this.isMinted(caller))
185
+ return { ok: false, reason: 'unauthenticated' };
186
+ if (!projectionAuthorizes(await this.channels.roleProjection(channelId, caller.actor), 'manage', at))
118
187
  return { ok: false, reason: 'not_authorized' };
119
188
  await this.channels.putRoleProjection({ ...projection, conversationId: channelId });
120
189
  return { ok: true };
121
190
  }
122
191
  /** MANAGE-gated: clear a member's conversation-role projection (the org revoked the underlying delegation). */
123
- async clearConversationRoleProjection(channelId, member, by, at) {
124
- if (!projectionAuthorizes(await this.channels.roleProjection(channelId, by), 'manage', at))
192
+ async clearConversationRoleProjection(channelId, member, caller, at) {
193
+ if (!this.isMinted(caller))
194
+ return { ok: false, reason: 'unauthenticated' };
195
+ if (!projectionAuthorizes(await this.channels.roleProjection(channelId, caller.actor), 'manage', at))
125
196
  return { ok: false, reason: 'not_authorized' };
126
197
  await this.channels.clearRoleProjection(channelId, member);
127
198
  return { ok: true };
128
199
  }
129
- /** MODERATE-gated (spec 324 §8): remove a post from the channel board. `by` must hold a cached projection
200
+ /** MODERATE-gated (spec 324 §8): remove a post from the channel board. The AUTHENTICATED actor must hold a cached projection
130
201
  * authorizing `moderate`. The exchange itself is NOT deleted (immutable stream + provenance); only its
131
202
  * channel index entry is removed. */
132
- async removeChannelPost(channelId, exchangeId, by, at) {
133
- if (!projectionAuthorizes(await this.channels.roleProjection(channelId, by), 'moderate', at))
203
+ async removeChannelPost(channelId, exchangeId, caller, at) {
204
+ if (!this.isMinted(caller))
205
+ return { ok: false, reason: 'unauthenticated' };
206
+ if (!projectionAuthorizes(await this.channels.roleProjection(channelId, caller.actor), 'moderate', at))
134
207
  return { ok: false, reason: 'not_authorized' };
135
208
  await this.channels.removePost(channelId, exchangeId);
136
209
  return { ok: true };
137
210
  }
138
211
  /**
139
- * Post to a channel — MEMBER-GATED, fail-closed: only a current member (`record.parties.from`) may post.
212
+ * Post to a channel — MEMBER-GATED, fail-closed: only a current member may post, and the poster is the
213
+ * AUTHENTICATED actor: `record.parties.from` must be `caller.actor` (DATA-3), so membership is asked
214
+ * about who is actually posting, never about a name in the record. A topic whose interaction is not
215
+ * `active` takes no posts (`interaction-not-active`, DATB-2).
140
216
  * The post is persisted on the org's exchange stream via `deliver()` (so it accretes a provenance trace like
141
217
  * any exchange) and indexed into the channel. Fan-out to members is the org re-delivering (§4 / Blocker 6).
142
218
  */
143
- async postToChannel(channelId, record) {
219
+ async postToChannel(channelId, record, caller) {
220
+ if (!this.isMinted(caller))
221
+ return { ok: false, reason: 'unauthenticated' };
144
222
  const from = record.parties.from;
223
+ if (!sameAgent(String(from), String(caller.actor)))
224
+ return { ok: false, reason: 'actor-mismatch' };
145
225
  // A poster is either a channel MEMBER (person/org, C1/C2) or an admitted channel BOT (Service SA, C4).
146
226
  // Kept as an explicit pre-check so the caller-facing reason stays `not_a_member`: ingress would also
147
227
  // refuse, but as `not-a-participant`, and a post rejection should say the thing the poster can act on.
@@ -157,7 +237,7 @@ export class PrincipalGatewayDO {
157
237
  };
158
238
  // Threading the refusal rather than discarding it: a post naming an interaction this gateway does
159
239
  // not hold must not be indexed into the channel as though it landed.
160
- const delivered = await this.deliver(posted);
240
+ const delivered = await this.deliver(posted, caller);
161
241
  if (!delivered.ok)
162
242
  return { ok: false, reason: delivered.reason };
163
243
  await this.channels.addPost(channelId, String(posted.exchangeId), posted.updatedAt);
@@ -216,8 +296,10 @@ export class PrincipalGatewayDO {
216
296
  async receiptPubkey() {
217
297
  return this.receipts.pubkey();
218
298
  }
219
- /** Sign a `DeliveryReceiptV1` with the DO-local key — the delivery-proof-under-authority-at-T the sender
220
- * gets, decoupled from body storage (spec 316 §3a receipt / Blocker 4). Verify with `verifyDeliveryReceipt`. */
299
+ /** Sign a `DeliveryReceiptV1` with the DO-local key. PRIVATE: a receipt attests to a delivery this
300
+ * gateway RECORDED, so the only callers are the two delivery paths, after the `delivered` fact is in
301
+ * the stream. A public signer would be a receipt oracle for any fields a caller cared to name. Verify
302
+ * with `verifyDeliveryReceipt`. */
221
303
  async signDeliveryReceipt(input) {
222
304
  return this.receipts.sign(input);
223
305
  }
@@ -225,52 +307,156 @@ export class PrincipalGatewayDO {
225
307
  * RECIPIENT-side peer delivery (spec 316 §4 / W4). Transport-admits an inbound `DeliveryEnvelopeV1` (routing
226
308
  * + addressing + structural), then persists the exchange, records the `delivered` fact, and returns a
227
309
  * DO-signed `DeliveryReceiptV1`. Sender AUTHORITY (envelope signature) is re-verified separately by the gateway
228
- * (ADR-0041) — admission here is transport-level only. `recipient` is this gateway's principal.
310
+ * (ADR-0041) — admission here is transport-level only.
311
+ *
312
+ * `recipient` and `tier` are the AUTHENTICATED caller's `principal` and `tier`, and the envelope's `from`
313
+ * must be its `actor` (spec 445 §3.4, DATA-3): who a delivery is for, which lane it rides and who
314
+ * sent it are admission's findings, never fields of the call.
315
+ *
316
+ * A DELIVERY NEVER OVERWRITES (spec 445 §3.4, audit H-10 / DATA-1). The exchange id comes from the
317
+ * peer and is known to every counterparty, so it identifies nothing about who is sending. If a record
318
+ * is already held under it, exactly two things can be true:
319
+ *
320
+ * · the offered envelope IS the stored one (`isSameSignedEnvelope` — full canonical bytes, signature
321
+ * included) → an idempotent redelivery. Nothing is rewritten and no second `delivered` event is
322
+ * appended; the receipt returned is for the ORIGINAL delivery. Receipts are not stored, so it is
323
+ * re-signed over the original `delivered` event's time — same exchange, message, recipient and
324
+ * `at`, hence the same attested fact, never a fresh claim that it arrived again now.
325
+ * · anything else → refused `exchange-exists`, and NO receipt is signed: a receipt for a delivery
326
+ * we did not store would attest to something false.
229
327
  */
230
- async receiveDelivery(delivery, opts) {
231
- const admission = admitDeliveryEnvelope(delivery, { recipient: opts.recipient });
328
+ async receiveDelivery(delivery, caller, opts) {
329
+ if (!this.isMinted(caller))
330
+ return { ok: false, reason: 'unauthenticated' };
331
+ if (!sameAgent(String(delivery.envelope.from), String(caller.actor)))
332
+ return { ok: false, reason: 'actor-mismatch' };
333
+ const admission = admitDeliveryEnvelope(delivery, { recipient: caller.principal });
232
334
  if (!admission.ok)
233
335
  return admission;
234
- const rec = deliveryToExchangeRecord(delivery, { tier: opts.tier, at: opts.at });
336
+ const rec = deliveryToExchangeRecord(delivery, { tier: caller.tier, at: opts.at });
337
+ const held = await this.exchanges.get(rec.exchangeId);
338
+ if (held) {
339
+ if (!isSameSignedEnvelope(held, delivery.envelope))
340
+ return { ok: false, reason: 'exchange-exists' };
341
+ return this.redeliveryReceipt(held, delivery, opts.at);
342
+ }
235
343
  // A peer naming an interaction we do not hold, or one it is not in, is refused BEFORE the receipt:
236
344
  // signing a delivery receipt for an exchange we did not store would attest to something false.
237
- const delivered = await this.deliver(rec);
345
+ const delivered = await this.ingest(rec);
238
346
  if (!delivered.ok)
239
347
  return { ok: false, reason: delivered.reason };
240
- await this.applyEvent(rec.exchangeId, {
348
+ // A RECEIPT ATTESTS TO A RECORDED DELIVERY. If the `delivered` fact is not in the stream — the
349
+ // reducer refused it, or the exchange is not there — nothing is signed. The stored record stays;
350
+ // a redelivery of the same envelope completes it (`redeliveryReceipt`).
351
+ const recorded = await this.exchanges.append(rec.exchangeId, {
241
352
  kind: 'message', seq: 0, at: opts.at, eventType: 'delivered', actor: delivery.envelope.from, messageId: delivery.envelope.id,
242
353
  });
354
+ if (!recorded || !recorded.ok)
355
+ return { ok: false, reason: recorded ? recorded.reason : 'unknown_exchange' };
243
356
  const receipt = await this.signDeliveryReceipt({
244
357
  exchangeId: String(delivery.exchangeId), messageId: delivery.envelope.id, recipient: delivery.toGateway, at: opts.at,
245
358
  });
246
359
  return { ok: true, receipt };
247
360
  }
361
+ /**
362
+ * The receipt for a redelivery of an envelope this gateway already holds (spec 445 §3.4).
363
+ *
364
+ * The time attested is the ORIGINAL `delivered` event's. One case needs a write: a first attempt that
365
+ * stored the record and stopped before recording `delivered`. The redelivery then completes that
366
+ * delivery — one event, through the reducer — rather than signing for a fact the stream does not hold.
367
+ */
368
+ async redeliveryReceipt(held, delivery, at) {
369
+ const messageId = delivery.envelope.id;
370
+ const first = held.events.find((e) => e.kind === 'message' && e.eventType === 'delivered' && e.messageId === messageId);
371
+ let deliveredAt = first?.at;
372
+ if (deliveredAt === undefined) {
373
+ const applied = await this.exchanges.append(held.exchangeId, {
374
+ kind: 'message', seq: 0, at, eventType: 'delivered', actor: delivery.envelope.from, messageId,
375
+ });
376
+ if (!applied || !applied.ok)
377
+ return { ok: false, reason: applied ? applied.reason : 'unknown_exchange' };
378
+ deliveredAt = at;
379
+ }
380
+ const receipt = await this.signDeliveryReceipt({
381
+ exchangeId: String(held.exchangeId), messageId, recipient: delivery.toGateway, at: deliveredAt,
382
+ });
383
+ return { ok: true, receipt };
384
+ }
248
385
  /**
249
386
  * T2 orchestrated lane (spec 316 §15) — a `request`/agent-task INTENT is fulfilled by the Ring-0
250
387
  * orchestration loop running IN this gateway, with the co-resident MCP tools as the loop's ToolInvoker
251
- * (in-process over the DO-local vault, under the verified `principal`). Only the LLM `planner` is injected;
252
- * the loop, the tools, and the vault are the gateway's own. Returns the orchestration `RunResult`. (Absorbing
253
- * `A2aTaskDO` + driving this from `alarm()` via a durable task queue is the remaining async-scheduling wrap.)
388
+ * (in-process over the DO-local vault). Only the LLM `planner` is injected; the loop, the tools, and the
389
+ * vault are the gateway's own. (Absorbing `A2aTaskDO` + driving this from `alarm()` via a durable task
390
+ * queue is the remaining async-scheduling wrap.)
391
+ *
392
+ * THE SAME §3a GATE AS `invokeFast`, PER STEP (spec 445 §3.4, audit DATA-4). A T2 intent is by design
393
+ * a peer's request and its text reaches the planner, so nothing here trusts the plan:
394
+ *
395
+ * (A) the cached args-independent verdict for `key` — a miss runs NOTHING (cold path first). The
396
+ * principal the tools run as, and whose KEK is resolved, is the VERDICT's. It must be this
397
+ * gateway's principal when the deployment names one, and the one the caller says it expects
398
+ * (`principal`) when it says: a verdict for A never reaches B's records.
399
+ * (B) `runCaveatPass(verdict, tool, args)` for EVERY planned step against the concrete tool and
400
+ * arguments — never cached; a denied step throws inside the loop and its tool does not run.
401
+ * (C) a single-use `requestId` (+ `now`) for the run. A WRITE tool does not run without one — it is
402
+ * not even offered to the planner — and a replayed id is refused before anything runs, so a
403
+ * retried intent is not applied twice. The id is released only when no step ran (a legitimate
404
+ * retry); once any tool ran it stays consumed, whatever the outcome.
254
405
  */
255
406
  async orchestrate(input) {
256
407
  const deps = this.requireDeps();
257
408
  if (!deps.planner)
258
409
  throw new Error('PrincipalGatewayDO: no planner configured for T2 orchestration');
259
- const vault = this.vault(await deps.wrapperFor(input.principal));
260
- const result = await runGatewayOrchestration(input.intent, {
261
- planner: deps.planner,
262
- registry: deps.tools,
263
- vault,
264
- principal: input.principal,
265
- maxSteps: input.maxSteps,
266
- });
267
- // W5 — bridge the executed plan into the exchange's provenance A-box as a P-Plan (the SAME trace as the
268
- // OrchestratedTask activity, §15). Merged by `exchangeId` when the caller ties the run to an exchange.
269
- if (input.exchangeId && input.at) {
270
- const { plan } = projectRunProvenance({ exchangeId: input.exchangeId, goal: input.intent.goal, steps: result.plan.steps.map((s) => ({ toolId: s.toolId })) });
271
- await this.provenance.put(input.exchangeId, { version: 'ap.provenance.v1', agents: [], activities: [], entities: [], plans: [plan] }, input.at);
410
+ if (typeof input.runCaveatPass !== 'function')
411
+ throw new Error('PrincipalGatewayDO: orchestrate requires runCaveatPass');
412
+ const verdict = await this.verdicts.get(input.key); // (A)
413
+ if (!verdict)
414
+ return { ok: false, reason: 'verdict_miss_cold_path_required' };
415
+ const own = this.env.PRINCIPAL_SA?.trim();
416
+ if (own && !sameAccount(own, verdict.principal))
417
+ return { ok: false, reason: 'principal_mismatch' };
418
+ if (input.principal !== undefined && !sameAccount(input.principal, verdict.principal))
419
+ return { ok: false, reason: 'principal_mismatch' };
420
+ if ((input.requestId === undefined) !== (input.now === undefined) || input.requestId === '') {
421
+ return { ok: false, reason: 'request_id_incomplete' };
422
+ }
423
+ const reservation = input.requestId !== undefined ? await this.requestIds.reserve(input.requestId, input.now) : null; // (C)
424
+ if (input.requestId !== undefined && !reservation)
425
+ return { ok: false, reason: 'replay' };
426
+ let ran = 0;
427
+ try {
428
+ const vault = this.vault(await deps.wrapperFor(verdict.principal));
429
+ const offered = gatewayToolSpecs(deps.tools).filter((t) => reservation !== null || deps.tools.get(t.id)?.effect === 'read');
430
+ const result = await runGatewayOrchestration(input.intent, {
431
+ planner: deps.planner,
432
+ registry: deps.tools,
433
+ vault,
434
+ principal: verdict.principal,
435
+ tools: offered,
436
+ maxSteps: input.maxSteps,
437
+ authorizeStep: async (tool, args) => {
438
+ if (tool.effect !== 'read' && !reservation)
439
+ throw new Error('request_id_required');
440
+ if (!(await input.runCaveatPass(verdict, tool.name, args)))
441
+ throw new Error('caveat_denied'); // (B), never cached
442
+ ran += 1;
443
+ },
444
+ });
445
+ // W5 — bridge the executed plan into the exchange's provenance A-box as a P-Plan (the SAME trace as the
446
+ // OrchestratedTask activity, §15). Merged by `exchangeId` when the caller ties the run to an exchange.
447
+ if (input.exchangeId && input.at) {
448
+ const { plan } = projectRunProvenance({ exchangeId: input.exchangeId, goal: input.intent.goal, steps: result.plan.steps.map((s) => ({ toolId: s.toolId })) });
449
+ await this.provenance.put(input.exchangeId, { version: 'ap.provenance.v1', agents: [], activities: [], entities: [], plans: [plan] }, input.at);
450
+ }
451
+ if (reservation)
452
+ await (ran > 0 ? reservation.commit() : reservation.release());
453
+ return { ok: true, principal: verdict.principal, result };
454
+ }
455
+ catch (e) {
456
+ if (reservation)
457
+ await (ran > 0 ? reservation.commit() : reservation.release());
458
+ throw e;
272
459
  }
273
- return result;
274
460
  }
275
461
  /** SENDER-side store-and-forward (spec 316 §4): queue a peer delivery that could not be handed to the
276
462
  * recipient gateway now; `alarm()` retries with backoff until delivered (offline-first, §11a). */
@@ -302,15 +488,44 @@ export class PrincipalGatewayDO {
302
488
  /**
303
489
  * T0 communication lane — persist a delivered exchange (sender-proof-only; no case, no RPC — §3). The
304
490
  * record is built upstream (envelope validated + `bodyHash`-verified at ingress); the DO is the durable
305
- * single writer. Idempotent by `exchangeId` (put overwrites the same id; a genuine redelivery is caught by
306
- * the message event's `idempotencyKey` at the reducer). Receipt signing (`DeliveryReceiptV1`, DO-local
307
- * software key) is a W2 follow-step.
491
+ * single writer.
492
+ *
493
+ * AUTHENTICATED (spec 445 §3.4, audit DATA-3): `caller` is minted by `admitCaller`; the record's
494
+ * `principal` must be the caller's, its `parties.from` the caller's `actor`, and its `tier` is the
495
+ * caller's whatever the record says.
496
+ *
497
+ * INSERT-ONLY (spec 445 §3.4, audit H-10 / DATA-1). Every way into this method carries an exchange id
498
+ * somebody else chose — a peer's delivery, a member's channel post, a `/deliver` body — so it never
499
+ * overwrites: the store's `insert` either writes a new record or returns the one already held. An
500
+ * offer that is canonically identical to the held record is a retry and succeeds without writing;
501
+ * anything else under a held id is refused `exchange-exists`. (An exchange that has since accrued
502
+ * events no longer equals its seed, so a late retry of the seed is refused — `receiveDelivery`
503
+ * decides redelivery by the signed envelope instead, which is the identity that survives.) Changing a
504
+ * held exchange is the reducer's job (`applyEvent`), never a second delivery's.
308
505
  */
309
- async deliver(record) {
506
+ async deliver(record, caller) {
507
+ // WHOSE record, on WHICH lane, from WHOM — admission's findings, never the body's (DATA-3). A record
508
+ // naming another principal or another sender is refused; its `tier` is replaced, not believed.
509
+ if (!this.isMinted(caller))
510
+ return { ok: false, reason: 'unauthenticated' };
511
+ if (!sameAgent(String(record.principal), String(caller.principal)))
512
+ return { ok: false, reason: 'principal-mismatch' };
513
+ if (!sameAgent(String(record.parties.from), String(caller.actor)))
514
+ return { ok: false, reason: 'actor-mismatch' };
515
+ return this.ingest(record.tier === caller.tier ? record : { ...record, tier: caller.tier });
516
+ }
517
+ /** The store half of `deliver`, after the caller has been established. */
518
+ async ingest(record) {
310
519
  const admission = await this.admitIntoInteraction(record);
311
520
  if (!admission.ok)
312
521
  return admission;
313
- await this.exchanges.put(record);
522
+ const stored = await this.exchanges.insert(record);
523
+ if (!stored.inserted) {
524
+ if (canonicalizeJson(stored.existing) !== canonicalizeJson(record))
525
+ return { ok: false, reason: 'exchange-exists' };
526
+ // The identical record again: already stored, already projected. Nothing to write.
527
+ return admission;
528
+ }
314
529
  // W5 — project the exchange into the PRIVATE provenance A-box (one trace, §15). Off the sender's hot path
315
530
  // conceptually, but co-located here (demo-scale); a public row is derived SEPARATELY via the S1 firewall.
316
531
  await this.provenance.put(String(record.exchangeId), projectExchangeProvenance(this.exchangeToProvenanceInput(record)), record.updatedAt);
@@ -326,12 +541,17 @@ export class PrincipalGatewayDO {
326
541
  * one. Auto-creating an interaction per message would make "a thread is a projection, not a
327
542
  * container" false by turning every message into a container.
328
543
  *
544
+ * Not unconditionally, though (spec 445 §3.4): with no interaction there is no membership to ask,
545
+ * so the record must at least be true about its own parties — `admitUnpinned`. Like outcome 3
546
+ * this is the record's shape, never authority.
547
+ *
329
548
  * 2. UNKNOWN `interactionId` → REFUSED. Interactions are opened by their custodian
330
549
  * (`openInteraction`), never conjured by a sender. If ingress created one from a sender-supplied
331
550
  * id, any peer could seed arbitrary interactions with arbitrary participations on someone else's
332
551
  * gateway, then post into them — inventing a governed situation the owner never entered.
333
552
  *
334
- * 3. KNOWN `interactionId` → the sender must be a current participant. An exchange claiming to occur
553
+ * 3. KNOWN `interactionId` → the interaction must be `active` (DATB-2), and the sender must be a
554
+ * current participant. An exchange claiming to occur
335
555
  * in an interaction whose membership does not include its sender is a record that misrepresents
336
556
  * the situation. This is an integrity check on the record's shape, NOT authority: being a
337
557
  * participant is not permission to do anything (ADR-0048 §4).
@@ -345,10 +565,17 @@ export class PrincipalGatewayDO {
345
565
  */
346
566
  async admitIntoInteraction(record) {
347
567
  if (!record.interactionId)
348
- return { ok: true, notes: [{ kind: 'unpinned-exchange' }] };
568
+ return this.admitUnpinned(record);
349
569
  const interaction = await this.interactionStore.getInteraction(record.interactionId);
350
570
  if (!interaction)
351
571
  return { ok: false, reason: 'unknown-interaction' };
572
+ // THE LIFECYCLE IS ENFORCED HERE (spec 445 §3.4, audit DATB-2). `suspend`, `conclude` and `abandon`
573
+ // are the custodian's stop controls; they stop something only if ingress asks. Only `active` takes
574
+ // exchanges — checked BEFORE membership, so the refusal names the situation rather than the sender.
575
+ // Leaving `suspended` is `resume` (custodial, through the machine); nothing leaves a terminal state.
576
+ if (interaction.lifecycleState !== 'active') {
577
+ return { ok: false, reason: 'interaction-not-active', state: interaction.lifecycleState };
578
+ }
352
579
  // ONE membership question, answered by whichever source actually owns it (spec 340 W11). For an
353
580
  // `asserted` interaction the record IS the membership. For a `derived` one — an open org topic —
354
581
  // the organization owns it, and this asks the org's own projection rather than a per-topic copy
@@ -368,6 +595,29 @@ export class PrincipalGatewayDO {
368
595
  }
369
596
  return { ok: true, interactionId: interaction.interactionId, notes };
370
597
  }
598
+ /**
599
+ * An exchange with no interaction is admitted only if its sender is a party of the record it
600
+ * delivers (spec 445 §3.4, audit H-10 / DATA-1). Two checks, both fail-closed:
601
+ *
602
+ * · the delivered envelope's `from` IS the record's `parties.from` — a record cannot attribute
603
+ * somebody's envelope to somebody else;
604
+ * · this gateway's principal is a party (`from`, or in `to`/`cc`) — a gateway stores its own
605
+ * principal's exchanges, as sender or as recipient, and nobody else's.
606
+ *
607
+ * The principal is `PRINCIPAL_SA` when the deployment names it, else the record's own `principal`
608
+ * (a gateway that cannot name itself can only check the record against itself).
609
+ */
610
+ admitUnpinned(record) {
611
+ const from = String(record.parties.from);
612
+ const seed = record.envelopes[0];
613
+ if (seed && !sameAgent(String(seed.from), from))
614
+ return { ok: false, reason: 'not-a-party' };
615
+ const holder = this.env.PRINCIPAL_SA?.trim() || String(record.principal);
616
+ const parties = [from, ...record.parties.to.map(String), ...(record.parties.cc ?? []).map(String)];
617
+ if (!parties.some((p) => sameAgent(p, holder)))
618
+ return { ok: false, reason: 'not-a-party' };
619
+ return { ok: true, notes: [{ kind: 'unpinned-exchange' }] };
620
+ }
371
621
  /**
372
622
  * Membership for a `derived` interaction.
373
623
  *
@@ -389,6 +639,14 @@ export class PrincipalGatewayDO {
389
639
  * Open a governed situation on this gateway. The custodian is always THIS principal — an interaction
390
640
  * is custodied by the gateway that holds it, and accepting a caller-supplied principal would let a
391
641
  * peer name someone else as custodian of a situation on our storage.
642
+ *
643
+ * CREATE-ONLY (spec 445 §3.4, audit DATB-2). Opening never overwrites: an interaction already held
644
+ * under this id is returned as it stands when it is LIVE and was opened with the same profile,
645
+ * participation mode, participations and bindings (a retry); anything else — a different shape, or
646
+ * an interaction that has ended — throws `InteractionExistsError` and writes nothing. In particular
647
+ * there is no reopening a concluded interaction: the machine has no transition out of a terminal
648
+ * state, and `open` is not a way round it. (`suspended → active` is `resume`, via
649
+ * `interactions.transitionInteraction`, custodial and counted in `rev`.)
392
650
  */
393
651
  async openInteraction(input) {
394
652
  const participationMode = input.participationMode ?? 'asserted';
@@ -397,6 +655,17 @@ export class PrincipalGatewayDO {
397
655
  // matters, and letting the write succeed would leave them believing a stale copy is authoritative.
398
656
  throw new Error('openInteraction: a derived interaction must not carry participations');
399
657
  }
658
+ const held = await this.interactionStore.getInteraction(input.interactionId);
659
+ if (held) {
660
+ const sameOpen = !TERMINAL_INTERACTION_STATES.includes(held.lifecycleState) &&
661
+ held.profileVersion === input.profileVersion &&
662
+ held.participationMode === participationMode &&
663
+ canonicalizeJson(held.participations) === canonicalizeJson(input.participations ?? []) &&
664
+ canonicalizeJson(held.protocolBindings ?? []) === canonicalizeJson(input.protocolBindings ?? []);
665
+ if (!sameOpen)
666
+ throw new InteractionExistsError(String(input.interactionId), held.lifecycleState);
667
+ return held;
668
+ }
400
669
  const record = {
401
670
  version: 'ap.interaction.v1',
402
671
  interactionId: input.interactionId,
@@ -413,10 +682,52 @@ export class PrincipalGatewayDO {
413
682
  await this.interactionStore.putInteraction(record);
414
683
  return record;
415
684
  }
416
- /** Append one event to an existing exchange (case transitions / transport moves), reduced fail-closed. */
417
- async applyEvent(exchangeId, event) {
685
+ /**
686
+ * Append one event to an existing exchange (case transitions / transport moves), reduced fail-closed.
687
+ *
688
+ * The case machine authorizes a transition by comparing `event.actor` with the record's parties, so
689
+ * the actor must not be the caller's to choose (DATA-3): `event.actor` must be the AUTHENTICATED
690
+ * actor, on a minted caller for this gateway's principal. `null` ⇒ no such exchange. (`event.at` is
691
+ * still the caller's — audit DATB-11, open: the embedder stamps it.)
692
+ */
693
+ async applyEvent(exchangeId, event, caller) {
694
+ if (!this.isMinted(caller))
695
+ return { ok: false, reason: 'unauthenticated' };
696
+ // `message`/`case` events name their actor; `artifact`/`transport` events are the runtime's own
697
+ // moves on this stream and name none, so only the gateway's principal makes them.
698
+ const claimed = event.kind === 'message' || event.kind === 'case' ? event.actor : caller.principal;
699
+ if (!sameAgent(String(claimed), String(caller.actor)))
700
+ return { ok: false, reason: 'actor-mismatch' };
418
701
  return this.exchanges.append(exchangeId, event);
419
702
  }
703
+ /**
704
+ * Mint the caller for one admitted request (spec 445 §3.4, audit DATA-3) — the ONLY way to obtain an
705
+ * `AuthenticatedCaller`. Runs the injected authenticator; throws `GatewayAuthError` when none is
706
+ * configured (fail closed), when the request is not admitted, or when it was admitted for a
707
+ * principal other than this gateway's (`PRINCIPAL_SA`). A gateway that does not name itself takes the
708
+ * authenticator's principal — the embedder that built the port is the one that addressed this DO.
709
+ */
710
+ async admitCaller(request) {
711
+ const authenticate = this.deps?.authenticate;
712
+ if (typeof authenticate !== 'function')
713
+ throw new GatewayAuthError('authenticator_not_configured');
714
+ const found = await authenticate(request);
715
+ if (!found || !found.principal || !found.actor || !found.tier)
716
+ throw new GatewayAuthError('unauthenticated');
717
+ const own = this.env.PRINCIPAL_SA?.trim();
718
+ if (own && !sameAgent(own, String(found.principal)))
719
+ throw new GatewayAuthError('principal_mismatch');
720
+ const caller = Object.freeze({ principal: found.principal, tier: found.tier, actor: found.actor });
721
+ this.minted.add(caller);
722
+ return caller;
723
+ }
724
+ isMinted(caller) {
725
+ return typeof caller === 'object' && caller !== null && this.minted.has(caller);
726
+ }
727
+ /** `member` is the authenticated actor, or the actor is this gateway's own principal acting on it. */
728
+ selfOrPrincipal(caller, member) {
729
+ return sameAgent(String(caller.actor), member) || sameAgent(String(caller.actor), String(caller.principal));
730
+ }
420
731
  // ─── The interaction spine (spec 340 W6) ────────────────────────────────────────────────────────────
421
732
  // Exposed as ONE accessor rather than a dozen forwarding methods. The DO is already large, and each
422
733
  // forwarder would be a place for the store's invariants to be quietly re-implemented — the point of
@@ -449,9 +760,11 @@ export class PrincipalGatewayDO {
449
760
  return sa;
450
761
  }
451
762
  requireDeps() {
452
- if (!this.deps)
763
+ const d = this.deps;
764
+ if (!d || !d.verifyToken || !d.wrapperFor || !d.tools) {
453
765
  throw new Error('PrincipalGatewayDO: co-resident MCP deps not configured (verifyToken/wrapperFor/tools)');
454
- return this.deps;
766
+ }
767
+ return d;
455
768
  }
456
769
  /** The verdict cache (revoke / epoch-sweep hooks + read for tests). */
457
770
  get authority() {
@@ -483,6 +796,10 @@ export class PrincipalGatewayDO {
483
796
  const verdict = await this.verdicts.get(input.key); // (A)
484
797
  if (!verdict)
485
798
  return { ok: false, reason: 'verdict_miss_cold_path_required' };
799
+ // A verdict for another principal never reaches this gateway's vault (spec 445 §3.4, DATA-4).
800
+ const own = this.env.PRINCIPAL_SA?.trim();
801
+ if (own && !sameAccount(own, verdict.principal))
802
+ return { ok: false, reason: 'principal_mismatch' };
486
803
  if (!(await input.runCaveatPass(verdict, input.tool, input.args)))
487
804
  return { ok: false, reason: 'caveat_denied' }; // (B), never cached
488
805
  const tool = deps.tools.get(input.tool);
@@ -507,24 +824,47 @@ export class PrincipalGatewayDO {
507
824
  async inbox() {
508
825
  return this.exchanges.list();
509
826
  }
827
+ /**
828
+ * The HTTP surface. EVERY route authenticates first (spec 445 §3.4, audit DATA-3): `admitCaller`
829
+ * runs the injected authenticator before the path is even looked at, so with no authenticator the
830
+ * answer is 503 for everything, and an unadmitted request is 401 whatever it asked for. `principal`,
831
+ * `tier` and `actor` then come from that result alone — a body naming another principal or another
832
+ * actor is 403, and a body's `tier` is ignored.
833
+ */
510
834
  async fetch(request) {
511
835
  const url = new URL(request.url);
512
836
  try {
837
+ let caller;
838
+ try {
839
+ caller = await this.admitCaller(request);
840
+ }
841
+ catch (e) {
842
+ if (!(e instanceof GatewayAuthError))
843
+ throw e;
844
+ const status = e.reason === 'authenticator_not_configured' ? 503 : e.reason === 'unauthenticated' ? 401 : 403;
845
+ return json({ ok: false, error: e.reason }, status);
846
+ }
847
+ const refusedIdentity = (reason) => reason === 'principal-mismatch' || reason === 'actor-mismatch' || reason === 'unauthenticated';
513
848
  if (request.method === 'POST' && url.pathname === '/deliver') {
514
- const outcome = await this.deliver((await request.json()));
515
- // 409, not 400: the request is well-formed and the situation is what refuses it.
849
+ const outcome = await this.deliver((await request.json()), caller);
850
+ // 403 for an identity the body asserted and admission did not; otherwise 409, not 400: the
851
+ // request is well-formed and the situation is what refuses it.
516
852
  if (!outcome.ok)
517
- return json({ ok: false, error: outcome.reason }, 409);
853
+ return json({ ok: false, error: outcome.reason, ...(outcome.state ? { state: outcome.state } : {}) }, refusedIdentity(outcome.reason) ? 403 : 409);
518
854
  return json({ ok: true, interactionId: outcome.interactionId, notes: outcome.notes });
519
855
  }
520
856
  if (request.method === 'POST' && url.pathname === '/event') {
521
857
  const body = (await request.json());
522
- const result = await this.applyEvent(body.exchangeId, body.event);
858
+ const result = await this.applyEvent(body.exchangeId, body.event, caller);
523
859
  if (result === null)
524
860
  return json({ ok: false, error: 'unknown_exchange' }, 404);
525
- return result.ok ? json({ ok: true }) : json({ ok: false, error: result.reason }, 409);
861
+ return result.ok ? json({ ok: true }) : json({ ok: false, error: result.reason }, refusedIdentity(result.reason) ? 403 : 409);
526
862
  }
527
863
  if (request.method === 'GET' && url.pathname === '/inbox') {
864
+ // The whole stream is the principal's own: an admitted third party (a peer delivering, a member
865
+ // posting) is a participant, and that does not make it a reader.
866
+ if (!sameAgent(String(caller.actor), String(caller.principal)))
867
+ return json({ ok: false, error: 'actor-mismatch' }, 403);
528
868
  return json({ ok: true, exchanges: await this.inbox() });
529
869
  }
530
870
  // TODO (W2 follow-steps, spec 316 §3/§4/§15):