@agenticprimitives/fabric 0.0.0-alpha.4 → 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 (198) hide show
  1. package/CLAUDE.md +28 -35
  2. package/README.md +18 -16
  3. package/dist/adoption.d.ts +79 -0
  4. package/dist/adoption.d.ts.map +1 -0
  5. package/dist/adoption.js +95 -0
  6. package/dist/adoption.js.map +1 -0
  7. package/dist/case-emissions.d.ts +95 -0
  8. package/dist/case-emissions.d.ts.map +1 -0
  9. package/dist/case-emissions.js +208 -0
  10. package/dist/case-emissions.js.map +1 -0
  11. package/dist/case-machine.d.ts +28 -2
  12. package/dist/case-machine.d.ts.map +1 -1
  13. package/dist/case-machine.js +34 -7
  14. package/dist/case-machine.js.map +1 -1
  15. package/dist/cloudflare/authority-verdict-cache.d.ts +28 -1
  16. package/dist/cloudflare/authority-verdict-cache.d.ts.map +1 -1
  17. package/dist/cloudflare/authority-verdict-cache.js +32 -1
  18. package/dist/cloudflare/authority-verdict-cache.js.map +1 -1
  19. package/dist/cloudflare/exchange-store.d.ts +20 -1
  20. package/dist/cloudflare/exchange-store.d.ts.map +1 -1
  21. package/dist/cloudflare/exchange-store.js +26 -4
  22. package/dist/cloudflare/exchange-store.js.map +1 -1
  23. package/dist/cloudflare/index.d.ts +7 -4
  24. package/dist/cloudflare/index.d.ts.map +1 -1
  25. package/dist/cloudflare/index.js +8 -2
  26. package/dist/cloudflare/index.js.map +1 -1
  27. package/dist/cloudflare/interaction-store.d.ts +81 -0
  28. package/dist/cloudflare/interaction-store.d.ts.map +1 -0
  29. package/dist/cloudflare/interaction-store.js +281 -0
  30. package/dist/cloudflare/interaction-store.js.map +1 -0
  31. package/dist/cloudflare/orchestration-runtime.d.ts +19 -5
  32. package/dist/cloudflare/orchestration-runtime.d.ts.map +1 -1
  33. package/dist/cloudflare/orchestration-runtime.js +11 -6
  34. package/dist/cloudflare/orchestration-runtime.js.map +1 -1
  35. package/dist/cloudflare/principal-gateway-do.d.ts +360 -30
  36. package/dist/cloudflare/principal-gateway-do.d.ts.map +1 -1
  37. package/dist/cloudflare/principal-gateway-do.js +580 -58
  38. package/dist/cloudflare/principal-gateway-do.js.map +1 -1
  39. package/dist/cloudflare/receipt-signer.d.ts.map +1 -1
  40. package/dist/cloudflare/receipt-signer.js +6 -3
  41. package/dist/cloudflare/receipt-signer.js.map +1 -1
  42. package/dist/cloudflare/sql-vault.d.ts.map +1 -1
  43. package/dist/cloudflare/sql-vault.js +56 -0
  44. package/dist/cloudflare/sql-vault.js.map +1 -1
  45. package/dist/cloudflare/vault-exchange-store.d.ts +47 -0
  46. package/dist/cloudflare/vault-exchange-store.d.ts.map +1 -0
  47. package/dist/cloudflare/vault-exchange-store.js +135 -0
  48. package/dist/cloudflare/vault-exchange-store.js.map +1 -0
  49. package/dist/cloudflare/vault-interaction-store.d.ts +44 -0
  50. package/dist/cloudflare/vault-interaction-store.d.ts.map +1 -0
  51. package/dist/cloudflare/vault-interaction-store.js +259 -0
  52. package/dist/cloudflare/vault-interaction-store.js.map +1 -0
  53. package/dist/communicative-act.d.ts +24 -1
  54. package/dist/communicative-act.d.ts.map +1 -1
  55. package/dist/communicative-act.js +28 -0
  56. package/dist/communicative-act.js.map +1 -1
  57. package/dist/delivery-envelope.d.ts.map +1 -1
  58. package/dist/delivery-envelope.js +0 -1
  59. package/dist/delivery-envelope.js.map +1 -1
  60. package/dist/email/index.d.ts +79 -0
  61. package/dist/email/index.d.ts.map +1 -0
  62. package/dist/email/index.js +91 -0
  63. package/dist/email/index.js.map +1 -0
  64. package/dist/enum-retirement.d.ts +43 -0
  65. package/dist/enum-retirement.d.ts.map +1 -0
  66. package/dist/enum-retirement.js +93 -0
  67. package/dist/enum-retirement.js.map +1 -0
  68. package/dist/exchange-record.d.ts +35 -4
  69. package/dist/exchange-record.d.ts.map +1 -1
  70. package/dist/exchange-record.js.map +1 -1
  71. package/dist/exchange-reducer.d.ts +32 -4
  72. package/dist/exchange-reducer.d.ts.map +1 -1
  73. package/dist/exchange-reducer.js +75 -1
  74. package/dist/exchange-reducer.js.map +1 -1
  75. package/dist/exchange-state.d.ts +30 -8
  76. package/dist/exchange-state.d.ts.map +1 -1
  77. package/dist/exchange-state.js +12 -10
  78. package/dist/exchange-state.js.map +1 -1
  79. package/dist/inbox-doc.d.ts +2 -2
  80. package/dist/inbox-doc.d.ts.map +1 -1
  81. package/dist/inbox-doc.js.map +1 -1
  82. package/dist/inbox-projection.d.ts +2 -2
  83. package/dist/inbox-projection.d.ts.map +1 -1
  84. package/dist/inbox-projection.js.map +1 -1
  85. package/dist/inbox-revision.d.ts +42 -0
  86. package/dist/inbox-revision.d.ts.map +1 -0
  87. package/dist/inbox-revision.js +57 -0
  88. package/dist/inbox-revision.js.map +1 -0
  89. package/dist/index.d.ts +26 -3
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +40 -2
  92. package/dist/index.js.map +1 -1
  93. package/dist/interaction-machines.d.ts +136 -0
  94. package/dist/interaction-machines.d.ts.map +1 -0
  95. package/dist/interaction-machines.js +174 -0
  96. package/dist/interaction-machines.js.map +1 -0
  97. package/dist/interaction-profile.d.ts +136 -0
  98. package/dist/interaction-profile.d.ts.map +1 -0
  99. package/dist/interaction-profile.js +212 -0
  100. package/dist/interaction-profile.js.map +1 -0
  101. package/dist/interaction-record.d.ts +168 -0
  102. package/dist/interaction-record.d.ts.map +1 -0
  103. package/dist/interaction-record.js +108 -0
  104. package/dist/interaction-record.js.map +1 -0
  105. package/dist/interactions/grant-scopes.d.ts +21 -0
  106. package/dist/interactions/grant-scopes.d.ts.map +1 -0
  107. package/dist/interactions/grant-scopes.js +88 -0
  108. package/dist/interactions/grant-scopes.js.map +1 -0
  109. package/dist/interactions/index.d.ts +1 -0
  110. package/dist/interactions/index.d.ts.map +1 -1
  111. package/dist/interactions/index.js +3 -0
  112. package/dist/interactions/index.js.map +1 -1
  113. package/dist/interactions/machine.d.ts +2 -2
  114. package/dist/interactions/machine.d.ts.map +1 -1
  115. package/dist/interactions/machine.js +2 -2
  116. package/dist/interactions/machine.js.map +1 -1
  117. package/dist/interactions/open-case.d.ts +60 -0
  118. package/dist/interactions/open-case.d.ts.map +1 -0
  119. package/dist/interactions/open-case.js +83 -0
  120. package/dist/interactions/open-case.js.map +1 -0
  121. package/dist/interactions/store.d.ts +9 -0
  122. package/dist/interactions/store.d.ts.map +1 -1
  123. package/dist/interactions/store.js +57 -2
  124. package/dist/interactions/store.js.map +1 -1
  125. package/dist/messaging/board.d.ts +4 -4
  126. package/dist/messaging/board.d.ts.map +1 -1
  127. package/dist/messaging/board.js +2 -2
  128. package/dist/messaging/board.js.map +1 -1
  129. package/dist/messaging/consult-routing.d.ts +103 -23
  130. package/dist/messaging/consult-routing.d.ts.map +1 -1
  131. package/dist/messaging/consult-routing.js +157 -47
  132. package/dist/messaging/consult-routing.js.map +1 -1
  133. package/dist/messaging/consult.d.ts +4 -0
  134. package/dist/messaging/consult.d.ts.map +1 -1
  135. package/dist/messaging/consult.js +3 -1
  136. package/dist/messaging/consult.js.map +1 -1
  137. package/dist/messaging/conversation-interaction.d.ts +66 -0
  138. package/dist/messaging/conversation-interaction.d.ts.map +1 -0
  139. package/dist/messaging/conversation-interaction.js +139 -0
  140. package/dist/messaging/conversation-interaction.js.map +1 -0
  141. package/dist/messaging/conversation.d.ts +69 -1
  142. package/dist/messaging/conversation.d.ts.map +1 -1
  143. package/dist/messaging/conversation.js +131 -0
  144. package/dist/messaging/conversation.js.map +1 -1
  145. package/dist/messaging/delivery.d.ts +2 -2
  146. package/dist/messaging/delivery.d.ts.map +1 -1
  147. package/dist/messaging/delivery.js +8 -1
  148. package/dist/messaging/delivery.js.map +1 -1
  149. package/dist/messaging/envelope.d.ts +7 -3
  150. package/dist/messaging/envelope.d.ts.map +1 -1
  151. package/dist/messaging/envelope.js +18 -1
  152. package/dist/messaging/envelope.js.map +1 -1
  153. package/dist/messaging/hash.d.ts +8 -2
  154. package/dist/messaging/hash.d.ts.map +1 -1
  155. package/dist/messaging/hash.js +6 -0
  156. package/dist/messaging/hash.js.map +1 -1
  157. package/dist/messaging/inbox-assistant.d.ts +16 -4
  158. package/dist/messaging/inbox-assistant.d.ts.map +1 -1
  159. package/dist/messaging/inbox-assistant.js +27 -4
  160. package/dist/messaging/inbox-assistant.js.map +1 -1
  161. package/dist/messaging/index.d.ts +7 -3
  162. package/dist/messaging/index.d.ts.map +1 -1
  163. package/dist/messaging/index.js +11 -2
  164. package/dist/messaging/index.js.map +1 -1
  165. package/dist/messaging/outbound-message.d.ts +42 -0
  166. package/dist/messaging/outbound-message.d.ts.map +1 -0
  167. package/dist/messaging/outbound-message.js +64 -0
  168. package/dist/messaging/outbound-message.js.map +1 -0
  169. package/dist/messaging/projector.d.ts +3 -3
  170. package/dist/messaging/projector.d.ts.map +1 -1
  171. package/dist/messaging/projector.js.map +1 -1
  172. package/dist/messaging/receipts.d.ts +3 -3
  173. package/dist/messaging/receipts.d.ts.map +1 -1
  174. package/dist/messaging/receipts.js.map +1 -1
  175. package/dist/messaging/rules.d.ts +10 -3
  176. package/dist/messaging/rules.d.ts.map +1 -1
  177. package/dist/messaging/rules.js +8 -1
  178. package/dist/messaging/rules.js.map +1 -1
  179. package/dist/messaging/store.d.ts +4 -4
  180. package/dist/messaging/store.d.ts.map +1 -1
  181. package/dist/messaging/store.js +5 -3
  182. package/dist/messaging/store.js.map +1 -1
  183. package/dist/messaging/types.d.ts +30 -0
  184. package/dist/messaging/types.d.ts.map +1 -1
  185. package/dist/messaging/types.js +3 -0
  186. package/dist/messaging/types.js.map +1 -1
  187. package/dist/messaging/vault-store.d.ts +2 -2
  188. package/dist/messaging/vault-store.d.ts.map +1 -1
  189. package/dist/messaging/vault-store.js +8 -0
  190. package/dist/messaging/vault-store.js.map +1 -1
  191. package/dist/protocol-binding.d.ts +71 -0
  192. package/dist/protocol-binding.d.ts.map +1 -0
  193. package/dist/protocol-binding.js +0 -0
  194. package/dist/protocol-binding.js.map +1 -0
  195. package/dist/record-catalog.d.ts.map +1 -1
  196. package/dist/record-catalog.js +12 -0
  197. package/dist/record-catalog.js.map +1 -1
  198. package/package.json +28 -15
@@ -1,29 +1,79 @@
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';
4
6
  import { createDurableObjectExchangeStore } from './exchange-store.js';
7
+ import { createDurableObjectInteractionStore } from './interaction-store.js';
8
+ import { topicInteractionId, channelIdFromTopicInteraction } from '../protocol-binding.js';
9
+ import { TOPIC_PROFILE } from '../interaction-profile.js';
5
10
  import { createDurableObjectAuthorityVerdictCache, } from './authority-verdict-cache.js';
6
11
  import { createDurableObjectRequestIdLedger } from './request-id-ledger.js';
7
12
  import { createDurableObjectReceiptSigner, } from './receipt-signer.js';
8
- import { runGatewayOrchestration } from './orchestration-runtime.js';
13
+ import { runGatewayOrchestration, gatewayToolSpecs } from './orchestration-runtime.js';
9
14
  import { projectExchangeProvenance, projectRunProvenance, } from '@agenticprimitives/provenance';
10
15
  import { createDurableObjectProvenanceStore } from './provenance-store.js';
11
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
+ }
12
39
  function json(body, status = 200) {
13
40
  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
14
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);
15
62
  export class PrincipalGatewayDO {
16
63
  state;
17
64
  env;
18
65
  deps;
19
66
  sql;
20
67
  exchanges;
68
+ interactionStore;
21
69
  verdicts;
22
70
  requestIds;
23
71
  receipts;
24
72
  outbox;
25
73
  provenance;
26
74
  channels;
75
+ /** The callers `admitCaller` minted. Membership IS the proof of minting — nothing else is. */
76
+ minted = new WeakSet();
27
77
  constructor(state, env, deps) {
28
78
  this.state = state;
29
79
  this.env = env;
@@ -31,7 +81,9 @@ export class PrincipalGatewayDO {
31
81
  this.sql = state.storage.sql;
32
82
  // Co-resident, DO-local: exchange stream + vault + T1 verdict cache + request-id ledger all share the
33
83
  // one SQLite trust domain (L2, §4.0). The receipt signer holds a DO-local software key in DO KV storage.
34
- this.exchanges = createDurableObjectExchangeStore(this.sql);
84
+ // Injected when the deployment's records live elsewhere (ADR-0055); DO-local otherwise.
85
+ this.exchanges = deps?.exchangeStore ?? createDurableObjectExchangeStore(this.sql);
86
+ this.interactionStore = deps?.interactionStore ?? createDurableObjectInteractionStore(this.sql);
35
87
  this.verdicts = createDurableObjectAuthorityVerdictCache(this.sql);
36
88
  this.requestIds = createDurableObjectRequestIdLedger(this.sql);
37
89
  this.receipts = createDurableObjectReceiptSigner(state.storage);
@@ -43,9 +95,32 @@ export class PrincipalGatewayDO {
43
95
  // A channel is a shared context on this org's exchange stream; membership is the org's policy (projected
44
96
  // from the app directory listing, spec 313); posting is member-gated fail-closed. Roles = delegations
45
97
  // (C2) and token-gated join (C3) layer on top; the org agent acting in-channel is `orchestrate()` (§15).
46
- /** Create/rename a channel owned by this org gateway. */
98
+ /**
99
+ * Create/rename a channel owned by this org gateway, and open the Interaction it IS (spec 340 W11).
100
+ *
101
+ * A topic was already a governed situation in everything but name: the org owns the board, members
102
+ * join and leave, `facilitator`/`contributor` are its roles, and it opens and closes. Opening the
103
+ * Interaction alongside it names that rather than adding a parallel object — the id is DERIVED
104
+ * (`ixn_topic_<channelId>`), so the two cannot drift and no join table exists to go stale.
105
+ *
106
+ * `participationMode: 'derived'`. Membership belongs to the channel (and, for an open topic, to the
107
+ * organization behind it), so the Interaction stores NO member list. Copying one per topic is what
108
+ * `apmsg:openParticipation` forbids, and a stale copy is how a removed member keeps posting.
109
+ */
47
110
  async createChannel(channel) {
48
- return this.channels.createChannel(channel);
111
+ await this.channels.createChannel(channel);
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;
118
+ await this.openInteraction({
119
+ interactionId: topicInteractionId(channel.channelId),
120
+ profileVersion: TOPIC_PROFILE.profileVersion,
121
+ participationMode: 'derived',
122
+ at: channel.createdAt,
123
+ });
49
124
  }
50
125
  /**
51
126
  * Add a member with a role (spec 318 C2 — the role IS the projection of an org-SA delegation caveat).
@@ -53,9 +128,19 @@ export class PrincipalGatewayDO {
53
128
  * satisfies it via the injected `verifyEntitlement` (fail-closed — no credential / no verifier / invalid ⇒
54
129
  * rejected). An ungated channel joins openly.
55
130
  */
56
- 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' };
57
138
  const channel = await this.channels.getChannel(channelId);
58
- 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) {
59
144
  const verify = this.deps?.verifyEntitlement;
60
145
  if (!verify)
61
146
  return { ok: false, reason: 'gate_unverifiable' };
@@ -70,8 +155,13 @@ export class PrincipalGatewayDO {
70
155
  return { ok: true };
71
156
  }
72
157
  /** Remove a member — **kick = the org revokes** its role delegation (C2). */
73
- async leaveChannel(channelId, member) {
74
- 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 };
75
165
  }
76
166
  async channelMembers(channelId) {
77
167
  return this.channels.members(channelId);
@@ -86,44 +176,71 @@ export class PrincipalGatewayDO {
86
176
  async projectConversationRole(channelId, projection) {
87
177
  await this.channels.putRoleProjection({ ...projection, conversationId: channelId });
88
178
  }
89
- /** 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
90
181
  * authorizing `manage`. This CACHES an org-issued RoleAssignment + its materializing delegation — it does
91
182
  * not itself grant authority (ADR-0048 #4); the org issues the delegation, then the projection mirrors it. */
92
- async putConversationRoleProjection(channelId, projection, by, at) {
93
- 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))
94
187
  return { ok: false, reason: 'not_authorized' };
95
188
  await this.channels.putRoleProjection({ ...projection, conversationId: channelId });
96
189
  return { ok: true };
97
190
  }
98
191
  /** MANAGE-gated: clear a member's conversation-role projection (the org revoked the underlying delegation). */
99
- async clearConversationRoleProjection(channelId, member, by, at) {
100
- 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))
101
196
  return { ok: false, reason: 'not_authorized' };
102
197
  await this.channels.clearRoleProjection(channelId, member);
103
198
  return { ok: true };
104
199
  }
105
- /** 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
106
201
  * authorizing `moderate`. The exchange itself is NOT deleted (immutable stream + provenance); only its
107
202
  * channel index entry is removed. */
108
- async removeChannelPost(channelId, exchangeId, by, at) {
109
- 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))
110
207
  return { ok: false, reason: 'not_authorized' };
111
208
  await this.channels.removePost(channelId, exchangeId);
112
209
  return { ok: true };
113
210
  }
114
211
  /**
115
- * 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).
116
216
  * The post is persisted on the org's exchange stream via `deliver()` (so it accretes a provenance trace like
117
217
  * any exchange) and indexed into the channel. Fan-out to members is the org re-delivering (§4 / Blocker 6).
118
218
  */
119
- async postToChannel(channelId, record) {
219
+ async postToChannel(channelId, record, caller) {
220
+ if (!this.isMinted(caller))
221
+ return { ok: false, reason: 'unauthenticated' };
120
222
  const from = record.parties.from;
223
+ if (!sameAgent(String(from), String(caller.actor)))
224
+ return { ok: false, reason: 'actor-mismatch' };
121
225
  // A poster is either a channel MEMBER (person/org, C1/C2) or an admitted channel BOT (Service SA, C4).
226
+ // Kept as an explicit pre-check so the caller-facing reason stays `not_a_member`: ingress would also
227
+ // refuse, but as `not-a-participant`, and a post rejection should say the thing the poster can act on.
122
228
  if (!(await this.channels.isMember(channelId, from)) && !(await this.channels.isBot(channelId, from))) {
123
229
  return { ok: false, reason: 'not_a_member' };
124
230
  }
125
- await this.deliver(record);
126
- await this.channels.addPost(channelId, String(record.exchangeId), record.updatedAt);
231
+ // The post now OCCURS IN the topic's Interaction (spec 340 W11) — pinned here rather than trusted
232
+ // from the caller, because a poster naming someone else's interaction would otherwise file into it.
233
+ const posted = {
234
+ ...record,
235
+ interactionId: topicInteractionId(channelId),
236
+ profileVersion: TOPIC_PROFILE.profileVersion,
237
+ };
238
+ // Threading the refusal rather than discarding it: a post naming an interaction this gateway does
239
+ // not hold must not be indexed into the channel as though it landed.
240
+ const delivered = await this.deliver(posted, caller);
241
+ if (!delivered.ok)
242
+ return { ok: false, reason: delivered.reason };
243
+ await this.channels.addPost(channelId, String(posted.exchangeId), posted.updatedAt);
127
244
  return { ok: true };
128
245
  }
129
246
  // ─── C4: channel bots ARE Service Smart Agents (spec 318 §8) ─────────────────────────────────────────
@@ -179,8 +296,10 @@ export class PrincipalGatewayDO {
179
296
  async receiptPubkey() {
180
297
  return this.receipts.pubkey();
181
298
  }
182
- /** Sign a `DeliveryReceiptV1` with the DO-local key — the delivery-proof-under-authority-at-T the sender
183
- * 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`. */
184
303
  async signDeliveryReceipt(input) {
185
304
  return this.receipts.sign(input);
186
305
  }
@@ -188,48 +307,156 @@ export class PrincipalGatewayDO {
188
307
  * RECIPIENT-side peer delivery (spec 316 §4 / W4). Transport-admits an inbound `DeliveryEnvelopeV1` (routing
189
308
  * + addressing + structural), then persists the exchange, records the `delivered` fact, and returns a
190
309
  * DO-signed `DeliveryReceiptV1`. Sender AUTHORITY (envelope signature) is re-verified separately by the gateway
191
- * (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.
192
327
  */
193
- async receiveDelivery(delivery, opts) {
194
- 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 });
195
334
  if (!admission.ok)
196
335
  return admission;
197
- const rec = deliveryToExchangeRecord(delivery, { tier: opts.tier, at: opts.at });
198
- await this.deliver(rec);
199
- await this.applyEvent(rec.exchangeId, {
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
+ }
343
+ // A peer naming an interaction we do not hold, or one it is not in, is refused BEFORE the receipt:
344
+ // signing a delivery receipt for an exchange we did not store would attest to something false.
345
+ const delivered = await this.ingest(rec);
346
+ if (!delivered.ok)
347
+ return { ok: false, reason: delivered.reason };
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, {
200
352
  kind: 'message', seq: 0, at: opts.at, eventType: 'delivered', actor: delivery.envelope.from, messageId: delivery.envelope.id,
201
353
  });
354
+ if (!recorded || !recorded.ok)
355
+ return { ok: false, reason: recorded ? recorded.reason : 'unknown_exchange' };
202
356
  const receipt = await this.signDeliveryReceipt({
203
357
  exchangeId: String(delivery.exchangeId), messageId: delivery.envelope.id, recipient: delivery.toGateway, at: opts.at,
204
358
  });
205
359
  return { ok: true, receipt };
206
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
+ }
207
385
  /**
208
386
  * T2 orchestrated lane (spec 316 §15) — a `request`/agent-task INTENT is fulfilled by the Ring-0
209
387
  * orchestration loop running IN this gateway, with the co-resident MCP tools as the loop's ToolInvoker
210
- * (in-process over the DO-local vault, under the verified `principal`). Only the LLM `planner` is injected;
211
- * the loop, the tools, and the vault are the gateway's own. Returns the orchestration `RunResult`. (Absorbing
212
- * `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.
213
405
  */
214
406
  async orchestrate(input) {
215
407
  const deps = this.requireDeps();
216
408
  if (!deps.planner)
217
409
  throw new Error('PrincipalGatewayDO: no planner configured for T2 orchestration');
218
- const vault = this.vault(await deps.wrapperFor(input.principal));
219
- const result = await runGatewayOrchestration(input.intent, {
220
- planner: deps.planner,
221
- registry: deps.tools,
222
- vault,
223
- principal: input.principal,
224
- maxSteps: input.maxSteps,
225
- });
226
- // W5 — bridge the executed plan into the exchange's provenance A-box as a P-Plan (the SAME trace as the
227
- // OrchestratedTask activity, §15). Merged by `exchangeId` when the caller ties the run to an exchange.
228
- if (input.exchangeId && input.at) {
229
- const { plan } = projectRunProvenance({ exchangeId: input.exchangeId, goal: input.intent.goal, steps: result.plan.steps.map((s) => ({ toolId: s.toolId })) });
230
- 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;
231
459
  }
232
- return result;
233
460
  }
234
461
  /** SENDER-side store-and-forward (spec 316 §4): queue a peer delivery that could not be handed to the
235
462
  * recipient gateway now; `alarm()` retries with backoff until delivered (offline-first, §11a). */
@@ -250,29 +477,294 @@ export class PrincipalGatewayDO {
250
477
  * accessor stays key-material-free. Same `state.storage.sql` as the exchange stream — one trust domain.
251
478
  */
252
479
  vault(wrapper) {
480
+ // INJECTED WINS. A deployment whose records live in the owner's vault passes `deps.vault`; the
481
+ // DO-SQL construction below is the fallback for tests and single-tenant self-hosting. Inheriting
482
+ // the fallback in production is exactly the drift ADR-0055's amendment refuses: a custody change
483
+ // arriving as a side effect of a runtime change.
484
+ if (this.deps?.vault)
485
+ return this.deps.vault(wrapper);
253
486
  return createDurableObjectSqlVault(this.sql, wrapper);
254
487
  }
255
488
  /**
256
489
  * T0 communication lane — persist a delivered exchange (sender-proof-only; no case, no RPC — §3). The
257
490
  * record is built upstream (envelope validated + `bodyHash`-verified at ingress); the DO is the durable
258
- * single writer. Idempotent by `exchangeId` (put overwrites the same id; a genuine redelivery is caught by
259
- * the message event's `idempotencyKey` at the reducer). Receipt signing (`DeliveryReceiptV1`, DO-local
260
- * 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.
261
505
  */
262
- async deliver(record) {
263
- await this.exchanges.put(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) {
519
+ const admission = await this.admitIntoInteraction(record);
520
+ if (!admission.ok)
521
+ return admission;
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
+ }
264
529
  // W5 — project the exchange into the PRIVATE provenance A-box (one trace, §15). Off the sender's hot path
265
530
  // conceptually, but co-located here (demo-scale); a public row is derived SEPARATELY via the S1 firewall.
266
531
  await this.provenance.put(String(record.exchangeId), projectExchangeProvenance(this.exchangeToProvenanceInput(record)), record.updatedAt);
532
+ return admission;
267
533
  }
268
- /** Append one event to an existing exchange (case transitions / transport moves), reduced fail-closed. */
269
- async applyEvent(exchangeId, event) {
534
+ /**
535
+ * The interaction-aware half of ingress (spec 340 W7).
536
+ *
537
+ * THREE OUTCOMES, and which is which is the whole design:
538
+ *
539
+ * 1. NO `interactionId` → delivered, with an `unpinned-exchange` note. An Exchange without an
540
+ * Interaction is a MESSAGE (ADR-0058 §12) — a legitimate state, and the overwhelmingly common
541
+ * one. Auto-creating an interaction per message would make "a thread is a projection, not a
542
+ * container" false by turning every message into a container.
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
+ *
548
+ * 2. UNKNOWN `interactionId` → REFUSED. Interactions are opened by their custodian
549
+ * (`openInteraction`), never conjured by a sender. If ingress created one from a sender-supplied
550
+ * id, any peer could seed arbitrary interactions with arbitrary participations on someone else's
551
+ * gateway, then post into them — inventing a governed situation the owner never entered.
552
+ *
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
555
+ * in an interaction whose membership does not include its sender is a record that misrepresents
556
+ * the situation. This is an integrity check on the record's shape, NOT authority: being a
557
+ * participant is not permission to do anything (ADR-0048 §4).
558
+ *
559
+ * Two conditions are NOTED rather than refused, because refusing them would lose true records:
560
+ * · a profile-version mismatch — a mid-flight profile bump is legal, and the per-exchange pin is
561
+ * the signal that survives, so this reports (spec 340 §6);
562
+ * · a pinned context digest we do not hold — normal for an INBOUND delivery, where the snapshot
563
+ * lives on the sender's gateway. Dropping the message would be fail-closed in the wrong place;
564
+ * the digest stays pinned and resolvable later. What we must not do is pretend it is replayable.
565
+ */
566
+ async admitIntoInteraction(record) {
567
+ if (!record.interactionId)
568
+ return this.admitUnpinned(record);
569
+ const interaction = await this.interactionStore.getInteraction(record.interactionId);
570
+ if (!interaction)
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
+ }
579
+ // ONE membership question, answered by whichever source actually owns it (spec 340 W11). For an
580
+ // `asserted` interaction the record IS the membership. For a `derived` one — an open org topic —
581
+ // the organization owns it, and this asks the org's own projection rather than a per-topic copy
582
+ // that could disagree with it. A stale copy of a membership list is how a removed member keeps
583
+ // posting, so there is no copy.
584
+ const isParticipant = interaction.participationMode === 'derived'
585
+ ? await this.resolveDerivedParticipation(interaction.interactionId, record.parties.from)
586
+ : interaction.participations.some((p) => p.agent === record.parties.from && p.leftAt === undefined);
587
+ if (!isParticipant)
588
+ return { ok: false, reason: 'not-a-participant' };
589
+ const notes = [];
590
+ if (record.profileVersion && record.profileVersion !== interaction.profileVersion) {
591
+ notes.push({ kind: 'profile-downgrade', pinned: record.profileVersion, inForce: interaction.profileVersion });
592
+ }
593
+ if (record.contextDigest && !(await this.interactionStore.getContextSnapshot(record.contextDigest))) {
594
+ notes.push({ kind: 'context-unavailable', digest: record.contextDigest });
595
+ }
596
+ return { ok: true, interactionId: interaction.interactionId, notes };
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
+ }
621
+ /**
622
+ * Membership for a `derived` interaction.
623
+ *
624
+ * Topic interactions carry `ixn_topic_<channelId>`, so the channel is recoverable from the id and the
625
+ * answer comes from the channel store — the same member-or-bot test `postToChannel` already applies.
626
+ * That is the point of doing this: the gate stops being a second check that could drift from the
627
+ * first, and becomes the same check asked once.
628
+ *
629
+ * Fail-closed by construction: an id that names no known derivation source resolves to `false`, so a
630
+ * `derived` interaction with no resolver admits nobody rather than everybody.
631
+ */
632
+ async resolveDerivedParticipation(interactionId, agent) {
633
+ const channelId = channelIdFromTopicInteraction(interactionId);
634
+ if (!channelId)
635
+ return false;
636
+ return (await this.channels.isMember(channelId, agent)) || (await this.channels.isBot(channelId, agent));
637
+ }
638
+ /**
639
+ * Open a governed situation on this gateway. The custodian is always THIS principal — an interaction
640
+ * is custodied by the gateway that holds it, and accepting a caller-supplied principal would let a
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`.)
650
+ */
651
+ async openInteraction(input) {
652
+ const participationMode = input.participationMode ?? 'asserted';
653
+ if (participationMode === 'derived' && (input.participations?.length ?? 0) > 0) {
654
+ // Refused rather than silently dropped: a caller passing both has a mental model where the list
655
+ // matters, and letting the write succeed would leave them believing a stale copy is authoritative.
656
+ throw new Error('openInteraction: a derived interaction must not carry participations');
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
+ }
669
+ const record = {
670
+ version: 'ap.interaction.v1',
671
+ interactionId: input.interactionId,
672
+ profileVersion: input.profileVersion,
673
+ principal: this.requirePrincipal(),
674
+ lifecycleState: 'active',
675
+ participationMode,
676
+ participations: input.participations ?? [],
677
+ ...(input.protocolBindings ? { protocolBindings: input.protocolBindings } : {}),
678
+ createdAt: input.at,
679
+ updatedAt: input.at,
680
+ rev: 0,
681
+ };
682
+ await this.interactionStore.putInteraction(record);
683
+ return record;
684
+ }
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' };
270
701
  return this.exchanges.append(exchangeId, event);
271
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
+ }
731
+ // ─── The interaction spine (spec 340 W6) ────────────────────────────────────────────────────────────
732
+ // Exposed as ONE accessor rather than a dozen forwarding methods. The DO is already large, and each
733
+ // forwarder would be a place for the store's invariants to be quietly re-implemented — the point of
734
+ // routing every transition through its machine is undone by a wrapper that sets state itself.
735
+ //
736
+ // Persisting here confers nothing: an interaction record says a situation exists, a commitment record
737
+ // says an obligation was recorded. Neither authorizes anything (ADR-0041/0043).
738
+ /** Interactions, context snapshots, decisions, commitments, per-viewer inbox state. */
739
+ get interactions() {
740
+ return this.interactionStore;
741
+ }
742
+ /**
743
+ * Everything still owed on this gateway — the query the single `case` field could not answer.
744
+ *
745
+ * A case could be `denied` while an obligation stayed outstanding with nowhere to record it, because
746
+ * one field was serving three machines (spec 340 §R). Concluding an interaction deliberately does NOT
747
+ * appear here as a discharge: closing a conversation ends the conversation, never the promise made
748
+ * inside it.
749
+ */
750
+ async openCommitments() {
751
+ return this.interactionStore.listOpenCommitments();
752
+ }
753
+ /** The gateway's own SA. Throws rather than defaulting: a wrong custodian on a governed situation is
754
+ * worse than a refused write, and empty-string config is treated as unset (a wrangler `VAR = ""`
755
+ * binds an empty string, which `??` would happily accept). */
756
+ requirePrincipal() {
757
+ const sa = this.env.PRINCIPAL_SA?.trim();
758
+ if (!sa)
759
+ throw new Error('PrincipalGatewayDO: PRINCIPAL_SA is not configured; cannot custody an interaction');
760
+ return sa;
761
+ }
272
762
  requireDeps() {
273
- if (!this.deps)
763
+ const d = this.deps;
764
+ if (!d || !d.verifyToken || !d.wrapperFor || !d.tools) {
274
765
  throw new Error('PrincipalGatewayDO: co-resident MCP deps not configured (verifyToken/wrapperFor/tools)');
275
- return this.deps;
766
+ }
767
+ return d;
276
768
  }
277
769
  /** The verdict cache (revoke / epoch-sweep hooks + read for tests). */
278
770
  get authority() {
@@ -304,6 +796,10 @@ export class PrincipalGatewayDO {
304
796
  const verdict = await this.verdicts.get(input.key); // (A)
305
797
  if (!verdict)
306
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' };
307
803
  if (!(await input.runCaveatPass(verdict, input.tool, input.args)))
308
804
  return { ok: false, reason: 'caveat_denied' }; // (B), never cached
309
805
  const tool = deps.tools.get(input.tool);
@@ -328,21 +824,47 @@ export class PrincipalGatewayDO {
328
824
  async inbox() {
329
825
  return this.exchanges.list();
330
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
+ */
331
834
  async fetch(request) {
332
835
  const url = new URL(request.url);
333
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';
334
848
  if (request.method === 'POST' && url.pathname === '/deliver') {
335
- await this.deliver((await request.json()));
336
- return json({ ok: true });
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.
852
+ if (!outcome.ok)
853
+ return json({ ok: false, error: outcome.reason, ...(outcome.state ? { state: outcome.state } : {}) }, refusedIdentity(outcome.reason) ? 403 : 409);
854
+ return json({ ok: true, interactionId: outcome.interactionId, notes: outcome.notes });
337
855
  }
338
856
  if (request.method === 'POST' && url.pathname === '/event') {
339
857
  const body = (await request.json());
340
- const result = await this.applyEvent(body.exchangeId, body.event);
858
+ const result = await this.applyEvent(body.exchangeId, body.event, caller);
341
859
  if (result === null)
342
860
  return json({ ok: false, error: 'unknown_exchange' }, 404);
343
- 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);
344
862
  }
345
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);
346
868
  return json({ ok: true, exchanges: await this.inbox() });
347
869
  }
348
870
  // TODO (W2 follow-steps, spec 316 §3/§4/§15):