@loomwork/replay-vm 0.0.0-stage → 0.1.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.
@@ -0,0 +1,1563 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EVENT_SCHEMA_VERSION = exports.BUILTIN_DIMENSION_NAMES = exports.CUSTOM_DIMENSION_CEILING = exports.CUSTOM_DIMENSION_MAX = exports.CUSTOM_DIMENSION_PATTERN = exports.QUARANTINE_FOLD_CAP = exports.INBOX_DEDUPE_CAP = exports.KERNEL_STAMPED_EVENT_TYPES = exports.FAILED_WHILE_PARKED = exports.FLOW_DIVERGED_GATE = exports.FLOW_EMIT_DIGEST = void 0;
4
+ exports.resetFoldOwnership = resetFoldOwnership;
5
+ exports.useFoldOwnership = useFoldOwnership;
6
+ exports.countFlowEmit = countFlowEmit;
7
+ exports.flowEmitCount = flowEmitCount;
8
+ exports.getHandler = getHandler;
9
+ exports.knownEventTypes = knownEventTypes;
10
+ // Copy-on-write a single frame into the frames container, with per-fold ownership
11
+ // (mirrors `appendAudit` below). The first step event of a fold pass spreads the
12
+ // caller's input container once — O(S) key refs, untouched frames shared — and marks
13
+ // the result fold-owned; every later step event in the same pass writes in place onto
14
+ // that owned container (O(1)). `foldEvents` resets ownership at the start of each call,
15
+ // and the input state (snapshot / prior state) is never mutated, so copy-on-write —
16
+ // and the async snapshot saves that hold prior states — are preserved. Byte-identical
17
+ // output; per-replay frame allocations drop from O(S²) to O(S). Handlers never mutate a
18
+ // frame in place; they always write a new frame object, so sharing untouched frames is safe.
19
+ //
20
+ // Uses the assignment form (`frames[stepId] = frame`), so reserved keys must be rejected
21
+ // (see assertSafeKey) — unlike a computed-key object literal, `frames["__proto__"] = …`
22
+ // would invoke the prototype setter and corrupt this execution's own fold.
23
+ function setFrame(frames, stepId, frame) {
24
+ assertSafeKey(stepId);
25
+ if (foldOwned.has(frames)) {
26
+ frames[stepId] = frame;
27
+ return frames;
28
+ }
29
+ const next = { ...frames, [stepId]: frame };
30
+ foldOwned.add(next);
31
+ return next;
32
+ }
33
+ // Defense-in-depth: reject prototype-related keys as fold container keys. They are never
34
+ // valid stepIds/decisionIds, and the assignment form (`frames[key] = …`) would otherwise
35
+ // invoke the prototype setter / shadow `constructor` and corrupt that execution's own fold.
36
+ // (No global pollution is reachable — see the audit — but fail-closed on a reserved key is
37
+ // cheap and clear.)
38
+ const RESERVED_FOLD_KEYS = new Set(["__proto__", "constructor", "prototype"]);
39
+ function assertSafeKey(key) {
40
+ if (RESERVED_FOLD_KEYS.has(key)) {
41
+ throw new Error(`fold: reserved key "${key}" is not a valid identifier`);
42
+ }
43
+ }
44
+ // ── O(1)-amortized audit-list append (per-fold ownership) ────────────────────
45
+ //
46
+ // The audit handlers below append to durable lists (`_outcomes`, `_timers`, …). A naive
47
+ // `[...existing, item]` per event makes a full-log fold O(k²) for k events of one type.
48
+ // Instead we track the arrays THIS fold pass created in `foldOwned` and push in place onto
49
+ // those, copying only when the array came from the caller's input state (snapshot / prior
50
+ // state) — which we must never mutate. `foldEvents` resets the set at the start of every
51
+ // call; folding is synchronous and non-reentrant, so a module-level set is safe, and the
52
+ // returned state's arrays are always fold-owned (never aliased into the input). Output is
53
+ // byte-identical; only intermediate allocations drop from O(k²) to O(k).
54
+ let foldOwned = new WeakSet();
55
+ function resetFoldOwnership() {
56
+ foldOwned = new WeakSet();
57
+ }
58
+ /**
59
+ * R1a — install the ownership set a fold pass writes against. A one-shot fold (`foldEvents`
60
+ * with no set) starts empty every call. A LIVE fold (`ReplayVM.applyEvent`) hands in the set
61
+ * its VM keeps between commits, so containers the VM itself created stay writable in place
62
+ * across commits (O(1) per event) instead of being re-copied by every single-event fold
63
+ * (O(size) per event — quadratic over a run). The VM must drop the set the moment it lets a
64
+ * state escape to a caller (`ReplayVM.getState`), so an escaped state is never written to.
65
+ * Folding is synchronous and non-reentrant, so swapping the module-level reference is safe.
66
+ */
67
+ function useFoldOwnership(owned) {
68
+ foldOwned = owned;
69
+ }
70
+ function appendAudit(existing, item) {
71
+ if (existing !== undefined && foldOwned.has(existing)) {
72
+ existing.push(item);
73
+ return existing;
74
+ }
75
+ const next = existing === undefined ? [item] : [...existing, item];
76
+ foldOwned.add(next);
77
+ return next;
78
+ }
79
+ // ── 1.19.0 (ADR 0102) — `_flowEmits`: stamped flow-level emits counted per digest ──
80
+ //
81
+ // Shape: a fixed-depth, 16-way trie over the digest's LAST three hex digits (FNV-1a's
82
+ // best-mixed bits). Inner nodes are 16-slot arrays (`null` = empty), leaves are
83
+ // `{ [digest]: count }`:
84
+ //
85
+ // _flowEmits[hex(d[15])][hex(d[14])][hex(d[13])][d] = count
86
+ //
87
+ // Why a trie and not a flat `{ [digest]: count }`: a live commit is its own fold pass, so
88
+ // copy-on-write must copy what it touches, and a flat map costs O(k) per emit (worse in
89
+ // practice: a many-key object is a dictionary, and its spread is slow). The trie copies
90
+ // the root-to-leaf path — three 16-slot arrays (`slice`, no hidden-class churn) plus one
91
+ // leaf of ~k/4096 entries — O(1) for any realistic k, still plain JSON (it rides snapshots
92
+ // and survives `truncatePrefix` compaction), and O(1) amortized within a multi-event
93
+ // replay pass, where fold-owned nodes are updated in place (`appendAudit`'s ownership
94
+ // rule). Size is one leaf entry per DISTINCT stamped emit plus the touched inner arrays.
95
+ exports.FLOW_EMIT_DIGEST = /^[0-9a-f]{16}$/;
96
+ const TRIE_PATH = [15, 14, 13];
97
+ function ownedNode(existing) {
98
+ if (existing != null && foldOwned.has(existing))
99
+ return existing;
100
+ const next = existing == null ? new Array(16).fill(null) : existing.slice();
101
+ foldOwned.add(next);
102
+ return next;
103
+ }
104
+ function ownedLeaf(existing) {
105
+ if (existing != null && foldOwned.has(existing))
106
+ return existing;
107
+ const next = existing == null ? {} : { ...existing };
108
+ foldOwned.add(next);
109
+ return next;
110
+ }
111
+ function countFlowEmit(variables, digest) {
112
+ const root = variables._flowEmits;
113
+ const nextRoot = ownedNode(root);
114
+ const a = Number.parseInt(digest[15], 16);
115
+ const b = Number.parseInt(digest[14], 16);
116
+ const c = Number.parseInt(digest[13], 16);
117
+ const mid = ownedNode(nextRoot[a]);
118
+ nextRoot[a] = mid;
119
+ const low = ownedNode(mid[b]);
120
+ mid[b] = low;
121
+ const leaf = ownedLeaf(low[c]);
122
+ low[c] = leaf;
123
+ leaf[digest] = (leaf[digest] ?? 0) + 1;
124
+ // A fold-owned root is already inside a fold-created `variables`; only a fresh root
125
+ // needs a new `variables` object (never mutate the caller's).
126
+ return nextRoot === root ? variables : { ...variables, _flowEmits: nextRoot };
127
+ }
128
+ /** How many stamped flow-level emits with this digest the folded log holds. */
129
+ function flowEmitCount(variables, digest) {
130
+ let node = variables._flowEmits;
131
+ for (const i of TRIE_PATH) {
132
+ if (!Array.isArray(node))
133
+ return 0;
134
+ node = node[Number.parseInt(digest[i], 16)];
135
+ }
136
+ if (node == null || typeof node !== "object" || !Object.hasOwn(node, digest))
137
+ return 0;
138
+ const count = node[digest];
139
+ return typeof count === "number" ? count : 0;
140
+ }
141
+ /**
142
+ * 1.20.0 (ADR 0104) — a `null` EXECUTION_RESUMED that answers no suspend point re-opens the
143
+ * execution and records NO gate passthrough. The kernel writes one on `resumeAs` onto a
144
+ * terminal execution and on an in-doubt verdict (whose answer is recorded as a decision):
145
+ * `null` because every store round-trips it (SQLite's payload column is NOT NULL, so an
146
+ * absent payload cannot be written). Keyed on the state being answered, not on `null` alone,
147
+ * so a caller's `null` approval of a real gate is still that gate's answer. The one pre-0104 log
148
+ * this changes is one where a caller resumed `null` into either position: that entry answered
149
+ * no suspend point and would have been served to the NEXT gate as `null` — the stray this fixes.
150
+ */
151
+ const IN_DOUBT_GATE = "EffectInDoubtResolved";
152
+ /**
153
+ * ADR 0113 (R7 fix round 1) — the reserved gate a REFUSED re-run parks on. Like the in-doubt gate it
154
+ * is answered by the kernel's own status-only `null` resume (a re-drive after the code is fixed):
155
+ * the answer must NOT enter `_resumePayloads`, or the body's next gate would take it as its approval.
156
+ */
157
+ exports.FLOW_DIVERGED_GATE = "__loom_diverged__";
158
+ /**
159
+ * 1.21.0 (ADR 0105) — reserved fold key: `true` while the execution is PARKED and a
160
+ * `STEP_FAILED` has landed since it parked (a parallel sibling failing after the flow reached
161
+ * its gate). Set only by that event in that state; removed by whatever ends the park — the next
162
+ * `EXECUTION_SUSPENDED` or `EXECUTION_RESUMED`, or a terminal `EXECUTION_COMPLETED` /
163
+ * `_FAILED` / `_CANCELLED`; absent otherwise, so a log without such a failure never gains it.
164
+ *
165
+ * It exists to keep one rule the 1.20.0 fold had implicitly. 1.20.0 folded that failure to
166
+ * "failed", so a following `null` resume was status-only (it answered no gate, and a 1.20.0
167
+ * `resumeAs` wrote exactly that). Since 1.21.0 the execution stays `suspended`; without this
168
+ * key the same `null` would become the gate's answer, and a replay's outcome would depend on
169
+ * whether it was seeded from a 1.20.0 snapshot — breaking snapshot + tail ≡ full log. So a
170
+ * `null` resume in this position stays status-only (fail-closed: the gate re-parks rather than
171
+ * passing without an approval). The cost: a caller's explicit `null` answer to such a gate is
172
+ * not recorded as its answer; the gate re-parks and takes the next non-null one.
173
+ */
174
+ exports.FAILED_WHILE_PARKED = "_failedWhileParked";
175
+ function isStatusOnlyResume(state, payload) {
176
+ return (payload === null &&
177
+ (state.status !== "suspended" ||
178
+ state.suspendedOn?.on === IN_DOUBT_GATE ||
179
+ state.suspendedOn?.on === exports.FLOW_DIVERGED_GATE ||
180
+ state.variables[exports.FAILED_WHILE_PARKED] === true));
181
+ }
182
+ /** `variables` without the {@link FAILED_WHILE_PARKED} key (the same object when absent). */
183
+ function withoutFailedWhileParked(variables) {
184
+ if (!Object.hasOwn(variables, exports.FAILED_WHILE_PARKED))
185
+ return variables;
186
+ const { [exports.FAILED_WHILE_PARKED]: _cleared, ...rest } = variables;
187
+ return rest;
188
+ }
189
+ // ── 1.23.0 (Wave 2, W2-0) — reserved shapes, folded only from what the kernel wrote ──────────
190
+ //
191
+ // The new kinds (`EXECUTION_CONTINUED`, `MESSAGE_CONSUMED`) and the PARALLEL_COMPLETED payload
192
+ // fold ONLY an envelope carrying `metadata.kernel === true`. An application event that already
193
+ // carries one of those names in an existing log therefore folds as the no-op it always was
194
+ // (byte-identically), even though a handler now exists and no kernel writer does yet (ADR 0110
195
+ // "Marker"; design §3). The journal stamps the marker for {@link KERNEL_STAMPED_EVENT_TYPES}.
196
+ // Four more shapes ride the same stamp but are stamped by their WRITER through commit
197
+ // `opts.metadata` (the kind also has an unstamped, userspace form, so the journal cannot stamp by
198
+ // type): a wait-addressed EXECUTION_RESUMED (ADR 0110 D3), an EXECUTION_SUSPENDED carrying
199
+ // `condition.waits` (D4 — the R2 writer adds `waits` AFTER the commit site's strip and stamps
200
+ // the event; an unstamped park's `waits` is dropped by the fold), and an OUTCOME_RECORDED
201
+ // carrying `recordedBy` / `late` (ADR 0116; the `recordOutcome` writer).
202
+ /** Kinds whose fold requires the kernel stamp, and which the journal stamps when it writes one. */
203
+ exports.KERNEL_STAMPED_EVENT_TYPES = new Set([
204
+ "EXECUTION_CONTINUED",
205
+ "MESSAGE_CONSUMED",
206
+ "PARALLEL_COMPLETED",
207
+ // 1.25.0 (ADR 0130 residuals D9): written by quarantineInbox / releaseInbox / auto-release (slice S5).
208
+ "INBOX_QUARANTINED",
209
+ "INBOX_RELEASED",
210
+ ]);
211
+ function isKernelWritten(event) {
212
+ return event.metadata?.kernel === true;
213
+ }
214
+ function isPlain(v) {
215
+ return v !== null && typeof v === "object" && !Array.isArray(v);
216
+ }
217
+ /** Every inbox dedupe window the fold keeps is capped here; the runtime may apply a smaller one. */
218
+ exports.INBOX_DEDUPE_CAP = 1024;
219
+ /**
220
+ * 1.25.0 (ADR 0130 residuals D9) — the fold keeps at most this many quarantined entries in total
221
+ * (`_quarantined`, across topics). A fold cannot read a runtime setting (the lesson of ADR 0110 D6),
222
+ * so the ceiling is a fold constant; the runtime's own `quarantineMax` (default 256) may be raised
223
+ * to it, never above. A fact that would exceed it is ignored.
224
+ */
225
+ exports.QUARANTINE_FOLD_CAP = 1024;
226
+ /** ADR 0115 D2 — custom metering dimension names, count and per-value ceiling. */
227
+ exports.CUSTOM_DIMENSION_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
228
+ exports.CUSTOM_DIMENSION_MAX = 32;
229
+ exports.CUSTOM_DIMENSION_CEILING = 1e15;
230
+ /** A fold-owned copy-on-write record (the same ownership rule as `_decisions`). */
231
+ function ownedRecord(prior) {
232
+ if (prior !== undefined && foldOwned.has(prior))
233
+ return prior;
234
+ const next = prior === undefined ? {} : { ...prior };
235
+ foldOwned.add(next);
236
+ return next;
237
+ }
238
+ function safeMapKey(key) {
239
+ return typeof key === "string" && key.length > 0 && !RESERVED_FOLD_KEYS.has(key);
240
+ }
241
+ /**
242
+ * ADR 0115 D2 — add the valid entries of a `custom` metering map onto the running one. Never
243
+ * throws: a bad name, a prototype name, a non-finite / negative / over-ceiling value, or the
244
+ * 33rd distinct name is skipped deterministically and the rest folds, so every replica folds the
245
+ * same state from a log it did not write the guard for. Returns `prior` when nothing changed.
246
+ */
247
+ function addCustomDimensions(prior, raw) {
248
+ if (!isPlain(raw))
249
+ return prior;
250
+ let next;
251
+ let names = prior === undefined ? 0 : Object.keys(prior).length;
252
+ for (const [name, v] of Object.entries(raw)) {
253
+ if (!exports.CUSTOM_DIMENSION_PATTERN.test(name) || RESERVED_FOLD_KEYS.has(name))
254
+ continue;
255
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > exports.CUSTOM_DIMENSION_CEILING) {
256
+ continue;
257
+ }
258
+ const known = (next ?? prior) !== undefined && Object.hasOwn((next ?? prior), name);
259
+ if (!known) {
260
+ if (names >= exports.CUSTOM_DIMENSION_MAX)
261
+ continue;
262
+ names++;
263
+ }
264
+ next ??= { ...prior };
265
+ next[name] = (Object.hasOwn(next, name) ? next[name] : 0) + v;
266
+ }
267
+ return next ?? prior;
268
+ }
269
+ /**
270
+ * ADR 0115 D3 — the valid entries of a decision's descriptive `estimatedCost` map (a name
271
+ * matching the dimension pattern or a built-in, a finite non-negative value, at most 32), or
272
+ * `undefined` when none survive. Never throws, so a log written without the guard folds the same
273
+ * on every replica; null-prototype so a caller-chosen name cannot reach a prototype member.
274
+ */
275
+ function sanitizeEstimatedCost(raw) {
276
+ if (!isPlain(raw))
277
+ return undefined;
278
+ let out;
279
+ let names = 0;
280
+ for (const [name, v] of Object.entries(raw)) {
281
+ if (names >= exports.CUSTOM_DIMENSION_MAX)
282
+ break;
283
+ if (!exports.BUILTIN_DIMENSION_NAMES.has(name)) {
284
+ if (!exports.CUSTOM_DIMENSION_PATTERN.test(name) || RESERVED_FOLD_KEYS.has(name))
285
+ continue;
286
+ }
287
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
288
+ continue;
289
+ out ??= {};
290
+ out[name] = v;
291
+ names++;
292
+ }
293
+ return out;
294
+ }
295
+ exports.BUILTIN_DIMENSION_NAMES = new Set([
296
+ "tokens",
297
+ "costUsd",
298
+ "ms",
299
+ "memBytes",
300
+ "wallMs",
301
+ "netBytes",
302
+ ]);
303
+ /** A validated `_resources` seed from `continuedFrom.carried.resources`; invalid parts are ignored. */
304
+ function seedResources(raw) {
305
+ if (!isPlain(raw))
306
+ return undefined;
307
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
308
+ const custom = addCustomDimensions(undefined, raw.custom);
309
+ const memBytes = num(raw.memBytes);
310
+ const wallMs = num(raw.wallMs);
311
+ const netBytes = num(raw.netBytes);
312
+ // A quota denial survives a rotation: the successor of an execution that exceeded its budget
313
+ // starts exceeded, with the reason it was given (a continue-as-new cannot reset a quota).
314
+ const exceeded = raw.exceeded === true;
315
+ const reason = exceeded && typeof raw.reason === "string" ? raw.reason : undefined;
316
+ return {
317
+ tokens: num(raw.tokens),
318
+ costUsd: num(raw.costUsd),
319
+ ms: num(raw.ms),
320
+ count: num(raw.count),
321
+ ...(memBytes > 0 && { memBytes }),
322
+ ...(wallMs > 0 && { wallMs }),
323
+ ...(netBytes > 0 && { netBytes }),
324
+ ...(custom !== undefined && { custom }),
325
+ ...(exceeded && { exceeded: true }),
326
+ ...(reason !== undefined && { reason }),
327
+ };
328
+ }
329
+ /** A validated `_inbox` seed from `continuedFrom.carried.inbox`; invalid parts are ignored. */
330
+ function seedInbox(raw) {
331
+ if (!isPlain(raw))
332
+ return undefined;
333
+ const cursors = {};
334
+ if (isPlain(raw.cursors)) {
335
+ for (const [topic, seq] of Object.entries(raw.cursors)) {
336
+ if (safeMapKey(topic) && Number.isSafeInteger(seq) && seq >= 0) {
337
+ cursors[topic] = seq;
338
+ }
339
+ }
340
+ }
341
+ const dedupe = Array.isArray(raw.dedupe)
342
+ ? raw.dedupe.filter((id) => typeof id === "string").slice(-exports.INBOX_DEDUPE_CAP)
343
+ : [];
344
+ return { cursors, dedupe };
345
+ }
346
+ function quarantinedOf(state) {
347
+ const q = state.variables._quarantined;
348
+ return isPlain(q) ? q : undefined;
349
+ }
350
+ /** `variables` with `_quarantined` set to `q`, or ABSENT when `q` is empty (never-quarantined shape). */
351
+ function withQuarantined(variables, q) {
352
+ const { _quarantined: _dropped, ...rest } = variables;
353
+ return Object.keys(q).length === 0 ? rest : { ...rest, _quarantined: q };
354
+ }
355
+ function quarantineTotal(q) {
356
+ let n = 0;
357
+ if (q !== undefined)
358
+ for (const queue of Object.values(q))
359
+ n += queue.length;
360
+ return n;
361
+ }
362
+ /**
363
+ * A validated `_quarantined` seed from `continuedFrom.carried.quarantined` (1.25.0); invalid parts
364
+ * are ignored, never thrown. A topic's queue must read `released? held*` (at most ONE released entry: the fold can only produce one) (the shape release
365
+ * preserves); a queue that does not is dropped whole. The total is capped at {@link QUARANTINE_FOLD_CAP}.
366
+ */
367
+ function seedQuarantined(raw) {
368
+ if (!isPlain(raw))
369
+ return undefined;
370
+ const out = {};
371
+ let total = 0;
372
+ for (const [topic, queue] of Object.entries(raw)) {
373
+ if (!safeMapKey(topic) || !Array.isArray(queue))
374
+ continue;
375
+ const entries = [];
376
+ let sawHeld = false;
377
+ let sawReleased = false;
378
+ let ok = true;
379
+ for (const e of queue) {
380
+ if (!isPlain(e) ||
381
+ typeof e.key !== "string" ||
382
+ e.key.length === 0 ||
383
+ !Number.isSafeInteger(e.inboxSeq) ||
384
+ e.inboxSeq < 0 ||
385
+ !Number.isSafeInteger(e.heldAt) ||
386
+ e.heldAt < 0 ||
387
+ (e.state !== "held" && e.state !== "released") ||
388
+ (sawHeld && e.state === "released") ||
389
+ (sawReleased && e.state === "released")) {
390
+ ok = false;
391
+ break;
392
+ }
393
+ if (e.state === "held")
394
+ sawHeld = true;
395
+ else
396
+ sawReleased = true;
397
+ entries.push({
398
+ key: e.key,
399
+ inboxSeq: e.inboxSeq,
400
+ heldAt: e.heldAt,
401
+ state: e.state,
402
+ });
403
+ }
404
+ if (!ok || entries.length === 0 || total + entries.length > exports.QUARANTINE_FOLD_CAP)
405
+ continue;
406
+ total += entries.length;
407
+ out[topic] = entries;
408
+ }
409
+ return total > 0 ? out : undefined;
410
+ }
411
+ /** The chain identity a `forkChain` stamp or the start payload names; `undefined` if malformed. */
412
+ function chainIdentity(v) {
413
+ if (!isPlain(v))
414
+ return undefined;
415
+ const { chainId, generation } = v;
416
+ if (typeof chainId !== "string" || chainId.length === 0)
417
+ return undefined;
418
+ if (!Number.isSafeInteger(generation) || generation < 0)
419
+ return undefined;
420
+ return { chainId, generation: generation };
421
+ }
422
+ function parseWaitResume(state, event) {
423
+ if (!isKernelWritten(event))
424
+ return undefined;
425
+ const p = event.payload;
426
+ if (!isPlain(p) || typeof p.waitId !== "string" || !Object.hasOwn(p, "payload")) {
427
+ return undefined;
428
+ }
429
+ // The table is the fold-owned `suspendedOn.waits`: it exists ONLY when the park was a
430
+ // kernel-stamped EXECUTION_SUSPENDED (the handler drops `waits` from an unstamped one), and
431
+ // holds only well-formed entries, so a userspace park can never be answered as a multi-wait.
432
+ // Total: a non-array or malformed entry is skipped, never thrown.
433
+ const table = state.status === "suspended" ? state.suspendedOn?.waits : undefined;
434
+ const wait = Array.isArray(table)
435
+ ? table.find((w) => isPlain(w) && w.waitId === p.waitId)
436
+ : undefined;
437
+ if (wait === undefined || !safeMapKey(p.waitId))
438
+ return { answer: false };
439
+ return { answer: true, waitId: p.waitId, wait, inner: p.payload };
440
+ }
441
+ const handlers = {
442
+ EXECUTION_STARTED: (state, event) => {
443
+ // `flowHash` (K11.1, additive — absent on legacy logs) is the content hash
444
+ // of the flow code this execution started under; resume/recovery compares
445
+ // it against the registered flow so changed code never silently folds an
446
+ // in-flight log. Reserved fold key: `variables._flowHash`.
447
+ //
448
+ // 1.23.0 (Wave 2) — further additive fields, each folded to a reserved key and each absent
449
+ // from every older log: `principal` → `_principal` (ADR 0111; a fork's `forkedBy` stamp
450
+ // wins), `parent` → `_parent`, `chainId` + `generation` (+ `continuedFrom`) → `_chain`
451
+ // (ADR 0109; a fork's `forkChain` stamp wins and drops `continuedFrom`), `codeHash` →
452
+ // `_codeHash` (ADR 0113). A successor's validated `continuedFrom.carried` seeds `_resources`
453
+ // and `_inbox` (never for a fork, which starts a fresh chain); an invalid carry is ignored,
454
+ // never thrown.
455
+ const payload = event.payload;
456
+ const meta = event.metadata;
457
+ const vars = {};
458
+ if (payload.input !== undefined)
459
+ vars._input = payload.input;
460
+ if (payload.flowHash !== undefined)
461
+ vars._flowHash = payload.flowHash;
462
+ const principal = isPlain(meta?.forkedBy) ? meta.forkedBy : payload.principal;
463
+ if (isPlain(principal))
464
+ vars._principal = principal;
465
+ if (typeof payload.parent === "string" && payload.parent.length > 0) {
466
+ vars._parent = payload.parent;
467
+ }
468
+ const forkChain = chainIdentity(meta?.forkChain);
469
+ let carriedFrom;
470
+ let forkResources;
471
+ const own = forkChain ?? chainIdentity(payload);
472
+ if (own !== undefined) {
473
+ const from = payload.continuedFrom;
474
+ const continuedFrom = forkChain === undefined &&
475
+ isPlain(from) &&
476
+ typeof from.executionId === "string" &&
477
+ (from.carried === undefined || isPlain(from.carried))
478
+ ? {
479
+ executionId: from.executionId,
480
+ ...(from.carried !== undefined && { carried: from.carried }),
481
+ }
482
+ : undefined;
483
+ vars._chain = { ...own, ...(continuedFrom !== undefined && { continuedFrom }) };
484
+ carriedFrom = continuedFrom?.carried;
485
+ // A fork of a chain member leaves the chain (no `continuedFrom`, no carried inbox cursors or
486
+ // deadline), but it keeps what is the execution's OWN history rather than the chain's
487
+ // membership: the running resource totals, so a carried `exceeded` quota is not shed by
488
+ // forking (a fork of an ordinary execution keeps it through its copied prefix).
489
+ if (forkChain !== undefined && isPlain(payload.continuedFrom)) {
490
+ const carried = payload.continuedFrom.carried;
491
+ if (isPlain(carried))
492
+ forkResources = carried.resources;
493
+ }
494
+ }
495
+ if (typeof payload.codeHash === "string")
496
+ vars._codeHash = payload.codeHash;
497
+ const resources = seedResources(carriedFrom?.resources ?? forkResources);
498
+ if (resources !== undefined)
499
+ vars._resources = resources;
500
+ const inbox = seedInbox(carriedFrom?.inbox);
501
+ if (inbox !== undefined)
502
+ vars._inbox = inbox;
503
+ // 1.25.0 (ADR 0130 residuals D9) — the quarantine queues ride the same carry as the inbox.
504
+ const quarantined = seedQuarantined(carriedFrom?.quarantined);
505
+ if (quarantined !== undefined)
506
+ vars._quarantined = quarantined;
507
+ return {
508
+ ...state,
509
+ status: "running",
510
+ startedAt: event.timestamp,
511
+ updatedAt: event.timestamp,
512
+ ...(Object.keys(vars).length > 0 && { variables: { ...state.variables, ...vars } }),
513
+ };
514
+ },
515
+ STEP_STARTED: (state, event) => {
516
+ const payload = event.payload;
517
+ const existing = state.frames[payload.stepId];
518
+ // ADR 0113 D3 — the log's first-start order, and (kernel-stamped only) the scope it started in.
519
+ const scope = existing?.scope ??
520
+ (isKernelWritten(event) && typeof payload.scope === "string" ? payload.scope : undefined);
521
+ const firstSeq = existing === undefined ? event.sequence : existing.firstSeq;
522
+ const frame = {
523
+ stepId: payload.stepId,
524
+ // Set ONLY when the frame is new: a frame folded before `firstSeq` existed (an old snapshot)
525
+ // that is re-started in the tail must not be stamped with the LATER sequence, or it would
526
+ // rank last and an unchanged body would be refused. It stays unranked (sorts first).
527
+ ...(firstSeq !== undefined && { firstSeq }),
528
+ ...(scope !== undefined && { scope }),
529
+ status: "running",
530
+ attempts: (existing?.attempts ?? 0) + 1,
531
+ startedAt: event.timestamp,
532
+ // K8.3 — the first attempt's logged start survives re-runs, so deadlines
533
+ // anchored here are never reset by crash recovery.
534
+ firstStartedAt: existing?.firstStartedAt ?? existing?.startedAt ?? event.timestamp,
535
+ ...(payload.input !== undefined && { input: payload.input }),
536
+ };
537
+ return {
538
+ ...state,
539
+ frames: setFrame(state.frames, payload.stepId, frame),
540
+ updatedAt: event.timestamp,
541
+ };
542
+ },
543
+ STEP_COMPLETED: (state, event) => {
544
+ const payload = event.payload;
545
+ const existing = state.frames[payload.stepId];
546
+ const frame = {
547
+ stepId: payload.stepId,
548
+ status: "completed",
549
+ attempts: existing?.attempts ?? 1,
550
+ ...(existing?.firstSeq !== undefined && { firstSeq: existing.firstSeq }),
551
+ ...(existing?.scope !== undefined && { scope: existing.scope }),
552
+ completedAt: event.timestamp,
553
+ ...(existing?.startedAt !== undefined && { startedAt: existing.startedAt }),
554
+ ...(existing?.firstStartedAt !== undefined && { firstStartedAt: existing.firstStartedAt }),
555
+ ...(payload.output !== undefined && { output: payload.output }),
556
+ };
557
+ return {
558
+ ...state,
559
+ frames: setFrame(state.frames, payload.stepId, frame),
560
+ updatedAt: event.timestamp,
561
+ };
562
+ },
563
+ STEP_FAILED: (state, event) => {
564
+ const payload = event.payload;
565
+ const existing = state.frames[payload.stepId];
566
+ const frame = {
567
+ stepId: payload.stepId,
568
+ status: "failed",
569
+ attempts: existing?.attempts ?? 1,
570
+ ...(existing?.firstSeq !== undefined && { firstSeq: existing.firstSeq }),
571
+ ...(existing?.scope !== undefined && { scope: existing.scope }),
572
+ ...(existing?.startedAt !== undefined && { startedAt: existing.startedAt }),
573
+ ...(existing?.firstStartedAt !== undefined && { firstStartedAt: existing.firstStartedAt }),
574
+ ...(payload.error !== undefined && { error: payload.error }),
575
+ ...(payload.channel !== undefined && {
576
+ errorChannel: payload.channel,
577
+ errorPayload: payload.payload,
578
+ }),
579
+ };
580
+ // ADR 0105 — the STEP failed, not the execution: leave execution `status` alone. A flow
581
+ // may catch the throw (try/catch, a saga compensation, a typed-failure fallback) and carry
582
+ // on; folding "failed" here made a live execution read terminal, so a crash after the
583
+ // catch was never re-driven and `cancel` refused it. A failure that escapes the runner is
584
+ // recorded by the EXECUTION_FAILED the kernel commits after it, which owns the terminal
585
+ // status (as EXECUTION_CANCELLED does for STEP_CANCELLED below). A failure that lands while
586
+ // the execution is parked (a parallel sibling) marks FAILED_WHILE_PARKED — see there.
587
+ const parked = state.status === "suspended" && state.variables[exports.FAILED_WHILE_PARKED] !== true;
588
+ return {
589
+ ...state,
590
+ frames: setFrame(state.frames, payload.stepId, frame),
591
+ ...(parked && { variables: { ...state.variables, [exports.FAILED_WHILE_PARKED]: true } }),
592
+ updatedAt: event.timestamp,
593
+ };
594
+ },
595
+ STEP_CANCELLED: (state, event) => {
596
+ // Marks the in-flight step as cancelled WITHOUT touching execution status —
597
+ // the surrounding EXECUTION_CANCELLED event owns the execution-level status,
598
+ // and this must not overwrite it back to a failure/other state.
599
+ const payload = event.payload;
600
+ const existing = state.frames[payload.stepId];
601
+ const frame = {
602
+ stepId: payload.stepId,
603
+ status: "cancelled",
604
+ attempts: existing?.attempts ?? 1,
605
+ ...(existing?.firstSeq !== undefined && { firstSeq: existing.firstSeq }),
606
+ ...(existing?.scope !== undefined && { scope: existing.scope }),
607
+ ...(existing?.startedAt !== undefined && { startedAt: existing.startedAt }),
608
+ ...(existing?.firstStartedAt !== undefined && { firstStartedAt: existing.firstStartedAt }),
609
+ };
610
+ return {
611
+ ...state,
612
+ frames: setFrame(state.frames, payload.stepId, frame),
613
+ updatedAt: event.timestamp,
614
+ };
615
+ },
616
+ STEP_RETRYING: (state, event) => {
617
+ const payload = event.payload;
618
+ const existing = state.frames[payload.stepId];
619
+ const frame = {
620
+ stepId: payload.stepId,
621
+ status: "pending",
622
+ attempts: payload.attempt,
623
+ ...(existing?.firstSeq !== undefined && { firstSeq: existing.firstSeq }),
624
+ ...(existing?.scope !== undefined && { scope: existing.scope }),
625
+ ...(existing?.input !== undefined && { input: existing.input }),
626
+ };
627
+ return {
628
+ ...state,
629
+ frames: setFrame(state.frames, payload.stepId, frame),
630
+ updatedAt: event.timestamp,
631
+ };
632
+ },
633
+ PARALLEL_STARTED: (state, event) => {
634
+ const payload = event.payload;
635
+ // One fresh container, then write each new frame into it (the container is
636
+ // not shared, so in-place writes are safe). Mark it fold-owned so subsequent
637
+ // step events in the same fold pass mutate it in place instead of re-spreading.
638
+ const frames = { ...state.frames };
639
+ foldOwned.add(frames);
640
+ for (const stepId of payload.stepIds) {
641
+ assertSafeKey(stepId);
642
+ frames[stepId] = {
643
+ stepId,
644
+ status: "pending",
645
+ attempts: 0,
646
+ startedAt: event.timestamp,
647
+ };
648
+ }
649
+ return { ...state, frames, updatedAt: event.timestamp };
650
+ },
651
+ // 1.23.0 (ADR 0112 D2) — the journaled fail-fast scope outcome. Folds `_scopes[scopePath]` ONLY
652
+ // from a kernel-stamped envelope; an unstamped one keeps the pre-1.23.0 behaviour (no variable
653
+ // change, `updatedAt` only), so a log that already holds such an event replays identically.
654
+ PARALLEL_COMPLETED: (state, event) => {
655
+ if (!isKernelWritten(event))
656
+ return { ...state, updatedAt: event.timestamp };
657
+ const p = event.payload;
658
+ if (!isPlain(p) || !safeMapKey(p.scopePath))
659
+ return { ...state, updatedAt: event.timestamp };
660
+ const scopes = ownedRecord(state.variables._scopes);
661
+ // Amended 2026-10-06 (R6 review, user decision) — ADDITIVE optional fields, each folded only
662
+ // when well-formed; a record without them folds exactly as before (and a re-run serves it
663
+ // as before: it launches every branch and takes gate answers by position).
664
+ const nonNegInt = (v) => Number.isSafeInteger(v) && v >= 0;
665
+ const consumed = Array.isArray(p.consumed)
666
+ ? p.consumed.filter((e) => Array.isArray(e) &&
667
+ e.length === 3 &&
668
+ typeof e[0] === "string" &&
669
+ nonNegInt(e[1]) &&
670
+ nonNegInt(e[2]) &&
671
+ e[2] >= 1)
672
+ : undefined;
673
+ scopes[p.scopePath] = {
674
+ ...(typeof p.failedBranch === "number" && { failedBranch: p.failedBranch }),
675
+ ...(p.error !== undefined && { error: p.error }),
676
+ ...(typeof p.errorName === "string" && { errorName: p.errorName }),
677
+ ...(typeof p.errorChannel === "string" && { errorChannel: p.errorChannel }),
678
+ ...(p.errorPayload !== undefined && { errorPayload: p.errorPayload }),
679
+ cancelled: Array.isArray(p.cancelled)
680
+ ? p.cancelled.filter((id) => typeof id === "string")
681
+ : [],
682
+ ...(nonNegInt(p.launched) && { launched: p.launched }),
683
+ ...(consumed !== undefined && { consumed }),
684
+ ...(nonNegInt(p.cursor) && { cursor: p.cursor }),
685
+ };
686
+ return {
687
+ ...state,
688
+ variables: { ...state.variables, _scopes: scopes },
689
+ updatedAt: event.timestamp,
690
+ };
691
+ },
692
+ // 1.23.0 (ADR 0109 D2) — the predecessor of a continue-as-new ends, naming its successor. A NEW
693
+ // terminal status, `continued` (never `completed`: there is no output), and `_continuedTo`.
694
+ // Kernel-stamped envelopes only; an unstamped one is the no-op an unknown kind always was.
695
+ EXECUTION_CONTINUED: (state, event) => {
696
+ if (!isKernelWritten(event))
697
+ return state;
698
+ const p = event.payload;
699
+ if (!isPlain(p) || typeof p.successorId !== "string" || p.successorId.length === 0) {
700
+ return state;
701
+ }
702
+ return {
703
+ ...state,
704
+ status: "continued",
705
+ variables: { ...withoutFailedWhileParked(state.variables), _continuedTo: p.successorId },
706
+ updatedAt: event.timestamp,
707
+ };
708
+ },
709
+ // 1.23.0 (ADR 0110 D5/D6) — the owner consumed an inbox message: the per-topic cursor advances,
710
+ // the bounded dedupe window remembers the id, and the delivered payload answers its wait
711
+ // (`_waitAnswers[waitId]`, first answer wins). A `messageId` already in the window is a
712
+ // duplicate: the cursor advances, nothing is delivered. Kernel-stamped envelopes only.
713
+ MESSAGE_CONSUMED: (state, event) => {
714
+ if (!isKernelWritten(event))
715
+ return state;
716
+ const p = event.payload;
717
+ if (!isPlain(p) ||
718
+ !safeMapKey(p.waitId) ||
719
+ !safeMapKey(p.topic) ||
720
+ typeof p.messageId !== "string" ||
721
+ !Number.isSafeInteger(p.inboxSeq) ||
722
+ p.inboxSeq < 0) {
723
+ return state;
724
+ }
725
+ const prior = state.variables._inbox;
726
+ const cursors = ownedRecord(prior?.cursors);
727
+ // Own-property read: a topic named `toString` / `valueOf` must not read a prototype member.
728
+ const was = Object.hasOwn(cursors, p.topic) ? cursors[p.topic] : -1;
729
+ cursors[p.topic] = Math.max(was, p.inboxSeq);
730
+ const window = prior?.dedupe;
731
+ const duplicate = window?.includes(p.messageId) === true;
732
+ let dedupe = window;
733
+ if (!duplicate) {
734
+ dedupe = appendAudit(window, p.messageId);
735
+ if (dedupe.length > exports.INBOX_DEDUPE_CAP)
736
+ dedupe.splice(0, dedupe.length - exports.INBOX_DEDUPE_CAP);
737
+ }
738
+ const variables = {
739
+ ...state.variables,
740
+ _inbox: { cursors, dedupe: dedupe ?? [] },
741
+ };
742
+ if (!duplicate) {
743
+ const answers = ownedRecord(state.variables._waitAnswers);
744
+ if (!Object.hasOwn(answers, p.waitId)) {
745
+ answers[p.waitId] = { kind: "inbox", name: p.topic, payload: p.payload };
746
+ }
747
+ variables._waitAnswers = answers;
748
+ }
749
+ // 1.25.0 (ADR 0130 residuals D9) — gated on `_quarantined`: a log that never quarantines folds
750
+ // byte-identically. Delivering the topic's first RELEASED entry (matched by its dedupe key, which
751
+ // is this fact's `messageId`) drops it from the queue; the cursor already sits at or above its
752
+ // original `inboxSeq`, so it does not move.
753
+ const held = quarantinedOf(state);
754
+ if (held !== undefined && Object.hasOwn(held, p.topic)) {
755
+ const queue = held[p.topic];
756
+ const first = queue[0];
757
+ if (first !== undefined && first.state === "released" && first.key === p.messageId) {
758
+ const next = ownedRecord(held);
759
+ if (queue.length === 1)
760
+ delete next[p.topic];
761
+ else
762
+ next[p.topic] = queue.slice(1);
763
+ // An emptied record is deleted, so a full hold/release/consume cycle folds to the
764
+ // never-quarantined shape.
765
+ return {
766
+ ...state,
767
+ variables: withQuarantined(variables, next),
768
+ updatedAt: event.timestamp,
769
+ };
770
+ }
771
+ }
772
+ return { ...state, variables, updatedAt: event.timestamp };
773
+ },
774
+ // 1.25.0 (ADR 0130 residuals D9; written by `quarantineInbox`) — the topic's head message is HELD: the
775
+ // cursor advances past the original entry (so it no longer pins the stream), the entry joins the
776
+ // topic's FIFO queue `_quarantined[topic]`, and the dedupe window is NOT touched (the release
777
+ // delivery must not read as a duplicate). Kernel-stamped envelopes only; a malformed fact, or one
778
+ // that would take the total past QUARANTINE_FOLD_CAP, is ignored.
779
+ INBOX_QUARANTINED: (state, event) => {
780
+ if (!isKernelWritten(event))
781
+ return state;
782
+ const p = event.payload;
783
+ if (!isPlain(p) ||
784
+ !safeMapKey(p.topic) ||
785
+ !Number.isSafeInteger(p.inboxSeq) ||
786
+ p.inboxSeq < 0 ||
787
+ typeof p.key !== "string" ||
788
+ p.key.length === 0 ||
789
+ typeof p.messageId !== "string" ||
790
+ !Number.isSafeInteger(p.heldAt) ||
791
+ p.heldAt < 0 ||
792
+ !isPlain(p.operator)) {
793
+ return state;
794
+ }
795
+ const prior = quarantinedOf(state);
796
+ if (quarantineTotal(prior) + 1 > exports.QUARANTINE_FOLD_CAP)
797
+ return state;
798
+ const inbox = state.variables._inbox;
799
+ const cursors = ownedRecord(inbox?.cursors);
800
+ const was = Object.hasOwn(cursors, p.topic) ? cursors[p.topic] : -1;
801
+ cursors[p.topic] = Math.max(was, p.inboxSeq);
802
+ const quarantined = ownedRecord(prior);
803
+ const queue = Object.hasOwn(quarantined, p.topic)
804
+ ? quarantined[p.topic]
805
+ : [];
806
+ quarantined[p.topic] = [
807
+ ...queue,
808
+ {
809
+ key: p.key,
810
+ inboxSeq: p.inboxSeq,
811
+ heldAt: p.heldAt,
812
+ state: "held",
813
+ },
814
+ ];
815
+ return {
816
+ ...state,
817
+ variables: {
818
+ ...state.variables,
819
+ _inbox: { cursors, dedupe: inbox?.dedupe ?? [] },
820
+ _quarantined: quarantined,
821
+ },
822
+ updatedAt: event.timestamp,
823
+ };
824
+ },
825
+ // 1.25.0 (ADR 0130 residuals D9; written by `releaseInbox` and the capable-node auto-release) — the topic's OLDEST held entry is
826
+ // released or dropped. EXACTLY ONE of `operator` (a manual release) / `auto` (an automatic one,
827
+ // only with `deliver`) is present; a fact with both or neither is ignored, as is one that does not
828
+ // name the topic's first entry while it is `held`. "deliver" marks it `released`; "discard" removes
829
+ // it and adds its key to the dedupe window (a sender's retry of that id is then a duplicate).
830
+ INBOX_RELEASED: (state, event) => {
831
+ if (!isKernelWritten(event))
832
+ return state;
833
+ const p = event.payload;
834
+ if (!isPlain(p) ||
835
+ !safeMapKey(p.topic) ||
836
+ typeof p.key !== "string" ||
837
+ p.key.length === 0 ||
838
+ typeof p.messageId !== "string" ||
839
+ (p.disposition !== "deliver" && p.disposition !== "discard")) {
840
+ return state;
841
+ }
842
+ const hasOperator = p.operator !== undefined;
843
+ const hasAuto = p.auto !== undefined;
844
+ if (hasOperator === hasAuto)
845
+ return state; // both, or neither
846
+ if (hasOperator && !isPlain(p.operator))
847
+ return state;
848
+ if (hasAuto && (p.auto !== true || p.disposition !== "deliver"))
849
+ return state;
850
+ const prior = quarantinedOf(state);
851
+ if (prior === undefined || !Object.hasOwn(prior, p.topic))
852
+ return state;
853
+ const queue = prior[p.topic];
854
+ const first = queue[0];
855
+ if (first === undefined || first.state !== "held" || first.key !== p.key)
856
+ return state;
857
+ const quarantined = ownedRecord(prior);
858
+ if (p.disposition === "deliver") {
859
+ quarantined[p.topic] = [{ ...first, state: "released" }, ...queue.slice(1)];
860
+ return {
861
+ ...state,
862
+ variables: { ...state.variables, _quarantined: quarantined },
863
+ updatedAt: event.timestamp,
864
+ };
865
+ }
866
+ if (queue.length === 1)
867
+ delete quarantined[p.topic];
868
+ else
869
+ quarantined[p.topic] = queue.slice(1);
870
+ const inbox = state.variables._inbox;
871
+ const window = inbox?.dedupe;
872
+ let dedupe = window;
873
+ if (window?.includes(first.key) !== true) {
874
+ dedupe = appendAudit(window, first.key);
875
+ if (dedupe.length > exports.INBOX_DEDUPE_CAP)
876
+ dedupe.splice(0, dedupe.length - exports.INBOX_DEDUPE_CAP);
877
+ }
878
+ const variables = withQuarantined({ ...state.variables, _inbox: { cursors: inbox?.cursors ?? {}, dedupe: dedupe ?? [] } }, quarantined);
879
+ return { ...state, variables, updatedAt: event.timestamp };
880
+ },
881
+ EXECUTION_SUSPENDED: (state, event) => {
882
+ const payload = event.payload;
883
+ let variables = withoutFailedWhileParked(state.variables);
884
+ // 1.23.0 (ADR 0110 D4) — `condition.waits` is honoured ONLY on a kernel-stamped park (the R2
885
+ // writer stamps `metadata.kernel` on every EXECUTION_SUSPENDED that carries waits; the
886
+ // runtime's commit site strips `waits` from every userspace park, so no unstamped event
887
+ // carries them — this is the fold's own guard for a log it did not write). An unstamped
888
+ // park's `waits` is dropped from `suspendedOn` too, so nothing downstream (the wait-addressed
889
+ // resume's lookup, `loom` introspection) can read a table userspace wrote.
890
+ let condition = payload.condition;
891
+ if (isPlain(condition) && Object.hasOwn(condition, "waits")) {
892
+ const { waits: raw, ...rest } = condition;
893
+ condition = rest;
894
+ if (isKernelWritten(event) && Array.isArray(raw)) {
895
+ const clean = raw.filter((w) => isPlain(w) &&
896
+ safeMapKey(w.waitId) &&
897
+ (w.kind === "gate" || w.kind === "inbox" || w.kind === "timer"));
898
+ condition = { ...rest, waits: clean };
899
+ // A timer arm's `wakeAt` is recorded at its FIRST registration and served on every
900
+ // later one, so sibling traffic cannot push a deadline out.
901
+ let recorded;
902
+ for (const w of clean) {
903
+ if (w.kind !== "timer")
904
+ continue;
905
+ if (typeof w.wakeAt !== "number" || !Number.isFinite(w.wakeAt))
906
+ continue;
907
+ recorded ??= ownedRecord(variables._waits);
908
+ if (!Object.hasOwn(recorded, w.waitId)) {
909
+ recorded[w.waitId] = { kind: "timer", wakeAt: w.wakeAt };
910
+ }
911
+ }
912
+ if (recorded !== undefined)
913
+ variables = { ...variables, _waits: recorded };
914
+ // A park lists EVERY unanswered arm of every pending select, so a recorded timer whose
915
+ // waitId this park does not list belongs to a select another arm already settled: drop
916
+ // it, or a loop of `select([inbox, timer])` grows `_waits` (state and every snapshot)
917
+ // by one entry per iteration, forever.
918
+ const priorWaits = variables._waits;
919
+ if (isPlain(priorWaits)) {
920
+ const listed = new Set(clean.map((w) => w.waitId));
921
+ const stale = Object.keys(priorWaits).filter((id) => !listed.has(id));
922
+ if (stale.length > 0) {
923
+ const kept = ownedRecord(priorWaits);
924
+ for (const id of stale)
925
+ delete kept[id];
926
+ variables = { ...variables, _waits: kept };
927
+ }
928
+ }
929
+ }
930
+ }
931
+ return {
932
+ ...state,
933
+ status: "suspended",
934
+ suspendedOn: condition,
935
+ ...(variables !== state.variables && { variables }),
936
+ updatedAt: event.timestamp,
937
+ };
938
+ },
939
+ EXECUTION_RESUMED: (state, event) => {
940
+ const { suspendedOn: _removed, ...rest } = state;
941
+ // 1.23.0 (ADR 0110 D3) — a wait-addressed resume answers ONE wait of a multi-wait park: its
942
+ // payload is `{ waitId, payload }` and the fold unwraps it into `_waitAnswers[waitId]`
943
+ // (kind and name from the park's wait; first answer wins) and drops the wait's pinned
944
+ // `_waits` entry. It never enters the positional `_resumePayloads` (the two mechanisms do
945
+ // not share entries). One naming no wait of the current park is a status-only re-open.
946
+ const addressed = parseWaitResume(state, event);
947
+ if (addressed !== undefined) {
948
+ const variables = { ...withoutFailedWhileParked(state.variables) };
949
+ if (addressed.answer) {
950
+ const { waitId, wait, inner } = addressed;
951
+ const answers = ownedRecord(variables._waitAnswers);
952
+ if (!Object.hasOwn(answers, waitId)) {
953
+ answers[waitId] = {
954
+ kind: wait.kind,
955
+ name: wait.on ?? wait.topic ?? wait.kind,
956
+ payload: inner,
957
+ };
958
+ }
959
+ variables._waitAnswers = answers;
960
+ if (isPlain(variables._waits) && Object.hasOwn(variables._waits, waitId)) {
961
+ const waits = ownedRecord(variables._waits);
962
+ delete waits[waitId];
963
+ variables._waits = waits;
964
+ }
965
+ }
966
+ return { ...rest, status: "running", variables, updatedAt: event.timestamp };
967
+ }
968
+ return {
969
+ ...rest,
970
+ status: "running",
971
+ variables: {
972
+ ...withoutFailedWhileParked(state.variables),
973
+ ...(event.payload !== undefined &&
974
+ !isStatusOnlyResume(state, event.payload) && {
975
+ // Latest payload (legacy key, kept for compat with pre-1.16 snapshots)…
976
+ _resumePayload: event.payload,
977
+ // …and the ORDERED list of every resume payload (1.16.0): a flow with
978
+ // N suspend gates passes gate i through with payload i on replay —
979
+ // the single-latest key silently fed gate 1 the LAST gate's payload.
980
+ _resumePayloads: appendAudit(state.variables._resumePayloads, event.payload),
981
+ }),
982
+ },
983
+ updatedAt: event.timestamp,
984
+ };
985
+ },
986
+ // Loom 2.0 (P9) — a durable record of an advisory/optimizer choice. Stored in a
987
+ // reserved `_decisions` map keyed by decisionId so replay serves the recorded
988
+ // choice instead of re-consulting the optimizer, keeping the fold pure (P2).
989
+ DECISION_RECORDED: (state, event) => {
990
+ const payload = event.payload;
991
+ assertSafeKey(payload.decisionId);
992
+ // R1a — the decision map is a per-fold-owned container, written in place (the same rule
993
+ // as `setFrame`/`appendAudit`): spreading it per event made a fold of D decisions O(D²)
994
+ // (20k decisions = 42 s). The caller's map (snapshot / prior state / an escaped state) is
995
+ // copied once, then owned. Insertion order is unchanged: a new id appends, a repeated id
996
+ // keeps its slot — exactly what `{ ...existing, [id]: v }` did.
997
+ const prior = state.variables._decisions;
998
+ let decisions;
999
+ if (prior !== undefined && foldOwned.has(prior)) {
1000
+ decisions = prior;
1001
+ }
1002
+ else {
1003
+ decisions = prior === undefined ? {} : { ...prior };
1004
+ foldOwned.add(decisions);
1005
+ }
1006
+ const estimatedCost = sanitizeEstimatedCost(payload.estimatedCost);
1007
+ // 1.24.0 (ADR 0128) — the record's own envelope sequence, so the ORDER decisions were made in
1008
+ // survives a checkpoint (the map's key order does not: array-index-like ids sort first, and a
1009
+ // normalising snapshot store such as Postgres `jsonb` reorders keys). From the ENVELOPE, never
1010
+ // the payload; a repeated id keeps its FIRST `seq` (as it keeps its slot). A repeat of a legacy
1011
+ // entry (folded before 1.24.0, no `seq`) stays without one: its first sequence is unknowable.
1012
+ const had = Object.hasOwn(decisions, payload.decisionId)
1013
+ ? decisions[payload.decisionId]
1014
+ : undefined;
1015
+ const seq = had === undefined ? event.sequence : had.seq;
1016
+ decisions[payload.decisionId] = {
1017
+ choice: payload.choice,
1018
+ ...(payload.rationale !== undefined && { rationale: payload.rationale }),
1019
+ ...(payload.source !== undefined && { source: payload.source }),
1020
+ ...(payload.alternatives !== undefined && { alternatives: payload.alternatives }),
1021
+ ...(payload.estimatedCostUsd !== undefined && {
1022
+ estimatedCostUsd: payload.estimatedCostUsd,
1023
+ }),
1024
+ ...(estimatedCost !== undefined && { estimatedCost }),
1025
+ ...(payload.estimatedLatencyMs !== undefined && {
1026
+ estimatedLatencyMs: payload.estimatedLatencyMs,
1027
+ }),
1028
+ ...(payload.confidence !== undefined && { confidence: payload.confidence }),
1029
+ ...(payload.context !== undefined && { context: payload.context }),
1030
+ ...(payload.policy !== undefined && { policy: payload.policy }),
1031
+ ...(isKernelWritten(event) &&
1032
+ Array.isArray(payload.innerSteps) && {
1033
+ innerSteps: payload.innerSteps.filter((id) => typeof id === "string"),
1034
+ ...(payload.innerStepsOverflow === true &&
1035
+ Number.isSafeInteger(payload.innerFrom) && {
1036
+ innerStepsOverflow: true,
1037
+ innerFrom: payload.innerFrom,
1038
+ recordedAtSeq: event.sequence,
1039
+ }),
1040
+ }),
1041
+ ...(Number.isSafeInteger(seq) && { seq: seq }),
1042
+ };
1043
+ return {
1044
+ ...state,
1045
+ variables: { ...state.variables, _decisions: decisions },
1046
+ updatedAt: event.timestamp,
1047
+ };
1048
+ },
1049
+ // Loom 2.0 (K2) — a capability denial: the EffectRuntime refused an ungranted
1050
+ // effect. Recorded as a durable audit fact in a reserved `_denials` list (the
1051
+ // step itself still fails via the STEP_FAILED that follows). Pure audit — it does
1052
+ // not change frame/execution status.
1053
+ EFFECT_DENIED: (state, event) => {
1054
+ const payload = event.payload;
1055
+ return {
1056
+ ...state,
1057
+ variables: {
1058
+ ...state.variables,
1059
+ _denials: appendAudit(state.variables._denials, {
1060
+ toolId: payload.toolId,
1061
+ ...(payload.stepId !== undefined && { stepId: payload.stepId }),
1062
+ ...(payload.reason !== undefined && { reason: payload.reason }),
1063
+ }),
1064
+ },
1065
+ updatedAt: event.timestamp,
1066
+ };
1067
+ },
1068
+ // C.1 (execution-lifecycle authz) — an EXECUTION_DENIED fact: an ExecutionAuthorizer
1069
+ // refused a lifecycle operation (resume/cancel) on an existing execution. Recorded as
1070
+ // a durable audit fact in a reserved `_executionDenials` list, symmetric to
1071
+ // EFFECT_DENIED (which gates effects). Pure audit — the op is refused by a thrown
1072
+ // error; this fold does not change frame/execution status.
1073
+ EXECUTION_DENIED: (state, event) => {
1074
+ const payload = event.payload;
1075
+ return {
1076
+ ...state,
1077
+ variables: {
1078
+ ...state.variables,
1079
+ _executionDenials: appendAudit(state.variables._executionDenials, {
1080
+ op: payload.op,
1081
+ ...(payload.reason !== undefined && { reason: payload.reason }),
1082
+ ...(payload.actor !== undefined && { actor: payload.actor }),
1083
+ }),
1084
+ },
1085
+ updatedAt: event.timestamp,
1086
+ };
1087
+ },
1088
+ // K10.1 (R5) — retention control facts on a dedicated lifecycle stream. When
1089
+ // `applyRetention` archives or deletes a terminal execution it journals the action
1090
+ // so the audit trail records its own pruning (P9): the values are gone but the FACT
1091
+ // that they were pruned survives. Pure audit into `_lifecycle`; replay is byte-identical
1092
+ // for any log that never prunes.
1093
+ EXECUTION_ARCHIVED: (state, event) => {
1094
+ const p = event.payload;
1095
+ return {
1096
+ ...state,
1097
+ variables: {
1098
+ ...state.variables,
1099
+ _lifecycle: appendAudit(state.variables._lifecycle, {
1100
+ op: "archived",
1101
+ executionId: p.executionId,
1102
+ at: event.timestamp,
1103
+ }),
1104
+ },
1105
+ updatedAt: event.timestamp,
1106
+ };
1107
+ },
1108
+ EXECUTION_DELETED: (state, event) => {
1109
+ const p = event.payload;
1110
+ return {
1111
+ ...state,
1112
+ variables: {
1113
+ ...state.variables,
1114
+ _lifecycle: appendAudit(state.variables._lifecycle, {
1115
+ op: "deleted",
1116
+ executionId: p.executionId,
1117
+ at: event.timestamp,
1118
+ }),
1119
+ },
1120
+ updatedAt: event.timestamp,
1121
+ };
1122
+ },
1123
+ // ADR 0107 — an operator repair re-homed events the pre-0106 compaction bug had written to
1124
+ // the blank execution `""` into the execution they belong to. Journaled on the lifecycle
1125
+ // stream beside the retention facts (P9): the log records its own repair. Pure audit into
1126
+ // `_lifecycle`; a log that is never repaired replays byte-identically.
1127
+ EVENTS_REHOMED: (state, event) => {
1128
+ const p = event.payload;
1129
+ return {
1130
+ ...state,
1131
+ variables: {
1132
+ ...state.variables,
1133
+ _lifecycle: appendAudit(state.variables._lifecycle, {
1134
+ op: "rehomed",
1135
+ executionId: p.executionId,
1136
+ fromSequence: p.fromSequence,
1137
+ throughSequence: p.throughSequence,
1138
+ proof: p.proof,
1139
+ at: event.timestamp,
1140
+ }),
1141
+ },
1142
+ updatedAt: event.timestamp,
1143
+ };
1144
+ },
1145
+ // T3-R4.5 — an erasure was requested but deferred because an execution of the subject was
1146
+ // still live (never shred a fold in flight). Folding the erasure control stream recovers the
1147
+ // set of subjects awaiting completion, so a parked erasure survives a restart.
1148
+ ERASURE_PENDING: (state, event) => {
1149
+ const p = event.payload;
1150
+ return {
1151
+ ...state,
1152
+ variables: {
1153
+ ...state.variables,
1154
+ _erasure: appendAudit(state.variables._erasure, {
1155
+ op: "pending",
1156
+ subjectId: p.subjectId,
1157
+ at: event.timestamp,
1158
+ }),
1159
+ },
1160
+ updatedAt: event.timestamp,
1161
+ };
1162
+ },
1163
+ // Loom 2.0 (learning seam) — an OUTCOME_RECORDED fact: how a prior choice (a
1164
+ // route/capability/tool, by `key`) actually turned out. The symmetric counterpart
1165
+ // to DECISION_RECORDED ("what was chosen"): this records "how it went", so
1166
+ // @loomwork/analytics can mine it into a ranking fed back to the router — closing the
1167
+ // loop while keeping every future route a logged decision (P9). Folded into a
1168
+ // reserved `_outcomes` audit list; pure audit, does not change status.
1169
+ OUTCOME_RECORDED: (state, event) => {
1170
+ const kernel = isKernelWritten(event);
1171
+ const p = event.payload;
1172
+ return {
1173
+ ...state,
1174
+ variables: {
1175
+ ...state.variables,
1176
+ _outcomes: appendAudit(state.variables._outcomes, {
1177
+ key: p.key,
1178
+ ...(p.success !== undefined && { success: p.success }),
1179
+ ...(p.score !== undefined && { score: p.score }),
1180
+ ...(p.decisionId !== undefined && { decisionId: p.decisionId }),
1181
+ ...(kernel && typeof p.recordedBy === "string" && { recordedBy: p.recordedBy }),
1182
+ ...(kernel && p.late === true && { late: true }),
1183
+ }),
1184
+ },
1185
+ updatedAt: event.timestamp,
1186
+ };
1187
+ },
1188
+ // Loom 2.0 (K6) — a behavioral-safety block: a safety validator refused an
1189
+ // effect's input or output. Recorded as a durable audit fact in a reserved
1190
+ // `_safety` list (the step itself still fails via the STEP_FAILED that follows).
1191
+ // Pure audit — it does not change frame/execution status, so a blocked run
1192
+ // reconstructs its safety verdict exactly on replay.
1193
+ SAFETY_BLOCKED: (state, event) => {
1194
+ const p = event.payload;
1195
+ return {
1196
+ ...state,
1197
+ variables: {
1198
+ ...state.variables,
1199
+ _safety: appendAudit(state.variables._safety, {
1200
+ validatorId: p.validatorId,
1201
+ ...(p.stepId !== undefined && { stepId: p.stepId }),
1202
+ ...(p.phase !== undefined && { phase: p.phase }),
1203
+ ...(p.reason !== undefined && { reason: p.reason }),
1204
+ ...(p.category !== undefined && { category: p.category }),
1205
+ }),
1206
+ },
1207
+ updatedAt: event.timestamp,
1208
+ };
1209
+ },
1210
+ // Loom 2.1 (K9) — an in-doubt effect: recovery found a tool declared
1211
+ // `idempotent: false` whose prior attempt is in an UNKNOWN state (STEP_STARTED
1212
+ // with no terminal step event) and refused to silently re-run it. Recorded in a
1213
+ // reserved `_indoubt` audit list; the EXECUTION_SUSPENDED that follows owns the
1214
+ // status change. Pure audit — replay reconstructs the parking exactly, and the
1215
+ // list length per step is the durable epoch for fresh idempotency keys after a
1216
+ // "retry-fresh" verdict.
1217
+ EFFECT_INDOUBT: (state, event) => {
1218
+ const p = event.payload;
1219
+ return {
1220
+ ...state,
1221
+ variables: {
1222
+ ...state.variables,
1223
+ _indoubt: appendAudit(state.variables._indoubt, {
1224
+ stepId: p.stepId,
1225
+ ...(p.toolId !== undefined && { toolId: p.toolId }),
1226
+ ...(p.attempts !== undefined && { attempts: p.attempts }),
1227
+ ...(p.idempotencyKey !== undefined && { idempotencyKey: p.idempotencyKey }),
1228
+ }),
1229
+ },
1230
+ updatedAt: event.timestamp,
1231
+ };
1232
+ },
1233
+ // Loom 2.1 (K11.3) — a durable migration verdict: the execution was explicitly
1234
+ // resumed on DIFFERENT flow code (resumeAs, replay-check or operator-ack).
1235
+ // Re-points the recorded flow-code fingerprint (`_flowHash`) and the flow
1236
+ // resolution pointer (`_migratedTo` — the flow id this execution now resolves
1237
+ // to; unconditional so the fold never depends on seed identity), and appends
1238
+ // an audit entry to the reserved `_migrations` list. Replay re-applies the
1239
+ // same verdict at the same sequence (G1).
1240
+ EXECUTION_MIGRATED: (state, event) => {
1241
+ const p = event.payload;
1242
+ return {
1243
+ ...state,
1244
+ variables: {
1245
+ ...state.variables,
1246
+ ...(p.toHash !== undefined && { _flowHash: p.toHash }),
1247
+ ...(typeof p.toCodeHash === "string" && { _codeHash: p.toCodeHash }),
1248
+ // K11.5 — contract-versioned state migration: the recorded TRANSFORMED
1249
+ // input replaces `_input`, so every later resume/restore/replay folds
1250
+ // the same migrated value (the outcome is durable, not the function).
1251
+ ...(p.migratedInput !== undefined && { _input: p.migratedInput }),
1252
+ _migratedTo: p.toFlowId,
1253
+ _migrations: appendAudit(state.variables._migrations, {
1254
+ toFlowId: p.toFlowId,
1255
+ ...(p.toHash !== undefined && { toHash: p.toHash }),
1256
+ ...(p.fromHash !== undefined && { fromHash: p.fromHash }),
1257
+ ...(p.strategy !== undefined && { strategy: p.strategy }),
1258
+ ...(p.migratedInput !== undefined && { stateMigrated: true }),
1259
+ }),
1260
+ },
1261
+ updatedAt: event.timestamp,
1262
+ };
1263
+ },
1264
+ // Loom 2.1 (K12) — a VERIFIED approval: a ResumeAuthenticator confirmed the
1265
+ // identity behind an authority-gating resume, so "who approved" is durable
1266
+ // evidence, not an unverified claim. Folded into a reserved `_approvals`
1267
+ // audit list. Pure audit — the accompanying EXECUTION_RESUMED owns the
1268
+ // lifecycle; a REJECTED approval never reaches the log (the resume is refused
1269
+ // before any event is committed).
1270
+ APPROVAL_GRANTED: (state, event) => {
1271
+ const p = event.payload;
1272
+ return {
1273
+ ...state,
1274
+ variables: {
1275
+ ...state.variables,
1276
+ _approvals: appendAudit(state.variables._approvals, {
1277
+ ...(p.actor !== undefined && { actor: p.actor }),
1278
+ ...(p.verifiedBy !== undefined && { verifiedBy: p.verifiedBy }),
1279
+ ...(p.on !== undefined && { on: p.on }),
1280
+ at: event.timestamp,
1281
+ }),
1282
+ },
1283
+ updatedAt: event.timestamp,
1284
+ };
1285
+ },
1286
+ // Loom 2.1 (K10) — a crypto-shred tombstone: the subject's encryption key was
1287
+ // destroyed, so this execution's sensitive payload fields are permanently
1288
+ // unrecoverable (GDPR erasure of an append-only log). Written AT SHRED TIME by
1289
+ // the lifecycle service (never at replay). Marks `variables._shredded` so the
1290
+ // folded state records the erasure; a decrypting store read of the affected
1291
+ // fields fails loudly with PayloadShreddedError (the erasure is not silent).
1292
+ PAYLOAD_SHREDDED: (state, event) => {
1293
+ const p = event.payload;
1294
+ return {
1295
+ ...state,
1296
+ variables: {
1297
+ ...state.variables,
1298
+ _shredded: {
1299
+ ...(p.subjectId !== undefined && { subjectId: p.subjectId }),
1300
+ ...(p.reason !== undefined && { reason: p.reason }),
1301
+ at: event.timestamp,
1302
+ },
1303
+ },
1304
+ updatedAt: event.timestamp,
1305
+ };
1306
+ },
1307
+ // Loom 2.1 (K7) — an egress denial: the kernel-side broker refused an
1308
+ // isolated tool's network target (not in its static allowlist), BEFORE any
1309
+ // connection was opened. Folded into a reserved `_egress` audit list,
1310
+ // mirroring EFFECT_DENIED's `_denials`. Pure audit — whether the step fails
1311
+ // is the tool's error path (an uncaught denial fails via STEP_FAILED).
1312
+ EGRESS_DENIED: (state, event) => {
1313
+ const p = event.payload;
1314
+ return {
1315
+ ...state,
1316
+ variables: {
1317
+ ...state.variables,
1318
+ _egress: appendAudit(state.variables._egress, {
1319
+ ...(p.toolId !== undefined && { toolId: p.toolId }),
1320
+ ...(p.stepId !== undefined && { stepId: p.stepId }),
1321
+ ...(p.target !== undefined && { target: p.target }),
1322
+ ...(p.reason !== undefined && { reason: p.reason }),
1323
+ }),
1324
+ },
1325
+ updatedAt: event.timestamp,
1326
+ };
1327
+ },
1328
+ // Loom 2.1 (T1) — a durable timer fired: the wake that ends a
1329
+ // `ctx.sleep`/`sleepUntil` (or a timer-service-backed `suspend({ timeout })`).
1330
+ // Pure audit into the reserved `_timers` list — the EXECUTION_RESUMED that
1331
+ // accompanies it owns the status change. `wakeAt` is the SCHEDULED time (from
1332
+ // the log); `firedAt` (event timestamp) records actual delivery, so drift is
1333
+ // observable but never enters control flow.
1334
+ TIMER_FIRED: (state, event) => {
1335
+ const p = event.payload;
1336
+ return {
1337
+ ...state,
1338
+ variables: {
1339
+ ...state.variables,
1340
+ _timers: appendAudit(state.variables._timers, {
1341
+ firedAt: event.timestamp,
1342
+ ...(p.wakeAt !== undefined && { wakeAt: p.wakeAt }),
1343
+ ...(p.scheduleId !== undefined && { scheduleId: p.scheduleId }),
1344
+ ...(p.occurrence !== undefined && { occurrence: p.occurrence }),
1345
+ }),
1346
+ },
1347
+ updatedAt: event.timestamp,
1348
+ };
1349
+ },
1350
+ // Loom 2.0 (K3) — accumulate metered resource usage (tokens / cost / wall-time)
1351
+ // into a reserved `_resources` running total. Emitted by accounting middleware
1352
+ // when an effect actually runs, so the total is deterministic on replay.
1353
+ RESOURCE_METERED: (state, event) => {
1354
+ const p = event.payload;
1355
+ const r = state.variables._resources ?? { tokens: 0, costUsd: 0, ms: 0, count: 0 };
1356
+ // The new dimensions are included only when non-zero, so a log that never meters
1357
+ // them folds to exactly the pre-0032 `_resources` shape (byte-identical replay).
1358
+ const memBytes = (r.memBytes ?? 0) + (p.memBytes ?? 0);
1359
+ const wallMs = (r.wallMs ?? 0) + (p.wallMs ?? 0);
1360
+ const netBytes = (r.netBytes ?? 0) + (p.netBytes ?? 0);
1361
+ const custom = addCustomDimensions(r.custom, p.custom);
1362
+ return {
1363
+ ...state,
1364
+ variables: {
1365
+ ...state.variables,
1366
+ _resources: {
1367
+ tokens: r.tokens + (p.tokens ?? 0),
1368
+ costUsd: r.costUsd + (p.costUsd ?? 0),
1369
+ ms: r.ms + (p.ms ?? 0),
1370
+ count: r.count + 1,
1371
+ ...(memBytes > 0 && { memBytes }),
1372
+ ...(wallMs > 0 && { wallMs }),
1373
+ ...(netBytes > 0 && { netBytes }),
1374
+ ...(custom !== undefined && { custom }),
1375
+ ...(r.exceeded === true && { exceeded: true }),
1376
+ },
1377
+ },
1378
+ updatedAt: event.timestamp,
1379
+ };
1380
+ },
1381
+ // A quota admission-control denial: the execution exceeded its budget, so the
1382
+ // effect was refused. Marks `_resources.exceeded` (the STEP_FAILED that follows
1383
+ // fails the step). Pure audit.
1384
+ QUOTA_EXCEEDED: (state, event) => {
1385
+ const p = event.payload;
1386
+ const r = state.variables._resources ?? { tokens: 0, costUsd: 0, ms: 0, count: 0 };
1387
+ return {
1388
+ ...state,
1389
+ variables: {
1390
+ ...state.variables,
1391
+ _resources: { ...r, exceeded: true, ...(p.reason !== undefined && { reason: p.reason }) },
1392
+ },
1393
+ updatedAt: event.timestamp,
1394
+ };
1395
+ },
1396
+ // The three terminal handlers also end any open park, so they drop FAILED_WHILE_PARKED
1397
+ // (ADR 0105): the mark describes a park, and must not outlive it into a terminal state (where
1398
+ // a 1.20.0 snapshot, repaired, would not carry it — snapshot + tail ≡ full log).
1399
+ EXECUTION_COMPLETED: (state, event) => {
1400
+ const payload = event.payload;
1401
+ const variables = withoutFailedWhileParked(state.variables);
1402
+ return {
1403
+ ...state,
1404
+ status: "completed",
1405
+ ...(payload.output !== undefined
1406
+ ? { variables: { ...variables, _output: payload.output } }
1407
+ : variables !== state.variables && { variables }),
1408
+ updatedAt: event.timestamp,
1409
+ };
1410
+ },
1411
+ EXECUTION_FAILED: (state, event) => {
1412
+ const payload = event.payload;
1413
+ const variables = withoutFailedWhileParked(state.variables);
1414
+ return {
1415
+ ...state,
1416
+ status: "failed",
1417
+ ...(payload.error !== undefined
1418
+ ? { variables: { ...variables, _error: payload.error } }
1419
+ : variables !== state.variables && { variables }),
1420
+ updatedAt: event.timestamp,
1421
+ };
1422
+ },
1423
+ EXECUTION_CANCELLED: (state, event) => {
1424
+ const payload = event.payload;
1425
+ const variables = withoutFailedWhileParked(state.variables);
1426
+ return {
1427
+ ...state,
1428
+ status: "cancelled",
1429
+ ...(payload.reason !== undefined
1430
+ ? { variables: { ...variables, _cancelReason: payload.reason } }
1431
+ : variables !== state.variables && { variables }),
1432
+ updatedAt: event.timestamp,
1433
+ };
1434
+ },
1435
+ };
1436
+ function getHandler(eventType) {
1437
+ // `Object.hasOwn`, not plain indexing: `eventType` is caller-controlled all the way from an
1438
+ // ingress adapter (`runtime.resume`, and A2A `provideInput` passes a network-supplied
1439
+ // ResumeEvent straight through), and `handlers["__proto__"]` resolves up the prototype chain
1440
+ // to `Object.prototype` — TRUTHY, so `foldEvents`' `if (handler)` guard admits it and then
1441
+ // calls a non-function. An unknown event type must fold to a no-op, whatever it is named.
1442
+ if (!Object.hasOwn(handlers, eventType))
1443
+ return undefined;
1444
+ return handlers[eventType];
1445
+ }
1446
+ // ── Event-schema ABI governance (Loom 2.0 K5) ────────────────────────────────
1447
+ //
1448
+ // The event log is the kernel's on-disk ABI: state is `fold(events)`, and stored
1449
+ // logs must replay across deploys (this is what `@loomwork/deploy-sim` guards at the
1450
+ // flow level). This names and versions the *set of event types the fold understands*.
1451
+ // Governance: adding an event type is additive (bump MINOR, update the pinned list);
1452
+ // removing or repurposing one changes how historical logs fold and is a MAJOR break
1453
+ // that must be deliberate — the conformance test pins `knownEventTypes()`.
1454
+ // 1.4.0: additive optional `flowHash` payload field on EXECUTION_STARTED (K11.1) —
1455
+ // per the K5 payload-extension rule, folds treat absence as legacy.
1456
+ // 1.5.0: added EXECUTION_MIGRATED (K11.3).
1457
+ // 1.6.0: added TIMER_FIRED (T1 — durable timers).
1458
+ // 1.7.0: added EGRESS_DENIED (K7 — data-plane isolation & egress control).
1459
+ // 1.8.0: added PAYLOAD_SHREDDED (K10 — crypto-shredding).
1460
+ // 1.9.0: added APPROVAL_GRANTED (K12 — verified approvals).
1461
+ // 1.10.0: additive optional DECISION_RECORDED payload fields `alternatives`,
1462
+ // `estimatedCostUsd`, `estimatedLatencyMs`, `confidence` (ADR 0030 D1 — decision
1463
+ // materialization). Descriptive only; `getDecision` still returns just `choice`, so
1464
+ // folds treat absence as legacy and replay is byte-identical.
1465
+ // 1.11.0: additive optional RESOURCE_METERED payload fields `memBytes`, `wallMs`,
1466
+ // `netBytes` (ADR 0032 R1 — resource dimensions). Folded into `_resources` only when
1467
+ // non-zero, so a log that never meters them replays byte-identically.
1468
+ // 1.12.0: added EXECUTION_DENIED (C.1 — execution-lifecycle authz). Pure audit into
1469
+ // `_executionDenials`; a log that never denies replays byte-identically.
1470
+ // 1.13.0: added EXECUTION_ARCHIVED / EXECUTION_DELETED (K10.1 R5 — retention control
1471
+ // facts). Pure audit into `_lifecycle` on a dedicated stream; a log that never prunes
1472
+ // replays byte-identically.
1473
+ // 1.14.0: added ERASURE_PENDING (T3-R4.5 — deferred-erasure control fact). Pure audit into
1474
+ // `_erasure` on a dedicated stream; a log that never defers an erasure replays identically.
1475
+ // 1.15.0: additive optional EXECUTION_MIGRATED payload field `migratedInput` (K11.5,
1476
+ // ADR 0056 — contract-versioned state migration on resumeAs). When present it replaces
1477
+ // `_input` in the fold and stamps `stateMigrated: true` on the `_migrations` audit
1478
+ // entry; a log that never migrates state replays byte-identically.
1479
+ // 1.16.0: EXECUTION_RESUMED additionally folds `_resumePayloads` — the ORDERED list of
1480
+ // every resume payload (ADR 0057). Fixes the multi-gate suspend bug: the single-latest
1481
+ // `_resumePayload` (kept for compat) fed gate 1 the LAST gate's payload on replay.
1482
+ // Additive fold key from existing events; no payload or kind changes.
1483
+ // 1.17.0: additive optional STEP_FAILED payload fields `channel`, `payload` (DE-4,
1484
+ // ADR 0060 — typed failure channels). Folded into frame `errorChannel`/`errorPayload`;
1485
+ // presence makes the execute seam SUBSTITUTE the recorded failure on replay instead of
1486
+ // re-executing the step. A log that never fails typed replays byte-identically.
1487
+ // 1.18.0: additive optional DECISION_RECORDED payload fields `context`, `policy` (T2,
1488
+ // ADR 0079 — the observation a policy saw and the policy that saw it, incl. the
1489
+ // propensity that makes unbiased off-policy evaluation possible). Same descriptive
1490
+ // contract as the 1.10.0 D1 fields: folded verbatim into `_decisions`, NEVER read by
1491
+ // getDecision, so `choice` stays the sole replay input. A log whose decisions carry
1492
+ // neither field replays byte-identically.
1493
+ // 1.19.0: additive fold key `_flowEmits` from an additive envelope stamp (ADR 0102 —
1494
+ // flow-level emits append once per execution). A flow-level `ctx.emit` / `ctx.stream` /
1495
+ // `ctx.ui` envelope may carry `metadata.flowEmit = <16-hex content digest>`; the fold
1496
+ // counts stamped emits per digest for ANY event type (a generic step in `foldEvents`,
1497
+ // not a handler), so a re-run serves the emits the log already holds. No new kind, no
1498
+ // payload change; a log that never stamps replays byte-identically.
1499
+ // 1.20.0: EXECUTION_RESUMED with a `null` payload that answers no suspend point (the execution
1500
+ // was not parked, or was parked in doubt on "EffectInDoubtResolved") re-opens the execution
1501
+ // WITHOUT adding a gate passthrough (ADR 0104). Every other resume folds as before, so a log
1502
+ // that never carries such a resume replays byte-identically. Also additive: a human step's
1503
+ // EXECUTION_SUSPENDED condition carries the kernel-set `stepId` (folded verbatim into
1504
+ // `suspendedOn`, as the condition always was). No new event kind.
1505
+ // 1.21.0: STEP_FAILED no longer folds the EXECUTION status to "failed" (ADR 0105) — the flow may
1506
+ // catch it, and EXECUTION_FAILED is the execution's failure. No new kind or payload field; the
1507
+ // kernel writes the same events. A STEP_FAILED landing while the execution is parked sets the
1508
+ // reserved fold key `_failedWhileParked` (cleared by whatever ends the park), and a `null`
1509
+ // EXECUTION_RESUMED in that position stays status-only, exactly as 1.20.0 folded it — so every
1510
+ // gate passthrough (`_resumePayloads`) is identical under both. What differs from 1.20.0: the
1511
+ // derived `status` after a STEP_FAILED no lifecycle event has followed (`running`/`suspended`,
1512
+ // not `failed`), and the new key while such a park is open. Snapshots saved by an older fold
1513
+ // are read through `normalizeSnapshotState`.
1514
+ // 1.22.0: added EVENTS_REHOMED (ADR 0107 — the operator repair of the pre-0106 blank-stream
1515
+ // events). Pure audit into `_lifecycle` on the lifecycle stream; a log that is never repaired
1516
+ // replays byte-identically. The re-homed events themselves are the kinds they always were.
1517
+ // 1.23.0: Wave 2 (W2-0, ADRs 0109–0116) — every new fold handler and reserved shape, with no
1518
+ // writer yet. New kinds `EXECUTION_CONTINUED` (→ status `continued`, `_continuedTo`) and
1519
+ // `MESSAGE_CONSUMED` (→ `_inbox`, `_waitAnswers`); a payload and handler for the existing
1520
+ // `PARALLEL_COMPLETED` (→ `_scopes`); additive `EXECUTION_STARTED.{principal, parent, chainId,
1521
+ // generation, continuedFrom, codeHash}` (→ `_principal`, `_parent`, `_chain`, `_codeHash`; the
1522
+ // carry seeds `_resources` / `_inbox`), `EXECUTION_SUSPENDED.condition.waits[]` (→ `_waits`),
1523
+ // a wait-addressed `EXECUTION_RESUMED` `{ waitId, payload }` (→ `_waitAnswers`),
1524
+ // `RESOURCE_METERED.custom` (→ `_resources.custom`), `DECISION_RECORDED.estimatedCost` (the
1525
+ // descriptive open spelling of `estimatedCostUsd`, never a replay input),
1526
+ // `OUTCOME_RECORDED.{recordedBy, late}`.
1527
+ // The envelope metadata the fold now reads: `kernel` (the new kinds, `PARALLEL_COMPLETED` and a
1528
+ // wait-addressed resume fold ONLY a kernel-stamped envelope), `forkChain`, `forkedBy`. A log
1529
+ // carrying none of these replays byte-identically.
1530
+ // 1.24.0: ADR 0128 — each folded `_decisions` entry carries `seq`, the envelope sequence of the
1531
+ // FIRST `DECISION_RECORDED` for that id, so decision ORDER survives a checkpoint (key order does
1532
+ // not survive array-index-like ids or a normalising snapshot store such as Postgres `jsonb`).
1533
+ // Descriptive only: `getDecision` still returns just `choice`. A snapshot written by a 1.23 fold
1534
+ // has no `seq` on its absorbed entries; those are LEGACY and read in map order (no migration, the
1535
+ // same documented exception to snapshot+tail == full fold as 1.21.0). Log bytes are unchanged.
1536
+ // 1.25.0: ADR 0130 residuals (D7-D9), RESERVED - shapes frozen, handlers present, NO writer yet, so
1537
+ // a log written before this version replays byte-identically. (1) The envelope key
1538
+ // `metadata.seal` (`{ v: 1, cipher, covers }`, D7: a sealing decorator's marker): the fold
1539
+ // IGNORES it, so state equals the same log without it. (2) New kinds `INBOX_QUARANTINED`
1540
+ // (`{ topic, inboxSeq, key, messageId, heldAt, operator, reason? }` -> cursor advance by `max`,
1541
+ // an entry pushed onto `_quarantined[topic]`, the dedupe window untouched) and `INBOX_RELEASED`
1542
+ // (`{ topic, key, messageId, disposition: "deliver" | "discard", operator? | auto?, reason? }`:
1543
+ // exactly one of `operator` / `auto`, `auto` only with `deliver`; a fact with both or neither is
1544
+ // ignored; acts only on the topic's FIRST entry while `held`), BOTH folded only from a
1545
+ // kernel-stamped envelope (`metadata.kernel === true`) and both `ignorable` for the park fence.
1546
+ // (3) The fold variable `_quarantined` (`{ [topic]: Array<{ key, inboxSeq, heldAt, state }> }`,
1547
+ // queue shape `released? held*`, at most QUARANTINE_FOLD_CAP = 1024 entries in total). (4)
1548
+ // `MESSAGE_CONSUMED` additionally drops the topic's first `released` entry whose `key` equals the
1549
+ // fact's `messageId`, gated on `_quarantined` existing. (5) `EXECUTION_STARTED.continuedFrom.
1550
+ // carried.quarantined`, a validated seed (invalid parts ignored). Byte-identity: a log carrying
1551
+ // none of these shapes folds exactly as under 1.24.0.
1552
+ // 1.26.0: ADR 0138 D8, NO new kind and NO payload change. The fold stops copying `input` into a frame
1553
+ // when it SETTLES (STEP_COMPLETED / STEP_FAILED / STEP_CANCELLED); STEP_STARTED and STEP_RETRYING keep it,
1554
+ // so an in-flight frame still shows what is running. The folded BYTES change (state is linear in turns, not
1555
+ // quadratic); `normalizeSnapshotState` strips `input` from the settled frames of an older snapshot in one
1556
+ // O(frames) pass, so `fold(snapshot, tail) == fold(events)` holds. Nothing reads a settled frame's input
1557
+ // from the fold; the log (or the compaction archive, ADR 0138) holds it. A pre-1.26 node re-adds an input
1558
+ // to a frame it settles (harmless; stripped by the next normalize) - the documented mixed-fleet exception.
1559
+ exports.EVENT_SCHEMA_VERSION = "1.26.0";
1560
+ function knownEventTypes() {
1561
+ return Object.keys(handlers).sort();
1562
+ }
1563
+ //# sourceMappingURL=eventHandlers.js.map