@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.
- package/CLAUDE.md +28 -35
- package/README.md +18 -16
- package/dist/adoption.d.ts +79 -0
- package/dist/adoption.d.ts.map +1 -0
- package/dist/adoption.js +95 -0
- package/dist/adoption.js.map +1 -0
- package/dist/case-emissions.d.ts +95 -0
- package/dist/case-emissions.d.ts.map +1 -0
- package/dist/case-emissions.js +208 -0
- package/dist/case-emissions.js.map +1 -0
- package/dist/case-machine.d.ts +28 -2
- package/dist/case-machine.d.ts.map +1 -1
- package/dist/case-machine.js +34 -7
- package/dist/case-machine.js.map +1 -1
- package/dist/cloudflare/authority-verdict-cache.d.ts +28 -1
- package/dist/cloudflare/authority-verdict-cache.d.ts.map +1 -1
- package/dist/cloudflare/authority-verdict-cache.js +32 -1
- package/dist/cloudflare/authority-verdict-cache.js.map +1 -1
- package/dist/cloudflare/exchange-store.d.ts +20 -1
- package/dist/cloudflare/exchange-store.d.ts.map +1 -1
- package/dist/cloudflare/exchange-store.js +26 -4
- package/dist/cloudflare/exchange-store.js.map +1 -1
- package/dist/cloudflare/index.d.ts +7 -4
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +8 -2
- package/dist/cloudflare/index.js.map +1 -1
- package/dist/cloudflare/interaction-store.d.ts +81 -0
- package/dist/cloudflare/interaction-store.d.ts.map +1 -0
- package/dist/cloudflare/interaction-store.js +281 -0
- package/dist/cloudflare/interaction-store.js.map +1 -0
- package/dist/cloudflare/orchestration-runtime.d.ts +19 -5
- package/dist/cloudflare/orchestration-runtime.d.ts.map +1 -1
- package/dist/cloudflare/orchestration-runtime.js +11 -6
- package/dist/cloudflare/orchestration-runtime.js.map +1 -1
- package/dist/cloudflare/principal-gateway-do.d.ts +360 -30
- package/dist/cloudflare/principal-gateway-do.d.ts.map +1 -1
- package/dist/cloudflare/principal-gateway-do.js +580 -58
- package/dist/cloudflare/principal-gateway-do.js.map +1 -1
- package/dist/cloudflare/receipt-signer.d.ts.map +1 -1
- package/dist/cloudflare/receipt-signer.js +6 -3
- package/dist/cloudflare/receipt-signer.js.map +1 -1
- package/dist/cloudflare/sql-vault.d.ts.map +1 -1
- package/dist/cloudflare/sql-vault.js +56 -0
- package/dist/cloudflare/sql-vault.js.map +1 -1
- package/dist/cloudflare/vault-exchange-store.d.ts +47 -0
- package/dist/cloudflare/vault-exchange-store.d.ts.map +1 -0
- package/dist/cloudflare/vault-exchange-store.js +135 -0
- package/dist/cloudflare/vault-exchange-store.js.map +1 -0
- package/dist/cloudflare/vault-interaction-store.d.ts +44 -0
- package/dist/cloudflare/vault-interaction-store.d.ts.map +1 -0
- package/dist/cloudflare/vault-interaction-store.js +259 -0
- package/dist/cloudflare/vault-interaction-store.js.map +1 -0
- package/dist/communicative-act.d.ts +24 -1
- package/dist/communicative-act.d.ts.map +1 -1
- package/dist/communicative-act.js +28 -0
- package/dist/communicative-act.js.map +1 -1
- package/dist/delivery-envelope.d.ts.map +1 -1
- package/dist/delivery-envelope.js +0 -1
- package/dist/delivery-envelope.js.map +1 -1
- package/dist/email/index.d.ts +79 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +91 -0
- package/dist/email/index.js.map +1 -0
- package/dist/enum-retirement.d.ts +43 -0
- package/dist/enum-retirement.d.ts.map +1 -0
- package/dist/enum-retirement.js +93 -0
- package/dist/enum-retirement.js.map +1 -0
- package/dist/exchange-record.d.ts +35 -4
- package/dist/exchange-record.d.ts.map +1 -1
- package/dist/exchange-record.js.map +1 -1
- package/dist/exchange-reducer.d.ts +32 -4
- package/dist/exchange-reducer.d.ts.map +1 -1
- package/dist/exchange-reducer.js +75 -1
- package/dist/exchange-reducer.js.map +1 -1
- package/dist/exchange-state.d.ts +30 -8
- package/dist/exchange-state.d.ts.map +1 -1
- package/dist/exchange-state.js +12 -10
- package/dist/exchange-state.js.map +1 -1
- package/dist/inbox-doc.d.ts +2 -2
- package/dist/inbox-doc.d.ts.map +1 -1
- package/dist/inbox-doc.js.map +1 -1
- package/dist/inbox-projection.d.ts +2 -2
- package/dist/inbox-projection.d.ts.map +1 -1
- package/dist/inbox-projection.js.map +1 -1
- package/dist/inbox-revision.d.ts +42 -0
- package/dist/inbox-revision.d.ts.map +1 -0
- package/dist/inbox-revision.js +57 -0
- package/dist/inbox-revision.js.map +1 -0
- package/dist/index.d.ts +26 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -2
- package/dist/index.js.map +1 -1
- package/dist/interaction-machines.d.ts +136 -0
- package/dist/interaction-machines.d.ts.map +1 -0
- package/dist/interaction-machines.js +174 -0
- package/dist/interaction-machines.js.map +1 -0
- package/dist/interaction-profile.d.ts +136 -0
- package/dist/interaction-profile.d.ts.map +1 -0
- package/dist/interaction-profile.js +212 -0
- package/dist/interaction-profile.js.map +1 -0
- package/dist/interaction-record.d.ts +168 -0
- package/dist/interaction-record.d.ts.map +1 -0
- package/dist/interaction-record.js +108 -0
- package/dist/interaction-record.js.map +1 -0
- package/dist/interactions/grant-scopes.d.ts +21 -0
- package/dist/interactions/grant-scopes.d.ts.map +1 -0
- package/dist/interactions/grant-scopes.js +88 -0
- package/dist/interactions/grant-scopes.js.map +1 -0
- package/dist/interactions/index.d.ts +1 -0
- package/dist/interactions/index.d.ts.map +1 -1
- package/dist/interactions/index.js +3 -0
- package/dist/interactions/index.js.map +1 -1
- package/dist/interactions/machine.d.ts +2 -2
- package/dist/interactions/machine.d.ts.map +1 -1
- package/dist/interactions/machine.js +2 -2
- package/dist/interactions/machine.js.map +1 -1
- package/dist/interactions/open-case.d.ts +60 -0
- package/dist/interactions/open-case.d.ts.map +1 -0
- package/dist/interactions/open-case.js +83 -0
- package/dist/interactions/open-case.js.map +1 -0
- package/dist/interactions/store.d.ts +9 -0
- package/dist/interactions/store.d.ts.map +1 -1
- package/dist/interactions/store.js +57 -2
- package/dist/interactions/store.js.map +1 -1
- package/dist/messaging/board.d.ts +4 -4
- package/dist/messaging/board.d.ts.map +1 -1
- package/dist/messaging/board.js +2 -2
- package/dist/messaging/board.js.map +1 -1
- package/dist/messaging/consult-routing.d.ts +103 -23
- package/dist/messaging/consult-routing.d.ts.map +1 -1
- package/dist/messaging/consult-routing.js +157 -47
- package/dist/messaging/consult-routing.js.map +1 -1
- package/dist/messaging/consult.d.ts +4 -0
- package/dist/messaging/consult.d.ts.map +1 -1
- package/dist/messaging/consult.js +3 -1
- package/dist/messaging/consult.js.map +1 -1
- package/dist/messaging/conversation-interaction.d.ts +66 -0
- package/dist/messaging/conversation-interaction.d.ts.map +1 -0
- package/dist/messaging/conversation-interaction.js +139 -0
- package/dist/messaging/conversation-interaction.js.map +1 -0
- package/dist/messaging/conversation.d.ts +69 -1
- package/dist/messaging/conversation.d.ts.map +1 -1
- package/dist/messaging/conversation.js +131 -0
- package/dist/messaging/conversation.js.map +1 -1
- package/dist/messaging/delivery.d.ts +2 -2
- package/dist/messaging/delivery.d.ts.map +1 -1
- package/dist/messaging/delivery.js +8 -1
- package/dist/messaging/delivery.js.map +1 -1
- package/dist/messaging/envelope.d.ts +7 -3
- package/dist/messaging/envelope.d.ts.map +1 -1
- package/dist/messaging/envelope.js +18 -1
- package/dist/messaging/envelope.js.map +1 -1
- package/dist/messaging/hash.d.ts +8 -2
- package/dist/messaging/hash.d.ts.map +1 -1
- package/dist/messaging/hash.js +6 -0
- package/dist/messaging/hash.js.map +1 -1
- package/dist/messaging/inbox-assistant.d.ts +16 -4
- package/dist/messaging/inbox-assistant.d.ts.map +1 -1
- package/dist/messaging/inbox-assistant.js +27 -4
- package/dist/messaging/inbox-assistant.js.map +1 -1
- package/dist/messaging/index.d.ts +7 -3
- package/dist/messaging/index.d.ts.map +1 -1
- package/dist/messaging/index.js +11 -2
- package/dist/messaging/index.js.map +1 -1
- package/dist/messaging/outbound-message.d.ts +42 -0
- package/dist/messaging/outbound-message.d.ts.map +1 -0
- package/dist/messaging/outbound-message.js +64 -0
- package/dist/messaging/outbound-message.js.map +1 -0
- package/dist/messaging/projector.d.ts +3 -3
- package/dist/messaging/projector.d.ts.map +1 -1
- package/dist/messaging/projector.js.map +1 -1
- package/dist/messaging/receipts.d.ts +3 -3
- package/dist/messaging/receipts.d.ts.map +1 -1
- package/dist/messaging/receipts.js.map +1 -1
- package/dist/messaging/rules.d.ts +10 -3
- package/dist/messaging/rules.d.ts.map +1 -1
- package/dist/messaging/rules.js +8 -1
- package/dist/messaging/rules.js.map +1 -1
- package/dist/messaging/store.d.ts +4 -4
- package/dist/messaging/store.d.ts.map +1 -1
- package/dist/messaging/store.js +5 -3
- package/dist/messaging/store.js.map +1 -1
- package/dist/messaging/types.d.ts +30 -0
- package/dist/messaging/types.d.ts.map +1 -1
- package/dist/messaging/types.js +3 -0
- package/dist/messaging/types.js.map +1 -1
- package/dist/messaging/vault-store.d.ts +2 -2
- package/dist/messaging/vault-store.d.ts.map +1 -1
- package/dist/messaging/vault-store.js +8 -0
- package/dist/messaging/vault-store.js.map +1 -1
- package/dist/protocol-binding.d.ts +71 -0
- package/dist/protocol-binding.d.ts.map +1 -0
- package/dist/protocol-binding.js +0 -0
- package/dist/protocol-binding.js.map +1 -0
- package/dist/record-catalog.d.ts.map +1 -1
- package/dist/record-catalog.js +12 -0
- package/dist/record-catalog.js.map +1 -1
- 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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
93
|
-
if (!
|
|
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,
|
|
100
|
-
if (!
|
|
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.
|
|
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,
|
|
109
|
-
if (!
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
183
|
-
*
|
|
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.
|
|
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
|
-
|
|
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:
|
|
198
|
-
await this.
|
|
199
|
-
|
|
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
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (input.
|
|
229
|
-
|
|
230
|
-
|
|
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.
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|