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