@ixo/oracle-runtime-workers 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +15 -13
  2. package/package.json +10 -4
  3. package/src/action-proposals/policy.ts +54 -0
  4. package/src/action-proposals/reconcile.ts +87 -0
  5. package/src/action-proposals/store.ts +863 -0
  6. package/src/action-proposals/topic-budget.ts +84 -0
  7. package/src/action-proposals/topic-chain.ts +272 -0
  8. package/src/action-proposals/topic-effects.ts +44 -0
  9. package/src/action-proposals/topic-errors.ts +79 -0
  10. package/src/action-proposals/topic-history.ts +267 -0
  11. package/src/action-proposals/topic-matrix.ts +292 -0
  12. package/src/action-proposals/topic-prepare.ts +299 -0
  13. package/src/action-proposals/topic-store.ts +999 -0
  14. package/src/action-proposals/topic-verify.ts +279 -0
  15. package/src/action-proposals/verify.ts +223 -0
  16. package/src/channels/auth.ts +6 -1
  17. package/src/channels/contract.ts +4 -0
  18. package/src/channels/turns.ts +1 -0
  19. package/src/core/env.ts +93 -6
  20. package/src/core/main-agent-types.ts +8 -0
  21. package/src/core/main-agent.ts +2 -0
  22. package/src/core/runtime-context.ts +12 -0
  23. package/src/delivery/plan.ts +43 -2
  24. package/src/delivery/schema.ts +25 -1
  25. package/src/delivery/types.ts +13 -2
  26. package/src/do/ambient.ts +6 -0
  27. package/src/do/contracts.ts +39 -0
  28. package/src/do/user-oracle-do.ts +1275 -8
  29. package/src/hub-actions/client.ts +163 -0
  30. package/src/hub-actions/request.ts +110 -0
  31. package/src/hub-actions/store.ts +485 -0
  32. package/src/matrix/gateway-do.ts +176 -0
  33. package/src/matrix/reply-parts.ts +8 -2
  34. package/src/memory/session-history-indexer.ts +30 -3
  35. package/src/memory/user-context-fetcher.ts +36 -12
  36. package/src/plugin-api/types.ts +54 -0
  37. package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
  38. package/src/plugins/action-proposals/evidence.ts +67 -0
  39. package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
  40. package/src/plugins/index.ts +6 -0
  41. package/src/plugins/memory/index.ts +4 -0
  42. package/src/plugins/memory/memory-entity.ts +49 -0
  43. package/src/plugins/memory/memory-tools.ts +7 -4
  44. package/src/plugins/memory/memory-ucan.ts +10 -1
  45. package/src/plugins/memory/memory.plugin.ts +22 -7
  46. package/src/shell/app.ts +195 -1
@@ -1,5 +1,61 @@
1
+ import { digestTopicProposalEffect } from '../action-proposals/topic-store';
2
+ import {
3
+ chainIdsFromConfig,
4
+ chainIdConfigSchema,
5
+ } from '../plugins/ixo-transaction/chain-ids';
6
+ import { TopicProposalStore } from '../action-proposals/topic-store';
7
+ import {
8
+ prepareTopicSubject,
9
+ type TopicCandidate,
10
+ } from '../action-proposals/topic-prepare';
11
+ import {
12
+ assertTopicDependencies,
13
+ reconcileTopicReferences,
14
+ type TopicVerificationContext,
15
+ } from '../action-proposals/topic-verify';
16
+ import { topicExpectedEffect } from '../action-proposals/topic-effects';
17
+ import {
18
+ topicActionOutcome,
19
+ type ActionProposalWire,
20
+ type TopicActionProposal,
21
+ type TopicProposalSubject,
22
+ type BeginTopicStep,
23
+ type TopicReceiptReferences,
24
+ } from '@ixo/ixo-transaction/topic-proposals';
25
+ import type { ChannelTurnResponse } from '../channels/contract';
26
+ import { refreshActionProposal } from '../action-proposals/reconcile';
27
+ import { assertActionProposalPolicy } from '../action-proposals/policy';
28
+ import { TopicReadUnavailable } from '../action-proposals/topic-errors';
29
+ import { actionOutcome } from '@ixo/ixo-transaction/action-proposals';
30
+ import {
31
+ ActionProposalConflict,
32
+ ActionProposalStore,
33
+ type ProposalCommand,
34
+ } from '../action-proposals/store';
35
+ import type {
36
+ ActionProposal,
37
+ ActionProposalSubject,
38
+ } from '@ixo/ixo-transaction/action-proposals';
1
39
  import { ChannelTurns } from '../channels/turns';
2
- import { assertChannelAttemptAllowed } from '../channels/auth';
40
+ import { HubActionClient } from '../hub-actions/client';
41
+ import { prepareHubAction } from '../hub-actions/request';
42
+ import {
43
+ HUB_CHECK_AFTER_EXPIRY_MS,
44
+ HubActionRefused,
45
+ HubActionStore,
46
+ TERMINAL_HUB_STATUSES,
47
+ } from '../hub-actions/store';
48
+ import {
49
+ canonicalJson,
50
+ type ActionCapabilitiesV1,
51
+ type HubActionOptionsV2,
52
+ type ActionOperationV1,
53
+ type ActionOutcomeV1,
54
+ } from '@ixo/action-protocol';
55
+ import {
56
+ assertActiveChannelBinding,
57
+ assertChannelAttemptAllowed,
58
+ } from '../channels/auth';
3
59
  import {
4
60
  CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
5
61
  ChannelError,
@@ -325,6 +381,10 @@ import {
325
381
  fetchMemorySchemaDump,
326
382
  getMemorySchemaDump,
327
383
  } from '../plugins/memory/memory-tools';
384
+ import {
385
+ MEMORY_ENTITY_HEADER,
386
+ memoryEntityDid,
387
+ } from '../plugins/memory/memory-entity';
328
388
  import {
329
389
  contentToText,
330
390
  isSummarizationMessage,
@@ -348,6 +408,59 @@ import {
348
408
  } from './ucan-service';
349
409
  import type { UcanDelegation, UserContextData } from '../plugin-api/types';
350
410
 
411
+ /**
412
+ * The gateway's topic read errors that describe what the homeserver
413
+ * returned rather than a read that failed (`MatrixGatewayDO.getTopicEvent`
414
+ * / `getTopicHistory`).
415
+ */
416
+ const GATEWAY_TOPIC_FACTS = new Set([
417
+ 'Topic event room binding mismatch',
418
+ 'Topic history event room binding mismatch',
419
+ 'Topic history event has no ID',
420
+ ]);
421
+
422
+ /**
423
+ * The transfer card store failed to open for this owner: every transfer
424
+ * route is told to retry later, with the reason in the log.
425
+ */
426
+ function storeUnavailable(id: string): {
427
+ ok: false;
428
+ kind: 'unavailable';
429
+ error: string;
430
+ } {
431
+ console.warn(`[action-proposals] ${id} refused: card store unavailable`);
432
+ return {
433
+ ok: false,
434
+ kind: 'unavailable',
435
+ error: 'Action cards are unavailable',
436
+ };
437
+ }
438
+
439
+ /**
440
+ * A topic command's refusal, for the shell: a `conflict` is the card or an
441
+ * authority it was sealed on having changed (the Portal re-reads and
442
+ * retries), `unavailable` is a room, DID or chain read that failed and may
443
+ * succeed later. Anything else is a bug and surfaces as one.
444
+ */
445
+ function topicRefusal(
446
+ error: unknown,
447
+ id: string,
448
+ ): { ok: false; kind: 'conflict' | 'unavailable'; error: string } {
449
+ if (error instanceof ActionProposalConflict)
450
+ return { ok: false, kind: 'conflict', error: error.message };
451
+ if (error instanceof TopicReadUnavailable) {
452
+ console.warn(
453
+ `[topic-actions] ${id} authority unavailable: ${error.message}`,
454
+ );
455
+ return {
456
+ ok: false,
457
+ kind: 'unavailable',
458
+ error: 'Topic authority is unavailable',
459
+ };
460
+ }
461
+ throw error;
462
+ }
463
+
351
464
  /** What one attempt's agent build reads from the request body. */
352
465
  type TurnBuildBody = Pick<
353
466
  TurnBody,
@@ -476,6 +589,22 @@ const ACCESS_PERSIST_INTERVAL_MS = 60 * 60_000;
476
589
  const TITLE_TIMEOUT_MS = 15_000;
477
590
  /** A new sweep of expired ambient blobs starts at most this often per instance. */
478
591
  const BLOB_SWEEP_INTERVAL_MS = 60 * 60_000;
592
+ /**
593
+ * Wall time the alarm's action-card tick may spend on chain lookups (two at
594
+ * most, run together); an abort counts as an RPC outage (the card stays
595
+ * `unknown`, its attempt spent).
596
+ */
597
+ const ACTION_TICK_BUDGET_MS = 15_000;
598
+ /** The card retention sweep runs at most this often per instance. */
599
+ const ACTION_PRUNE_INTERVAL_MS = 60 * 60_000;
600
+ /** How long the Hub's capabilities answer is reused per object. */
601
+ const HUB_CAPABILITIES_CACHE_MS = 60_000;
602
+ /** How long a `hub_action_options` answer vouches for a Gmail connection. */
603
+ const HUB_OPTIONS_REUSE_MS = 10 * 60_000;
604
+ /** A channel poll waits this long for Auth Hub, once per poll at most. */
605
+ const HUB_POLL_TIMEOUT_MS = 3_000;
606
+ /** How long a passed topic authority read is reused for the same card. */
607
+ const TOPIC_AUTHORITY_MEMO_MS = 30_000;
479
608
  /** The Blocksync lookup of the user's homeserver is abandoned after this long (the fallback applies). */
480
609
  const HOMESERVER_LOOKUP_TIMEOUT_MS = 3_000;
481
610
  /** Set once the legacy Matrix copy has been removed (or found absent): no more lookups. */
@@ -864,6 +993,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
864
993
  private resultStore: ResultStore | null = null;
865
994
  /** Chat delivery's artefacts; null when no bucket is bound (see `canShare` for new ones). */
866
995
  private artifacts: ArtifactStore | null = null;
996
+ private actionProposals: ActionProposalStore | null = null;
997
+ private hubActionStore: HubActionStore | null = null;
998
+ private hubActionClient: HubActionClient | null = null;
999
+ /** The Hub's capabilities for this oracle account, reused for `HUB_CAPABILITIES_CACHE_MS`. */
1000
+ private hubCapabilities: {
1001
+ value: ActionCapabilitiesV1;
1002
+ at: number;
1003
+ } | null = null;
1004
+ /** The last `hub_action_options` answer for this owner (Gmail connection checks). */
1005
+ private lastHubOptions: { value: HubActionOptionsV2; at: number } | null =
1006
+ null;
1007
+ private topicProposals: TopicProposalStore | null = null;
1008
+ /** `assertTopicDependenciesMemo`: card id and digest -> when it passed. */
1009
+ private topicAuthorityMemo: Map<string, number> | undefined;
1010
+ /** When this instance last ran the card retention sweep (at most hourly). */
1011
+ private lastActionPruneAt: number | null = null;
867
1012
  /** Text `view_attachment` extracted, per session (`attachments/view-cache.ts`). */
868
1013
  private attachmentTextCache: AttachmentTextCacheStore | null = null;
869
1014
  /**
@@ -1767,6 +1912,48 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1767
1912
  oracleName: this.core.identity.name,
1768
1913
  });
1769
1914
  await this.saver.setup();
1915
+ if (this.env.HUB_ACTIONS_ENABLED === 'true') {
1916
+ try {
1917
+ if (!this.env.AUTH_HUB_URL || !this.env.AUTH_HUB_CHANNEL_SERVICE_KEY)
1918
+ throw new Error('Hub URL and oracle service key are required');
1919
+ const store = new HubActionStore(liveDb);
1920
+ await store.setup();
1921
+ this.hubActionStore = store;
1922
+ this.hubActionClient = new HubActionClient({
1923
+ origin: this.env.AUTH_HUB_URL,
1924
+ key: this.env.AUTH_HUB_CHANNEL_SERVICE_KEY,
1925
+ fetcher: this.env.AUTH_HUB,
1926
+ });
1927
+ } catch (error) {
1928
+ this.hubActionStore = null;
1929
+ this.hubActionClient = null;
1930
+ console.error(
1931
+ `[hub-actions] unavailable: ${error instanceof Error ? error.message : String(error)}`,
1932
+ );
1933
+ }
1934
+ }
1935
+ // A card store this build cannot open must not take the user's whole
1936
+ // object down: the feature is unavailable for this user, the rest works.
1937
+ try {
1938
+ const actionProposals = new ActionProposalStore(liveDb);
1939
+ await actionProposals.setup();
1940
+ this.actionProposals = actionProposals;
1941
+ } catch (err) {
1942
+ this.actionProposals = null;
1943
+ console.error(
1944
+ `[action-proposals] store unavailable for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1945
+ );
1946
+ }
1947
+ try {
1948
+ const topicProposals = new TopicProposalStore(liveDb);
1949
+ await topicProposals.setup();
1950
+ this.topicProposals = topicProposals;
1951
+ } catch (err) {
1952
+ this.topicProposals = null;
1953
+ console.error(
1954
+ `[topic-actions] store unavailable for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1955
+ );
1956
+ }
1770
1957
  this.sessions = new SessionsStore(liveDb);
1771
1958
  await this.sessions.setup();
1772
1959
  this.matrixLedger = new MatrixTurnLedger(liveDb);
@@ -1984,6 +2171,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1984
2171
  logger: console,
1985
2172
  });
1986
2173
  this.ambient = createAmbientServices({
2174
+ ...(this.hubActionStore && this.hubActionClient
2175
+ ? {
2176
+ hubActions: {
2177
+ capabilities: () => this.cachedHubCapabilities(),
2178
+ options: (input) => this.hubActionOptions(input),
2179
+ request: (input: {
2180
+ ownerDid: string;
2181
+ sessionId: string;
2182
+ runId: string;
2183
+ requestId: string;
2184
+ provider: string;
2185
+ bindingId: string;
2186
+ bindingRevision: number;
2187
+ requestKey: string;
2188
+ operation: ActionOperationV1;
2189
+ }) => this.requestHubAction(input),
2190
+ status: (ownerDid: string, actionId: string) =>
2191
+ this.statusHubAction(ownerDid, actionId),
2192
+ },
2193
+ }
2194
+ : {}),
2195
+ actionProposals: {
2196
+ create: (subject) => this.prepareActionProposal(subject),
2197
+ prepareTopic: (input) => this.prepareTopicProposal(input),
2198
+ },
1987
2199
  ...(this.taskScheduler ? { tasks: this.taskScheduler.surface } : {}),
1988
2200
  preferences: this.preferences,
1989
2201
  kv: new SqliteUserKv(liveDb),
@@ -2061,6 +2273,12 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2061
2273
  this.runs = null;
2062
2274
  this.resultStore = null;
2063
2275
  this.artifacts = null;
2276
+ this.actionProposals = null;
2277
+ this.hubActionStore = null;
2278
+ this.hubActionClient = null;
2279
+ this.hubCapabilities = null;
2280
+ this.lastHubOptions = null;
2281
+ this.topicProposals = null;
2064
2282
  this.attachmentTextCache = null;
2065
2283
  this.domainContextStore = null;
2066
2284
  // Its store is bound to the database just dropped: a caller that sees
@@ -2468,6 +2686,24 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2468
2686
  }
2469
2687
  }
2470
2688
 
2689
+ // Action cards: look up recorded transactions the chain has not
2690
+ // confirmed yet, close cards claimed and never executed, post the
2691
+ // receipts that are due, drop old cards. It runs after the flush and
2692
+ // the task scheduler (a slow chain RPC must not delay a user's task)
2693
+ // under one wall budget, and before the eviction check (its deadline
2694
+ // keeps the copy). One bad row must not stop what follows, so the tick
2695
+ // is guarded like its neighbours.
2696
+ try {
2697
+ const actionAt = await this.tickActionProposals();
2698
+ if (actionAt !== undefined)
2699
+ deadlines.push(Math.max(actionAt, now + 1000));
2700
+ } catch (err) {
2701
+ console.error(
2702
+ `[user-do] action proposal tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
2703
+ );
2704
+ deadlines.push(now + 60_000);
2705
+ }
2706
+
2471
2707
  // Legacy-blob compaction: one bounded batch per tick until the whole
2472
2708
  // file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
2473
2709
  // flight holds a snapshot of the file, which the compactor's swap-in
@@ -2852,7 +3088,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2852
3088
  }
2853
3089
  return {
2854
3090
  ok: true,
2855
- result: await this.channelTurns!.submit(identity, input, requestHash),
3091
+ result: await this.withActionOutcomes(
3092
+ identity.userDid,
3093
+ await this.channelTurns!.submit(identity, input, requestHash),
3094
+ ),
2856
3095
  };
2857
3096
  } catch (error) {
2858
3097
  // A failure retrying cannot fix (the room refused the oracle, the
@@ -2894,7 +3133,363 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2894
3133
  const result = await this.channelTurns.poll(identity, input, requestHash);
2895
3134
  if (!result) return null;
2896
3135
  await this.recordAccess();
2897
- return { ok: true, result };
3136
+ return {
3137
+ ok: true,
3138
+ result: await this.withActionOutcomes(identity.userDid, result),
3139
+ };
3140
+ }
3141
+
3142
+ private async withActionOutcomes(
3143
+ ownerDid: string,
3144
+ result: ChannelTurnResponse,
3145
+ ): Promise<ChannelTurnResponse> {
3146
+ if (result.status !== 'finished') return result;
3147
+ const actionOutcomes = [];
3148
+ let attempted = false;
3149
+ // Each store on its own: the other one failing to open must not hide
3150
+ // this one's cards from the poll.
3151
+ const store = this.actionProposals;
3152
+ if (store)
3153
+ for (const proposal of await store.forRun(
3154
+ ownerDid,
3155
+ result.sessionId,
3156
+ result.runId,
3157
+ )) {
3158
+ const refreshed: Awaited<ReturnType<typeof refreshActionProposal>> =
3159
+ attempted
3160
+ ? { proposal, attempted: false }
3161
+ : await refreshActionProposal(
3162
+ store,
3163
+ proposal,
3164
+ this.actionProposalRpc(proposal),
3165
+ );
3166
+ attempted ||= refreshed.attempted;
3167
+ actionOutcomes.push(actionOutcome(refreshed.proposal));
3168
+ }
3169
+ const topics = this.topicProposals;
3170
+ if (topics) {
3171
+ let cards: TopicActionProposal[] = [];
3172
+ try {
3173
+ cards = await topics.forRun(ownerDid, result.sessionId, result.runId);
3174
+ } catch (error) {
3175
+ console.error(
3176
+ `[topic-actions] cards of run ${result.runId} unreadable: ${error instanceof Error ? error.message : String(error)}`,
3177
+ );
3178
+ }
3179
+ for (const proposal of cards) {
3180
+ let refreshed = proposal;
3181
+ // A card whose reference row this build cannot read is reported as
3182
+ // it stands and taken off the verification schedule; the turn
3183
+ // result is not failed over it.
3184
+ try {
3185
+ const refs = await topics.references(ownerDid, proposal.id);
3186
+ if (
3187
+ !attempted &&
3188
+ refs.length &&
3189
+ (await topics.claimVerification(ownerDid, proposal.id))
3190
+ ) {
3191
+ attempted = true;
3192
+ refreshed = await reconcileTopicReferences(
3193
+ topics,
3194
+ proposal,
3195
+ { v: 2, references: refs },
3196
+ this.topicVerificationContext(proposal),
3197
+ true,
3198
+ );
3199
+ }
3200
+ } catch (error) {
3201
+ await topics.quarantineVerification(ownerDid, proposal.id, error);
3202
+ }
3203
+ actionOutcomes.push(topicActionOutcome(refreshed));
3204
+ }
3205
+ }
3206
+ if (attempted) {
3207
+ this.markDirty();
3208
+ await this.requestHousekeeping(Date.now() + 1000);
3209
+ }
3210
+ const hubActionOutcomes = await this.hubOutcomesForRun(
3211
+ ownerDid,
3212
+ result.sessionId,
3213
+ result.runId,
3214
+ );
3215
+ return {
3216
+ ...result,
3217
+ ...(actionOutcomes.length ? { actionOutcomes } : {}),
3218
+ ...(hubActionOutcomes.length ? { hubActionOutcomes } : {}),
3219
+ };
3220
+ }
3221
+
3222
+ private async hubActionOptions(input: {
3223
+ ownerDid: string;
3224
+ provider: string;
3225
+ bindingId: string;
3226
+ bindingRevision: number;
3227
+ }) {
3228
+ if (!this.hubActionClient || input.ownerDid !== this.userDid)
3229
+ throw new Error('Hub action options are unavailable for this owner');
3230
+ await assertActiveChannelBinding(
3231
+ {
3232
+ userDid: input.ownerDid,
3233
+ channel: {
3234
+ provider: input.provider,
3235
+ bindingId: input.bindingId,
3236
+ bindingRevision: input.bindingRevision,
3237
+ },
3238
+ },
3239
+ this.env,
3240
+ );
3241
+ const options = await this.hubActionClient.options(input);
3242
+ if (
3243
+ options.ownerDid !== input.ownerDid ||
3244
+ options.bindingId !== input.bindingId ||
3245
+ options.bindingRevision !== input.bindingRevision
3246
+ )
3247
+ throw new Error(
3248
+ 'Hub action options do not match this owner and binding',
3249
+ );
3250
+ this.lastHubOptions = { value: options, at: Date.now() };
3251
+ return options;
3252
+ }
3253
+
3254
+ /**
3255
+ * A Gmail operation that names a connection must name one the owner's
3256
+ * options list (service, id and label), from the last
3257
+ * `hub_action_options` read when it is recent, else a fresh one. One
3258
+ * Gmail send in flight per mailbox: while an earlier send to the same
3259
+ * connection or account label is authorized, submitted or unknown, a
3260
+ * new one is refused with that action's id.
3261
+ */
3262
+ private async assertGmailOperationAllowed(input: {
3263
+ ownerDid: string;
3264
+ provider: string;
3265
+ bindingId: string;
3266
+ bindingRevision: number;
3267
+ operation: ActionOperationV1;
3268
+ }): Promise<void> {
3269
+ const operation = input.operation;
3270
+ if (
3271
+ operation.kind !== 'gmail.message.send.v1' &&
3272
+ operation.kind !== 'gmail.disconnect.v1'
3273
+ )
3274
+ return;
3275
+ const cached = this.lastHubOptions ?? null;
3276
+ const options =
3277
+ cached &&
3278
+ Date.now() - cached.at < HUB_OPTIONS_REUSE_MS &&
3279
+ cached.value.ownerDid === input.ownerDid &&
3280
+ cached.value.bindingId === input.bindingId &&
3281
+ cached.value.bindingRevision === input.bindingRevision
3282
+ ? cached.value
3283
+ : await this.hubActionOptions(input);
3284
+ if (
3285
+ !options.connections.some(
3286
+ (c) =>
3287
+ c.service === 'gmail' &&
3288
+ c.id === operation.connectionId &&
3289
+ c.label === operation.accountLabel,
3290
+ )
3291
+ )
3292
+ throw new HubActionRefused(
3293
+ 'refused',
3294
+ "That Gmail connection is not one of this owner's; read hub_action_options and use a listed connection.",
3295
+ );
3296
+ if (operation.kind !== 'gmail.message.send.v1') return;
3297
+ const prior = await this.hubActionStore?.gmailSendInFlight(
3298
+ input.ownerDid,
3299
+ operation.connectionId,
3300
+ operation.accountLabel,
3301
+ );
3302
+ if (prior)
3303
+ throw new HubActionRefused(
3304
+ 'refused',
3305
+ `An earlier email from this mailbox (action ${prior}) is still in flight or unconfirmed; wait for its outcome, or ask the owner to check Gmail's Sent folder, before preparing another.`,
3306
+ prior,
3307
+ );
3308
+ }
3309
+
3310
+ /** The Hub's capabilities, cached per object for `HUB_CAPABILITIES_CACHE_MS`. */
3311
+ private async cachedHubCapabilities(): Promise<ActionCapabilitiesV1> {
3312
+ const client = this.hubActionClient;
3313
+ if (!client) throw new Error('Hub actions are unavailable');
3314
+ const now = Date.now();
3315
+ const cached = this.hubCapabilities ?? null;
3316
+ if (cached && now - cached.at < HUB_CAPABILITIES_CACHE_MS)
3317
+ return cached.value;
3318
+ const value = await client.capabilities();
3319
+ this.hubCapabilities = { value, at: now };
3320
+ return value;
3321
+ }
3322
+
3323
+ private async requestHubAction(input: {
3324
+ ownerDid: string;
3325
+ sessionId: string;
3326
+ runId: string;
3327
+ requestId: string;
3328
+ provider: string;
3329
+ bindingId: string;
3330
+ bindingRevision: number;
3331
+ requestKey: string;
3332
+ operation: ActionOperationV1;
3333
+ }) {
3334
+ const store = this.hubActionStore;
3335
+ const client = this.hubActionClient;
3336
+ if (!store || !client || input.ownerDid !== this.userDid)
3337
+ throw new Error('Hub actions are unavailable for this channel');
3338
+ // Only a run an inbound channel message started may prepare a review:
3339
+ // a scheduled or background run has no user asking for it.
3340
+ const record = await this.runStore?.get(input.runId);
3341
+ const turn = record
3342
+ ? (JSON.parse(record.request) as StoredRunRequest).turn
3343
+ : null;
3344
+ if (
3345
+ !turn ||
3346
+ turn.client !== 'channel' ||
3347
+ !turn.channel?.remoteMessageRef ||
3348
+ turn.identity.userDid !== input.ownerDid ||
3349
+ turn.sessionId !== input.sessionId ||
3350
+ turn.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX)
3351
+ )
3352
+ throw new HubActionRefused(
3353
+ 'limit',
3354
+ 'Reviews are prepared only in reply to a message from the user.',
3355
+ );
3356
+ await assertActiveChannelBinding(
3357
+ {
3358
+ userDid: input.ownerDid,
3359
+ channel: {
3360
+ provider: input.provider,
3361
+ bindingId: input.bindingId,
3362
+ bindingRevision: input.bindingRevision,
3363
+ },
3364
+ },
3365
+ this.env,
3366
+ );
3367
+ await this.assertGmailOperationAllowed(input);
3368
+ const reference = await prepareHubAction(
3369
+ {
3370
+ capabilities: () => this.cachedHubCapabilities(),
3371
+ request: (body) => client.request(body),
3372
+ },
3373
+ {
3374
+ reserve: async (origin, operation, expiresAt) => {
3375
+ const reserved = await store.reserve(origin, operation, expiresAt);
3376
+ this.markDirty();
3377
+ return reserved;
3378
+ },
3379
+ record: (origin, reference) => store.record(origin, reference),
3380
+ byRequestKey: (key) => store.byRequestKey(key),
3381
+ },
3382
+ input,
3383
+ );
3384
+ this.markDirty();
3385
+ return reference;
3386
+ }
3387
+
3388
+ private async statusHubAction(
3389
+ ownerDid: string,
3390
+ actionId: string,
3391
+ timeoutMs?: number,
3392
+ ): Promise<ActionOutcomeV1 | null> {
3393
+ const held = await this.hubActionStore?.get(ownerDid, actionId);
3394
+ if (!held || !this.hubActionClient) return null;
3395
+ const status = await this.hubActionClient.status(actionId, timeoutMs);
3396
+ if (
3397
+ status.ownerDid !== ownerDid ||
3398
+ canonicalJson(status.reference) !== canonicalJson(held.reference)
3399
+ )
3400
+ throw new Error('Hub status does not match the prepared action');
3401
+ if (status.outcome) {
3402
+ if (
3403
+ status.revision !== status.outcome.authorizedRevision ||
3404
+ status.digest !== status.outcome.authorizedDigest
3405
+ )
3406
+ return null;
3407
+ if (await this.hubActionStore!.recordOutcome(status.outcome))
3408
+ this.markDirty();
3409
+ }
3410
+ return status.outcome;
3411
+ }
3412
+
3413
+ /**
3414
+ * The run's Hub outcomes for a channel poll: stored outcomes first
3415
+ * (callback-delivered ones included, so a Hub outage hides nothing
3416
+ * already known), then at most ONE status refresh per poll, only for an
3417
+ * action with no terminal outcome whose backoff (15 s doubling to
3418
+ * 10 min) has passed, with a 3 s timeout. The newer outcome by
3419
+ * sequence wins.
3420
+ */
3421
+ private async hubOutcomesForRun(
3422
+ ownerDid: string,
3423
+ sessionId: string,
3424
+ runId: string,
3425
+ ): Promise<ActionOutcomeV1[]> {
3426
+ const store = this.hubActionStore;
3427
+ if (!store) return [];
3428
+ const now = Date.now();
3429
+ let refreshed = false;
3430
+ const current: ActionOutcomeV1[] = [];
3431
+ for (const row of await store.runState(ownerDid, sessionId, runId)) {
3432
+ let latest = row.latest;
3433
+ if (
3434
+ !refreshed &&
3435
+ this.hubActionClient &&
3436
+ !(latest && TERMINAL_HUB_STATUSES.has(latest.status)) &&
3437
+ row.nextCheckAt <= now &&
3438
+ row.reference.expiresAt * 1000 + HUB_CHECK_AFTER_EXPIRY_MS > now
3439
+ ) {
3440
+ refreshed = true;
3441
+ // The attempt is spent before the read: a Hub that hangs is
3442
+ // asked again only after the backoff.
3443
+ await store.scheduleCheck(row.reference.id, row.checkAttempts);
3444
+ // The schedule is SQLite state the owner copy must carry (else a
3445
+ // restored copy asks the Hub again at once). Only a poll that
3446
+ // refreshed writes it, at most one per poll.
3447
+ this.markDirty();
3448
+ try {
3449
+ const fresh = await this.statusHubAction(
3450
+ ownerDid,
3451
+ row.reference.id,
3452
+ HUB_POLL_TIMEOUT_MS,
3453
+ );
3454
+ if (fresh && (!latest || fresh.sequence > latest.sequence))
3455
+ latest = fresh;
3456
+ } catch (error) {
3457
+ console.warn(
3458
+ `[hub-actions] status recovery unavailable for ${row.reference.id}: ${error instanceof Error ? error.message : String(error)}`,
3459
+ );
3460
+ }
3461
+ }
3462
+ if (latest) current.push(latest);
3463
+ }
3464
+ return current;
3465
+ }
3466
+
3467
+ async recordHubActionOutcome(input: ActionOutcomeV1): Promise<void> {
3468
+ if (this.env.HUB_ACTIONS_ENABLED !== 'true')
3469
+ throw new Error('Hub actions disabled');
3470
+ // A Hub callback is not the user: it must not count as access (that
3471
+ // would delay idle eviction).
3472
+ await this.ready({ userDid: input.ownerDid }, { recordAccess: false });
3473
+ const held = await this.hubActionStore?.get(
3474
+ input.ownerDid,
3475
+ input.reference.id,
3476
+ );
3477
+ if (!held || !this.hubActionClient) throw new Error('Unknown Hub action');
3478
+ const remote = await this.hubActionClient.status(input.reference.id);
3479
+ if (
3480
+ !remote.outcome ||
3481
+ remote.ownerDid !== input.ownerDid ||
3482
+ canonicalJson(remote.reference) !== canonicalJson(held.reference) ||
3483
+ canonicalJson(input.reference) !== canonicalJson(held.reference) ||
3484
+ remote.outcome.sequence < input.sequence ||
3485
+ (remote.outcome.sequence === input.sequence &&
3486
+ canonicalJson(remote.outcome) !== canonicalJson(input))
3487
+ )
3488
+ throw new Error(
3489
+ 'Hub outcome was not confirmed by authenticated status',
3490
+ );
3491
+ if (await this.hubActionStore!.recordOutcome(remote.outcome))
3492
+ this.markDirty();
2898
3493
  }
2899
3494
 
2900
3495
  async listSessions(
@@ -3035,9 +3630,652 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3035
3630
  }
3036
3631
 
3037
3632
  /**
3038
- * `GET /artifacts/:id` — the canonical copy, for its owner. `url` is
3039
- * present only while the share link still works.
3633
+ * `assertTopicDependencies`, remembered per card (id and digest) for
3634
+ * `TOPIC_AUTHORITY_MEMO_MS` after it passed (or after preparation read
3635
+ * the same facts): prepare, claim and begin within seconds of each other
3636
+ * read the room, history and chain once, not three times. Only a pass
3637
+ * is remembered; a refusal re-reads.
3638
+ */
3639
+ private async assertTopicDependenciesMemo(
3640
+ proposal: TopicActionProposal,
3641
+ ): Promise<void> {
3642
+ const key = `${proposal.id}:${proposal.digest}`;
3643
+ const now = Date.now();
3644
+ const passed = this.topicAuthorityMemo?.get(key);
3645
+ if (passed !== undefined && now - passed < TOPIC_AUTHORITY_MEMO_MS)
3646
+ return;
3647
+ await assertTopicDependencies(
3648
+ proposal,
3649
+ this.topicVerificationContext(proposal),
3650
+ );
3651
+ this.rememberTopicAuthority(key, Date.now());
3652
+ }
3653
+ private rememberTopicAuthority(key: string, at: number): void {
3654
+ const memo = (this.topicAuthorityMemo ??= new Map());
3655
+ for (const [k, t] of memo)
3656
+ if (at - t >= TOPIC_AUTHORITY_MEMO_MS) memo.delete(k);
3657
+ memo.set(key, at);
3658
+ }
3659
+
3660
+ private topicVerificationContext(
3661
+ proposal: TopicActionProposal,
3662
+ ): TopicVerificationContext {
3663
+ // The gateway's errors arrive as plain errors over the stub. Most are
3664
+ // reads that failed (oversized state, timed-out history, a room it
3665
+ // cannot read, an event it cannot decrypt yet); the few that describe
3666
+ // what the homeserver returned (an event bound to another room, an
3667
+ // event without an id) are facts about the reference.
3668
+ const read = async <T>(run: () => Promise<T>): Promise<T> => {
3669
+ try {
3670
+ return await run();
3671
+ } catch (error) {
3672
+ const message =
3673
+ error instanceof Error ? error.message : String(error);
3674
+ throw GATEWAY_TOPIC_FACTS.has(message)
3675
+ ? new ActionProposalConflict(message)
3676
+ : new TopicReadUnavailable(message);
3677
+ }
3678
+ };
3679
+ // Room state and history are lists of events; anything else from the
3680
+ // gateway is a read that failed, not content for the contract to judge.
3681
+ const readList = (raw: string): unknown[] => {
3682
+ const value: unknown = JSON.parse(raw);
3683
+ if (!Array.isArray(value))
3684
+ throw new TopicReadUnavailable('Topic read is not a list of events');
3685
+ return value;
3686
+ };
3687
+ return {
3688
+ reader: {
3689
+ state: async (room) =>
3690
+ readList(await read(() => this.gateway.getTopicRoomState(room))),
3691
+ event: async (room, id, opts) => {
3692
+ const raw = await read(() =>
3693
+ this.gateway.getTopicEvent(room, id, opts),
3694
+ );
3695
+ return raw ? JSON.parse(raw) : null;
3696
+ },
3697
+ history: async (room, root, opts) =>
3698
+ readList(
3699
+ await read(() => this.gateway.getTopicHistory(room, root, opts)),
3700
+ ),
3701
+ },
3702
+ blocksync: this.env.BLOCKSYNC_GRAPHQL_URL,
3703
+ rpc: this.actionProposalRpc(proposal),
3704
+ };
3705
+ }
3706
+ private async prepareTopicProposal(input: {
3707
+ candidate: TopicCandidate;
3708
+ origin: TopicProposalSubject['origin'];
3709
+ evidenceRefs: string[];
3710
+ }): Promise<TopicActionProposal> {
3711
+ const topics = this.topicProposals;
3712
+ if (
3713
+ this.env.ACTION_PROPOSALS_ENABLED !== 'true' ||
3714
+ this.env.ACTION_TOPIC_PROPOSALS_ENABLED !== 'true' ||
3715
+ !this.userDid ||
3716
+ !topics
3717
+ )
3718
+ throw new Error('Topic preparation unavailable');
3719
+ const record = await this.runStore!.get(input.origin.runId);
3720
+ if (!record) throw new Error('Originating run unavailable');
3721
+ const request = (JSON.parse(record.request) as StoredRunRequest).turn;
3722
+ if (
3723
+ request.identity.userDid !== this.userDid ||
3724
+ request.sessionId !== input.origin.sessionId
3725
+ )
3726
+ throw new Error('Topic origin mismatch');
3727
+ const candidateDigest = await digestTopicProposalEffect(input.candidate);
3728
+ const prior = await topics.replayPreparation(
3729
+ this.userDid,
3730
+ input.origin,
3731
+ input.candidate,
3732
+ input.evidenceRefs,
3733
+ );
3734
+ if (prior) return prior;
3735
+ // The caps before the authority read it would waste (create checks
3736
+ // them again, atomically).
3737
+ await topics.assertCapacity(
3738
+ this.userDid,
3739
+ input.origin.sessionId,
3740
+ input.origin.runId,
3741
+ );
3742
+ const rpc = {
3743
+ devnet: this.env.ACTION_PROPOSALS_RPC_DEVNET,
3744
+ testnet: this.env.ACTION_PROPOSALS_RPC_TESTNET,
3745
+ mainnet: this.env.ACTION_PROPOSALS_RPC_MAINNET,
3746
+ }[input.candidate.network];
3747
+ const chainId = chainIdsFromConfig(chainIdConfigSchema.parse(this.env))[
3748
+ input.candidate.network
3749
+ ];
3750
+ const subject = await prepareTopicSubject(
3751
+ input.candidate,
3752
+ [...input.evidenceRefs, `candidate:${candidateDigest}`],
3753
+ {
3754
+ ownerDid: this.userDid,
3755
+ ...input.origin,
3756
+ blocksync: this.env.BLOCKSYNC_GRAPHQL_URL,
3757
+ rpc,
3758
+ chainId,
3759
+ reader: {
3760
+ state: async (room) =>
3761
+ JSON.parse(await this.gateway.getTopicRoomState(room)),
3762
+ event: async (room, id, opts) => {
3763
+ const raw = await this.gateway.getTopicEvent(room, id, opts);
3764
+ return raw ? JSON.parse(raw) : null;
3765
+ },
3766
+ history: async (room, root, opts) =>
3767
+ JSON.parse(await this.gateway.getTopicHistory(room, root, opts)),
3768
+ },
3769
+ },
3770
+ );
3771
+ const roomId =
3772
+ request.roomId ??
3773
+ (await this.sessions!.getSession(request.sessionId))?.roomId;
3774
+ const origin =
3775
+ request.sessionId.startsWith('$') && roomId
3776
+ ? {
3777
+ sessionId: request.sessionId,
3778
+ roomId,
3779
+ client: request.client,
3780
+ ...(request.channel ? { channel: request.channel } : {}),
3781
+ }
3782
+ : null;
3783
+ const p = await topics.create(subject, origin);
3784
+ this.markDirty();
3785
+ // Preparation just read and sealed every fact the claim re-checks.
3786
+ this.rememberTopicAuthority(`${p.id}:${p.digest}`, Date.now());
3787
+ return p;
3788
+ }
3789
+ /** Both switches: topic cards may be claimed and their steps permitted. */
3790
+ private topicExecutionEnabled(): boolean {
3791
+ return (
3792
+ this.env.ACTION_PROPOSALS_ENABLED === 'true' &&
3793
+ this.env.ACTION_TOPIC_PROPOSALS_ENABLED === 'true'
3794
+ );
3795
+ }
3796
+ async beginTopicStep(
3797
+ identity: TurnIdentity,
3798
+ id: string,
3799
+ input: BeginTopicStep,
3800
+ ): Promise<string | null> {
3801
+ await this.ready(identity);
3802
+ const store = this.topicProposals;
3803
+ if (!store)
3804
+ return JSON.stringify(
3805
+ topicRefusal(new TopicReadUnavailable('topic store unavailable'), id),
3806
+ );
3807
+ try {
3808
+ const proposal = await store.get(identity.userDid, id);
3809
+ if (!proposal) return null;
3810
+ // Switching the feature off stops new permits; reads, reconciliation
3811
+ // and receipts of cards already in flight keep working.
3812
+ if (!this.topicExecutionEnabled())
3813
+ throw new ActionProposalConflict(
3814
+ 'Topic action execution is disabled',
3815
+ );
3816
+ // The local refusals first (not claimed, expired, out of order,
3817
+ // another executor); the ten second authority read only for a step
3818
+ // that could actually start.
3819
+ await store.assertBeginnable(identity.userDid, id, input);
3820
+ // A step whose reference is recorded is spoken for: no new permit,
3821
+ // and no authority read to find that out.
3822
+ if (await store.hasReference(identity.userDid, id, input.step))
3823
+ return JSON.stringify({
3824
+ ok: true,
3825
+ result: { status: 'already_started', proposal },
3826
+ });
3827
+ await this.assertTopicDependenciesMemo(proposal);
3828
+ const effect = await topicExpectedEffect(proposal, input.step);
3829
+ const result = await store.begin(identity.userDid, id, input, effect);
3830
+ this.markDirty();
3831
+ return result ? JSON.stringify({ ok: true, result }) : null;
3832
+ } catch (error) {
3833
+ return JSON.stringify(topicRefusal(error, id));
3834
+ }
3835
+ }
3836
+
3837
+ private async prepareActionProposal(
3838
+ subject: ActionProposalSubject,
3839
+ ): Promise<ActionProposal> {
3840
+ const userDid = this.userDid;
3841
+ if (this.env.ACTION_PROPOSALS_ENABLED !== 'true' || !userDid)
3842
+ throw new Error('Action proposal preparation unavailable');
3843
+ const store = this.requireActionProposals();
3844
+ // The owner object's own policy, whichever plugin prepared the card:
3845
+ // this owner signs from their own account, off mainnet, on a chain the
3846
+ // Worker is configured for and can verify against, stamped now, within
3847
+ // the expiry window.
3848
+ assertActionProposalPolicy(subject, {
3849
+ ownerDid: userDid,
3850
+ chainIds: chainIdsFromConfig(chainIdConfigSchema.parse(this.env)),
3851
+ rpc: this.actionProposalRpcs(),
3852
+ now: store.now(),
3853
+ });
3854
+ const record = await this.runStore!.get(subject.origin.runId);
3855
+ if (!record) throw new Error('Action proposal origin unavailable');
3856
+ const request = (JSON.parse(record.request) as StoredRunRequest).turn;
3857
+ if (
3858
+ request.identity.userDid !== subject.ownerDid ||
3859
+ request.sessionId !== subject.origin.sessionId
3860
+ )
3861
+ throw new Error('Action proposal origin mismatch');
3862
+ // A card names one person's wallet; a group room would show its link
3863
+ // and its receipt to everyone in the room. The gateway marks every
3864
+ // Matrix turn, so an unmarked one is refused rather than trusted.
3865
+ if (request.client === 'matrix' && request.roomKind !== 'direct')
3866
+ throw new ActionProposalConflict(
3867
+ 'Action cards are prepared in direct chats only',
3868
+ );
3869
+ let origin = null;
3870
+ if (request.sessionId.startsWith('$')) {
3871
+ const roomId =
3872
+ request.roomId ??
3873
+ this.sessionRooms.get(request.sessionId) ??
3874
+ (await this.sessions!.getSession(request.sessionId))?.roomId ??
3875
+ (
3876
+ await retryGateway(() =>
3877
+ this.gateway.resolveUserRoom(subject.ownerDid),
3878
+ )
3879
+ )?.roomId;
3880
+ if (!roomId) throw new Error('Action proposal room unavailable');
3881
+ origin = {
3882
+ sessionId: request.sessionId,
3883
+ roomId,
3884
+ client: request.client,
3885
+ ...(request.channel ? { channel: request.channel } : {}),
3886
+ };
3887
+ }
3888
+ const proposal = await store.create(subject, origin);
3889
+ this.markDirty();
3890
+ return proposal;
3891
+ }
3892
+
3893
+ /** The card store, or the one error every card route answers when boot could not open it. */
3894
+ private requireActionProposals(): ActionProposalStore {
3895
+ if (!this.actionProposals)
3896
+ throw new Error('Action proposals are unavailable for this user');
3897
+ return this.actionProposals;
3898
+ }
3899
+
3900
+ /** The chain RPC per network, as configured. */
3901
+ private actionProposalRpcs(): Record<
3902
+ 'devnet' | 'testnet' | 'mainnet',
3903
+ string | undefined
3904
+ > {
3905
+ return {
3906
+ devnet: this.env.ACTION_PROPOSALS_RPC_DEVNET,
3907
+ testnet: this.env.ACTION_PROPOSALS_RPC_TESTNET,
3908
+ mainnet: this.env.ACTION_PROPOSALS_RPC_MAINNET,
3909
+ };
3910
+ }
3911
+
3912
+ /**
3913
+ * The alarm's share of action cards: at most two chain lookups (run
3914
+ * together) for cards whose transaction is recorded but unconfirmed (so
3915
+ * a card the user signed and walked away from still gets its receipt
3916
+ * once the chain has indexed it), closing claimed cards whose execution
3917
+ * window passed with no transaction, the receipts due now (posted in the
3918
+ * background: their schedule already moved forward, so a crash cannot
3919
+ * post one sooner), and the retention sweep, at most hourly. All of it
3920
+ * shares `ACTION_TICK_BUDGET_MS` of wall time. Returns the earliest
3921
+ * moment any of it is due again.
3040
3922
  */
3923
+ private async tickActionProposals(): Promise<number | undefined> {
3924
+ if (!this.userDid) return undefined;
3925
+ const userDid = this.userDid;
3926
+ // Each store on its own: the other one failing to open must not stop
3927
+ // this one's verifications, receipts or retention.
3928
+ const store = this.actionProposals;
3929
+ const topics = this.topicProposals;
3930
+ const budget = new AbortController();
3931
+ const timer = setTimeout(() => budget.abort(), ACTION_TICK_BUDGET_MS);
3932
+ const settle = (
3933
+ label: string,
3934
+ results: PromiseSettledResult<unknown>[],
3935
+ ) => {
3936
+ for (const result of results)
3937
+ if (result.status === 'rejected')
3938
+ console.warn(
3939
+ `[${label}] verification failed for ${userDid}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
3940
+ );
3941
+ };
3942
+ try {
3943
+ await Promise.all([
3944
+ (async () => {
3945
+ if (!store) return;
3946
+ const due = await store.dueVerifications(userDid);
3947
+ const settled = await Promise.allSettled(
3948
+ due.map(async (proposal) => {
3949
+ const refreshed = await refreshActionProposal(
3950
+ store,
3951
+ proposal,
3952
+ this.actionProposalRpc(proposal),
3953
+ fetch,
3954
+ budget.signal,
3955
+ );
3956
+ if (refreshed.attempted) this.markDirty();
3957
+ }),
3958
+ );
3959
+ settle('action-proposals', settled);
3960
+ })(),
3961
+ // Topic cards with started steps the room or chain has not
3962
+ // confirmed yet: reconcile their recorded references on the same
3963
+ // cooldown, under the same budget. Guarded on its own: a topic
3964
+ // card's trouble must not hold up the transfer receipts below.
3965
+ (async () => {
3966
+ if (!topics) return;
3967
+ try {
3968
+ const due = await topics.dueVerifications(userDid);
3969
+ const settled = await Promise.allSettled(
3970
+ due.map(async (proposal) => {
3971
+ // The attempt is spent before the references are read: a
3972
+ // reference row this build cannot parse is quarantined,
3973
+ // not retried every second.
3974
+ if (!(await topics.claimVerification(userDid, proposal.id)))
3975
+ return;
3976
+ this.markDirty();
3977
+ try {
3978
+ const refs = await topics.references(userDid, proposal.id);
3979
+ if (refs.length)
3980
+ await reconcileTopicReferences(
3981
+ topics,
3982
+ proposal,
3983
+ { v: 2, references: refs },
3984
+ {
3985
+ ...this.topicVerificationContext(proposal),
3986
+ signal: budget.signal,
3987
+ },
3988
+ true,
3989
+ );
3990
+ } catch (error) {
3991
+ await topics.quarantineVerification(
3992
+ userDid,
3993
+ proposal.id,
3994
+ error,
3995
+ );
3996
+ }
3997
+ }),
3998
+ );
3999
+ settle('topic-actions', settled);
4000
+ } catch (err) {
4001
+ console.error(
4002
+ `[topic-actions] tick failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
4003
+ );
4004
+ }
4005
+ })(),
4006
+ ]);
4007
+ } finally {
4008
+ clearTimeout(timer);
4009
+ }
4010
+ if (store && (await store.closeUnexecuted(userDid)) > 0) this.markDirty();
4011
+ const now = store?.now() ?? Date.now();
4012
+ const lastPruneAt = this.lastActionPruneAt ?? null;
4013
+ if (
4014
+ lastPruneAt === null ||
4015
+ now - lastPruneAt >= ACTION_PRUNE_INTERVAL_MS
4016
+ ) {
4017
+ this.lastActionPruneAt = now;
4018
+ if (store && (await store.prune(userDid)) > 0) this.markDirty();
4019
+ try {
4020
+ if (topics && (await topics.prune(userDid)) > 0) this.markDirty();
4021
+ } catch (err) {
4022
+ console.error(
4023
+ `[topic-actions] prune failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
4024
+ );
4025
+ }
4026
+ // Hub action rows (requests, references, outcomes) 30 days after
4027
+ // their review expired.
4028
+ try {
4029
+ if (this.hubActionStore && (await this.hubActionStore.prune()) > 0)
4030
+ this.markDirty();
4031
+ } catch (err) {
4032
+ console.error(
4033
+ `[hub-actions] prune failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
4034
+ );
4035
+ }
4036
+ }
4037
+ const receiptAt = await this.mirrorActionReceipts(true);
4038
+ const next = [
4039
+ receiptAt,
4040
+ store ? await store.nextVerificationAt(userDid) : undefined,
4041
+ store ? await store.nextUnexecutedCloseAt(userDid) : undefined,
4042
+ topics ? await topics.nextVerificationAt(userDid) : undefined,
4043
+ ].filter((at): at is number => at !== undefined);
4044
+ return next.length ? Math.min(...next) : undefined;
4045
+ }
4046
+
4047
+ /**
4048
+ * Take the receipts due now (both card stores) and post them. `detach`
4049
+ * hands the posts to `ctx.waitUntil` (the alarm does not wait on a slow
4050
+ * room); otherwise they are awaited.
4051
+ */
4052
+ private async mirrorActionReceipts(
4053
+ detach = false,
4054
+ ): Promise<number | undefined> {
4055
+ if (!this.userDid) return undefined;
4056
+ const store = this.actionProposals;
4057
+ const topics = this.topicProposals;
4058
+ const receipts = store ? await store.takeReceipts(this.userDid) : [];
4059
+ const topicReceipts = topics
4060
+ ? await topics.takeReceipts(this.userDid)
4061
+ : [];
4062
+ if (receipts.length || topicReceipts.length) this.markDirty();
4063
+ const posting = Promise.all([
4064
+ store ? this.postActionReceipts(store, receipts) : undefined,
4065
+ topics ? this.postTopicReceipts(topics, topicReceipts) : undefined,
4066
+ ]).then(() => undefined);
4067
+ if (detach) this.ctx.waitUntil(posting);
4068
+ else await posting;
4069
+ const due = store
4070
+ ? await store.nextReceiptAttempt(this.userDid)
4071
+ : undefined;
4072
+ const topicDue = topics
4073
+ ? await topics.nextReceiptAttempt(this.userDid)
4074
+ : undefined;
4075
+ return due === undefined
4076
+ ? topicDue
4077
+ : topicDue === undefined
4078
+ ? due
4079
+ : Math.min(due, topicDue);
4080
+ }
4081
+
4082
+ private async postActionReceipts(
4083
+ store: ActionProposalStore,
4084
+ receipts: Awaited<ReturnType<ActionProposalStore['takeReceipts']>>,
4085
+ ): Promise<void> {
4086
+ for (const { proposal, origin } of receipts) {
4087
+ try {
4088
+ if (
4089
+ proposal.lifecycle.status !== 'succeeded' &&
4090
+ proposal.lifecycle.status !== 'failed'
4091
+ )
4092
+ continue;
4093
+ const status =
4094
+ proposal.lifecycle.status === 'succeeded'
4095
+ ? 'confirmed on chain'
4096
+ : 'failed on chain';
4097
+ const text = `${proposal.title} ${status}. Transaction ${proposal.lifecycle.transactionHash}. Block ${proposal.lifecycle.height}. Code ${proposal.lifecycle.code}.`;
4098
+ await this.replayToRoom(
4099
+ {
4100
+ ...origin,
4101
+ identity: { userDid: proposal.ownerDid },
4102
+ message: '',
4103
+ requestId: `action-receipt:${proposal.id}:${proposal.digest}`,
4104
+ },
4105
+ text,
4106
+ 'oracle',
4107
+ true,
4108
+ );
4109
+ await store.receiptMirrored(proposal.ownerDid, proposal.id);
4110
+ this.markDirty();
4111
+ } catch (error) {
4112
+ console.warn(
4113
+ '[action-proposals] receipt mirror pending',
4114
+ error instanceof Error ? error.message : 'mirror failed',
4115
+ );
4116
+ }
4117
+ }
4118
+ }
4119
+
4120
+ private async postTopicReceipts(
4121
+ topics: TopicProposalStore,
4122
+ receipts: Awaited<ReturnType<TopicProposalStore['takeReceipts']>>,
4123
+ ): Promise<void> {
4124
+ for (const { proposal, origin } of receipts) {
4125
+ try {
4126
+ const receipt = proposal.lifecycle.receipt;
4127
+ if (!receipt) continue;
4128
+ const text =
4129
+ receipt.kind === 'topic-created'
4130
+ ? `${proposal.title} created. Room ${receipt.roomId}. Topic ${receipt.topicId}. Root ${receipt.rootEventId}.`
4131
+ : `${proposal.title} ${receipt.kind === 'topic-access-granted' ? 'confirmed' : 'failed'} on chain. Transaction ${receipt.transactionHash}. Block ${receipt.height}. Code ${receipt.code}.`;
4132
+ await this.replayToRoom(
4133
+ {
4134
+ ...origin,
4135
+ identity: { userDid: proposal.ownerDid },
4136
+ message: '',
4137
+ requestId: `action-receipt:${proposal.id}:${proposal.digest}`,
4138
+ },
4139
+ text,
4140
+ 'oracle',
4141
+ true,
4142
+ );
4143
+ await topics.receiptMirrored(proposal.ownerDid, proposal.id);
4144
+ this.markDirty();
4145
+ } catch (error) {
4146
+ console.warn(
4147
+ '[topic-actions] receipt mirror pending',
4148
+ error instanceof Error ? error.message : 'mirror failed',
4149
+ );
4150
+ }
4151
+ }
4152
+ }
4153
+
4154
+ private actionProposalRpc(
4155
+ proposal: ActionProposalWire,
4156
+ ): string | undefined {
4157
+ return this.actionProposalRpcs()[proposal.action.network];
4158
+ }
4159
+ async actionProposal(
4160
+ identity: TurnIdentity,
4161
+ id: string,
4162
+ command?:
4163
+ | ProposalCommand
4164
+ | { kind: 'topic-reconcile'; input: TopicReceiptReferences },
4165
+ ): Promise<string | null> {
4166
+ await this.ready(identity);
4167
+ const topics = this.topicProposals;
4168
+ const topic = topics ? await topics.get(identity.userDid, id) : null;
4169
+ if (topics && topic) {
4170
+ try {
4171
+ let result = topic;
4172
+ let topicAttempted = false;
4173
+ const verification = {
4174
+ ...this.topicVerificationContext(topic),
4175
+ onAttempt: () => {
4176
+ topicAttempted = true;
4177
+ },
4178
+ };
4179
+ if (command?.kind === 'topic-reconcile')
4180
+ result = await reconcileTopicReferences(
4181
+ topics,
4182
+ topic,
4183
+ command.input,
4184
+ verification,
4185
+ );
4186
+ else if (command) {
4187
+ if (command.kind === 'reconcile')
4188
+ throw new ActionProposalConflict(
4189
+ 'Topic reconciliation requires references',
4190
+ );
4191
+ if (command.kind === 'claim') {
4192
+ if (!this.topicExecutionEnabled())
4193
+ throw new ActionProposalConflict(
4194
+ 'Topic action execution is disabled',
4195
+ );
4196
+ // The local refusals first (already consumed, changed,
4197
+ // expired): the remote authority read only for a claim that
4198
+ // could succeed.
4199
+ await topics.assertClaimable(
4200
+ identity.userDid,
4201
+ id,
4202
+ command.digest,
4203
+ );
4204
+ await this.assertTopicDependenciesMemo(topic);
4205
+ }
4206
+ const next = await topics.transition(
4207
+ identity.userDid,
4208
+ id,
4209
+ command.kind,
4210
+ command.digest,
4211
+ );
4212
+ if (!next) return null;
4213
+ result = next;
4214
+ } else {
4215
+ const refs = await topics.references(identity.userDid, id);
4216
+ if (refs.length)
4217
+ result = await reconcileTopicReferences(
4218
+ topics,
4219
+ topic,
4220
+ { v: 2, references: refs },
4221
+ verification,
4222
+ );
4223
+ }
4224
+ if (command || topicAttempted) {
4225
+ this.markDirty();
4226
+ await this.requestHousekeeping(Date.now() + 1000);
4227
+ }
4228
+ return JSON.stringify({ ok: true, proposal: result });
4229
+ } catch (error) {
4230
+ return JSON.stringify(topicRefusal(error, id));
4231
+ }
4232
+ }
4233
+ // Without the topic store an id the transfer store does not know may
4234
+ // be a topic card: the Portal is told to retry, not that the card is
4235
+ // gone. Without the transfer store, the same for the other side.
4236
+ const unknownCard = () =>
4237
+ topics
4238
+ ? null
4239
+ : JSON.stringify(
4240
+ topicRefusal(
4241
+ new TopicReadUnavailable('topic store unavailable'),
4242
+ id,
4243
+ ),
4244
+ );
4245
+ if (command?.kind === 'topic-reconcile') return unknownCard();
4246
+ const store = this.actionProposals;
4247
+ if (!store) return JSON.stringify(storeUnavailable(id));
4248
+ try {
4249
+ let result = command
4250
+ ? await store.transition(identity.userDid, id, command)
4251
+ : await store.get(identity.userDid, id);
4252
+ if (command && result) this.markDirty();
4253
+ if (command?.kind === 'reconcile' && result) {
4254
+ const refreshed = await refreshActionProposal(
4255
+ store,
4256
+ result,
4257
+ this.actionProposalRpc(result),
4258
+ );
4259
+ result = refreshed.proposal;
4260
+ if (refreshed.attempted) {
4261
+ this.markDirty();
4262
+ await this.requestHousekeeping(Date.now() + 1000);
4263
+ }
4264
+ }
4265
+ return result
4266
+ ? JSON.stringify({ ok: true, proposal: result })
4267
+ : unknownCard();
4268
+ } catch (error) {
4269
+ if (error instanceof ActionProposalConflict)
4270
+ return JSON.stringify({
4271
+ ok: false,
4272
+ kind: 'conflict',
4273
+ error: error.message,
4274
+ });
4275
+ throw error;
4276
+ }
4277
+ }
4278
+
3041
4279
  async artifact(
3042
4280
  identity: TurnIdentity,
3043
4281
  artifactId: string,
@@ -3107,6 +4345,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3107
4345
  const v = this.core.validatedEnv['MEMORY_ENGINE_URL'];
3108
4346
  return typeof v === 'string' && v.length > 0 ? v : undefined;
3109
4347
  })(),
4348
+ // The configured entity only: `identity.entityDid` falls back to
4349
+ // `ORACLE_DID`, which the engine refuses as a space key.
4350
+ entityDid: memoryEntityDid(this.core.validatedEnv['ORACLE_ENTITY_DID']),
3110
4351
  getSession: async (id) => {
3111
4352
  const snapshot = this.historySnapshots.get(id);
3112
4353
  const row =
@@ -3195,7 +4436,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3195
4436
  this.userContextFetcher ??= new UserContextFetcher(
3196
4437
  {
3197
4438
  memoryEngineUrl: settings.engineUrl,
3198
- oracleDid: this.env.ORACLE_DID,
4439
+ // The configured entity only: `identity.entityDid` falls back to
4440
+ // `ORACLE_DID`, which the engine refuses as a space key.
4441
+ entityDid: memoryEntityDid(
4442
+ this.core.validatedEnv['ORACLE_ENTITY_DID'],
4443
+ ),
3199
4444
  // Looked up per call, not captured: a working-copy reset replaces
3200
4445
  // the ambient services while a gather may still be running.
3201
4446
  ucan: {
@@ -4149,6 +5394,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4149
5394
  if (!ambient || typeof url !== 'string' || url.length === 0) {
4150
5395
  return { error: 'MEMORY_MCP_URL is not configured' };
4151
5396
  }
5397
+ const entityDid = memoryEntityDid(
5398
+ this.core.validatedEnv['ORACLE_ENTITY_DID'],
5399
+ );
5400
+ if (!entityDid) {
5401
+ return {
5402
+ error:
5403
+ 'ORACLE_ENTITY_DID is not a domain entity DID (did:ixo:entity:<32 hex>)',
5404
+ };
5405
+ }
4152
5406
  const memoryDid = await ambient.ucan.resolveServiceDid(url);
4153
5407
  if (!memoryDid)
4154
5408
  return { error: 'memory engine DID could not be resolved' };
@@ -4166,6 +5420,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4166
5420
  Authorization: `Bearer ${invocation}`,
4167
5421
  'X-Auth-Type': 'ucan',
4168
5422
  'User-Agent': 'LangChain-MCP-Client/1.0',
5423
+ [MEMORY_ENTITY_HEADER]: entityDid,
4169
5424
  ...(room?.roomId ? { 'x-room-id': room.roomId } : {}),
4170
5425
  };
4171
5426
  try {
@@ -6185,6 +7440,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6185
7440
  id: req.sessionId,
6186
7441
  client: req.client,
6187
7442
  requestId: req.requestId,
7443
+ runId: run.runId,
7444
+ ...(req.channel?.bindingRevision
7445
+ ? {
7446
+ channel: {
7447
+ provider: req.channel.provider,
7448
+ bindingId: req.channel.bindingId,
7449
+ bindingRevision: req.channel.bindingRevision,
7450
+ },
7451
+ }
7452
+ : {}),
6188
7453
  roomId: sessionRoomId,
6189
7454
  },
6190
7455
  model: effectiveModel,
@@ -6342,8 +7607,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6342
7607
  req: TurnRequest,
6343
7608
  text: string,
6344
7609
  who: 'user' | 'oracle',
7610
+ receipt = false,
6345
7611
  ): Promise<void> {
6346
- if (req.client === 'matrix') return Promise.resolve();
7612
+ if (req.client === 'matrix' && !receipt) return Promise.resolve();
6347
7613
  if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX))
6348
7614
  return Promise.resolve();
6349
7615
  // A local session id has no marker event to thread on: the user had
@@ -6358,6 +7624,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6358
7624
  req.sessionId,
6359
7625
  async () => {
6360
7626
  const roomId =
7627
+ (receipt ? req.roomId : undefined) ??
6361
7628
  this.sessionRooms.get(req.sessionId) ??
6362
7629
  req.roomId ??
6363
7630
  (await this.sessions?.getSession(req.sessionId))?.roomId ??
@@ -6385,7 +7652,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
6385
7652
  };
6386
7653
  },
6387
7654
  label,
6388
- req.client === 'channel',
7655
+ req.client === 'channel' || receipt,
6389
7656
  );
6390
7657
  }
6391
7658