@ixo/oracle-runtime-workers 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -33,7 +33,7 @@
33
33
  "!src/plugins/pod-creator/test-fixtures.ts"
34
34
  ],
35
35
  "dependencies": {
36
- "@ixo/action-protocol": "0.4.1",
36
+ "@ixo/action-protocol": "0.5.0",
37
37
  "@blocknote/server-util": "0.29.1",
38
38
  "@composio/core": "0.10.0",
39
39
  "@composio/langchain": "0.9.2",
@@ -65,12 +65,13 @@
65
65
  "protobufjs": "^7.6.5",
66
66
  "@ixo/topic-protocol": "1.0.0-rc.8",
67
67
  "@ixo/common": "1.7.1",
68
- "@ixo/ixo-transaction": "0.2.0",
69
- "@ixo/ucan": "2.3.0"
68
+ "@ixo/ucan": "2.3.0",
69
+ "@ixo/ixo-transaction": "0.2.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@cloudflare/vitest-pool-workers": "^0.22.0",
73
73
  "@cloudflare/workers-types": "^5.20260825.1",
74
+ "@ixo/action-protocol-041": "npm:@ixo/action-protocol@0.4.1",
74
75
  "@modelcontextprotocol/sdk": "1.30.0",
75
76
  "happy-dom": "^19.0.0",
76
77
  "typescript": "^5.9.3",
@@ -76,12 +76,6 @@ export const ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS = 180 * 24 * 60 * 60_000;
76
76
  * still possible, so the link stays used up.
77
77
  */
78
78
  export const ACTION_PROPOSAL_EXECUTION_WINDOW_MS = 60 * 60_000;
79
- /** Cards one agent run may prepare; more is a looping run, refused. */
80
- export const ACTION_PROPOSALS_PER_RUN = 8;
81
- /** Cards still awaiting the owner (live `prepared`, `claimed`, hash-less `unknown`) per owner. */
82
- export const ACTION_PROPOSALS_OPEN_PER_OWNER = 16;
83
- /** A `prepared` card counts as open this long after creation (its TTL plus clock skew). */
84
- export const OPEN_PREPARED_MS = 20 * 60_000;
85
79
 
86
80
  /**
87
81
  * A card's claim time from its lifecycle JSON, NULL when the JSON is not
@@ -256,11 +250,6 @@ export class ActionProposalStore {
256
250
  }
257
251
  if (Date.parse(subject.expiresAt) <= this.now())
258
252
  throw new ActionProposalConflict('Proposal expired');
259
- await this.assertCapacity(
260
- subject.ownerDid,
261
- subject.origin.sessionId,
262
- subject.origin.runId,
263
- );
264
253
  await this.db.run(
265
254
  `INSERT INTO action_proposals (id,owner_did,invocation_id,session_id,run_id,subject,digest,lifecycle,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)`,
266
255
  [
@@ -293,35 +282,6 @@ export class ActionProposalStore {
293
282
  });
294
283
  }
295
284
 
296
- /**
297
- * A looping or injected run must not mint cards without bound: at most
298
- * `ACTION_PROPOSALS_PER_RUN` per run and `ACTION_PROPOSALS_OPEN_PER_OWNER`
299
- * still awaiting the owner. A resumed tool call never reaches here (it
300
- * returns its original card above).
301
- */
302
- private async assertCapacity(
303
- ownerDid: string,
304
- sessionId: string,
305
- runId: string,
306
- ): Promise<void> {
307
- const run = await this.db.get<{ n: number }>(
308
- `SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND session_id=? AND run_id=?`,
309
- [ownerDid, sessionId, runId],
310
- );
311
- if ((run?.n ?? 0) >= ACTION_PROPOSALS_PER_RUN)
312
- throw new ActionProposalConflict(
313
- 'Too many action cards in this request; finish the open ones first',
314
- );
315
- const open = await this.db.get<{ n: number }>(
316
- `SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND ((status='prepared' AND created_at>?) OR status='claimed' OR (status='unknown' AND transaction_hash IS NULL))`,
317
- [ownerDid, this.now() - OPEN_PREPARED_MS],
318
- );
319
- if ((open?.n ?? 0) >= ACTION_PROPOSALS_OPEN_PER_OWNER)
320
- throw new ActionProposalConflict(
321
- 'Too many action cards are open; finish or decline them first',
322
- );
323
- }
324
-
325
285
  async get(ownerDid: string, id: string): Promise<ActionProposal | null> {
326
286
  const row = await this.db.get<{
327
287
  subject: string;
@@ -24,9 +24,6 @@ import {
24
24
  ActionProposalConflict,
25
25
  ACTION_PROPOSAL_RETENTION_MS,
26
26
  ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS,
27
- ACTION_PROPOSALS_OPEN_PER_OWNER,
28
- ACTION_PROPOSALS_PER_RUN,
29
- OPEN_PREPARED_MS,
30
27
  RECEIPT_MAX_ATTEMPTS,
31
28
  RECEIPT_RETRY_BASE_MS,
32
29
  RECEIPT_RETRY_MAX_MS,
@@ -163,11 +160,6 @@ export class TopicProposalStore {
163
160
  }
164
161
  if (Date.parse(subject.expiresAt) <= this.now())
165
162
  throw new ActionProposalConflict('Proposal expired');
166
- await this.assertCapacity(
167
- subject.ownerDid,
168
- subject.origin.sessionId,
169
- subject.origin.runId,
170
- );
171
163
  const steps: TopicStepId[] =
172
164
  subject.action.name === 'create_topic'
173
165
  ? ['anchor', 'body', 'operation', 'snapshot']
@@ -201,40 +193,6 @@ export class TopicProposalStore {
201
193
  return TopicProposalV2Schema.parse({ ...subject, digest, lifecycle });
202
194
  });
203
195
  }
204
- /**
205
- * At most `ACTION_PROPOSALS_PER_RUN` cards per run and
206
- * `ACTION_PROPOSALS_OPEN_PER_OWNER` open ones (prepared in the last 20
207
- * minutes, or claimed / unknown in the last two hours: the claim window
208
- * plus the execution window). The owner object runs this before the
209
- * authority read a refused card would waste; `create` runs it again.
210
- */
211
- async assertCapacity(
212
- owner: string,
213
- sessionId: string,
214
- runId: string,
215
- ): Promise<void> {
216
- const now = this.now();
217
- const run = await this.db.get<{ n: number }>(
218
- `SELECT COUNT(*) AS n FROM topic_action_proposals WHERE owner_did=? AND session_id=? AND run_id=?`,
219
- [owner, sessionId, runId],
220
- );
221
- if ((run?.n ?? 0) >= ACTION_PROPOSALS_PER_RUN)
222
- throw new ActionProposalConflict(
223
- 'Too many action cards in this request; finish the open ones first',
224
- );
225
- const open = await this.db.get<{ n: number }>(
226
- `SELECT COUNT(*) AS n FROM topic_action_proposals WHERE owner_did=? AND ((status='prepared' AND created_at>?) OR (status IN ('claimed','unknown') AND created_at>?))`,
227
- [
228
- owner,
229
- now - OPEN_PREPARED_MS,
230
- now - OPEN_PREPARED_MS - TOPIC_EXECUTION_WINDOW_MS - 40 * 60_000,
231
- ],
232
- );
233
- if ((open?.n ?? 0) >= ACTION_PROPOSALS_OPEN_PER_OWNER)
234
- throw new ActionProposalConflict(
235
- 'Too many action cards are open; finish or decline them first',
236
- );
237
- }
238
196
  async get(owner: string, id: string): Promise<TopicActionProposal | null> {
239
197
  const row = await this.db.get<{
240
198
  subject: string;
package/src/core/env.ts CHANGED
@@ -129,7 +129,10 @@ export const baseEnvSchema = z.object({
129
129
  ORACLE_DID: z.string().min(1),
130
130
  /**
131
131
  * On-chain entity DID surfaced to the agent as `identity.entityDid`. Falls
132
- * back to `ORACLE_DID` when unset (see `createRuntimeCore`).
132
+ * back to `ORACLE_DID` when unset (see `createRuntimeCore`) for rooms and
133
+ * feedback. The memory plugin redeclares it as required
134
+ * (`did:ixo:entity:<32 hex>`): the Memory Engine keys every space by it and
135
+ * never receives the fallback.
133
136
  */
134
137
  ORACLE_ENTITY_DID: z.string().min(1).optional(),
135
138
  NETWORK: z.enum(['mainnet', 'testnet', 'devnet']).default('mainnet'),
@@ -163,7 +163,10 @@ export function draftReplyPlan(input: {
163
163
  else parts.push({ kind: 'spill', key: `step-${i}`, spill: shape.spill });
164
164
  }
165
165
  for (const call of step.toolCalls) {
166
- if (call.name === 'request_hub_action') {
166
+ if (
167
+ call.name === 'request_hub_action' ||
168
+ call.name === 'request_hub_transfer'
169
+ ) {
167
170
  try {
168
171
  const result = JSON.parse(input.toolResults.get(call.id) ?? 'null');
169
172
  if (result?.status === 'prepared') {
@@ -85,7 +85,6 @@ export interface OracleWorkerEnv {
85
85
  AUTH_HUB_URL?: string;
86
86
  AUTH_HUB_CHANNEL_SERVICE_KEY?: string;
87
87
  HUB_ACTIONS_ENABLED?: string;
88
- AUTH_HUB_ACTION_OUTCOME_KEY?: string;
89
88
 
90
89
  // --- matrix -------------------------------------------------------------
91
90
  MATRIX_BASE_URL: string;
@@ -39,6 +39,11 @@ import type {
39
39
  import { ChannelTurns } from '../channels/turns';
40
40
  import { HubActionClient } from '../hub-actions/client';
41
41
  import { prepareHubAction } from '../hub-actions/request';
42
+ import {
43
+ bankReceiptSummary,
44
+ describeAmount,
45
+ toBaseUnits,
46
+ } from '../hub-actions/transfer';
42
47
  import {
43
48
  HUB_CHECK_AFTER_EXPIRY_MS,
44
49
  HubActionRefused,
@@ -49,6 +54,9 @@ import {
49
54
  canonicalJson,
50
55
  type ActionCapabilitiesV1,
51
56
  type HubActionOptionsV2,
57
+ type HubActionOptionsV3,
58
+ parseOperation,
59
+ v1Receipt,
52
60
  type ActionOperationV1,
53
61
  type ActionOutcomeV1,
54
62
  } from '@ixo/action-protocol';
@@ -381,6 +389,10 @@ import {
381
389
  fetchMemorySchemaDump,
382
390
  getMemorySchemaDump,
383
391
  } from '../plugins/memory/memory-tools';
392
+ import {
393
+ MEMORY_ENTITY_HEADER,
394
+ memoryEntityDid,
395
+ } from '../plugins/memory/memory-entity';
384
396
  import {
385
397
  contentToText,
386
398
  isSummarizationMessage,
@@ -595,6 +607,25 @@ const ACTION_TICK_BUDGET_MS = 15_000;
595
607
  const ACTION_PRUNE_INTERVAL_MS = 60 * 60_000;
596
608
  /** How long the Hub's capabilities answer is reused per object. */
597
609
  const HUB_CAPABILITIES_CACHE_MS = 60_000;
610
+ /** A Hub network name as the chain-id config key it is configured under. */
611
+ /**
612
+ * Whether two Hub outcomes are the same event. Receipts are compared in the
613
+ * 0.4.1 shape: an action prepared at protocol version 2 (runtime 0.23.0) gets
614
+ * its callback without the 0.5.0 bank fields (`denom`, `amountBaseUnits`),
615
+ * while the confirming status read (version 3) carries them.
616
+ */
617
+ function sameHubOutcome(a: ActionOutcomeV1, b: ActionOutcomeV1): boolean {
618
+ return (
619
+ canonicalJson({ ...a, receipt: v1Receipt(a.receipt) }) ===
620
+ canonicalJson({ ...b, receipt: v1Receipt(b.receipt) })
621
+ );
622
+ }
623
+
624
+ const HUB_NETWORK_CHAIN = {
625
+ 'ixo mainnet': 'mainnet',
626
+ 'ixo testnet': 'testnet',
627
+ 'ixo devnet': 'devnet',
628
+ } as const;
598
629
  /** How long a `hub_action_options` answer vouches for a Gmail connection. */
599
630
  const HUB_OPTIONS_REUSE_MS = 10 * 60_000;
600
631
  /** A channel poll waits this long for Auth Hub, once per poll at most. */
@@ -998,8 +1029,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
998
1029
  at: number;
999
1030
  } | null = null;
1000
1031
  /** The last `hub_action_options` answer for this owner (Gmail connection checks). */
1001
- private lastHubOptions: { value: HubActionOptionsV2; at: number } | null =
1002
- null;
1032
+ private lastHubOptions: {
1033
+ value: HubActionOptionsV2 | HubActionOptionsV3;
1034
+ at: number;
1035
+ } | null = null;
1003
1036
  private topicProposals: TopicProposalStore | null = null;
1004
1037
  /** `assertTopicDependenciesMemo`: card id and digest -> when it passed. */
1005
1038
  private topicAuthorityMemo: Map<string, number> | undefined;
@@ -2183,8 +2216,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2183
2216
  requestKey: string;
2184
2217
  operation: ActionOperationV1;
2185
2218
  }) => this.requestHubAction(input),
2219
+ transfer: (input) => this.transferHubAction(input),
2186
2220
  status: (ownerDid: string, actionId: string) =>
2187
- this.statusHubAction(ownerDid, actionId),
2221
+ this.hubActionStatusForTool(ownerDid, actionId),
2188
2222
  },
2189
2223
  }
2190
2224
  : {}),
@@ -3203,11 +3237,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3203
3237
  this.markDirty();
3204
3238
  await this.requestHousekeeping(Date.now() + 1000);
3205
3239
  }
3206
- const hubActionOutcomes = await this.hubOutcomesForRun(
3207
- ownerDid,
3208
- result.sessionId,
3209
- result.runId,
3210
- );
3240
+ // The gateway parses with @ixo/action-protocol 0.4.1, whose bank
3241
+ // receipt is exact: the 0.5.0 `denom`/`amountBaseUnits` fields are
3242
+ // dropped here (the owner-facing amount line comes from
3243
+ // hub_action_status instead), so the emitted shape stays 0.4.1.
3244
+ const hubActionOutcomes = (
3245
+ await this.hubOutcomesForRun(ownerDid, result.sessionId, result.runId)
3246
+ ).map((outcome) => ({ ...outcome, receipt: v1Receipt(outcome.receipt) }));
3211
3247
  return {
3212
3248
  ...result,
3213
3249
  ...(actionOutcomes.length ? { actionOutcomes } : {}),
@@ -3215,14 +3251,64 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3215
3251
  };
3216
3252
  }
3217
3253
 
3218
- private async hubActionOptions(input: {
3254
+ /** `hub_action_options` and the transfer path: version 3, with the owner's tokens. */
3255
+ private async hubActionOptions(
3256
+ input: {
3257
+ ownerDid: string;
3258
+ provider: string;
3259
+ bindingId: string;
3260
+ bindingRevision: number;
3261
+ },
3262
+ bindingChecked = false,
3263
+ ): Promise<HubActionOptionsV3> {
3264
+ const client = this.hubActionClient;
3265
+ if (!client || input.ownerDid !== this.userDid)
3266
+ throw new Error('Hub action options are unavailable for this owner');
3267
+ return this.readHubOptions(input, bindingChecked, (binding) =>
3268
+ client.options(binding),
3269
+ );
3270
+ }
3271
+
3272
+ /**
3273
+ * Read the owner's options for the trusted binding (validated here
3274
+ * unless the caller already validated it in this same call) and keep
3275
+ * the read for the Gmail connection check.
3276
+ */
3277
+ private async readHubOptions<
3278
+ T extends HubActionOptionsV2 | HubActionOptionsV3,
3279
+ >(
3280
+ input: {
3281
+ ownerDid: string;
3282
+ provider: string;
3283
+ bindingId: string;
3284
+ bindingRevision: number;
3285
+ },
3286
+ bindingChecked: boolean,
3287
+ read: (binding: {
3288
+ bindingId: string;
3289
+ bindingRevision: number;
3290
+ }) => Promise<T>,
3291
+ ): Promise<T> {
3292
+ if (!bindingChecked) await this.assertHubBinding(input);
3293
+ const options = await read(input);
3294
+ if (
3295
+ options.ownerDid !== input.ownerDid ||
3296
+ options.bindingId !== input.bindingId ||
3297
+ options.bindingRevision !== input.bindingRevision
3298
+ )
3299
+ throw new Error(
3300
+ 'Hub action options do not match this owner and binding',
3301
+ );
3302
+ this.lastHubOptions = { value: options, at: Date.now() };
3303
+ return options;
3304
+ }
3305
+
3306
+ private async assertHubBinding(input: {
3219
3307
  ownerDid: string;
3220
3308
  provider: string;
3221
3309
  bindingId: string;
3222
3310
  bindingRevision: number;
3223
- }) {
3224
- if (!this.hubActionClient || input.ownerDid !== this.userDid)
3225
- throw new Error('Hub action options are unavailable for this owner');
3311
+ }): Promise<void> {
3226
3312
  await assertActiveChannelBinding(
3227
3313
  {
3228
3314
  userDid: input.ownerDid,
@@ -3234,17 +3320,34 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3234
3320
  },
3235
3321
  this.env,
3236
3322
  );
3237
- const options = await this.hubActionClient.options(input);
3323
+ }
3324
+
3325
+ /**
3326
+ * Only a run an inbound channel message started may prepare a review:
3327
+ * a scheduled or background run has no user asking for it. Checked
3328
+ * before any Hub read.
3329
+ */
3330
+ private async assertHubTurnOrigin(input: {
3331
+ ownerDid: string;
3332
+ sessionId: string;
3333
+ runId: string;
3334
+ }): Promise<void> {
3335
+ const record = await this.runStore?.get(input.runId);
3336
+ const turn = record
3337
+ ? (JSON.parse(record.request) as StoredRunRequest).turn
3338
+ : null;
3238
3339
  if (
3239
- options.ownerDid !== input.ownerDid ||
3240
- options.bindingId !== input.bindingId ||
3241
- options.bindingRevision !== input.bindingRevision
3340
+ !turn ||
3341
+ turn.client !== 'channel' ||
3342
+ !turn.channel?.remoteMessageRef ||
3343
+ turn.identity.userDid !== input.ownerDid ||
3344
+ turn.sessionId !== input.sessionId ||
3345
+ turn.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX)
3242
3346
  )
3243
- throw new Error(
3244
- 'Hub action options do not match this owner and binding',
3347
+ throw new HubActionRefused(
3348
+ 'limit',
3349
+ 'Reviews are prepared only in reply to a message from the user.',
3245
3350
  );
3246
- this.lastHubOptions = { value: options, at: Date.now() };
3247
- return options;
3248
3351
  }
3249
3352
 
3250
3353
  /**
@@ -3276,7 +3379,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3276
3379
  cached.value.bindingId === input.bindingId &&
3277
3380
  cached.value.bindingRevision === input.bindingRevision
3278
3381
  ? cached.value
3279
- : await this.hubActionOptions(input);
3382
+ : await this.readHubOptions(input, true, (binding) =>
3383
+ this.hubActionClient!.connectionOptions(binding),
3384
+ );
3280
3385
  if (
3281
3386
  !options.connections.some(
3282
3387
  (c) =>
@@ -3316,50 +3421,32 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3316
3421
  return value;
3317
3422
  }
3318
3423
 
3319
- private async requestHubAction(input: {
3320
- ownerDid: string;
3321
- sessionId: string;
3322
- runId: string;
3323
- requestId: string;
3324
- provider: string;
3325
- bindingId: string;
3326
- bindingRevision: number;
3327
- requestKey: string;
3328
- operation: ActionOperationV1;
3329
- }) {
3424
+ /**
3425
+ * `checked`: the transfer path already ran the turn-origin refusal and
3426
+ * validated the binding in this same call.
3427
+ */
3428
+ private async requestHubAction(
3429
+ input: {
3430
+ ownerDid: string;
3431
+ sessionId: string;
3432
+ runId: string;
3433
+ requestId: string;
3434
+ provider: string;
3435
+ bindingId: string;
3436
+ bindingRevision: number;
3437
+ requestKey: string;
3438
+ operation: ActionOperationV1;
3439
+ },
3440
+ checked = false,
3441
+ ) {
3330
3442
  const store = this.hubActionStore;
3331
3443
  const client = this.hubActionClient;
3332
3444
  if (!store || !client || input.ownerDid !== this.userDid)
3333
3445
  throw new Error('Hub actions are unavailable for this channel');
3334
- // Only a run an inbound channel message started may prepare a review:
3335
- // a scheduled or background run has no user asking for it.
3336
- const record = await this.runStore?.get(input.runId);
3337
- const turn = record
3338
- ? (JSON.parse(record.request) as StoredRunRequest).turn
3339
- : null;
3340
- if (
3341
- !turn ||
3342
- turn.client !== 'channel' ||
3343
- !turn.channel?.remoteMessageRef ||
3344
- turn.identity.userDid !== input.ownerDid ||
3345
- turn.sessionId !== input.sessionId ||
3346
- turn.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX)
3347
- )
3348
- throw new HubActionRefused(
3349
- 'limit',
3350
- 'Reviews are prepared only in reply to a message from the user.',
3351
- );
3352
- await assertActiveChannelBinding(
3353
- {
3354
- userDid: input.ownerDid,
3355
- channel: {
3356
- provider: input.provider,
3357
- bindingId: input.bindingId,
3358
- bindingRevision: input.bindingRevision,
3359
- },
3360
- },
3361
- this.env,
3362
- );
3446
+ if (!checked) {
3447
+ await this.assertHubTurnOrigin(input);
3448
+ await this.assertHubBinding(input);
3449
+ }
3363
3450
  await this.assertGmailOperationAllowed(input);
3364
3451
  const reference = await prepareHubAction(
3365
3452
  {
@@ -3381,6 +3468,107 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3381
3468
  return reference;
3382
3469
  }
3383
3470
 
3471
+ /**
3472
+ * A bank send of one of the owner's listed tokens. The token comes from
3473
+ * a fresh options read (its balance must be current), the amount is
3474
+ * converted to base units with the listed decimals (unverified tokens
3475
+ * list 0: base units), refused when zero or above the balance; the
3476
+ * network is the account's capability, the chain id this Worker's own
3477
+ * for that network, the sender the owner's account. No fee is sent: the
3478
+ * Hub simulates gas and sets it.
3479
+ */
3480
+ private async transferHubAction(input: {
3481
+ ownerDid: string;
3482
+ sessionId: string;
3483
+ runId: string;
3484
+ requestId: string;
3485
+ provider: string;
3486
+ bindingId: string;
3487
+ bindingRevision: number;
3488
+ requestKey: string;
3489
+ toAddress: string;
3490
+ denom: string;
3491
+ amount: string;
3492
+ memo: string;
3493
+ }) {
3494
+ if (!this.hubActionStore || input.ownerDid !== this.userDid)
3495
+ throw new Error('Hub actions are unavailable for this channel');
3496
+ await this.assertHubTurnOrigin(input);
3497
+ await this.assertHubBinding(input);
3498
+ const options = await this.hubActionOptions(input, true);
3499
+ const tokens = options.transfer?.tokens;
3500
+ if (!tokens?.length)
3501
+ throw new HubActionRefused(
3502
+ 'refused',
3503
+ 'Bank sends are not available for this owner right now (no token list from Auth Hub).',
3504
+ );
3505
+ const token = tokens.find((item) => item.denom === input.denom);
3506
+ if (!token)
3507
+ throw new HubActionRefused(
3508
+ 'refused',
3509
+ `That token is not in the owner's list; use a denom exactly as hub_action_options lists it (${tokens
3510
+ .map((item) => item.denom)
3511
+ .slice(0, 10)
3512
+ .join(', ')}).`,
3513
+ );
3514
+ const amountBaseUnits = toBaseUnits(input.amount, token.decimals);
3515
+ if (amountBaseUnits > BigInt(token.balanceBaseUnits))
3516
+ throw new HubActionRefused(
3517
+ 'refused',
3518
+ `The owner holds ${describeAmount(token.balanceBaseUnits, token)}, less than this transfer.`,
3519
+ );
3520
+ const capabilities = await this.cachedHubCapabilities();
3521
+ if (!capabilities.network)
3522
+ throw new HubActionRefused(
3523
+ 'refused',
3524
+ 'Bank sends are not enabled for this oracle account.',
3525
+ );
3526
+ const network = HUB_NETWORK_CHAIN[capabilities.network];
3527
+ const chainId = chainIdsFromConfig(chainIdConfigSchema.parse(this.env))[
3528
+ network
3529
+ ];
3530
+ let operation: ActionOperationV1;
3531
+ try {
3532
+ operation = parseOperation({
3533
+ kind: 'ixo.bank.send.v1',
3534
+ chainId,
3535
+ network: capabilities.network,
3536
+ fromAddress: input.ownerDid.replace(/^did:ixo:/, ''),
3537
+ toAddress: input.toAddress,
3538
+ amountBaseUnits: amountBaseUnits.toString(),
3539
+ denom: token.denom,
3540
+ memo: input.memo,
3541
+ });
3542
+ } catch (error) {
3543
+ // Only the protocol refusing the operation built from the model's
3544
+ // fields (an invalid recipient or memo) is the model's to fix.
3545
+ throw new HubActionRefused(
3546
+ 'refused',
3547
+ `The transfer is incomplete (${error instanceof Error ? error.message : String(error)}).`,
3548
+ );
3549
+ }
3550
+ const reference = await this.requestHubAction(
3551
+ { ...input, operation },
3552
+ true,
3553
+ );
3554
+ await this.hubActionStore.setToken(reference.id, token);
3555
+ return reference;
3556
+ }
3557
+
3558
+ /**
3559
+ * `hub_action_status`: the outcome plus, for a bank send, the
3560
+ * owner-facing line (amount from the token stored at preparation).
3561
+ */
3562
+ private async hubActionStatusForTool(
3563
+ ownerDid: string,
3564
+ actionId: string,
3565
+ ): Promise<{ outcome: ActionOutcomeV1 | null; summary: string | null }> {
3566
+ const outcome = await this.statusHubAction(ownerDid, actionId);
3567
+ if (!outcome) return { outcome, summary: null };
3568
+ const token = await this.hubActionStore!.token(ownerDid, actionId);
3569
+ return { outcome, summary: bankReceiptSummary(outcome.receipt, token) };
3570
+ }
3571
+
3384
3572
  private async statusHubAction(
3385
3573
  ownerDid: string,
3386
3574
  actionId: string,
@@ -3471,15 +3659,24 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3471
3659
  input.reference.id,
3472
3660
  );
3473
3661
  if (!held || !this.hubActionClient) throw new Error('Unknown Hub action');
3662
+ if (canonicalJson(input.reference) !== canonicalJson(held.reference))
3663
+ throw new Error('Hub outcome does not match the prepared action');
3664
+ // A replay of an outcome already confirmed and stored (a Hub retry,
3665
+ // a callback delivered twice) is acknowledged without a Hub read.
3666
+ const stored = await this.hubActionStore!.outcomeAt(
3667
+ input.ownerDid,
3668
+ input.reference.id,
3669
+ input.sequence,
3670
+ );
3671
+ if (stored && sameHubOutcome(stored, input)) return;
3474
3672
  const remote = await this.hubActionClient.status(input.reference.id);
3475
3673
  if (
3476
3674
  !remote.outcome ||
3477
3675
  remote.ownerDid !== input.ownerDid ||
3478
3676
  canonicalJson(remote.reference) !== canonicalJson(held.reference) ||
3479
- canonicalJson(input.reference) !== canonicalJson(held.reference) ||
3480
3677
  remote.outcome.sequence < input.sequence ||
3481
3678
  (remote.outcome.sequence === input.sequence &&
3482
- canonicalJson(remote.outcome) !== canonicalJson(input))
3679
+ !sameHubOutcome(remote.outcome, input))
3483
3680
  )
3484
3681
  throw new Error(
3485
3682
  'Hub outcome was not confirmed by authenticated status',
@@ -3728,13 +3925,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3728
3925
  input.evidenceRefs,
3729
3926
  );
3730
3927
  if (prior) return prior;
3731
- // The caps before the authority read it would waste (create checks
3732
- // them again, atomically).
3733
- await topics.assertCapacity(
3734
- this.userDid,
3735
- input.origin.sessionId,
3736
- input.origin.runId,
3737
- );
3738
3928
  const rpc = {
3739
3929
  devnet: this.env.ACTION_PROPOSALS_RPC_DEVNET,
3740
3930
  testnet: this.env.ACTION_PROPOSALS_RPC_TESTNET,
@@ -4341,6 +4531,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4341
4531
  const v = this.core.validatedEnv['MEMORY_ENGINE_URL'];
4342
4532
  return typeof v === 'string' && v.length > 0 ? v : undefined;
4343
4533
  })(),
4534
+ // The configured entity only: `identity.entityDid` falls back to
4535
+ // `ORACLE_DID`, which the engine refuses as a space key.
4536
+ entityDid: memoryEntityDid(this.core.validatedEnv['ORACLE_ENTITY_DID']),
4344
4537
  getSession: async (id) => {
4345
4538
  const snapshot = this.historySnapshots.get(id);
4346
4539
  const row =
@@ -4429,7 +4622,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4429
4622
  this.userContextFetcher ??= new UserContextFetcher(
4430
4623
  {
4431
4624
  memoryEngineUrl: settings.engineUrl,
4432
- oracleDid: this.env.ORACLE_DID,
4625
+ // The configured entity only: `identity.entityDid` falls back to
4626
+ // `ORACLE_DID`, which the engine refuses as a space key.
4627
+ entityDid: memoryEntityDid(
4628
+ this.core.validatedEnv['ORACLE_ENTITY_DID'],
4629
+ ),
4433
4630
  // Looked up per call, not captured: a working-copy reset replaces
4434
4631
  // the ambient services while a gather may still be running.
4435
4632
  ucan: {
@@ -5383,6 +5580,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5383
5580
  if (!ambient || typeof url !== 'string' || url.length === 0) {
5384
5581
  return { error: 'MEMORY_MCP_URL is not configured' };
5385
5582
  }
5583
+ const entityDid = memoryEntityDid(
5584
+ this.core.validatedEnv['ORACLE_ENTITY_DID'],
5585
+ );
5586
+ if (!entityDid) {
5587
+ return {
5588
+ error:
5589
+ 'ORACLE_ENTITY_DID is not a domain entity DID (did:ixo:entity:<32 hex>)',
5590
+ };
5591
+ }
5386
5592
  const memoryDid = await ambient.ucan.resolveServiceDid(url);
5387
5593
  if (!memoryDid)
5388
5594
  return { error: 'memory engine DID could not be resolved' };
@@ -5400,6 +5606,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
5400
5606
  Authorization: `Bearer ${invocation}`,
5401
5607
  'X-Auth-Type': 'ucan',
5402
5608
  'User-Agent': 'LangChain-MCP-Client/1.0',
5609
+ [MEMORY_ENTITY_HEADER]: entityDid,
5403
5610
  ...(room?.roomId ? { 'x-room-id': room.roomId } : {}),
5404
5611
  };
5405
5612
  try {