@ixo/oracle-runtime-workers 0.39.0 → 0.40.1

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.
@@ -0,0 +1,1038 @@
1
+ import {
2
+ ActionInteractionError,
3
+ canonicalJson,
4
+ interactionValuesDigest,
5
+ InteractionSchema,
6
+ InteractionReferenceSchema,
7
+ type Interaction,
8
+ type InteractionReference,
9
+ type InteractionResult,
10
+ type PrepareInteraction,
11
+ type UpdateInteraction,
12
+ type ReserveInteraction,
13
+ type ObserveInteraction,
14
+ type CheckClaimDispatch,
15
+ type CheckDispatch,
16
+ type JsonValue,
17
+ } from '@ixo/action-interactions';
18
+ /* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
19
+
20
+ const PREFIX = 'interaction:';
21
+ const REQUEST_PREFIX = 'interaction-request:';
22
+ /** `<id>:<requestId>` → requestId, so retention can drop a record's request tombstones. */
23
+ const REQUESTS_OF_PREFIX = 'interaction-requests-of:';
24
+ /** `<sha256(sessionId)>:<createdAt>:<id>` → id: lists one session without scanning the user. */
25
+ const SESSION_PREFIX = 'interaction-session:';
26
+ /** `<expiresAt>:<id>` → id: retention reads only what is due. */
27
+ const EXPIRY_PREFIX = 'interaction-expiry:';
28
+ /** `<operationKey>` → `true` once dispatched, `'released'` when the host reported nothing was sent. */
29
+ const DISPATCH_PREFIX = 'interaction-dispatch:';
30
+ /** `<id>` → the last few `{requestId, fingerprint}` of update/reserve/observe on that record. */
31
+ const RECENT_PREFIX = 'interaction-recent:';
32
+ /** `<owner key hash>` → when the ordinary form last passed the dispatch check for that claim step. */
33
+ const MANUAL_PREFIX = 'interaction-manual:';
34
+ /** `<id>` → how many chunks hold the values of a record too large for one storage value. */
35
+ const CHUNK_COUNT_PREFIX = 'interaction-chunks:';
36
+ /** `<id>:<index>` → one chunk of such a record's serialized values. */
37
+ const CHUNK_PREFIX = 'interaction-value:';
38
+ /**
39
+ * Replays of edits only need the newest requests: an older retry fails the
40
+ * revision compare-and-swap instead, which never applies an edit twice.
41
+ */
42
+ const RECENT_LIMIT = 16;
43
+ /** Values plus the user-edit list of one record, in UTF-8 bytes. */
44
+ const MAX_RECORD_BYTES = 64 * 1024;
45
+ /** A domain-write approval carries a file batch; 16 KiB stays free for the record's metadata. */
46
+ const MAX_DOMAIN_WRITE_BYTES = 2 * 1024 * 1024 - 16 * 1024;
47
+ const DAY_MS = 24 * 60 * 60 * 1000;
48
+ const TTL_MS = 7 * DAY_MS;
49
+ /** How long an expired record stays readable before retention removes its values. */
50
+ const RETAIN_MS = 30 * DAY_MS;
51
+ /** The newest records one session list returns. */
52
+ const LIST_LIMIT = 50;
53
+ /** One full session list, values included, in UTF-8 bytes. */
54
+ const LIST_MAX_BYTES = 8 * 1024 * 1024;
55
+ /** The newest records one reference list returns. */
56
+ const REFERENCE_LIST_LIMIT = 1000;
57
+ /**
58
+ * Cards one chat may create within one draft lifetime (`TTL_MS`). A question
59
+ * owner is keyed by its request id, so without a cap a runaway agent could
60
+ * write drafts as fast as the rate limit allows.
61
+ */
62
+ const SESSION_DRAFT_LIMIT = 200;
63
+ /** Records retention handles per prepare, so a prepare never pays for a backlog. */
64
+ const PRUNE_BATCH = 16;
65
+ /** Durable Object storage takes at most 128 keys per multi-key get, put or delete. */
66
+ const STORAGE_BATCH = 128;
67
+
68
+ type Storage = Pick<DurableObjectStorage, 'get' | 'put' | 'delete' | 'list'>;
69
+
70
+ async function sha256Hex(text: string): Promise<string> {
71
+ const hash = await crypto.subtle.digest(
72
+ 'SHA-256',
73
+ new TextEncoder().encode(text),
74
+ );
75
+ return Array.from(new Uint8Array(hash), (byte) =>
76
+ byte.toString(16).padStart(2, '0'),
77
+ ).join('');
78
+ }
79
+ /** Fixed-width so lexicographic key order is numeric order. */
80
+ const sortable = (value: number) =>
81
+ Math.max(0, value).toString().padStart(16, '0');
82
+ async function sessionIndexKey(record: Interaction): Promise<string> {
83
+ return `${SESSION_PREFIX}${await sha256Hex(record.sessionId)}:${sortable(record.createdAt)}:${record.id}`;
84
+ }
85
+ const expiryIndexKey = (record: Interaction) =>
86
+ `${EXPIRY_PREFIX}${sortable(record.expiresAt)}:${record.id}`;
87
+ const utf8Bytes = (text: string) => new TextEncoder().encode(text).byteLength;
88
+ function assertRecordSize(
89
+ values: Interaction['values'],
90
+ updatedByUser: string[],
91
+ owner: Interaction['owner'],
92
+ ) {
93
+ const limit =
94
+ owner.kind === 'approval' && owner.family === 'domain-write'
95
+ ? MAX_DOMAIN_WRITE_BYTES
96
+ : MAX_RECORD_BYTES;
97
+ if (
98
+ utf8Bytes(canonicalJson(values)) +
99
+ utf8Bytes(JSON.stringify(updatedByUser)) >
100
+ limit
101
+ )
102
+ throw new ActionInteractionError(
103
+ 413,
104
+ 'too_large',
105
+ 'Draft values exceed the owner payload limit',
106
+ );
107
+ }
108
+ /**
109
+ * A record this version cannot read (corrupt, a missing chunk, or a value a
110
+ * newer runtime added to a closed list). Refused as a conflict, never a 500:
111
+ * the Portal treats a 5xx from the dispatch check as "could not ask" and
112
+ * would let a claim through.
113
+ */
114
+ const unreadableRecord = () =>
115
+ new ActionInteractionError(
116
+ 409,
117
+ 'record_unreadable',
118
+ 'This Companion draft cannot be read; check the claims list before submitting',
119
+ );
120
+ type RecentRequest = { requestId: string; fingerprint: string };
121
+ const manualMarkerKey = (ownerKey: string) =>
122
+ `${MANUAL_PREFIX}${ownerKey.slice(ownerKey.indexOf(':') + 1)}`;
123
+ async function deleteKeys(storage: Storage, keys: string[]) {
124
+ for (let i = 0; i < keys.length; i += STORAGE_BATCH)
125
+ await storage.delete(keys.slice(i, i + STORAGE_BATCH));
126
+ }
127
+ type Command =
128
+ | { kind: 'prepare'; input: PrepareInteraction }
129
+ | { kind: 'update'; id: string; input: UpdateInteraction }
130
+ | { kind: 'reserve'; id: string; input: ReserveInteraction }
131
+ | { kind: 'observe'; id: string; input: ObserveInteraction };
132
+ export type InteractionCommand = Command;
133
+ export type InteractionOutcome<T> =
134
+ | { ok: true; value: T }
135
+ | { ok: false; status: number; code: string; message: string };
136
+ export async function interactionOutcome<T>(
137
+ work: () => Promise<T>,
138
+ ): Promise<InteractionOutcome<T>> {
139
+ try {
140
+ return { ok: true, value: await work() };
141
+ } catch (error) {
142
+ if (error instanceof ActionInteractionError)
143
+ return {
144
+ ok: false,
145
+ status: error.status,
146
+ code: error.code,
147
+ message: error.message,
148
+ };
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ async function ownerStorageKey(
154
+ owner: Interaction['owner'],
155
+ sessionId: string,
156
+ ): Promise<string> {
157
+ const binding =
158
+ owner.kind === 'flow-claim' ||
159
+ owner.kind === 'flow-claim-block' ||
160
+ owner.kind === 'flow-bid'
161
+ ? {
162
+ ...(owner.kind === 'flow-claim-block'
163
+ ? { collectionId: owner.collectionId }
164
+ : {}),
165
+ // A bid for another role or group of the same step is a separate operation.
166
+ ...(owner.kind === 'flow-bid'
167
+ ? {
168
+ roleId: owner.roleId,
169
+ ...(owner.groupId === undefined
170
+ ? {}
171
+ : { groupId: owner.groupId }),
172
+ }
173
+ : {}),
174
+ kind: owner.kind,
175
+ flowId: owner.flowId,
176
+ runId: owner.runId,
177
+ stepId: owner.stepId,
178
+ accountDid: owner.accountDid,
179
+ network: owner.network,
180
+ }
181
+ : owner;
182
+ return `interaction-owner:${await sha256Hex(
183
+ canonicalJson(
184
+ owner.kind === 'flow-claim' ||
185
+ owner.kind === 'flow-claim-block' ||
186
+ owner.kind === 'flow-bid' ||
187
+ owner.kind === 'approval'
188
+ ? { owner: binding }
189
+ : { sessionId, owner: binding },
190
+ ),
191
+ )}`;
192
+ }
193
+
194
+ /** Receipt checkpoints may add facts, but cannot erase an acknowledged effect. */
195
+ function extendsReceipt(
196
+ previous: JsonValue,
197
+ next: JsonValue | undefined,
198
+ ): boolean {
199
+ if (next === undefined) return false;
200
+ if (Array.isArray(previous))
201
+ return (
202
+ Array.isArray(next) &&
203
+ previous.every(
204
+ (value, index) =>
205
+ index < next.length && extendsReceipt(value, next[index]),
206
+ )
207
+ );
208
+ if (previous !== null && typeof previous === 'object')
209
+ return (
210
+ next !== null &&
211
+ typeof next === 'object' &&
212
+ !Array.isArray(next) &&
213
+ Object.entries(previous).every(
214
+ ([key, value]) =>
215
+ Object.hasOwn(next, key) && extendsReceipt(value, next[key]),
216
+ )
217
+ );
218
+ return previous === next;
219
+ }
220
+
221
+ /**
222
+ * Where an owner's existing record stands for a new card or a manual
223
+ * submission of the same step:
224
+ * - `blocked`: its operation reached the executor (the one-use dispatch key
225
+ * was consumed) or the host reported it observed. A second submission
226
+ * could duplicate the claim, so nothing replaces it.
227
+ * - `live`: an unexpired draft, or a reservation the host is dispatching
228
+ * right now. It belongs to the card that owns it.
229
+ * - `stale`: cancelled, expired, or a reservation that never reached the
230
+ * executor. Safe to supersede: its dispatch check can no longer pass.
231
+ */
232
+ type OwnerState = 'blocked' | 'live' | 'stale';
233
+
234
+ /** Personal drafts only. Execution and verified receipts remain owned by Flow. */
235
+ export class ActionInteractionStore {
236
+ constructor(
237
+ private readonly storage: DurableObjectStorage,
238
+ private readonly context: { oracleDid: string; userDid: string },
239
+ private readonly now: () => number = Date.now,
240
+ ) {}
241
+
242
+ /** A dispatch precondition, never an authorization grant or an executor. */
243
+ async checkClaimDispatch(input: CheckClaimDispatch) {
244
+ return this.checkDispatch(input);
245
+ }
246
+
247
+ async checkDispatch(
248
+ input: CheckDispatch,
249
+ ): Promise<{ allowed: true; managed: boolean }> {
250
+ if (input.owner.accountDid !== this.context.userDid)
251
+ throw new ActionInteractionError(
252
+ 403,
253
+ 'account_mismatch',
254
+ 'Claim account differs from authenticated user',
255
+ );
256
+ const ownerKey = await ownerStorageKey(input.owner, '');
257
+ const manualKey = manualMarkerKey(ownerKey);
258
+ return this.storage.transaction(async (tx) => {
259
+ const id = await tx.get<string>(ownerKey);
260
+ const record = id ? await this.recordFrom(tx, id) : undefined;
261
+ if (!input.cardRef) {
262
+ if (input.owner.kind === 'approval')
263
+ throw new ActionInteractionError(
264
+ 409,
265
+ 'review_required',
266
+ 'An approval dispatches only through its reviewed card',
267
+ );
268
+ // The ordinary form. It only has to wait for a Companion operation
269
+ // that may already have reached the executor; any other draft for
270
+ // this step is superseded, so its own dispatch check can no longer
271
+ // pass and the step is never locked by an abandoned card. The
272
+ // marker stops a card prepared later from filing the same claim.
273
+ if (id && !record)
274
+ throw new ActionInteractionError(
275
+ 409,
276
+ 'review_required',
277
+ 'This claim step has an unreadable Companion draft',
278
+ );
279
+ if (record && (await this.ownerState(tx, record)) === 'blocked')
280
+ throw new ActionInteractionError(
281
+ 409,
282
+ 'dispatch_consumed',
283
+ 'A Companion submission for this claim step already reached the claim service; check the claims list before submitting again',
284
+ );
285
+ if (record) await this.supersede(tx, record);
286
+ await tx.put(manualKey, this.now());
287
+ return { allowed: true, managed: false };
288
+ }
289
+ if (!record)
290
+ throw new ActionInteractionError(
291
+ 409,
292
+ 'review_required',
293
+ 'No matching managed draft exists for this owner',
294
+ );
295
+ const manualAt = await tx.get<number>(manualKey);
296
+ if (manualAt !== undefined && manualAt >= record.createdAt)
297
+ throw new ActionInteractionError(
298
+ 409,
299
+ 'dispatch_consumed',
300
+ 'This claim step was submitted from the form; check the claims list before submitting again',
301
+ );
302
+ if (
303
+ canonicalJson(record.owner) !== canonicalJson(input.owner) ||
304
+ record.status !== 'reserved' ||
305
+ record.expiresAt <= this.now() ||
306
+ record.id !== input.cardRef ||
307
+ !input.operationKey ||
308
+ record.reservation?.operationKey !== input.operationKey ||
309
+ !input.valuesDigest ||
310
+ record.reservation.valuesDigest !== input.valuesDigest
311
+ )
312
+ throw new ActionInteractionError(
313
+ 409,
314
+ 'review_required',
315
+ 'This action has a managed draft; reopen and review its current operation',
316
+ );
317
+ const consumedKey = `${DISPATCH_PREFIX}${input.operationKey}`;
318
+ if ((await tx.get<true | 'released'>(consumedKey)) !== undefined)
319
+ throw new ActionInteractionError(
320
+ 409,
321
+ 'dispatch_consumed',
322
+ 'The reserved operation was already dispatched; reconcile its outcome',
323
+ );
324
+ await tx.put(consumedKey, true);
325
+ return { allowed: true, managed: true };
326
+ });
327
+ }
328
+
329
+ async read(id: string, sessionId: string): Promise<Interaction> {
330
+ return this.readFrom(this.storage, id, sessionId);
331
+ }
332
+
333
+ /** The session's newest records, oldest first, read through its own index. */
334
+ async list(sessionId: string): Promise<Interaction[]> {
335
+ const ids = await this.sessionIds(sessionId, LIST_LIMIT);
336
+ const stored = await this.getMany(
337
+ ids.flatMap((id) => [`${PREFIX}${id}`, `${CHUNK_COUNT_PREFIX}${id}`]),
338
+ );
339
+ const result: Interaction[] = [];
340
+ let bytes = 0;
341
+ for (const id of ids) {
342
+ // One unreadable record is left out instead of failing the list.
343
+ const parsed = InteractionSchema.safeParse(stored.get(`${PREFIX}${id}`));
344
+ if (!parsed.success || parsed.data.sessionId !== sessionId) continue;
345
+ const record = stored.has(`${CHUNK_COUNT_PREFIX}${id}`)
346
+ ? await this.load(this.storage, id).catch(() => undefined)
347
+ : parsed.data;
348
+ if (!record) continue;
349
+ bytes += utf8Bytes(JSON.stringify(record));
350
+ if (bytes > LIST_MAX_BYTES)
351
+ throw new ActionInteractionError(
352
+ 413,
353
+ 'list_too_large',
354
+ 'Use reference listing and read individual interactions',
355
+ );
356
+ result.push(record);
357
+ }
358
+ return result;
359
+ }
360
+
361
+ /** The session's records without values, so large histories list cheaply. */
362
+ async listReferences(sessionId: string): Promise<InteractionReference[]> {
363
+ const ids = await this.sessionIds(sessionId, REFERENCE_LIST_LIMIT);
364
+ const stored = await this.getMany(ids.map((id) => `${PREFIX}${id}`));
365
+ return ids.flatMap((id) => {
366
+ const record = InteractionReferenceSchema.safeParse(
367
+ stored.get(`${PREFIX}${id}`),
368
+ );
369
+ return record.success && record.data.sessionId === sessionId
370
+ ? [record.data]
371
+ : [];
372
+ });
373
+ }
374
+
375
+ /** The session's newest record ids, oldest first. */
376
+ private async sessionIds(sessionId: string, limit: number) {
377
+ const index = await this.storage.list<string>({
378
+ prefix: `${SESSION_PREFIX}${await sha256Hex(sessionId)}:`,
379
+ reverse: true,
380
+ limit,
381
+ });
382
+ return Array.from(index.values()).reverse();
383
+ }
384
+
385
+ private async getMany(keys: string[]) {
386
+ const found = new Map<string, unknown>();
387
+ for (let i = 0; i < keys.length; i += STORAGE_BATCH)
388
+ for (const [key, value] of await this.storage.get<unknown>(
389
+ keys.slice(i, i + STORAGE_BATCH),
390
+ ))
391
+ found.set(key, value);
392
+ return found;
393
+ }
394
+
395
+ /**
396
+ * Retention for records past `expiresAt + RETAIN_MS`, a bounded batch per
397
+ * call. Superseded or abandoned drafts disappear with their owner key,
398
+ * indexes and request tombstones. A record whose operation reached the
399
+ * executor keeps its owner key, reservation and dispatch tombstone forever
400
+ * (they are what stops a duplicate claim), but loses its draft values.
401
+ */
402
+ async prune(): Promise<number> {
403
+ const due = await this.storage.list<string>({
404
+ prefix: EXPIRY_PREFIX,
405
+ end: `${EXPIRY_PREFIX}${sortable(this.now() - RETAIN_MS)}`,
406
+ limit: PRUNE_BATCH,
407
+ });
408
+ if (due.size === 0) return 0;
409
+ await this.storage.transaction(async (tx) => {
410
+ for (const [expiryKey, id] of due) await this.forget(tx, id, expiryKey);
411
+ });
412
+ return due.size;
413
+ }
414
+
415
+ /** When the oldest record becomes due for retention, or null when none is kept. */
416
+ async nextRetentionAt(): Promise<number | null> {
417
+ const first = await this.storage.list<string>({
418
+ prefix: EXPIRY_PREFIX,
419
+ limit: 1,
420
+ });
421
+ for (const key of first.keys()) {
422
+ const expiresAt = Number(key.slice(EXPIRY_PREFIX.length).split(':')[0]);
423
+ if (Number.isFinite(expiresAt)) return expiresAt + RETAIN_MS;
424
+ }
425
+ return null;
426
+ }
427
+
428
+ /**
429
+ * A deleted chat takes its drafts with it, by the same rules as
430
+ * retention: a dispatched claim keeps only its tombstones. Bounded per
431
+ * call; the caller repeats until it returns 0.
432
+ */
433
+ async forgetSession(sessionId: string): Promise<number> {
434
+ const index = await this.storage.list<string>({
435
+ prefix: `${SESSION_PREFIX}${await sha256Hex(sessionId)}:`,
436
+ limit: PRUNE_BATCH,
437
+ });
438
+ if (index.size === 0) return 0;
439
+ await this.storage.transaction(async (tx) => {
440
+ for (const [indexKey, id] of index) {
441
+ const record = await this.recordFrom(tx, id).catch(() => undefined);
442
+ await this.forget(tx, id, record ? expiryIndexKey(record) : undefined);
443
+ // A kept tombstone leaves the deleted chat's listing too.
444
+ await tx.delete(indexKey);
445
+ }
446
+ });
447
+ return index.size;
448
+ }
449
+
450
+ /**
451
+ * Removes a record and everything that points at it, except what stops a
452
+ * duplicate claim: a dispatched record keeps its owner key, reservation and
453
+ * dispatch tombstone, and only loses its values and user-edit list.
454
+ */
455
+ private async forget(tx: Storage, id: string, expiryKey?: string) {
456
+ // Only prepare requests are indexed per record (edits keep their
457
+ // replay window on the record itself), so these pages stay small.
458
+ for (;;) {
459
+ const requests = await tx.list<string>({
460
+ prefix: `${REQUESTS_OF_PREFIX}${id}:`,
461
+ limit: STORAGE_BATCH,
462
+ });
463
+ if (requests.size === 0) break;
464
+ await deleteKeys(tx, [
465
+ ...requests.keys(),
466
+ ...Array.from(requests.values(), (r) => `${REQUEST_PREFIX}${r}`),
467
+ ]);
468
+ }
469
+ const doomed = [...(expiryKey ? [expiryKey] : []), `${RECENT_PREFIX}${id}`];
470
+ let record: Interaction | undefined;
471
+ try {
472
+ record = await this.recordFrom(tx, id);
473
+ } catch (error) {
474
+ // An unreadable record is kept untouched (it may be what stops a
475
+ // duplicate claim) and leaves the retention queue, so one bad record
476
+ // never stalls the pass.
477
+ if (
478
+ error instanceof ActionInteractionError &&
479
+ error.code === 'record_unreadable'
480
+ ) {
481
+ console.warn(`[action-interactions] kept unreadable record ${id}`);
482
+ await deleteKeys(tx, doomed);
483
+ return;
484
+ }
485
+ throw error;
486
+ }
487
+ const chunks = record ? await this.chunkKeys(tx, id) : [];
488
+ if (record && (await this.ownerState(tx, record)) === 'blocked') {
489
+ if (
490
+ Object.keys(record.values).length ||
491
+ record.updatedByUser.length ||
492
+ chunks.length
493
+ )
494
+ await tx.put(`${PREFIX}${id}`, {
495
+ ...record,
496
+ values: {},
497
+ updatedByUser: [],
498
+ revision: record.revision + 1,
499
+ } satisfies Interaction);
500
+ doomed.push(...chunks);
501
+ } else if (record) {
502
+ doomed.push(`${PREFIX}${id}`, await sessionIndexKey(record), ...chunks);
503
+ const ownerKey = await ownerStorageKey(record.owner, record.sessionId);
504
+ if ((await tx.get<string>(ownerKey)) === id) doomed.push(ownerKey);
505
+ }
506
+ await deleteKeys(tx, doomed);
507
+ }
508
+
509
+ private async recordFrom(
510
+ storage: Pick<DurableObjectStorage, 'get'>,
511
+ id: string,
512
+ ): Promise<Interaction | undefined> {
513
+ const raw = await storage.get<unknown>(`${PREFIX}${id}`);
514
+ if (raw === undefined) return undefined;
515
+ const record = InteractionSchema.safeParse(raw);
516
+ if (!record.success) throw unreadableRecord();
517
+ return record.data;
518
+ }
519
+
520
+ private async ownerState(
521
+ storage: Pick<DurableObjectStorage, 'get'>,
522
+ record: Interaction,
523
+ ): Promise<OwnerState> {
524
+ if (record.status === 'observed' || record.status === 'partial')
525
+ return 'blocked';
526
+ if (record.reservation && (await this.dispatched(storage, record)))
527
+ return 'blocked';
528
+ if (
529
+ record.expiresAt > this.now() &&
530
+ (record.status === 'draft' || record.status === 'reserved')
531
+ )
532
+ return 'live';
533
+ return 'stale';
534
+ }
535
+
536
+ /** Whether the record's operation passed the dispatch check and was not released since. */
537
+ private async dispatched(
538
+ storage: Pick<DurableObjectStorage, 'get'>,
539
+ record: Interaction,
540
+ ): Promise<boolean> {
541
+ return (
542
+ !!record.reservation &&
543
+ (await storage.get<true | 'released'>(
544
+ `${DISPATCH_PREFIX}${record.reservation.operationKey}`,
545
+ )) === true
546
+ );
547
+ }
548
+
549
+ /** Ends a draft that can no longer dispatch; its card shows why. */
550
+ private async supersede(storage: Storage, record: Interaction) {
551
+ if (record.status === 'cancelled') return record;
552
+ const ended: Interaction = {
553
+ ...record,
554
+ status: 'cancelled',
555
+ cancelReason: 'superseded',
556
+ revision: record.revision + 1,
557
+ };
558
+ await storage.put(`${PREFIX}${record.id}`, ended);
559
+ return ended;
560
+ }
561
+
562
+ /** The record with its values, reassembled when `save` split them into chunks. */
563
+ private async load(
564
+ storage: Pick<DurableObjectStorage, 'get'>,
565
+ id: string,
566
+ ): Promise<Interaction | undefined> {
567
+ const record = await this.recordFrom(storage, id);
568
+ if (!record) return undefined;
569
+ const keys = (await this.chunkKeys(storage, id)).slice(1);
570
+ if (keys.length === 0) return record;
571
+ const parts: string[] = [];
572
+ for (let i = 0; i < keys.length; i += STORAGE_BATCH) {
573
+ const batch = keys.slice(i, i + STORAGE_BATCH);
574
+ const found = await storage.get<string>(batch);
575
+ for (const key of batch) {
576
+ const part = found.get(key);
577
+ if (part === undefined) throw unreadableRecord();
578
+ parts.push(part);
579
+ }
580
+ }
581
+ let values: unknown;
582
+ try {
583
+ values = JSON.parse(parts.join(''));
584
+ } catch {
585
+ throw unreadableRecord();
586
+ }
587
+ const full = InteractionSchema.safeParse({ ...record, values });
588
+ if (!full.success) throw unreadableRecord();
589
+ return full.data;
590
+ }
591
+
592
+ /** The chunk count key, then each chunk key, of a record `save` split; empty otherwise. */
593
+ private async chunkKeys(
594
+ storage: Pick<DurableObjectStorage, 'get'>,
595
+ id: string,
596
+ ): Promise<string[]> {
597
+ const count = await storage.get<number>(`${CHUNK_COUNT_PREFIX}${id}`);
598
+ if (!count) return [];
599
+ return [
600
+ `${CHUNK_COUNT_PREFIX}${id}`,
601
+ ...Array.from(
602
+ { length: count },
603
+ (_, index) => `${CHUNK_PREFIX}${id}:${index}`,
604
+ ),
605
+ ];
606
+ }
607
+
608
+ private async save(
609
+ storage: Pick<DurableObjectStorage, 'put'>,
610
+ record: Interaction,
611
+ ): Promise<void> {
612
+ const totalLimit =
613
+ record.owner.kind === 'approval' && record.owner.family === 'domain-write'
614
+ ? 2 * 1024 * 1024
615
+ : 96 * 1024;
616
+ if (utf8Bytes(JSON.stringify(record)) > totalLimit)
617
+ throw new ActionInteractionError(
618
+ 413,
619
+ 'too_large',
620
+ 'Interaction exceeds the owner record limit',
621
+ );
622
+ const payload = JSON.stringify(record.values);
623
+ if (utf8Bytes(JSON.stringify(record)) <= 96 * 1024) {
624
+ await storage.put(`${PREFIX}${record.id}`, record);
625
+ return;
626
+ }
627
+ // 24K UTF-16 units always encode below the 128KiB DO value ceiling.
628
+ const chunks = Math.ceil(payload.length / (24 * 1024));
629
+ for (let index = 0; index < chunks; index++)
630
+ await storage.put(
631
+ `${CHUNK_PREFIX}${record.id}:${index}`,
632
+ payload.slice(index * 24 * 1024, (index + 1) * 24 * 1024),
633
+ );
634
+ await storage.put(`${CHUNK_COUNT_PREFIX}${record.id}`, chunks);
635
+ await storage.put(`${PREFIX}${record.id}`, { ...record, values: {} });
636
+ }
637
+
638
+ private async readFrom(
639
+ storage: Pick<DurableObjectStorage, 'get'>,
640
+ id: string,
641
+ sessionId: string,
642
+ ): Promise<Interaction> {
643
+ const record = await this.load(storage, id);
644
+ if (!record || record.sessionId !== sessionId)
645
+ throw new ActionInteractionError(
646
+ 404,
647
+ 'not_found',
648
+ 'Interaction not found',
649
+ );
650
+ return record;
651
+ }
652
+
653
+ async command(command: Command): Promise<InteractionResult> {
654
+ const result = await this.apply(command);
655
+ if (command.kind === 'prepare' && !result.replayed)
656
+ // Retention rides on new drafts: reads stay read-only and a user who
657
+ // never prepares again has nothing new to retain.
658
+ await this.prune().catch((error: unknown) =>
659
+ console.warn(
660
+ `[action-interactions] retention skipped: ${error instanceof Error ? error.message : String(error)}`,
661
+ ),
662
+ );
663
+ return result;
664
+ }
665
+
666
+ private async apply(command: Command): Promise<InteractionResult> {
667
+ const requestId = command.input.requestId;
668
+ const requestKey = `${REQUEST_PREFIX}${requestId}`;
669
+ const fingerprint = await sha256Hex(
670
+ canonicalJson(JSON.parse(JSON.stringify(command))),
671
+ );
672
+ // A prepare is remembered globally (it has no record id yet); an edit
673
+ // only in its record's short replay window.
674
+ const recentKey = (id: string) => `${RECENT_PREFIX}${id}`;
675
+ const remember = async (tx: Storage, id: string) => {
676
+ if (command.kind === 'prepare') {
677
+ await tx.put({
678
+ [requestKey]: { fingerprint, id },
679
+ [`${REQUESTS_OF_PREFIX}${id}:${requestId}`]: requestId,
680
+ });
681
+ return;
682
+ }
683
+ const recent = (await tx.get<RecentRequest[]>(recentKey(id))) ?? [];
684
+ await tx.put(
685
+ recentKey(id),
686
+ [...recent, { requestId, fingerprint }].slice(-RECENT_LIMIT),
687
+ );
688
+ };
689
+ return this.storage.transaction(async (tx) => {
690
+ const previous =
691
+ command.kind === 'prepare'
692
+ ? await tx.get<{ fingerprint: string; id: string }>(requestKey)
693
+ : (await tx.get<RecentRequest[]>(recentKey(command.id)))
694
+ ?.filter((r) => r.requestId === requestId)
695
+ .map((r) => ({ fingerprint: r.fingerprint, id: command.id }))[0];
696
+ if (previous) {
697
+ if (previous.fingerprint !== fingerprint)
698
+ throw new ActionInteractionError(
699
+ 409,
700
+ 'request_conflict',
701
+ 'Request ID already used with different input',
702
+ );
703
+ return {
704
+ interaction: await this.readFrom(
705
+ tx,
706
+ previous.id,
707
+ command.input.sessionId,
708
+ ),
709
+ replayed: true,
710
+ };
711
+ }
712
+ let record: Interaction;
713
+ if (command.kind === 'prepare') {
714
+ const input = command.input;
715
+ if (
716
+ input.owner.kind === 'owner-reference' &&
717
+ Object.keys(input.values).length !== 0
718
+ )
719
+ throw new ActionInteractionError(
720
+ 400,
721
+ 'reference_values_forbidden',
722
+ 'Owner references contain locators only',
723
+ );
724
+ if (
725
+ 'accountDid' in input.owner &&
726
+ input.owner.accountDid !== this.context.userDid
727
+ )
728
+ throw new ActionInteractionError(
729
+ 403,
730
+ 'account_mismatch',
731
+ 'Claim account differs from authenticated user',
732
+ );
733
+ assertRecordSize(input.values, [], input.owner);
734
+ if (
735
+ input.owner.kind === 'approval' &&
736
+ (!input.values.request ||
737
+ typeof input.values.request !== 'object' ||
738
+ Array.isArray(input.values.request) ||
739
+ Object.keys(input.values).length !== 1)
740
+ )
741
+ throw new ActionInteractionError(
742
+ 400,
743
+ 'invalid_request',
744
+ 'Approval values must contain exactly one request object',
745
+ );
746
+ const ownerKey = await ownerStorageKey(input.owner, input.sessionId);
747
+ if (
748
+ (input.owner.kind === 'flow-claim' ||
749
+ input.owner.kind === 'flow-claim-block' ||
750
+ input.owner.kind === 'flow-bid') &&
751
+ (await tx.get<number>(manualMarkerKey(ownerKey))) !== undefined
752
+ )
753
+ // The ordinary form already passed its dispatch check for this
754
+ // step; a card could file the same claim a second time.
755
+ throw new ActionInteractionError(
756
+ 409,
757
+ 'dispatch_consumed',
758
+ 'This claim step was submitted from the form; check the claims list before preparing another',
759
+ );
760
+ const existingId = await tx.get<string>(ownerKey);
761
+ const existing = existingId
762
+ ? await this.recordFrom(tx, existingId)
763
+ : undefined;
764
+ if (existing) {
765
+ const sameOwner =
766
+ canonicalJson(existing.owner) === canonicalJson(input.owner);
767
+ if (
768
+ input.owner.kind === 'approval' &&
769
+ sameOwner &&
770
+ canonicalJson((await this.load(tx, existing.id))?.values ?? {}) !==
771
+ canonicalJson(input.values)
772
+ )
773
+ throw new ActionInteractionError(
774
+ 409,
775
+ 'request_conflict',
776
+ 'Approval request identity already binds different values',
777
+ );
778
+ const sameSession = existing.sessionId === input.sessionId;
779
+ const state = await this.ownerState(tx, existing);
780
+ const inFlight = state === 'live' && existing.status === 'reserved';
781
+ if (
782
+ state === 'blocked' ||
783
+ inFlight ||
784
+ (state === 'live' && !sameSession)
785
+ ) {
786
+ if (sameOwner && sameSession) {
787
+ await remember(tx, existing.id);
788
+ return { interaction: existing, replayed: true };
789
+ }
790
+ if (state === 'blocked')
791
+ throw new ActionInteractionError(
792
+ 409,
793
+ 'dispatch_consumed',
794
+ 'This claim step already has a Companion submission; check the claims list before preparing another',
795
+ );
796
+ if (!sameSession)
797
+ throw new ActionInteractionError(
798
+ 409,
799
+ 'target_in_use',
800
+ 'This claim step has an open card in another chat',
801
+ );
802
+ throw new ActionInteractionError(
803
+ 409,
804
+ 'target_changed',
805
+ 'The form changed while its card was being submitted; reopen it through the Flow owner',
806
+ );
807
+ }
808
+ if (state === 'live' && sameOwner) {
809
+ await remember(tx, existing.id);
810
+ return { interaction: existing, replayed: true };
811
+ }
812
+ // A stale draft, or this chat's own draft for a form whose
813
+ // collection or schema has since changed: it can no longer
814
+ // dispatch, so a fresh draft takes its place.
815
+ await this.supersede(tx, existing);
816
+ }
817
+ // Only cards created within one draft lifetime count: kept records of
818
+ // sent claims and expired cards awaiting retention never lock a chat.
819
+ const sessionPrefix = `${SESSION_PREFIX}${await sha256Hex(input.sessionId)}:`;
820
+ const recent = await tx.list<string>({
821
+ prefix: sessionPrefix,
822
+ start: `${sessionPrefix}${sortable(this.now() - TTL_MS)}`,
823
+ limit: SESSION_DRAFT_LIMIT,
824
+ });
825
+ if (recent.size >= SESSION_DRAFT_LIMIT)
826
+ throw new ActionInteractionError(
827
+ 409,
828
+ 'too_many_drafts',
829
+ `This chat created ${SESSION_DRAFT_LIMIT} Agent cards in the last week; start a new chat`,
830
+ );
831
+ record = InteractionSchema.parse({
832
+ version: 1,
833
+ id: crypto.randomUUID(),
834
+ revision: 1,
835
+ sessionId: input.sessionId,
836
+ oracleDid: this.context.oracleDid,
837
+ owner: input.owner,
838
+ values: input.values,
839
+ updatedByUser: [],
840
+ status: 'draft',
841
+ createdAt: this.now(),
842
+ expiresAt: this.now() + TTL_MS,
843
+ });
844
+ await tx.put({
845
+ [ownerKey]: record.id,
846
+ [await sessionIndexKey(record)]: record.id,
847
+ [expiryIndexKey(record)]: record.id,
848
+ });
849
+ } else {
850
+ record = await this.readFrom(tx, command.id, command.input.sessionId);
851
+ if (record.owner.kind === 'owner-reference')
852
+ throw new ActionInteractionError(
853
+ 409,
854
+ 'reference_read_only',
855
+ 'Native owners retain draft, decision and execution authority',
856
+ );
857
+ // A host may report the outcome of a reservation made just before
858
+ // expiry; every other mutation of an expired draft is refused.
859
+ if (command.kind !== 'observe' && record.expiresAt <= this.now())
860
+ throw new ActionInteractionError(
861
+ 410,
862
+ 'expired',
863
+ 'Interaction has expired',
864
+ );
865
+ if (record.revision !== command.input.expectedRevision)
866
+ throw new ActionInteractionError(
867
+ 409,
868
+ 'stale_revision',
869
+ 'Interaction revision changed',
870
+ );
871
+ switch (command.kind) {
872
+ case 'update': {
873
+ const cancel = command.input.kind === 'cancel';
874
+ // A reservation whose dispatch never happened (a lost reserve
875
+ // acknowledgement, a closed tab) can still be cancelled; once
876
+ // dispatched only its outcome may be recorded.
877
+ const editable =
878
+ record.status === 'draft' ||
879
+ (cancel &&
880
+ record.status === 'reserved' &&
881
+ !(await this.dispatched(tx, record)));
882
+ if (!editable)
883
+ throw new ActionInteractionError(
884
+ 409,
885
+ 'not_editable',
886
+ 'Reserved or terminal interactions cannot be edited',
887
+ );
888
+ if (command.input.kind === 'cancel')
889
+ record = { ...record, status: 'cancelled', cancelReason: 'user' };
890
+ else {
891
+ if (record.owner.kind === 'approval')
892
+ throw new ActionInteractionError(
893
+ 409,
894
+ 'immutable_request',
895
+ 'Approval requests cannot be changed after preparation',
896
+ );
897
+ const input = command.input;
898
+ const patch = Object.fromEntries(
899
+ Object.entries(input.patch).filter(
900
+ ([key]) =>
901
+ input.source === 'user' ||
902
+ !record.updatedByUser.includes(key),
903
+ ),
904
+ );
905
+ const removed = (input.remove ?? []).filter(
906
+ (name) =>
907
+ input.source === 'user' ||
908
+ !record.updatedByUser.includes(name),
909
+ );
910
+ const merged = { ...record.values, ...patch };
911
+ for (const name of removed) delete merged[name];
912
+ const updatedByUser =
913
+ input.source === 'user'
914
+ ? Array.from(
915
+ new Set([
916
+ ...record.updatedByUser,
917
+ ...Object.keys(patch),
918
+ ...removed,
919
+ ]),
920
+ )
921
+ : record.updatedByUser;
922
+ assertRecordSize(merged, updatedByUser, record.owner);
923
+ record = { ...record, values: merged, updatedByUser };
924
+ }
925
+ break;
926
+ }
927
+ case 'reserve': {
928
+ if (
929
+ ![
930
+ 'flow-claim',
931
+ 'flow-claim-block',
932
+ 'flow-bid',
933
+ 'approval',
934
+ ].includes(record.owner.kind) ||
935
+ record.status !== 'draft'
936
+ )
937
+ throw new ActionInteractionError(
938
+ 409,
939
+ 'not_dispatchable',
940
+ 'Only an unreserved executable draft can be reserved',
941
+ );
942
+ if (
943
+ (await interactionValuesDigest(record.values)) !==
944
+ command.input.valuesDigest
945
+ )
946
+ throw new ActionInteractionError(
947
+ 409,
948
+ 'values_changed',
949
+ 'Reviewed values do not match draft',
950
+ );
951
+ record = {
952
+ ...record,
953
+ status: 'reserved',
954
+ reservation: {
955
+ operationKey: crypto.randomUUID(),
956
+ valuesDigest: command.input.valuesDigest,
957
+ },
958
+ };
959
+ break;
960
+ }
961
+ case 'observe': {
962
+ const outcome = command.input.outcome;
963
+ const receipt =
964
+ 'receipt' in command.input ? command.input.receipt : undefined;
965
+ if (
966
+ record.receipt &&
967
+ receipt &&
968
+ !extendsReceipt(record.receipt, receipt)
969
+ )
970
+ throw new ActionInteractionError(
971
+ 409,
972
+ 'receipt_conflict',
973
+ 'Receipt checkpoints cannot remove or change acknowledged effects',
974
+ );
975
+ if (receipt && utf8Bytes(canonicalJson(receipt)) > 64 * 1024)
976
+ throw new ActionInteractionError(
977
+ 413,
978
+ 'too_large',
979
+ 'Receipt exceeds 64 KiB',
980
+ );
981
+ if (
982
+ !record.reservation ||
983
+ record.reservation.operationKey !== command.input.operationKey ||
984
+ !(outcome === 'not-submitted'
985
+ ? record.status === 'reserved'
986
+ : record.status === 'reserved' ||
987
+ record.status === 'outcome-unknown' ||
988
+ record.status === 'partial')
989
+ )
990
+ throw new ActionInteractionError(
991
+ 409,
992
+ 'reservation_mismatch',
993
+ 'No matching pending reservation',
994
+ );
995
+ if (command.input.outcome === 'not-submitted') {
996
+ // The executor refused before sending anything: the card ends
997
+ // and the step is free for the form or a new card.
998
+ await tx.put(
999
+ `${DISPATCH_PREFIX}${record.reservation.operationKey}`,
1000
+ 'released',
1001
+ );
1002
+ record = {
1003
+ ...record,
1004
+ status: 'cancelled',
1005
+ cancelReason: 'not-submitted',
1006
+ };
1007
+ } else
1008
+ record =
1009
+ command.input.outcome === 'unknown'
1010
+ ? {
1011
+ ...record,
1012
+ status: 'outcome-unknown',
1013
+ ...(receipt ? { receipt } : {}),
1014
+ }
1015
+ : {
1016
+ ...record,
1017
+ status:
1018
+ command.input.outcome === 'partial'
1019
+ ? 'partial'
1020
+ : 'observed',
1021
+ ...(receipt ? { receipt } : {}),
1022
+ ownerExecutionRef: command.input.ownerExecutionRef,
1023
+ };
1024
+ break;
1025
+ }
1026
+ }
1027
+ record = { ...record, revision: record.revision + 1 };
1028
+ }
1029
+ const result: InteractionResult = {
1030
+ interaction: record,
1031
+ replayed: false,
1032
+ };
1033
+ await this.save(tx, record);
1034
+ await remember(tx, record.id);
1035
+ return result;
1036
+ });
1037
+ }
1038
+ }