@sensigo/realm 0.31.2 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/engine/abandon-run.d.ts.map +1 -1
  2. package/dist/engine/abandon-run.js +17 -9
  3. package/dist/engine/abandon-run.js.map +1 -1
  4. package/dist/engine/apply-resume.d.ts +43 -0
  5. package/dist/engine/apply-resume.d.ts.map +1 -0
  6. package/dist/engine/apply-resume.js +80 -0
  7. package/dist/engine/apply-resume.js.map +1 -0
  8. package/dist/engine/defaulted-steps.d.ts +17 -0
  9. package/dist/engine/defaulted-steps.d.ts.map +1 -0
  10. package/dist/engine/defaulted-steps.js +26 -0
  11. package/dist/engine/defaulted-steps.js.map +1 -0
  12. package/dist/engine/eligibility.d.ts +20 -2
  13. package/dist/engine/eligibility.d.ts.map +1 -1
  14. package/dist/engine/eligibility.js +46 -24
  15. package/dist/engine/eligibility.js.map +1 -1
  16. package/dist/engine/execution-loop.d.ts +38 -3
  17. package/dist/engine/execution-loop.d.ts.map +1 -1
  18. package/dist/engine/execution-loop.js +1500 -112
  19. package/dist/engine/execution-loop.js.map +1 -1
  20. package/dist/engine/lifecycle.d.ts +14 -0
  21. package/dist/engine/lifecycle.d.ts.map +1 -1
  22. package/dist/engine/lifecycle.js +14 -0
  23. package/dist/engine/lifecycle.js.map +1 -1
  24. package/dist/engine/run-health.d.ts +1 -1
  25. package/dist/engine/run-health.d.ts.map +1 -1
  26. package/dist/engine/run-health.js +88 -4
  27. package/dist/engine/run-health.js.map +1 -1
  28. package/dist/engine/settlement.d.ts +38 -0
  29. package/dist/engine/settlement.d.ts.map +1 -0
  30. package/dist/engine/settlement.js +825 -0
  31. package/dist/engine/settlement.js.map +1 -0
  32. package/dist/index.d.ts +8 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +11 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/store/json-file-store.d.ts +56 -3
  37. package/dist/store/json-file-store.d.ts.map +1 -1
  38. package/dist/store/json-file-store.js +178 -22
  39. package/dist/store/json-file-store.js.map +1 -1
  40. package/dist/store/store-interface.d.ts +50 -1
  41. package/dist/store/store-interface.d.ts.map +1 -1
  42. package/dist/types/run-record.d.ts +90 -0
  43. package/dist/types/run-record.d.ts.map +1 -1
  44. package/dist/types/settlement.d.ts +248 -0
  45. package/dist/types/settlement.d.ts.map +1 -0
  46. package/dist/types/settlement.js +2 -0
  47. package/dist/types/settlement.js.map +1 -0
  48. package/dist/types/workflow-error.d.ts +1 -1
  49. package/dist/types/workflow-error.d.ts.map +1 -1
  50. package/dist/types/workflow-error.js.map +1 -1
  51. package/package.json +1 -1
@@ -0,0 +1,825 @@
1
+ import { deriveRunPhase, isWorkflowComplete, findEligibleSteps, findEligibleGuardSteps, propagateSkips, } from './eligibility.js';
2
+ import { deriveDefaultedSteps } from './defaulted-steps.js';
3
+ import { captureEvidence } from '../evidence/snapshot.js';
4
+ import { omitClaim } from './claim-liveness.js';
5
+ import { DRAIN_LEASE_MAX } from './lifecycle.js';
6
+ // ---------------------------------------------------------------------------
7
+ // §2 normative helpers
8
+ // ---------------------------------------------------------------------------
9
+ /** `isTerminal(fresh) := fresh.terminal_state === true` — BU-blocking adjudication; F1 preserved
10
+ * (abandon/abort SET terminal_state, so this reads correctly for both). */
11
+ function isTerminal(fresh) {
12
+ return fresh.terminal_state === true;
13
+ }
14
+ /** `norm(t) := t ?? null` — absent≡absent normalization (issue #197's grandfathered-claims
15
+ * precedent), so a token-less claim/entry is never spuriously distinguished from a `null` one. */
16
+ function norm(t) {
17
+ return t ?? null;
18
+ }
19
+ /** `tokensEqual(a,b) := norm(a) === norm(b)`. */
20
+ function tokensEqual(a, b) {
21
+ return norm(a) === norm(b);
22
+ }
23
+ /** `M := {complete: completed_steps, fail: failed_steps, skip: skipped_steps, gate:
24
+ * completed_steps}` — the membership array a settled-map entry's `outcome` maps to. `gate`
25
+ * (issue #279, increment 2, PR-C — design record §2) joined alongside `completed_steps`: a
26
+ * resolved gate's step physically lands there, same as a `complete` settle_step outcome. */
27
+ function membershipFor(fresh, outcome) {
28
+ switch (outcome) {
29
+ case 'complete':
30
+ case 'gate':
31
+ return fresh.completed_steps;
32
+ case 'fail':
33
+ return fresh.failed_steps;
34
+ case 'skip':
35
+ return fresh.skipped_steps;
36
+ }
37
+ }
38
+ /**
39
+ * `entryOf(fresh, s) := e = fresh.settled?.[s]; e !== undefined && s ∈ M[e.outcome](fresh) ? e :
40
+ * undefined` — the ORPHAN RULE: an entry without matching membership is treated as absent (the
41
+ * `claimStep` :512 overwrite-self-heal precedent, generalized). Never occurs through `settleStep`
42
+ * itself (APPLY always writes both atomically) — guards a hand-authored fixture or an external
43
+ * store's divergent history from wedging the predicate.
44
+ */
45
+ function entryOf(fresh, step) {
46
+ const e = fresh.settled?.[step];
47
+ if (e === undefined)
48
+ return undefined;
49
+ return membershipFor(fresh, e.outcome).includes(step) ? e : undefined;
50
+ }
51
+ /** `toSettledOutcome := {complete↦'complete', fail↦'fail', abort↦'skip'}`. */
52
+ function toSettledOutcome(outcome) {
53
+ switch (outcome) {
54
+ case 'complete':
55
+ return 'complete';
56
+ case 'fail':
57
+ return 'fail';
58
+ case 'abort':
59
+ return 'skip';
60
+ }
61
+ }
62
+ /** The pending subset of a finalizer ledger, ascending by `rank` — the order the drain loop (§6)
63
+ * consumes. A convenience snapshot for {@link SettlementResult}'s `pendingFinalizers` field. */
64
+ function pendingFinalizerNames(ledger) {
65
+ if (ledger === undefined)
66
+ return [];
67
+ return Object.entries(ledger)
68
+ .filter(([, e]) => e.status === 'pending')
69
+ .sort(([, a], [, b]) => a.rank - b.rank)
70
+ .map(([name]) => name);
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Finalizer selection — extracted from execution-loop.ts's buildFinalizedSeal (the ONE
74
+ // engine-file touch this PR makes). Behavior-preserving by construction: the legacy caller passes
75
+ // the SAME (definition, settled-step-names, outcome) inputs and gets the SAME ordered name list
76
+ // back that its own inline grouping loop used to compute.
77
+ // ---------------------------------------------------------------------------
78
+ /** Normalizes a finalizer's `on_outcome` to a set of triggers (moved verbatim from
79
+ * execution-loop.ts:3079 as part of the extraction). */
80
+ function finalizerTriggers(stepDef) {
81
+ const raw = stepDef.on_outcome;
82
+ if (raw === undefined)
83
+ return new Set();
84
+ return new Set(Array.isArray(raw) ? raw : [raw]);
85
+ }
86
+ /**
87
+ * Selects the finalizer steps that fire for a terminal `outcome`, in the drain order (design
88
+ * record §4/§6; extracted from `buildFinalizedSeal`'s selection, execution-loop.ts :3117-3126):
89
+ * Group A (rank precedence) — `on_outcome` contains `outcome` (the specific catch/complete arm);
90
+ * Group B — `on_outcome` contains `'always'` but NOT `outcome` (a finalizer listing both runs
91
+ * once, in Group A). Each group in `Object.entries` declaration order; Group A then Group B.
92
+ * `settledStepNames` excludes any finalizer already at-most-once settled (resume/re-drive safety)
93
+ * — pass `completed_steps ∪ failed_steps`, NEVER the `RunRecord.settled` map (a different,
94
+ * per-step-outcome-keyed structure this selection does not consult).
95
+ */
96
+ export function selectFinalizers(definition, settledStepNames, outcome) {
97
+ const groupA = [];
98
+ const groupB = [];
99
+ for (const [name, step] of Object.entries(definition.steps)) {
100
+ if (step.execution !== 'finalizer')
101
+ continue;
102
+ if (settledStepNames.has(name))
103
+ continue; // at-most-once per run (resume / re-drive safety)
104
+ const triggers = finalizerTriggers(step);
105
+ if (triggers.has(outcome))
106
+ groupA.push(name);
107
+ else if (triggers.has('always'))
108
+ groupB.push(name);
109
+ }
110
+ return [...groupA, ...groupB];
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // §4 mintFresh (terminal false→true edge only; same atomic write)
114
+ // ---------------------------------------------------------------------------
115
+ /**
116
+ * `mintFresh` (design record §4): on a terminal false→true edge, selects the matching finalizers
117
+ * and seeds a CLEAN `'pending'` entry for each one not already `completed`/`failed` (the
118
+ * never-downgrade guard — defensive; membership-skip in `selectFinalizers` should already exclude
119
+ * these). Rank totally orders the freshly-minted PENDING set only, starting at 0 for THIS mint
120
+ * pass — collisions with terminal-status entries are legal and inert (§1's "Resume VOIDS
121
+ * pendings, loudly" means no OTHER pending entry can coexist with a fresh mint in this
122
+ * increment's design). Non-selected entries keep their status verbatim (history:
123
+ * completed/failed/voided). Returns `record.finalizer_ledger` UNCHANGED (by reference) when
124
+ * nothing is selected — the zero-finalizer / no-matching-trigger case falls out of this naturally,
125
+ * without needing `buildFinalizedSeal`'s own explicit fast-path.
126
+ */
127
+ function mintFresh(record, definition, outcome) {
128
+ const settledStepNames = new Set([...record.completed_steps, ...record.failed_steps]);
129
+ const selected = selectFinalizers(definition, settledStepNames, outcome);
130
+ if (selected.length === 0)
131
+ return record.finalizer_ledger;
132
+ const ledger = { ...record.finalizer_ledger };
133
+ let rank = 0;
134
+ for (const name of selected) {
135
+ const prior = record.finalizer_ledger?.[name];
136
+ if (prior?.status === 'completed' || prior?.status === 'failed')
137
+ continue; // never-downgrade
138
+ ledger[name] = { status: 'pending', rank: rank++ }; // CLEAN mint — no lease fields cross an edge
139
+ }
140
+ return ledger;
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // §4 shared APPLY postconditions (design record design-d5-increment2.md §4, hoisted — lens-2 F4:
144
+ // "one implementation, every kind routes through it"). Every kind whose APPLY can terminalize
145
+ // (settle_step complete/fail/abort [shipped]; settle_gate resolution-complete; settle_guard
146
+ // pass/resolution_error/abort [increment 2, PR-C]) calls this ONE function to (1) mint fresh
147
+ // finalizers on a genuine terminal false→true edge (§4.1), (2) stamp `defaulted_steps` on a
148
+ // COMPLETE-terminal edge only (§4.2), and (3) derive `run_phase` uniformly (§4.5) — regardless of
149
+ // whether this particular APPLY actually transitioned.
150
+ // ---------------------------------------------------------------------------
151
+ /**
152
+ * `record.terminal_state` must already reflect the kind-specific terminal decision (each arm
153
+ * computes its own `isComplete`/unconditional-abort logic BEFORE calling this) — every in-contract
154
+ * caller has already refused `run_terminal` earlier in its own arm, so `record.terminal_state` can
155
+ * only be transitioning `false → true` here, never `true → true`; `transitioned` is therefore
156
+ * simply the post-write value, read back explicitly (not assumed) so a future caller that ever
157
+ * violates that precondition fails loudly via a wrong `transitioned` value rather than silently.
158
+ */
159
+ function applyTerminalPostconditions(record, definition, mintOutcome, stampDefaulted) {
160
+ const transitioned = record.terminal_state === true;
161
+ let sealed = record;
162
+ if (transitioned) {
163
+ // On terminal false→true edge: mintFresh (§4.1), same atomic write.
164
+ const ledger = mintFresh(record, definition, mintOutcome);
165
+ sealed = { ...record, ...(ledger !== undefined ? { finalizer_ledger: ledger } : {}) };
166
+ // defaulted_steps stamped IFF a COMPLETE-terminal edge (§4.2; the FM-5/#232 guard) — never on
167
+ // a fail/abort seal, even one that terminalizes.
168
+ if (stampDefaulted) {
169
+ const defaultedSteps = deriveDefaultedSteps(sealed.evidence);
170
+ if (defaultedSteps.length > 0)
171
+ sealed = { ...sealed, defaulted_steps: defaultedSteps };
172
+ }
173
+ }
174
+ const withPhase = { ...sealed, run_phase: deriveRunPhase(sealed) };
175
+ return { run: withPhase, transitioned };
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // §3 settleStepArms
179
+ // ---------------------------------------------------------------------------
180
+ function applySettleStep(fresh, delta, definition, now) {
181
+ const { step, outcome, claimToken, evidence, failureMessage, abort } = delta;
182
+ // Idempotence arms BEFORE terminal/claim (L21 ii).
183
+ const existing = entryOf(fresh, step);
184
+ if (existing !== undefined) {
185
+ if (!tokensEqual(existing.token, claimToken)) {
186
+ return { applied: false, reason: 'already_settled_by_other', run: fresh };
187
+ }
188
+ if (toSettledOutcome(outcome) === existing.outcome) {
189
+ return { applied: false, reason: 'already_settled', run: fresh }; // drain-aware (§6)
190
+ }
191
+ return { applied: false, reason: 'settled_outcome_divergence', run: fresh };
192
+ }
193
+ if (isTerminal(fresh)) {
194
+ return { applied: false, reason: 'run_terminal', run: fresh };
195
+ }
196
+ const claim = fresh.claims?.[step];
197
+ if (claim === undefined || !tokensEqual(claim.token, claimToken)) {
198
+ return { applied: false, reason: 'claim_lost', run: fresh };
199
+ }
200
+ if (fresh.pending_gate?.step_name === step) {
201
+ return { applied: false, reason: 'gate_mismatch', run: fresh }; // legacy gates coexist in inc-1
202
+ }
203
+ if (outcome === 'abort' && abort === undefined) {
204
+ // Caller-programming-error, not a predicate outcome — see SettleStepDelta's own doc.
205
+ throw new Error(`applySettlement contract violation: settle_step delta for step '${step}' has ` +
206
+ `outcome:'abort' but no 'abort' payload`);
207
+ }
208
+ // APPLY (total; bound to source semantics by line — design record §3).
209
+ const settledOutcome = toSettledOutcome(outcome);
210
+ const withMembership = {
211
+ ...fresh,
212
+ in_progress_steps: fresh.in_progress_steps.filter((s) => s !== step),
213
+ claims: omitClaim(fresh.claims, step),
214
+ evidence: [...fresh.evidence, ...evidence],
215
+ settled: { ...fresh.settled, [step]: { token: norm(claimToken), outcome: settledOutcome } },
216
+ ...(outcome === 'complete' ? { completed_steps: [...fresh.completed_steps, step] } : {}),
217
+ ...(outcome === 'fail' ? { failed_steps: [...fresh.failed_steps, step] } : {}),
218
+ ...(outcome === 'abort' ? { skipped_steps: [...fresh.skipped_steps, step] } : {}),
219
+ };
220
+ if (outcome === 'abort') {
221
+ return applyAbortEdge(fresh, withMembership, step, abort, definition, now);
222
+ }
223
+ return applyCompleteOrFailEdge(withMembership, step, outcome, failureMessage, definition);
224
+ }
225
+ /**
226
+ * Handler-abort (execution-loop.ts :1784-1811 semantics): UNCONDITIONALLY terminal — never gated
227
+ * by the two-disjunct `isComplete` predicate (that predicate governs complete/fail only; an abort
228
+ * always ends the run immediately, mirroring `executeGuardStep`'s own guard-abort branch).
229
+ */
230
+ function applyAbortEdge(fresh, withMembership, step, abort, definition, now) {
231
+ const propagated = propagateSkips(withMembership, definition);
232
+ const withSkipped = {
233
+ ...withMembership,
234
+ skipped_steps: propagated.skipped,
235
+ skip_details: { ...propagated.details, [step]: { kind: 'handler_abort' } },
236
+ };
237
+ let aborted = {
238
+ ...withSkipped,
239
+ terminal_state: true,
240
+ terminal_reason: `Handler '${step}' aborted the run: ${abort.abortMessage}`,
241
+ aborted_at: { step_id: abort.stepId, abort_message: abort.abortMessage },
242
+ };
243
+ // Cancel an open gate on ANOTHER step in the SAME write (design record §3) — a genuinely NEW
244
+ // capability the legacy handler-abort path lacks today (it preserves pending_gate untouched,
245
+ // which is exactly the class of inconsistent state #279 exists to close). `fresh.pending_gate`
246
+ // is read (not `aborted.pending_gate`) only for clarity — both are identical at this point since
247
+ // nothing above has touched it.
248
+ if (fresh.pending_gate !== undefined && fresh.pending_gate.step_name !== step) {
249
+ const gateStepName = fresh.pending_gate.step_name;
250
+ const cancelledGateId = fresh.pending_gate.gate_id;
251
+ const { pending_gate: _droppedGate, ...withoutGate } = aborted;
252
+ aborted = {
253
+ ...withoutGate,
254
+ in_progress_steps: withoutGate.in_progress_steps.filter((s) => s !== gateStepName),
255
+ claims: omitClaim(withoutGate.claims, gateStepName),
256
+ skipped_steps: [...withoutGate.skipped_steps, gateStepName],
257
+ skip_details: {
258
+ ...withoutGate.skip_details,
259
+ // gate_id additive (design record §5 D-4) — the settle_gate run_terminal envelope's
260
+ // cancelled-variant discriminator binds by this once populated.
261
+ [gateStepName]: { kind: 'gate_cancelled_by_abort', gate_id: cancelledGateId },
262
+ },
263
+ evidence: [
264
+ ...withoutGate.evidence,
265
+ captureEvidence({
266
+ stepId: gateStepName,
267
+ startedAt: now,
268
+ completedAt: now,
269
+ input: {},
270
+ output: { gate_cancelled_by_abort: true, aborted_by: step, gate_id: cancelledGateId },
271
+ }),
272
+ ],
273
+ };
274
+ }
275
+ // §4 shared postconditions: abort NEVER stamps defaulted_steps (the FM-5/#232 guard — only the
276
+ // complete edge does); `transitioned` is always true here (isTerminal(fresh) was already
277
+ // refused above, and `aborted.terminal_state` is unconditionally true).
278
+ const { run, transitioned } = applyTerminalPostconditions(aborted, definition, 'abort', false);
279
+ return {
280
+ applied: true,
281
+ run,
282
+ transitioned,
283
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
284
+ };
285
+ }
286
+ /** complete / fail: the TWO-DISJUNCT `isComplete` predicate (execution-loop.ts :2579-2583 /
287
+ * :2237-2241 — named, not implied). */
288
+ function applyCompleteOrFailEdge(withMembership, step, outcome, failureMessage, definition) {
289
+ const propagated = propagateSkips(withMembership, definition);
290
+ const withSkipped = {
291
+ ...withMembership,
292
+ skipped_steps: propagated.skipped,
293
+ skip_details: propagated.details,
294
+ };
295
+ const isComplete = isWorkflowComplete(withSkipped, definition) ||
296
+ (withSkipped.in_progress_steps.length === 0 &&
297
+ findEligibleSteps(definition, withSkipped).length === 0 &&
298
+ findEligibleGuardSteps(definition, withSkipped).length === 0);
299
+ const draft = {
300
+ ...withSkipped,
301
+ terminal_state: isComplete,
302
+ ...(isComplete
303
+ ? {
304
+ terminal_reason: outcome === 'complete'
305
+ ? 'Workflow completed.' // eligibility.ts:47 keys deriveRunPhase's 'completed' on this
306
+ : `Step '${step}' failed: ${failureMessage ?? 'unknown error'}`,
307
+ }
308
+ : {}),
309
+ };
310
+ // §4 shared postconditions: defaulted_steps stamps IFF this is a COMPLETE-terminal edge — never
311
+ // on a fail seal, even one that terminalizes.
312
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, outcome, outcome === 'complete' && isComplete);
313
+ return {
314
+ applied: true,
315
+ run,
316
+ transitioned,
317
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
318
+ };
319
+ }
320
+ // ---------------------------------------------------------------------------
321
+ // §3 openGateArms (issue #279, increment 2, PR-C) — fence = claimToken; entry lookup FIRST
322
+ // (design record lens-2 F1).
323
+ // ---------------------------------------------------------------------------
324
+ function applyOpenGate(fresh, delta) {
325
+ const { step, claimToken, pendingGate, evidence } = delta;
326
+ // Idempotence arm BEFORE terminal/claim (mirrors settleStepArms's own ordering, L21 ii).
327
+ const existing = entryOf(fresh, step);
328
+ if (existing !== undefined) {
329
+ if (existing.outcome === 'gate' && existing.token === pendingGate.gate_id) {
330
+ // Exact-delta replay AFTER the gate already resolved (BU F6) — the gate this delta is
331
+ // trying to open is the SAME one already committed as resolved.
332
+ return { applied: false, reason: 'already_settled', run: fresh };
333
+ }
334
+ // Envelope text stays neutral (N1 — no "by_other" amplification) at the caller (PR-D).
335
+ return { applied: false, reason: 'already_settled_by_other', run: fresh };
336
+ }
337
+ if (isTerminal(fresh)) {
338
+ return { applied: false, reason: 'run_terminal', run: fresh };
339
+ }
340
+ if (fresh.pending_gate !== undefined) {
341
+ if (fresh.pending_gate.step_name === step) {
342
+ if (fresh.pending_gate.gate_id === pendingGate.gate_id) {
343
+ // Exact-delta replay, gate still open (e.g. a retried gate-open write).
344
+ return { applied: false, reason: 'already_settled', run: fresh };
345
+ }
346
+ const claim = fresh.claims?.[step];
347
+ if (claim !== undefined && tokensEqual(claim.token, claimToken)) {
348
+ // D-1: the LIVE gate wins, rendered VERBATIM. In-contract UNREACHABLE (claimStep's
349
+ // in-flight guard + reclaim's own open-gate refusal both prevent a second open_gate
350
+ // attempt from ever reaching here with a live claim) — defensive.
351
+ return { applied: false, reason: 'already_open', run: fresh, gate: fresh.pending_gate };
352
+ }
353
+ // Same step, different claimant — defensive (a claim can't be re-acquired under an open
354
+ // gate; findEligibleSteps returns [] while a gate is open).
355
+ return { applied: false, reason: 'claim_lost', run: fresh };
356
+ }
357
+ // A gate open on ANOTHER step — serialization; the step named here STAYS claimed (L13
358
+ // asserts this — the caller's recovery path is to wait for the live gate to resolve).
359
+ return { applied: false, reason: 'gate_mismatch', run: fresh };
360
+ }
361
+ const claim = fresh.claims?.[step];
362
+ if (claim === undefined || !tokensEqual(claim.token, claimToken)) {
363
+ return { applied: false, reason: 'claim_lost', run: fresh };
364
+ }
365
+ // APPLY OPEN: pending_gate set (delta-carried verbatim); evidence append; CLAIM RETAINED + step
366
+ // stays in_progress (execution-loop.ts:2958 — retention keeps isComplete sound, G-1). Never
367
+ // terminalizes (design record §4.1) — run_phase still derives (§4.5: transform-owned uniformly).
368
+ const withGate = {
369
+ ...fresh,
370
+ evidence: [...fresh.evidence, ...evidence],
371
+ pending_gate: pendingGate,
372
+ };
373
+ const run = { ...withGate, run_phase: deriveRunPhase(withGate) };
374
+ return {
375
+ applied: true,
376
+ run,
377
+ transitioned: false,
378
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
379
+ };
380
+ }
381
+ // ---------------------------------------------------------------------------
382
+ // §3 settleGateArms (issue #279, increment 2, PR-C) — fence = gateId ONLY (L20). ZERO claim arms.
383
+ // ---------------------------------------------------------------------------
384
+ /**
385
+ * Finds the (at most one, per G-2) `settled` entry recording a resolved gate matching `gateId` —
386
+ * searched by gateId (the settle_gate fence), not by a known step name, since a gate submission
387
+ * carries only the gate_id. `first` (design record §3): lookup runs FIRST for fail-safety under
388
+ * corruption (D3 §0.2) — soundness of both the lookup and the "first" quantifier rests on G-2
389
+ * (TERMINAL_GATE_EXCLUSION) plus per-attempt gate_id uniqueness plus the membership conjunct (the
390
+ * orphan rule, generalized): a G-2-violating corrupt both-match record makes iteration order
391
+ * store-dependent, which is exactly why the fail-safe direction (NOOP, never RESOLVE) is pinned
392
+ * at the CALLER (this function returns whichever match Object.entries visits first — a real store
393
+ * never produces two, so this never matters in-contract).
394
+ */
395
+ function findSettledGateEntry(fresh, gateId) {
396
+ for (const [step, entry] of Object.entries(fresh.settled ?? {})) {
397
+ if (entry.outcome !== 'gate' || entry.token !== gateId)
398
+ continue;
399
+ if (!membershipFor(fresh, entry.outcome).includes(step))
400
+ continue; // orphan rule
401
+ return { step, choice: entry.choice };
402
+ }
403
+ return undefined;
404
+ }
405
+ function applySettleGate(fresh, delta, definition) {
406
+ const { gateId, choice, evidence } = delta;
407
+ // Lookup FIRST (D3 §0.2 fail-safer-under-corruption; L21 ii: the own-commit may have already
408
+ // flipped terminal).
409
+ const hit = findSettledGateEntry(fresh, gateId);
410
+ if (hit !== undefined) {
411
+ if (hit.choice === choice) {
412
+ // Double-submit / two-gates delayed retry (TD F1) — same choice, idempotent no-op.
413
+ return { applied: false, reason: 'already_settled', run: fresh };
414
+ }
415
+ return {
416
+ applied: false,
417
+ reason: 'gate_choice_conflict',
418
+ run: fresh,
419
+ ...(hit.choice !== undefined ? { winningChoice: hit.choice } : {}),
420
+ };
421
+ }
422
+ // Zombie / stale submit — BEFORE the live-gate arm (matches the shipped `applySettleStep`
423
+ // terminal-first order `:215-217`, AND the live `submitHumanResponse` site's own terminal-first
424
+ // check `:3431`): a grandfathered terminal∧pending_gate record refuses run_terminal instead of
425
+ // resurrecting the run or falsely completing it.
426
+ if (isTerminal(fresh)) {
427
+ return { applied: false, reason: 'run_terminal', run: fresh };
428
+ }
429
+ if (fresh.pending_gate !== undefined && fresh.pending_gate.gate_id === gateId) {
430
+ if (!fresh.pending_gate.choices.includes(choice)) {
431
+ return {
432
+ applied: false,
433
+ reason: 'choice_not_eligible',
434
+ run: fresh,
435
+ choices: fresh.pending_gate.choices,
436
+ };
437
+ }
438
+ // APPLY RESOLVE: clear pending_gate; completed_steps += step_name; release claim +
439
+ // in_progress (execution-loop.ts:3519-3520 parity); settled[step] = {token: gateId,
440
+ // outcome:'gate', choice} — 'gate' LITERAL here, toSettledOutcome's own SettleStepOutcome
441
+ // domain stays untouched (§2).
442
+ const stepName = fresh.pending_gate.step_name;
443
+ const { pending_gate: _pg, ...rest } = fresh;
444
+ const withMembership = {
445
+ ...rest,
446
+ in_progress_steps: rest.in_progress_steps.filter((s) => s !== stepName),
447
+ claims: omitClaim(rest.claims, stepName),
448
+ completed_steps: [...rest.completed_steps, stepName],
449
+ evidence: [...rest.evidence, ...evidence],
450
+ settled: { ...rest.settled, [stepName]: { token: gateId, outcome: 'gate', choice } },
451
+ };
452
+ const propagated = propagateSkips(withMembership, definition);
453
+ const withSkipped = {
454
+ ...withMembership,
455
+ skipped_steps: propagated.skipped,
456
+ skip_details: propagated.details,
457
+ };
458
+ const isComplete = isWorkflowComplete(withSkipped, definition) ||
459
+ (withSkipped.in_progress_steps.length === 0 &&
460
+ findEligibleSteps(definition, withSkipped).length === 0 &&
461
+ findEligibleGuardSteps(definition, withSkipped).length === 0);
462
+ const draft = {
463
+ ...withSkipped,
464
+ terminal_state: isComplete,
465
+ // eligibility.ts:47 keys deriveRunPhase's 'completed' on this exact string.
466
+ ...(isComplete ? { terminal_reason: 'Workflow completed.' } : {}),
467
+ };
468
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, 'complete', isComplete);
469
+ return {
470
+ applied: true,
471
+ run,
472
+ transitioned,
473
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
474
+ };
475
+ }
476
+ // Superseded/unknown gateId on a live run.
477
+ return { applied: false, reason: 'gate_mismatch', run: fresh };
478
+ }
479
+ // ---------------------------------------------------------------------------
480
+ // §3 settleGuardArms (issue #279, increment 2, PR-C) — fence = ⊥ (guards are never claimed,
481
+ // eligibility.ts:418); writes NO settled entry (SE-4).
482
+ // ---------------------------------------------------------------------------
483
+ function ownMembershipFor(fresh, outcome) {
484
+ switch (outcome) {
485
+ case 'pass':
486
+ return fresh.completed_steps;
487
+ case 'resolution_error':
488
+ return fresh.failed_steps;
489
+ case 'abort':
490
+ return fresh.skipped_steps;
491
+ }
492
+ }
493
+ function applySettleGuard(fresh, delta, definition) {
494
+ const { step, outcome, evidence, resolutionError, abort } = delta;
495
+ if (outcome === 'resolution_error' && resolutionError === undefined) {
496
+ // Caller-programming-error, not a predicate outcome (the SettleStepDelta abort precedent).
497
+ throw new Error(`applySettlement contract violation: settle_guard delta for step '${step}' has ` +
498
+ `outcome:'resolution_error' but no 'resolutionError' payload`);
499
+ }
500
+ if (outcome === 'abort' && abort === undefined) {
501
+ throw new Error(`applySettlement contract violation: settle_guard delta for step '${step}' has ` +
502
+ `outcome:'abort' but no 'abort' payload`);
503
+ }
504
+ // A := {pass: completed_steps, resolution_error: failed_steps, abort: skipped_steps} (lens-1 F8).
505
+ if (ownMembershipFor(fresh, outcome).includes(step)) {
506
+ if (outcome === 'abort' && fresh.skip_details?.[step]?.kind !== 'guard_abort') {
507
+ // In skipped_steps, but NOT via a prior guard_abort (e.g. when_false/trigger_rule_
508
+ // unsatisfiable instead) — a genuine divergence, not this guard's own convergent retry.
509
+ return {
510
+ applied: false,
511
+ reason: 'settled_outcome_divergence',
512
+ run: fresh,
513
+ persisted: 'skip-non-abort',
514
+ };
515
+ }
516
+ // Convergence on own-APPLY coordinates (L21) — idempotent retry.
517
+ return { applied: false, reason: 'already_settled', run: fresh };
518
+ }
519
+ // Any OTHER membership array already containing this step is a genuine divergence — a
520
+ // different settle already committed a DIFFERENT outcome for the same guard.
521
+ if (fresh.completed_steps.includes(step)) {
522
+ return {
523
+ applied: false,
524
+ reason: 'settled_outcome_divergence',
525
+ run: fresh,
526
+ persisted: 'complete',
527
+ };
528
+ }
529
+ if (fresh.failed_steps.includes(step)) {
530
+ return { applied: false, reason: 'settled_outcome_divergence', run: fresh, persisted: 'fail' };
531
+ }
532
+ if (fresh.skipped_steps.includes(step)) {
533
+ return { applied: false, reason: 'settled_outcome_divergence', run: fresh, persisted: 'skip' };
534
+ }
535
+ if (isTerminal(fresh)) {
536
+ return { applied: false, reason: 'run_terminal', run: fresh }; // terminal by OTHER
537
+ }
538
+ if (fresh.pending_gate !== undefined && outcome !== 'pass') {
539
+ // D-2: the GATE WINS; quiet end-of-pass — the guard re-evaluates at the NEXT drive (N8).
540
+ return { applied: false, reason: 'gate_open_wait', run: fresh };
541
+ }
542
+ // APPLY GUARD.
543
+ if (outcome === 'resolution_error') {
544
+ const withFailed = {
545
+ ...fresh,
546
+ evidence: [...fresh.evidence, evidence],
547
+ failed_steps: [...fresh.failed_steps, step],
548
+ };
549
+ const propagated = propagateSkips(withFailed, definition);
550
+ const withSkipped = {
551
+ ...withFailed,
552
+ skipped_steps: propagated.skipped,
553
+ skip_details: propagated.details,
554
+ };
555
+ const draft = {
556
+ ...withSkipped,
557
+ terminal_state: true,
558
+ // execution-loop.ts:3671 parity.
559
+ terminal_reason: `Guard step '${step}' failed: unresolvable path '${resolutionError.unresolvable_path}'`,
560
+ };
561
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, 'fail', false);
562
+ return {
563
+ applied: true,
564
+ run,
565
+ transitioned,
566
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
567
+ };
568
+ }
569
+ if (outcome === 'abort') {
570
+ const withSkippedSelf = {
571
+ ...fresh,
572
+ evidence: [...fresh.evidence, evidence],
573
+ skipped_steps: [...fresh.skipped_steps, step],
574
+ };
575
+ const propagated = propagateSkips(withSkippedSelf, definition);
576
+ const withSkipped = {
577
+ ...withSkippedSelf,
578
+ skipped_steps: propagated.skipped,
579
+ // #111: the merge preserves any cascade details for OTHER now-unreachable steps alongside
580
+ // this guard's own guard_abort tag (execution-loop.ts:3728-3735 parity).
581
+ skip_details: { ...propagated.details, [step]: { kind: 'guard_abort' } },
582
+ };
583
+ const draft = {
584
+ ...withSkipped,
585
+ terminal_state: true,
586
+ // terminal_reason ABSENT — phase 'aborted' derives from aborted_at (§4 table).
587
+ aborted_at: {
588
+ step_id: step,
589
+ conditions: abort.conditions,
590
+ ...(abort.abort_message !== undefined ? { abort_message: abort.abort_message } : {}),
591
+ },
592
+ };
593
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, 'abort', false);
594
+ return {
595
+ applied: true,
596
+ run,
597
+ transitioned,
598
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
599
+ };
600
+ }
601
+ // pass: two-disjunct isComplete predicate (same shape as settleStepArms's own).
602
+ const withCompleted = {
603
+ ...fresh,
604
+ evidence: [...fresh.evidence, evidence],
605
+ completed_steps: [...fresh.completed_steps, step],
606
+ };
607
+ const propagated = propagateSkips(withCompleted, definition);
608
+ const withSkipped = {
609
+ ...withCompleted,
610
+ skipped_steps: propagated.skipped,
611
+ skip_details: propagated.details,
612
+ };
613
+ const isComplete = isWorkflowComplete(withSkipped, definition) ||
614
+ (withSkipped.in_progress_steps.length === 0 &&
615
+ findEligibleSteps(definition, withSkipped).length === 0 &&
616
+ findEligibleGuardSteps(definition, withSkipped).length === 0);
617
+ const draft = {
618
+ ...withSkipped,
619
+ terminal_state: isComplete,
620
+ // execution-loop.ts:3675-3705 parity; eligibility.ts:47 keys deriveRunPhase's 'completed' on
621
+ // this exact string.
622
+ ...(isComplete ? { terminal_reason: 'Workflow completed.' } : {}),
623
+ };
624
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, 'complete', isComplete);
625
+ return {
626
+ applied: true,
627
+ run,
628
+ transitioned,
629
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
630
+ };
631
+ }
632
+ // ---------------------------------------------------------------------------
633
+ // §3 releaseStepArms (issue #279, increment 2, PR-C) — fence = claimToken. NEVER terminal, writes
634
+ // NO settled entry — the step returns to eligible.
635
+ // ---------------------------------------------------------------------------
636
+ function applyReleaseStep(fresh, delta, now) {
637
+ const { step, claimToken, capabilityBlock, evidence } = delta;
638
+ if (entryOf(fresh, step) !== undefined) {
639
+ return { applied: false, reason: 'already_settled_by_other', run: fresh };
640
+ }
641
+ if (isTerminal(fresh)) {
642
+ return { applied: false, reason: 'run_terminal', run: fresh };
643
+ }
644
+ const claim = fresh.claims?.[step];
645
+ if (claim === undefined) {
646
+ // TD F10: the claim is already gone — the RELEASE intent already holds. Idempotent no-op.
647
+ return { applied: false, reason: 'already_released', run: fresh };
648
+ }
649
+ if (!tokensEqual(claim.token, claimToken)) {
650
+ // Never stomp a successor's claim (execution-loop.ts:660-671 parity).
651
+ return { applied: false, reason: 'claim_lost', run: fresh };
652
+ }
653
+ if (fresh.pending_gate?.step_name === step) {
654
+ // reclaim-step.ts:389 parity — a claim pinned by an open gate is never released this way.
655
+ return { applied: false, reason: 'gate_mismatch', run: fresh };
656
+ }
657
+ // APPLY RELEASE: release claim + in_progress; optional capability_blocks merge
658
+ // (execution-loop.ts:2461-2475 semantics); optional evidence append (execution-loop.ts:679
659
+ // semantics — the compensating un-claim's own audit snapshot). NEVER terminal, NO settled entry.
660
+ const withRelease = {
661
+ ...fresh,
662
+ in_progress_steps: fresh.in_progress_steps.filter((s) => s !== step),
663
+ claims: omitClaim(fresh.claims, step),
664
+ ...(capabilityBlock !== undefined
665
+ ? {
666
+ capability_blocks: {
667
+ ...fresh.capability_blocks,
668
+ [step]: {
669
+ requirement: capabilityBlock.requirement,
670
+ code: capabilityBlock.code,
671
+ at: now.toISOString(),
672
+ },
673
+ },
674
+ }
675
+ : {}),
676
+ ...(evidence !== undefined ? { evidence: [...fresh.evidence, ...evidence] } : {}),
677
+ };
678
+ const run = { ...withRelease, run_phase: deriveRunPhase(withRelease) };
679
+ return {
680
+ applied: true,
681
+ run,
682
+ transitioned: false,
683
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
684
+ };
685
+ }
686
+ // ---------------------------------------------------------------------------
687
+ // §3 leaseFinalizerArms — CALLER-MINTED token (lens-1 F3a)
688
+ // ---------------------------------------------------------------------------
689
+ function applyLeaseFinalizer(fresh, delta, now) {
690
+ // Defensive; unreachable in-contract under §5 void-at-resume (no pending survives a resume).
691
+ if (!isTerminal(fresh)) {
692
+ return { applied: false, reason: 'run_not_terminal', run: fresh };
693
+ }
694
+ const e = fresh.finalizer_ledger?.[delta.finalizer];
695
+ if (e === undefined) {
696
+ return { applied: false, reason: 'not_eligible', run: fresh }; // unknown id — drain loop ABORTS loud
697
+ }
698
+ if (e.status !== 'pending') {
699
+ return { applied: false, reason: 'ledger_not_pending', run: fresh }; // done/failed/voided — loop ADVANCES
700
+ }
701
+ const nowMs = now.getTime();
702
+ const eDeadlineMs = e.lease_deadline !== undefined ? new Date(e.lease_deadline).getTime() : undefined;
703
+ if (tokensEqual(e.lease_token, delta.leaseToken) &&
704
+ eDeadlineMs !== undefined &&
705
+ eDeadlineMs > nowMs) {
706
+ return { applied: false, reason: 'already_leased', run: fresh }; // own ambiguous retry — L21
707
+ }
708
+ const blocking = Object.values(fresh.finalizer_ledger ?? {}).find((other) => other.status === 'pending' && other.rank < e.rank);
709
+ if (blocking !== undefined) {
710
+ return { applied: false, reason: 'rank_blocked', run: fresh };
711
+ }
712
+ if (e.lease_token !== undefined && eDeadlineMs !== undefined && eDeadlineMs > nowMs) {
713
+ return { applied: false, reason: 'lease_held', run: fresh };
714
+ }
715
+ // APPLY: lease_token = delta.leaseToken; lease_deadline = now + clamp(leaseSeconds, DRAIN_LEASE_MAX).
716
+ const clampedSeconds = Math.min(delta.leaseSeconds, DRAIN_LEASE_MAX);
717
+ const leaseDeadline = new Date(nowMs + clampedSeconds * 1000).toISOString();
718
+ const ledger = {
719
+ ...fresh.finalizer_ledger,
720
+ [delta.finalizer]: { ...e, lease_token: delta.leaseToken, lease_deadline: leaseDeadline },
721
+ };
722
+ const run = { ...fresh, finalizer_ledger: ledger };
723
+ return {
724
+ applied: true,
725
+ run,
726
+ transitioned: false,
727
+ pendingFinalizers: pendingFinalizerNames(ledger),
728
+ };
729
+ }
730
+ // ---------------------------------------------------------------------------
731
+ // §3 markFinalizerArms
732
+ // ---------------------------------------------------------------------------
733
+ function applyMarkFinalizer(fresh, delta) {
734
+ const e = fresh.finalizer_ledger?.[delta.finalizer];
735
+ if (e === undefined) {
736
+ return { applied: false, reason: 'not_eligible', run: fresh };
737
+ }
738
+ if (e.status !== 'pending') {
739
+ if (tokensEqual(e.lease_token, delta.leaseToken) && e.status === delta.result) {
740
+ // Own retry — L21 (lens-1 F3b). APPLY does NOT clear lease fields.
741
+ return { applied: false, reason: 'already_marked', run: fresh };
742
+ }
743
+ return { applied: false, reason: 'ledger_not_pending', run: fresh }; // peer marked / voided — benign
744
+ }
745
+ if (!tokensEqual(e.lease_token, delta.leaseToken)) {
746
+ return { applied: false, reason: 'lease_lost', run: fresh };
747
+ }
748
+ // AFTER the token arms: defensive; voided-at-resume makes this unreachable in-contract (no
749
+ // pending survives resume — the §0.4 audit question dissolves).
750
+ if (!isTerminal(fresh)) {
751
+ return { applied: false, reason: 'run_not_terminal', run: fresh };
752
+ }
753
+ // APPLY: status = result; completed_steps/failed_steps += name; evidence — ONE compound
754
+ // atomic write (I14).
755
+ const ledger = {
756
+ ...fresh.finalizer_ledger,
757
+ [delta.finalizer]: { ...e, status: delta.result },
758
+ };
759
+ const withLedgerAndEvidence = delta.result === 'completed'
760
+ ? {
761
+ ...fresh,
762
+ finalizer_ledger: ledger,
763
+ completed_steps: [...fresh.completed_steps, delta.finalizer],
764
+ evidence: [...fresh.evidence, delta.evidence],
765
+ }
766
+ : {
767
+ ...fresh,
768
+ finalizer_ledger: ledger,
769
+ failed_steps: [...fresh.failed_steps, delta.finalizer],
770
+ evidence: [...fresh.evidence, delta.evidence],
771
+ };
772
+ const run = {
773
+ ...withLedgerAndEvidence,
774
+ run_phase: deriveRunPhase(withLedgerAndEvidence),
775
+ };
776
+ return {
777
+ applied: true,
778
+ run,
779
+ transitioned: false,
780
+ pendingFinalizers: pendingFinalizerNames(ledger),
781
+ };
782
+ }
783
+ // ---------------------------------------------------------------------------
784
+ // Dispatcher
785
+ // ---------------------------------------------------------------------------
786
+ /**
787
+ * Applies one {@link SettlementDelta} against `fresh` — pure, synchronous, no I/O, no registry
788
+ * (design record §7 CS-purity: `options` carries VALUES only). `definition` is passed per call
789
+ * (never cached) — `lease_finalizer`/`mark_finalizer` deltas do not use it (their arms never
790
+ * reference the workflow definition; only `settle_step`'s terminal-edge `mintFresh` does).
791
+ *
792
+ * `result.run` on `applied: true` is the AS-APPLIED transform output — see
793
+ * {@link SettlementResult}'s own doc for the never-a-re-read invariant this carries.
794
+ */
795
+ export function applySettlement(fresh, delta, definition, options) {
796
+ const now = options?.now ?? new Date();
797
+ switch (delta.kind) {
798
+ case 'settle_step':
799
+ return applySettleStep(fresh, delta, definition, now);
800
+ case 'lease_finalizer':
801
+ return applyLeaseFinalizer(fresh, delta, now);
802
+ case 'mark_finalizer':
803
+ return applyMarkFinalizer(fresh, delta);
804
+ case 'open_gate':
805
+ return applyOpenGate(fresh, delta);
806
+ case 'settle_gate':
807
+ return applySettleGate(fresh, delta, definition);
808
+ case 'settle_guard':
809
+ return applySettleGuard(fresh, delta, definition);
810
+ case 'release_step':
811
+ return applyReleaseStep(fresh, delta, now);
812
+ }
813
+ }
814
+ /**
815
+ * Named constant (issue #279, increment 2, PR-C; design record §4.3) tying reclaim-step.ts's own
816
+ * open-gate refusal (`reclaimStep`, ~line 389: "the claim is legitimately pinned by a human gate")
817
+ * to this design's "unfenced-release soundness rests on reclaim" premise: `settle_step` abort's
818
+ * cancel-gate write (`applyAbortEdge`, above) is the ONLY path that may release an open gate's
819
+ * claim; `reclaimStep`/`isAutoReclaimable` must NEVER also release it, or the two paths could race
820
+ * and double-release the same claim. Referenced (not asserted via) by this invariant's two
821
+ * existing pinning tests (`reclaim-step.test.ts` + the CLI's `reclaim.test.ts`) so a future reader
822
+ * can grep this name to find both, and reclaim-step.ts's own SOURCE stays untouched by this PR.
823
+ */
824
+ export const RECLAIM_REFUSES_GATE_STEP = 'reclaim never releases the open-gate claim (design record design-d5-increment2.md §4.3, unfenced-release soundness)';
825
+ //# sourceMappingURL=settlement.js.map