@ixo/oracle-runtime-workers 0.21.2 → 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 (59) hide show
  1. package/README.md +16 -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 +107 -5
  20. package/src/core/index.ts +8 -2
  21. package/src/core/main-agent-types.ts +16 -0
  22. package/src/core/main-agent.ts +26 -0
  23. package/src/core/middlewares/tool-execution.ts +87 -46
  24. package/src/core/middlewares/tool-marks.ts +54 -19
  25. package/src/core/runtime-context.ts +27 -1
  26. package/src/core/state.ts +2 -0
  27. package/src/core/tool-scheduler.ts +129 -24
  28. package/src/delivery/plan.ts +43 -2
  29. package/src/delivery/schema.ts +25 -1
  30. package/src/delivery/types.ts +13 -2
  31. package/src/do/active-runs.ts +171 -0
  32. package/src/do/ambient.ts +6 -0
  33. package/src/do/contracts.ts +86 -3
  34. package/src/do/run-coordinator.ts +30 -0
  35. package/src/do/run-request.ts +15 -2
  36. package/src/do/run-store-test-do.ts +10 -0
  37. package/src/do/run-store.ts +66 -13
  38. package/src/do/turn-body.ts +13 -1
  39. package/src/do/user-oracle-do.ts +1420 -17
  40. package/src/hub-actions/client.ts +163 -0
  41. package/src/hub-actions/request.ts +110 -0
  42. package/src/hub-actions/store.ts +485 -0
  43. package/src/matrix/gateway-do.ts +176 -0
  44. package/src/matrix/reply-parts.ts +8 -2
  45. package/src/plugin-api/tool-helper.ts +6 -0
  46. package/src/plugin-api/types.ts +117 -7
  47. package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
  48. package/src/plugins/action-proposals/evidence.ts +67 -0
  49. package/src/plugins/agui/agui.plugin.ts +7 -5
  50. package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
  51. package/src/plugins/index.ts +6 -0
  52. package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
  53. package/src/plugins/portal/declared-tools.ts +12 -0
  54. package/src/plugins/portal/portal.plugin.ts +22 -8
  55. package/src/realtime/frontend-call-registry.ts +147 -27
  56. package/src/realtime/realtime-endpoint.ts +335 -41
  57. package/src/realtime/session-socket-hub.ts +90 -19
  58. package/src/shell/app.ts +218 -2
  59. package/src/sqlite/sessions-store.ts +22 -0
@@ -0,0 +1,863 @@
1
+ import type { VerifiedActionTransaction } from './verify';
2
+ import {
3
+ ActionProposalSchema,
4
+ canonicalizeIxoJson,
5
+ actionOutcome,
6
+ type ActionOutcome,
7
+ ActionProposalSubjectSchema,
8
+ assertActionProposalUnchanged,
9
+ digestActionProposal,
10
+ type ActionProposal,
11
+ type ActionProposalSubject,
12
+ } from '@ixo/ixo-transaction/action-proposals';
13
+ import type { DoSqliteDatabase } from '../sqlite/database';
14
+ import type { TurnRequest } from '../do/contracts';
15
+
16
+ export type ActionReceiptOrigin = {
17
+ sessionId: string;
18
+ roomId: string;
19
+ client: TurnRequest['client'];
20
+ channel?: TurnRequest['channel'];
21
+ };
22
+
23
+ export class ActionProposalConflict extends Error {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = 'ActionProposalConflict';
27
+ }
28
+ }
29
+ export type ProposalCommand =
30
+ | { kind: 'claim'; digest: string }
31
+ | { kind: 'reject' | 'revoke' | 'supersede'; digest: string }
32
+ | { kind: 'reconcile'; transactionHash?: string };
33
+
34
+ /**
35
+ * Chain lookups for a recorded transaction: a cooldown that doubles from 30 s
36
+ * to 10 min between attempts, and the alarm stops asking after ten of them
37
+ * (about an hour). A Portal reconcile still looks the transaction up once
38
+ * the cooldown has passed, however many attempts the alarm made.
39
+ */
40
+ export const VERIFY_RETRY_BASE_MS = 30_000;
41
+ export const VERIFY_RETRY_MAX_MS = 10 * 60_000;
42
+ export const VERIFY_MAX_ATTEMPTS = 10;
43
+ /**
44
+ * A hash recorded for the first time is looked up no sooner than this: the
45
+ * Portal reports the hash of the signed bytes BEFORE it broadcasts them.
46
+ */
47
+ export const VERIFY_FIRST_DELAY_MS = 30_000;
48
+ /**
49
+ * "Not found" this soon after the hash arrived is the broadcast or the
50
+ * indexer still in flight: the lookup is rescheduled without spending one
51
+ * of the ten attempts.
52
+ */
53
+ export const VERIFY_NOT_FOUND_GRACE_MS = 2 * 60_000;
54
+ /**
55
+ * Receipt posts into the originating room: the same doubling cooldown, up to
56
+ * an hour apart, abandoned after twelve attempts (about five hours) so a
57
+ * room that refuses the oracle does not keep its owner object awake for ever.
58
+ */
59
+ export const RECEIPT_RETRY_BASE_MS = 30_000;
60
+ export const RECEIPT_RETRY_MAX_MS = 60 * 60_000;
61
+ export const RECEIPT_MAX_ATTEMPTS = 12;
62
+ /** Finished cards older than this are dropped; `/a/<id>` then answers 404. */
63
+ export const ACTION_PROPOSAL_RETENTION_MS = 90 * 24 * 60 * 60_000;
64
+ /**
65
+ * Cards that never reached an outcome the chain proved (a hash whose
66
+ * lookups ran out, a blocked card nobody revoked) are kept twice as long,
67
+ * then dropped too, so no row stays in the owner copy for ever.
68
+ */
69
+ export const ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS = 180 * 24 * 60 * 60_000;
70
+ /**
71
+ * How long after its claim a card with no recorded transaction may still
72
+ * receive one. After it the card is closed as `revoked` (the existing
73
+ * terminal state; a new status would break strict readers of the contract):
74
+ * a wallet cancel or a lost claim reply must not leave it `claimed` for
75
+ * ever. It never goes back to `prepared`: a broadcast nobody observed is
76
+ * still possible, so the link stays used up.
77
+ */
78
+ export const ACTION_PROPOSAL_EXECUTION_WINDOW_MS = 60 * 60_000;
79
+ /** Cards one agent run may prepare; more is a looping run, refused. */
80
+ export const ACTION_PROPOSALS_PER_RUN = 8;
81
+ /** Cards still awaiting the owner (live `prepared`, `claimed`, hash-less `unknown`) per owner. */
82
+ export const ACTION_PROPOSALS_OPEN_PER_OWNER = 16;
83
+ /** A `prepared` card counts as open this long after creation (its TTL plus clock skew). */
84
+ export const OPEN_PREPARED_MS = 20 * 60_000;
85
+
86
+ /**
87
+ * A card's claim time from its lifecycle JSON, NULL when the JSON is not
88
+ * valid (such a row is the prune's, never a deadline). Claim times are
89
+ * written by `toISOString()`, so they order as text.
90
+ */
91
+ const UNEXECUTED_CLAIMED_AT = `(CASE WHEN json_valid(lifecycle) THEN json_extract(lifecycle,'$.claimedAt') END)`;
92
+
93
+ const backoff = (base: number, max: number, attempts: number) =>
94
+ Math.min(base * 2 ** attempts, max);
95
+
96
+ /** The verifier's answers that prove a recorded hash is not this card's transaction. */
97
+ /**
98
+ * The verifier's proof that a recorded hash is ANOTHER transaction (the
99
+ * chain holds it, signed under another card's memo or none): the owner may
100
+ * replace it. A hash mismatch is not in it: an honest node answers
101
+ * `/tx?hash=` with bytes that hash to the hash asked for, so that one is a
102
+ * faulty RPC, retried like any other read failure.
103
+ */
104
+ export const TRANSACTION_REJECTIONS = new Set(['Transaction memo mismatch']);
105
+ /**
106
+ * The verifier's proof that the recorded transaction carries THIS card's
107
+ * memo but other messages: an effect the owner bound to the card that went
108
+ * wrong. It is never set aside; the card is blocked instead.
109
+ */
110
+ export const TRANSACTION_CARD_BOUND_MISMATCH = 'Transaction subject mismatch';
111
+
112
+ export class ActionProposalStore {
113
+ constructor(
114
+ private readonly db: DoSqliteDatabase,
115
+ readonly now = Date.now,
116
+ ) {}
117
+
118
+ async setup(): Promise<void> {
119
+ // The unreleased first shape of these tables (no status, hash or attempt
120
+ // columns) exists only where a pre-release build of this branch ran: a
121
+ // local harness, a wrangler state directory, a devnet test user. Nothing
122
+ // in them is worth a migration; the tables are rebuilt and the fact
123
+ // logged, so an object holding the old shape boots instead of failing
124
+ // every request on the index below.
125
+ const columns = await this.db.exec<{ name: string }>(
126
+ `PRAGMA table_info(action_proposals)`,
127
+ );
128
+ if (columns.length && !columns.some((c) => c.name === 'transaction_hash')) {
129
+ console.warn(
130
+ '[action-proposals] rebuilding pre-release action_proposals tables',
131
+ );
132
+ await this.db.run(`DROP TABLE IF EXISTS action_proposal_receipts`);
133
+ await this.db.run(`DROP TABLE IF EXISTS action_proposals`);
134
+ }
135
+ await this.db.run(
136
+ `CREATE TABLE IF NOT EXISTS action_proposals (id TEXT PRIMARY KEY, owner_did TEXT NOT NULL, invocation_id TEXT NOT NULL, session_id TEXT NOT NULL, run_id TEXT NOT NULL, subject TEXT NOT NULL, digest TEXT NOT NULL, lifecycle TEXT NOT NULL, status TEXT NOT NULL, transaction_hash TEXT, transaction_hash_rejected INTEGER NOT NULL DEFAULT 0, verify_after INTEGER NOT NULL DEFAULT 0, verify_attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, UNIQUE(owner_did, invocation_id))`,
137
+ );
138
+ // One signed transaction verifies one card: the memo already pins it,
139
+ // the index refuses a second card recording the same hash.
140
+ // Added after the first released shape: when the current hash arrived
141
+ // (the not-found grace window counts from it). Adding a column is
142
+ // instant in SQLite.
143
+ const shape = await this.db.exec<{ name: string }>(
144
+ `PRAGMA table_info(action_proposals)`,
145
+ );
146
+ if (!shape.some((c) => c.name === 'hash_recorded_at'))
147
+ await this.db.run(
148
+ `ALTER TABLE action_proposals ADD COLUMN hash_recorded_at INTEGER NOT NULL DEFAULT 0`,
149
+ );
150
+ // `closeUnexecuted`'s mark: 1 = closed as revoked when the execution
151
+ // window passed with no transaction (`unexecuted_claimed_at` keeps the
152
+ // claim, so a late hash can still be accepted), 2 = unreadable, out of
153
+ // the sweep's schedule (the prune drops it).
154
+ if (!shape.some((c) => c.name === 'unexecuted_close'))
155
+ await this.db.run(
156
+ `ALTER TABLE action_proposals ADD COLUMN unexecuted_close INTEGER NOT NULL DEFAULT 0`,
157
+ );
158
+ if (!shape.some((c) => c.name === 'unexecuted_claimed_at'))
159
+ await this.db.run(
160
+ `ALTER TABLE action_proposals ADD COLUMN unexecuted_claimed_at TEXT`,
161
+ );
162
+ await this.db.run(
163
+ `CREATE UNIQUE INDEX IF NOT EXISTS action_proposals_transaction ON action_proposals(owner_did, transaction_hash)`,
164
+ );
165
+ await this.db.run(
166
+ `CREATE INDEX IF NOT EXISTS action_proposals_run ON action_proposals(owner_did, session_id, run_id)`,
167
+ );
168
+ await this.db.run(
169
+ `CREATE INDEX IF NOT EXISTS action_proposals_status ON action_proposals(owner_did, status, verify_after)`,
170
+ );
171
+ // The retention sweep reads `created_at` per row; without the index it
172
+ // walks every card's JSON overflow pages.
173
+ await this.db.run(
174
+ `CREATE INDEX IF NOT EXISTS action_proposals_created ON action_proposals(owner_did, created_at)`,
175
+ );
176
+ await this.db.run(
177
+ `CREATE TABLE IF NOT EXISTS action_proposal_receipts (proposal_id TEXT PRIMARY KEY, owner_did TEXT NOT NULL, origin TEXT, state TEXT NOT NULL, next_attempt INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0)`,
178
+ );
179
+ await this.db.run(
180
+ `CREATE INDEX IF NOT EXISTS action_proposal_receipts_due ON action_proposal_receipts(owner_did, state, next_attempt)`,
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Every lifecycle write goes through here so the indexed `status` and
186
+ * `transaction_hash` columns always mirror the JSON, and a hash another
187
+ * card of this owner already recorded is refused before the index does.
188
+ */
189
+ private async writeLifecycle(
190
+ ownerDid: string,
191
+ id: string,
192
+ lifecycle: ActionProposal['lifecycle'],
193
+ /**
194
+ * A hash the indexed column keeps although the lifecycle variant has no
195
+ * field for it (a revoked blocked card): the "one hash, one card" index
196
+ * and the audit trail survive without changing the wire contract.
197
+ */
198
+ retainedHash?: string,
199
+ ): Promise<void> {
200
+ const hash =
201
+ 'transactionHash' in lifecycle && lifecycle.transactionHash
202
+ ? lifecycle.transactionHash.toUpperCase()
203
+ : retainedHash
204
+ ? retainedHash.toUpperCase()
205
+ : null;
206
+ if (hash) {
207
+ const other = await this.db.get<{ id: string }>(
208
+ `SELECT id FROM action_proposals WHERE owner_did=? AND transaction_hash=? AND id<>?`,
209
+ [ownerDid, hash, id],
210
+ );
211
+ if (other)
212
+ throw new ActionProposalConflict(
213
+ 'Transaction already recorded for another proposal',
214
+ );
215
+ }
216
+ await this.db.run(
217
+ `UPDATE action_proposals SET lifecycle=?,status=?,transaction_hash=? WHERE id=? AND owner_did=?`,
218
+ [JSON.stringify(lifecycle), lifecycle.status, hash, id, ownerDid],
219
+ );
220
+ }
221
+
222
+ async create(
223
+ input: ActionProposalSubject,
224
+ receiptOrigin: ActionReceiptOrigin | null = null,
225
+ ): Promise<ActionProposal> {
226
+ const subject = ActionProposalSubjectSchema.parse(
227
+ JSON.parse(canonicalizeIxoJson(input)),
228
+ );
229
+ const digest = await digestActionProposal(subject);
230
+ return this.db.transaction(async () => {
231
+ const existing = await this.db.get<{ id: string }>(
232
+ `SELECT id FROM action_proposals WHERE owner_did=? AND invocation_id=?`,
233
+ [subject.ownerDid, subject.origin.invocationId],
234
+ );
235
+ if (existing) {
236
+ const prior = await this.get(subject.ownerDid, existing.id);
237
+ if (!prior) throw new ActionProposalConflict('Proposal missing');
238
+ // A resumed tool call returns its original immutable proposal: the
239
+ // retry minted its own id (and memo) and timestamps, everything else
240
+ // must be what the first call prepared.
241
+ const retry = {
242
+ ...subject,
243
+ id: prior.id,
244
+ createdAt: prior.createdAt,
245
+ expiresAt: prior.expiresAt,
246
+ action: {
247
+ ...subject.action,
248
+ inputs: { ...subject.action.inputs, memo: prior.id },
249
+ },
250
+ };
251
+ if ((await digestActionProposal(retry)) !== prior.digest)
252
+ throw new ActionProposalConflict(
253
+ 'Invocation already prepared different values',
254
+ );
255
+ return prior;
256
+ }
257
+ if (Date.parse(subject.expiresAt) <= this.now())
258
+ throw new ActionProposalConflict('Proposal expired');
259
+ await this.assertCapacity(
260
+ subject.ownerDid,
261
+ subject.origin.sessionId,
262
+ subject.origin.runId,
263
+ );
264
+ await this.db.run(
265
+ `INSERT INTO action_proposals (id,owner_did,invocation_id,session_id,run_id,subject,digest,lifecycle,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)`,
266
+ [
267
+ subject.id,
268
+ subject.ownerDid,
269
+ subject.origin.invocationId,
270
+ subject.origin.sessionId,
271
+ subject.origin.runId,
272
+ JSON.stringify(subject),
273
+ digest,
274
+ JSON.stringify({ status: 'prepared' }),
275
+ 'prepared',
276
+ Date.parse(subject.createdAt),
277
+ ],
278
+ );
279
+ await this.db.run(
280
+ `INSERT INTO action_proposal_receipts (proposal_id,owner_did,origin,state) VALUES (?,?,?,?)`,
281
+ [
282
+ subject.id,
283
+ subject.ownerDid,
284
+ receiptOrigin ? JSON.stringify(receiptOrigin) : null,
285
+ receiptOrigin ? 'waiting' : 'unavailable',
286
+ ],
287
+ );
288
+ return ActionProposalSchema.parse({
289
+ ...subject,
290
+ digest,
291
+ lifecycle: { status: 'prepared' },
292
+ });
293
+ });
294
+ }
295
+
296
+ /**
297
+ * A looping or injected run must not mint cards without bound: at most
298
+ * `ACTION_PROPOSALS_PER_RUN` per run and `ACTION_PROPOSALS_OPEN_PER_OWNER`
299
+ * still awaiting the owner. A resumed tool call never reaches here (it
300
+ * returns its original card above).
301
+ */
302
+ private async assertCapacity(
303
+ ownerDid: string,
304
+ sessionId: string,
305
+ runId: string,
306
+ ): Promise<void> {
307
+ const run = await this.db.get<{ n: number }>(
308
+ `SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND session_id=? AND run_id=?`,
309
+ [ownerDid, sessionId, runId],
310
+ );
311
+ if ((run?.n ?? 0) >= ACTION_PROPOSALS_PER_RUN)
312
+ throw new ActionProposalConflict(
313
+ 'Too many action cards in this request; finish the open ones first',
314
+ );
315
+ const open = await this.db.get<{ n: number }>(
316
+ `SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND ((status='prepared' AND created_at>?) OR status='claimed' OR (status='unknown' AND transaction_hash IS NULL))`,
317
+ [ownerDid, this.now() - OPEN_PREPARED_MS],
318
+ );
319
+ if ((open?.n ?? 0) >= ACTION_PROPOSALS_OPEN_PER_OWNER)
320
+ throw new ActionProposalConflict(
321
+ 'Too many action cards are open; finish or decline them first',
322
+ );
323
+ }
324
+
325
+ async get(ownerDid: string, id: string): Promise<ActionProposal | null> {
326
+ const row = await this.db.get<{
327
+ subject: string;
328
+ digest: string;
329
+ lifecycle: string;
330
+ }>(
331
+ `SELECT subject,digest,lifecycle FROM action_proposals WHERE id=? AND owner_did=?`,
332
+ [id, ownerDid],
333
+ );
334
+ return row
335
+ ? assertActionProposalUnchanged({
336
+ ...JSON.parse(row.subject),
337
+ digest: row.digest,
338
+ lifecycle: JSON.parse(row.lifecycle),
339
+ })
340
+ : null;
341
+ }
342
+
343
+ /**
344
+ * The receipts due now, at most four, each moved to its next attempt (or
345
+ * abandoned on the last one) before it is handed out, so a crash between
346
+ * here and the post retries on schedule instead of immediately. A row this
347
+ * build cannot read any more is quarantined rather than retried for ever.
348
+ */
349
+ async takeReceipts(
350
+ ownerDid: string,
351
+ ): Promise<{ proposal: ActionProposal; origin: ActionReceiptOrigin }[]> {
352
+ return this.db.transaction(async () => {
353
+ const now = this.now();
354
+ const rows = await this.db.exec<{
355
+ proposal_id: string;
356
+ origin: string;
357
+ attempts: number;
358
+ }>(
359
+ `SELECT proposal_id,origin,attempts FROM action_proposal_receipts WHERE owner_did=? AND state='pending' AND next_attempt<=? ORDER BY next_attempt,proposal_id LIMIT 4`,
360
+ [ownerDid, now],
361
+ );
362
+ const receipts: {
363
+ proposal: ActionProposal;
364
+ origin: ActionReceiptOrigin;
365
+ }[] = [];
366
+ for (const row of rows) {
367
+ let proposal: ActionProposal | null;
368
+ try {
369
+ proposal = await this.get(ownerDid, row.proposal_id);
370
+ } catch (error) {
371
+ await this.db.run(
372
+ `UPDATE action_proposal_receipts SET state='quarantined' WHERE owner_did=? AND proposal_id=?`,
373
+ [ownerDid, row.proposal_id],
374
+ );
375
+ console.error(
376
+ `[action-proposals] receipt ${row.proposal_id} quarantined: ${error instanceof Error ? error.message : String(error)}`,
377
+ );
378
+ continue;
379
+ }
380
+ if (!proposal) {
381
+ // A receipt whose card is gone would stay due for ever.
382
+ await this.db.run(
383
+ `UPDATE action_proposal_receipts SET state='quarantined' WHERE owner_did=? AND proposal_id=?`,
384
+ [ownerDid, row.proposal_id],
385
+ );
386
+ continue;
387
+ }
388
+ const attempts = row.attempts + 1;
389
+ await this.db.run(
390
+ attempts >= RECEIPT_MAX_ATTEMPTS
391
+ ? `UPDATE action_proposal_receipts SET state='abandoned',attempts=? WHERE owner_did=? AND proposal_id=?`
392
+ : `UPDATE action_proposal_receipts SET next_attempt=?,attempts=? WHERE owner_did=? AND proposal_id=?`,
393
+ attempts >= RECEIPT_MAX_ATTEMPTS
394
+ ? [attempts, ownerDid, row.proposal_id]
395
+ : [
396
+ now +
397
+ backoff(
398
+ RECEIPT_RETRY_BASE_MS,
399
+ RECEIPT_RETRY_MAX_MS,
400
+ row.attempts,
401
+ ),
402
+ attempts,
403
+ ownerDid,
404
+ row.proposal_id,
405
+ ],
406
+ );
407
+ receipts.push({ proposal, origin: JSON.parse(row.origin) });
408
+ }
409
+ return receipts;
410
+ });
411
+ }
412
+ async nextReceiptAttempt(ownerDid: string): Promise<number | undefined> {
413
+ const row = await this.db.get<{ due: number | null }>(
414
+ `SELECT MIN(next_attempt) AS due FROM action_proposal_receipts WHERE owner_did=? AND state='pending'`,
415
+ [ownerDid],
416
+ );
417
+ return row?.due ?? undefined;
418
+ }
419
+ async receiptMirrored(ownerDid: string, id: string): Promise<void> {
420
+ await this.db.run(
421
+ `UPDATE action_proposal_receipts SET state='delivered' WHERE owner_did=? AND proposal_id=? AND state IN ('pending','abandoned')`,
422
+ [ownerDid, id],
423
+ );
424
+ }
425
+ async forRun(
426
+ ownerDid: string,
427
+ sessionId: string,
428
+ runId: string,
429
+ ): Promise<ActionProposal[]> {
430
+ const rows = await this.db.exec<{ id: string }>(
431
+ `SELECT id FROM action_proposals WHERE owner_did=? AND session_id=? AND run_id=? ORDER BY verify_after,id LIMIT 32`,
432
+ [ownerDid, sessionId, runId],
433
+ );
434
+ const proposals: ActionProposal[] = [];
435
+ for (const row of rows) {
436
+ const p = await this.get(ownerDid, row.id);
437
+ if (p) proposals.push(p);
438
+ }
439
+ return proposals;
440
+ }
441
+ /**
442
+ * Cards with a recorded transaction the chain has not confirmed yet, whose
443
+ * cooldown has passed and whose attempts are not used up, oldest first.
444
+ */
445
+ async dueVerifications(
446
+ ownerDid: string,
447
+ limit = 2,
448
+ ): Promise<ActionProposal[]> {
449
+ const rows = await this.db.exec<{ id: string }>(
450
+ `SELECT id FROM action_proposals WHERE owner_did=? AND status='unknown' AND transaction_hash IS NOT NULL AND verify_after<=? AND verify_attempts<? ORDER BY verify_after,id LIMIT ?`,
451
+ [ownerDid, this.now(), VERIFY_MAX_ATTEMPTS, limit],
452
+ );
453
+ const proposals: ActionProposal[] = [];
454
+ for (const row of rows) {
455
+ try {
456
+ const p = await this.get(ownerDid, row.id);
457
+ if (p) proposals.push(p);
458
+ } catch (error) {
459
+ // A row this build cannot read must not fail the alarm every tick:
460
+ // its attempts are spent and it drops out of the schedule.
461
+ await this.db.run(
462
+ `UPDATE action_proposals SET verify_attempts=? WHERE owner_did=? AND id=?`,
463
+ [VERIFY_MAX_ATTEMPTS, ownerDid, row.id],
464
+ );
465
+ console.error(
466
+ `[action-proposals] verification of ${row.id} quarantined: ${error instanceof Error ? error.message : String(error)}`,
467
+ );
468
+ }
469
+ }
470
+ return proposals;
471
+ }
472
+ /**
473
+ * The verifier proved the recorded hash is another transaction (another
474
+ * card's memo or none): the owner may record the right one with a later
475
+ * reconcile instead of the card staying `unknown` for ever.
476
+ */
477
+ async markTransactionRejected(
478
+ ownerDid: string,
479
+ id: string,
480
+ transactionHash: string,
481
+ ): Promise<void> {
482
+ // Guarded on the hash: a verification still in flight for a hash the
483
+ // owner has since replaced must not reject its successor.
484
+ await this.db.run(
485
+ `UPDATE action_proposals SET transaction_hash_rejected=1 WHERE owner_did=? AND id=? AND status='unknown' AND transaction_hash=?`,
486
+ [ownerDid, id, transactionHash],
487
+ );
488
+ }
489
+ /**
490
+ * The recorded transaction carries this card's memo but other messages:
491
+ * an effect the owner bound to the card that went wrong. The hash stays
492
+ * listed and may not be replaced, verification stops, the card stays
493
+ * `unknown` with `executionBlockedAt`; the owner revokes it and prepares
494
+ * another.
495
+ */
496
+ async blockExecution(
497
+ ownerDid: string,
498
+ id: string,
499
+ reason: string,
500
+ transactionHash: string,
501
+ ): Promise<void> {
502
+ const blocked = await this.db.transaction(async () => {
503
+ const p = await this.get(ownerDid, id);
504
+ if (
505
+ !p ||
506
+ p.lifecycle.status !== 'unknown' ||
507
+ p.lifecycle.transactionHash !== transactionHash ||
508
+ p.lifecycle.executionBlockedAt
509
+ )
510
+ return false;
511
+ await this.writeLifecycle(ownerDid, id, {
512
+ ...p.lifecycle,
513
+ executionBlockedAt: new Date(this.now()).toISOString(),
514
+ });
515
+ await this.db.run(
516
+ `UPDATE action_proposals SET transaction_hash_rejected=0,verify_attempts=? WHERE owner_did=? AND id=?`,
517
+ [VERIFY_MAX_ATTEMPTS, ownerDid, id],
518
+ );
519
+ return true;
520
+ });
521
+ if (blocked)
522
+ console.error(`[action-proposals] ${id} execution blocked: ${reason}`);
523
+ }
524
+ /**
525
+ * The lookup just made found no transaction, inside the grace window
526
+ * after the hash arrived: give the attempt back and look again after the
527
+ * first-lookup delay. Guarded on the hash. Returns whether it applied.
528
+ */
529
+ async deferNotFound(
530
+ ownerDid: string,
531
+ id: string,
532
+ transactionHash: string,
533
+ ): Promise<boolean> {
534
+ const now = this.now();
535
+ const result = await this.db.run(
536
+ `UPDATE action_proposals SET verify_attempts=MAX(verify_attempts-1,0),verify_after=? WHERE owner_did=? AND id=? AND status='unknown' AND transaction_hash=? AND hash_recorded_at>?`,
537
+ [
538
+ now + VERIFY_FIRST_DELAY_MS,
539
+ ownerDid,
540
+ id,
541
+ transactionHash.toUpperCase(),
542
+ now - VERIFY_NOT_FOUND_GRACE_MS,
543
+ ],
544
+ );
545
+ return result.changes === 1;
546
+ }
547
+ /** When the alarm should next look a recorded transaction up, if ever. */
548
+ async nextVerificationAt(ownerDid: string): Promise<number | undefined> {
549
+ const row = await this.db.get<{ due: number | null }>(
550
+ `SELECT MIN(verify_after) AS due FROM action_proposals WHERE owner_did=? AND status='unknown' AND transaction_hash IS NOT NULL AND verify_attempts<?`,
551
+ [ownerDid, VERIFY_MAX_ATTEMPTS],
552
+ );
553
+ return row?.due ?? undefined;
554
+ }
555
+ /**
556
+ * Close the cards claimed more than `ACTION_PROPOSAL_EXECUTION_WINDOW_MS`
557
+ * ago that never recorded a transaction (a wallet cancel, a lost claim
558
+ * reply): `revoked`, ended now. Bounded per call; returns how many closed.
559
+ */
560
+ async closeUnexecuted(ownerDid: string): Promise<number> {
561
+ const now = this.now();
562
+ return this.db.transaction(async () => {
563
+ // Due rows only, oldest claim first: the same filter the deadline
564
+ // below reads, so a row this sweep cannot close never keeps the
565
+ // deadline in the past.
566
+ const rows = await this.db.exec<{ id: string }>(
567
+ `SELECT id FROM action_proposals WHERE owner_did=? AND status IN ('claimed','unknown') AND transaction_hash IS NULL AND unexecuted_close=0 AND ${UNEXECUTED_CLAIMED_AT}<=? ORDER BY ${UNEXECUTED_CLAIMED_AT},id LIMIT 16`,
568
+ [
569
+ ownerDid,
570
+ new Date(now - ACTION_PROPOSAL_EXECUTION_WINDOW_MS).toISOString(),
571
+ ],
572
+ );
573
+ let closed = 0;
574
+ for (const row of rows) {
575
+ let p: ActionProposal | null;
576
+ try {
577
+ p = await this.get(ownerDid, row.id);
578
+ } catch (error) {
579
+ // Unreadable: out of this sweep's schedule (the prune drops it),
580
+ // so its claim no longer holds the next deadline in the past.
581
+ await this.db.run(
582
+ `UPDATE action_proposals SET unexecuted_close=2 WHERE owner_did=? AND id=?`,
583
+ [ownerDid, row.id],
584
+ );
585
+ console.error(
586
+ `[action-proposals] unexecuted close of ${row.id} quarantined: ${error instanceof Error ? error.message : String(error)}`,
587
+ );
588
+ continue;
589
+ }
590
+ if (
591
+ !p ||
592
+ (p.lifecycle.status !== 'claimed' &&
593
+ p.lifecycle.status !== 'unknown') ||
594
+ ('transactionHash' in p.lifecycle && p.lifecycle.transactionHash)
595
+ )
596
+ continue;
597
+ await this.writeLifecycle(ownerDid, row.id, {
598
+ status: 'revoked',
599
+ endedAt: new Date(now).toISOString(),
600
+ });
601
+ await this.db.run(
602
+ `UPDATE action_proposals SET unexecuted_close=1,unexecuted_claimed_at=? WHERE owner_did=? AND id=?`,
603
+ [p.lifecycle.claimedAt, ownerDid, row.id],
604
+ );
605
+ closed++;
606
+ }
607
+ return closed;
608
+ });
609
+ }
610
+ /** When the next hash-less claimed card's execution window ends, if any. */
611
+ async nextUnexecutedCloseAt(ownerDid: string): Promise<number | undefined> {
612
+ const row = await this.db.get<{ claimed: string | null }>(
613
+ `SELECT MIN(${UNEXECUTED_CLAIMED_AT}) AS claimed FROM action_proposals WHERE owner_did=? AND status IN ('claimed','unknown') AND transaction_hash IS NULL AND unexecuted_close=0`,
614
+ [ownerDid],
615
+ );
616
+ const claimed = row?.claimed ? Date.parse(row.claimed) : NaN;
617
+ return Number.isFinite(claimed)
618
+ ? claimed + ACTION_PROPOSAL_EXECUTION_WINDOW_MS
619
+ : undefined;
620
+ }
621
+ /**
622
+ * Drop finished cards past the retention window, with their receipts, and
623
+ * unresolved ones (claimed, hash-less or out of lookups, blocked) past the
624
+ * longer window; a card whose transaction is still being looked up stays,
625
+ * as does one whose receipt is still being posted.
626
+ */
627
+ async prune(ownerDid: string): Promise<number> {
628
+ const now = this.now();
629
+ return this.db.transaction(async () => {
630
+ const rows = await this.db.exec<{ id: string }>(
631
+ `SELECT p.id FROM action_proposals p LEFT JOIN action_proposal_receipts r ON r.proposal_id=p.id WHERE p.owner_did=? AND p.created_at<? AND (r.state IS NULL OR r.state<>'pending') AND (p.status IN ('prepared','succeeded','failed','rejected','revoked','superseded') OR (p.created_at<? AND (p.status='claimed' OR (p.status='unknown' AND (p.transaction_hash IS NULL OR p.verify_attempts>=?))))) LIMIT 64`,
632
+ [
633
+ ownerDid,
634
+ now - ACTION_PROPOSAL_RETENTION_MS,
635
+ now - ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS,
636
+ VERIFY_MAX_ATTEMPTS,
637
+ ],
638
+ );
639
+ for (const row of rows) {
640
+ await this.db.run(
641
+ `DELETE FROM action_proposal_receipts WHERE owner_did=? AND proposal_id=?`,
642
+ [ownerDid, row.id],
643
+ );
644
+ await this.db.run(
645
+ `DELETE FROM action_proposals WHERE owner_did=? AND id=?`,
646
+ [ownerDid, row.id],
647
+ );
648
+ }
649
+ return rows.length;
650
+ });
651
+ }
652
+ async outcomesForRun(
653
+ ownerDid: string,
654
+ sessionId: string,
655
+ runId: string,
656
+ ): Promise<ActionOutcome[]> {
657
+ return (await this.forRun(ownerDid, sessionId, runId)).map((p) =>
658
+ actionOutcome(p, this.now()),
659
+ );
660
+ }
661
+ /**
662
+ * Take the one chain lookup the cooldown allows: the next one may run only
663
+ * after a wait that doubles with every attempt made so far.
664
+ */
665
+ async claimVerification(ownerDid: string, id: string): Promise<boolean> {
666
+ const now = this.now();
667
+ const row = await this.db.get<{ verify_attempts: number }>(
668
+ 'SELECT verify_attempts FROM action_proposals WHERE id=? AND owner_did=? AND verify_after<=?',
669
+ [id, ownerDid, now],
670
+ );
671
+ if (!row) return false;
672
+ const result = await this.db.run(
673
+ 'UPDATE action_proposals SET verify_after=?,verify_attempts=verify_attempts+1 WHERE id=? AND owner_did=? AND verify_after<=?',
674
+ [
675
+ now +
676
+ backoff(
677
+ VERIFY_RETRY_BASE_MS,
678
+ VERIFY_RETRY_MAX_MS,
679
+ row.verify_attempts,
680
+ ),
681
+ id,
682
+ ownerDid,
683
+ now,
684
+ ],
685
+ );
686
+ return result.changes === 1;
687
+ }
688
+
689
+ async recordVerified(
690
+ ownerDid: string,
691
+ id: string,
692
+ verified: VerifiedActionTransaction,
693
+ ): Promise<ActionProposal | null> {
694
+ return this.db.transaction(async () => {
695
+ const p = await this.get(ownerDid, id);
696
+ if (!p) return null;
697
+ if (
698
+ p.lifecycle.status !== 'unknown' ||
699
+ p.lifecycle.transactionHash !== verified.transactionHash
700
+ )
701
+ throw new ActionProposalConflict(
702
+ 'Transaction is not pending verification',
703
+ );
704
+ const lifecycle: ActionProposal['lifecycle'] = {
705
+ status: verified.code === 0 ? 'succeeded' : 'failed',
706
+ claimedAt: p.lifecycle.claimedAt,
707
+ ...verified,
708
+ };
709
+ await this.writeLifecycle(ownerDid, id, lifecycle);
710
+ await this.db.run(
711
+ `UPDATE action_proposal_receipts SET state='pending',next_attempt=? WHERE owner_did=? AND proposal_id=? AND state='waiting'`,
712
+ [this.now(), ownerDid, id],
713
+ );
714
+ return ActionProposalSchema.parse({ ...p, lifecycle });
715
+ });
716
+ }
717
+
718
+ async transition(
719
+ ownerDid: string,
720
+ id: string,
721
+ command: ProposalCommand,
722
+ ): Promise<ActionProposal | null> {
723
+ return this.db.transaction(async () => {
724
+ const p = await this.get(ownerDid, id);
725
+ if (!p) return null;
726
+ const at = new Date(this.now()).toISOString();
727
+ let lifecycle: ActionProposal['lifecycle'];
728
+ if (command.kind === 'reconcile') {
729
+ if (
730
+ (p.lifecycle.status === 'succeeded' ||
731
+ p.lifecycle.status === 'failed') &&
732
+ (!command.transactionHash ||
733
+ p.lifecycle.transactionHash ===
734
+ command.transactionHash.toUpperCase())
735
+ )
736
+ return p;
737
+ if (p.lifecycle.status === 'revoked' && command.transactionHash) {
738
+ // Closed by `closeUnexecuted` (the window passed with no hash) and
739
+ // the transaction landed after all: the hash is accepted and the
740
+ // card goes back to `unknown` on the normal verification path.
741
+ // The memo still binds the transaction to this card.
742
+ const closed = await this.db.get<{
743
+ unexecuted_close: number;
744
+ unexecuted_claimed_at: string | null;
745
+ }>(
746
+ `SELECT unexecuted_close,unexecuted_claimed_at FROM action_proposals WHERE owner_did=? AND id=?`,
747
+ [ownerDid, id],
748
+ );
749
+ if (closed?.unexecuted_close === 1 && closed.unexecuted_claimed_at) {
750
+ lifecycle = {
751
+ status: 'unknown',
752
+ claimedAt: closed.unexecuted_claimed_at,
753
+ transactionHash: command.transactionHash.toUpperCase(),
754
+ };
755
+ await this.writeLifecycle(ownerDid, id, lifecycle);
756
+ await this.db.run(
757
+ `UPDATE action_proposals SET unexecuted_close=0,transaction_hash_rejected=0,verify_after=?,verify_attempts=0,hash_recorded_at=? WHERE owner_did=? AND id=?`,
758
+ [this.now() + VERIFY_FIRST_DELAY_MS, this.now(), ownerDid, id],
759
+ );
760
+ return { ...p, lifecycle };
761
+ }
762
+ }
763
+ if (
764
+ p.lifecycle.status !== 'claimed' &&
765
+ p.lifecycle.status !== 'unknown'
766
+ )
767
+ throw new ActionProposalConflict('Proposal was not claimed');
768
+ if (
769
+ p.lifecycle.status === 'unknown' &&
770
+ p.lifecycle.executionBlockedAt
771
+ ) {
772
+ // The listed hash may be asked about again (Portal recovery); no
773
+ // other hash may take its place.
774
+ if (
775
+ !command.transactionHash ||
776
+ p.lifecycle.transactionHash ===
777
+ command.transactionHash.toUpperCase()
778
+ )
779
+ return p;
780
+ throw new ActionProposalConflict('Execution is blocked');
781
+ }
782
+ let replacing = false;
783
+ const firstHash =
784
+ !!command.transactionHash &&
785
+ !(p.lifecycle.status === 'unknown' && p.lifecycle.transactionHash);
786
+ if (
787
+ p.lifecycle.status === 'unknown' &&
788
+ p.lifecycle.transactionHash &&
789
+ command.transactionHash &&
790
+ p.lifecycle.transactionHash !== command.transactionHash.toUpperCase()
791
+ ) {
792
+ const row = await this.db.get<{ transaction_hash_rejected: number }>(
793
+ `SELECT transaction_hash_rejected FROM action_proposals WHERE owner_did=? AND id=?`,
794
+ [ownerDid, id],
795
+ );
796
+ if (!row?.transaction_hash_rejected)
797
+ throw new ActionProposalConflict(
798
+ 'Transaction reference already recorded',
799
+ );
800
+ replacing = true;
801
+ }
802
+ const hash =
803
+ command.transactionHash ??
804
+ (p.lifecycle.status === 'unknown'
805
+ ? p.lifecycle.transactionHash
806
+ : undefined);
807
+ lifecycle = {
808
+ status: 'unknown',
809
+ claimedAt: p.lifecycle.claimedAt,
810
+ ...(hash ? { transactionHash: hash.toUpperCase() } : {}),
811
+ };
812
+ if (replacing || firstHash)
813
+ // A fresh hash gets a fresh verification schedule, its first
814
+ // lookup no sooner than `VERIFY_FIRST_DELAY_MS` (the Portal may
815
+ // report it before broadcasting). The same hash again changes
816
+ // nothing.
817
+ await this.db.run(
818
+ `UPDATE action_proposals SET transaction_hash_rejected=0,verify_after=?,verify_attempts=0,hash_recorded_at=? WHERE owner_did=? AND id=?`,
819
+ [this.now() + VERIFY_FIRST_DELAY_MS, this.now(), ownerDid, id],
820
+ );
821
+ } else {
822
+ if (p.digest !== command.digest)
823
+ throw new ActionProposalConflict('Confirmed values changed');
824
+ if (
825
+ command.kind === 'revoke' &&
826
+ p.lifecycle.status === 'unknown' &&
827
+ p.lifecycle.executionBlockedAt
828
+ ) {
829
+ // A blocked card never confirms or fails: revoking it is the
830
+ // owner's way to close it, expiry or not. Its memo-bound hash stays
831
+ // in the indexed column (the `revoked` variant has no field for
832
+ // it), so no other card can record it and the audit trail holds.
833
+ await this.writeLifecycle(
834
+ ownerDid,
835
+ id,
836
+ { status: 'revoked', endedAt: at },
837
+ p.lifecycle.transactionHash,
838
+ );
839
+ return { ...p, lifecycle: { status: 'revoked', endedAt: at } };
840
+ } else {
841
+ if (p.lifecycle.status !== 'prepared')
842
+ throw new ActionProposalConflict('Proposal is already consumed');
843
+ if (Date.parse(p.expiresAt) <= this.now())
844
+ throw new ActionProposalConflict('Proposal expired');
845
+ lifecycle =
846
+ command.kind === 'claim'
847
+ ? { status: 'claimed', claimedAt: at }
848
+ : {
849
+ status:
850
+ command.kind === 'reject'
851
+ ? 'rejected'
852
+ : command.kind === 'revoke'
853
+ ? 'revoked'
854
+ : 'superseded',
855
+ endedAt: at,
856
+ };
857
+ }
858
+ }
859
+ await this.writeLifecycle(ownerDid, id, lifecycle);
860
+ return { ...p, lifecycle };
861
+ });
862
+ }
863
+ }