@sensigo/realm 0.32.0 → 0.34.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 (63) 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 +11 -2
  5. package/dist/engine/apply-resume.d.ts.map +1 -1
  6. package/dist/engine/apply-resume.js +15 -4
  7. package/dist/engine/apply-resume.js.map +1 -1
  8. package/dist/engine/eligibility.d.ts +20 -2
  9. package/dist/engine/eligibility.d.ts.map +1 -1
  10. package/dist/engine/eligibility.js +46 -24
  11. package/dist/engine/eligibility.js.map +1 -1
  12. package/dist/engine/execution-loop.d.ts +29 -5
  13. package/dist/engine/execution-loop.d.ts.map +1 -1
  14. package/dist/engine/execution-loop.js +1152 -90
  15. package/dist/engine/execution-loop.js.map +1 -1
  16. package/dist/engine/gate-timing.d.ts +17 -0
  17. package/dist/engine/gate-timing.d.ts.map +1 -0
  18. package/dist/engine/gate-timing.js +19 -0
  19. package/dist/engine/gate-timing.js.map +1 -0
  20. package/dist/engine/reclaim-step.d.ts +8 -0
  21. package/dist/engine/reclaim-step.d.ts.map +1 -1
  22. package/dist/engine/reclaim-step.js +48 -3
  23. package/dist/engine/reclaim-step.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 +100 -2
  27. package/dist/engine/run-health.js.map +1 -1
  28. package/dist/engine/settlement.d.ts +56 -7
  29. package/dist/engine/settlement.d.ts.map +1 -1
  30. package/dist/engine/settlement.js +690 -37
  31. package/dist/engine/settlement.js.map +1 -1
  32. package/dist/index.d.ts +6 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +5 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/store/json-file-store.d.ts +6 -4
  37. package/dist/store/json-file-store.d.ts.map +1 -1
  38. package/dist/store/json-file-store.js +26 -9
  39. package/dist/store/json-file-store.js.map +1 -1
  40. package/dist/store/store-interface.d.ts +11 -8
  41. package/dist/store/store-interface.d.ts.map +1 -1
  42. package/dist/types/response-envelope.d.ts +16 -0
  43. package/dist/types/response-envelope.d.ts.map +1 -1
  44. package/dist/types/run-record.d.ts +132 -3
  45. package/dist/types/run-record.d.ts.map +1 -1
  46. package/dist/types/settlement.d.ts +164 -11
  47. package/dist/types/settlement.d.ts.map +1 -1
  48. package/dist/types/workflow-definition.d.ts +113 -32
  49. package/dist/types/workflow-definition.d.ts.map +1 -1
  50. package/dist/types/workflow-definition.js +20 -0
  51. package/dist/types/workflow-definition.js.map +1 -1
  52. package/dist/workflow/diagnostics.d.ts +1 -1
  53. package/dist/workflow/diagnostics.d.ts.map +1 -1
  54. package/dist/workflow/diagnostics.js +3 -0
  55. package/dist/workflow/diagnostics.js.map +1 -1
  56. package/dist/workflow/structured-output-eligibility.d.ts +81 -0
  57. package/dist/workflow/structured-output-eligibility.d.ts.map +1 -0
  58. package/dist/workflow/structured-output-eligibility.js +359 -0
  59. package/dist/workflow/structured-output-eligibility.js.map +1 -0
  60. package/dist/workflow/yaml-loader.d.ts.map +1 -1
  61. package/dist/workflow/yaml-loader.js +145 -2
  62. package/dist/workflow/yaml-loader.js.map +1 -1
  63. package/package.json +1 -1
@@ -20,11 +20,14 @@ function norm(t) {
20
20
  function tokensEqual(a, b) {
21
21
  return norm(a) === norm(b);
22
22
  }
23
- /** `M := {complete: completed_steps, fail: failed_steps, skip: skipped_steps}` — the membership
24
- * array a settled-map entry's `outcome` maps to. */
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. */
25
27
  function membershipFor(fresh, outcome) {
26
28
  switch (outcome) {
27
29
  case 'complete':
30
+ case 'gate':
28
31
  return fresh.completed_steps;
29
32
  case 'fail':
30
33
  return fresh.failed_steps;
@@ -81,16 +84,29 @@ function finalizerTriggers(stepDef) {
81
84
  return new Set(Array.isArray(raw) ? raw : [raw]);
82
85
  }
83
86
  /**
84
- * Selects the finalizer steps that fire for a terminal `outcome`, in the drain order (design
85
- * record §4/§6; extracted from `buildFinalizedSeal`'s selection, execution-loop.ts :3117-3126):
86
- * Group A (rank precedence) — `on_outcome` contains `outcome` (the specific catch/complete arm);
87
- * Group B — `on_outcome` contains `'always'` but NOT `outcome` (a finalizer listing both runs
88
- * once, in Group A). Each group in `Object.entries` declaration order; Group A then Group B.
87
+ * Selects the finalizer steps that fire for a terminal outcome, in the drain order (design
88
+ * record §4/§6, widened by #302's S2 fold; extracted from `buildFinalizedSeal`'s selection,
89
+ * execution-loop.ts :3117-3126): Group A (rank precedence) — the finalizer's OWN declared
90
+ * `on_outcome` set intersects the EFFECTIVE trigger set non-emptily; Group B — `on_outcome`
91
+ * contains `'always'` but the finalizer missed Group A (a finalizer listing both runs once, in
92
+ * Group A). Each group in `Object.entries` declaration order; Group A then Group B.
89
93
  * `settledStepNames` excludes any finalizer already at-most-once settled (resume/re-drive safety)
90
94
  * — pass `completed_steps ∪ failed_steps`, NEVER the `RunRecord.settled` map (a different,
91
95
  * per-step-outcome-keyed structure this selection does not consult).
96
+ *
97
+ * `outcome` accepts EITHER shape (issue #302, S2 — `selectFinalizers` is a PUBLIC export,
98
+ * `index.ts`, so widening the param rather than replacing it is an API-compat requirement, not a
99
+ * style choice):
100
+ * - a bare `SettleStepOutcome` string — every pre-#302 caller's shape, normalized internally to
101
+ * a singleton set; BYTE-IDENTICAL selection to the pre-widening behavior (pinned by the
102
+ * string-form compat test) and supported INDEFINITELY — this is not a deprecated compat shim.
103
+ * - a pre-derived `ReadonlySet<FinalizerTrigger>` (via {@link deriveEffectiveTriggers}) — the
104
+ * #302 call shape both engine chokepoints (`mintFresh`, `buildFinalizedSeal`) now use, letting
105
+ * a seal satisfy more than one trigger at once (e.g. `{'complete', 'completed_with_failed_steps'}`).
106
+ * Semver: additive or (minor) — no existing call site's behavior changes.
92
107
  */
93
108
  export function selectFinalizers(definition, settledStepNames, outcome) {
109
+ const effective = typeof outcome === 'string' ? new Set([outcome]) : outcome;
94
110
  const groupA = [];
95
111
  const groupB = [];
96
112
  for (const [name, step] of Object.entries(definition.steps)) {
@@ -99,13 +115,45 @@ export function selectFinalizers(definition, settledStepNames, outcome) {
99
115
  if (settledStepNames.has(name))
100
116
  continue; // at-most-once per run (resume / re-drive safety)
101
117
  const triggers = finalizerTriggers(step);
102
- if (triggers.has(outcome))
118
+ let inGroupA = false;
119
+ for (const t of triggers) {
120
+ if (effective.has(t)) {
121
+ inGroupA = true;
122
+ break;
123
+ }
124
+ }
125
+ if (inGroupA)
103
126
  groupA.push(name);
104
127
  else if (triggers.has('always'))
105
128
  groupB.push(name);
106
129
  }
107
130
  return [...groupA, ...groupB];
108
131
  }
132
+ /**
133
+ * Derives the full set of finalizer triggers a terminal `outcome` satisfies for `record` (issue
134
+ * #302, design record §Mechanics-1): `{outcome}` — always — plus `'completed_with_failed_steps'`
135
+ * when `outcome === 'complete' ∧ record.failed_steps.length > 0` (a "mixed complete" seal — the
136
+ * SAME class #304's `completed_with_failed_steps` run-health finding already surfaces at read
137
+ * time; this is the mint-time trigger, sharing that one predicate).
138
+ *
139
+ * Uniform across epochs (design record M1, deliberate): a second-epoch complete seal whose ONLY
140
+ * `failed_steps` scar is a PRIOR epoch's finalizer self-failure (unresumable, so it never leaves
141
+ * `failed_steps`) still fires this trigger — no exclusion of finalizer-declared step names. The
142
+ * alternative (excluding finalizer names from the predicate) buys mint-time purity at the cost of
143
+ * diverging from the read-time #304 finding's own uniform predicate; this design keeps ONE
144
+ * predicate for both surfaces, deliberately.
145
+ *
146
+ * Pure; this ONE function is the seam a future authorable tolerance threshold (SFN-style: fire
147
+ * only when `failed_steps.length` exceeds some declared N) would extend — banked, not built
148
+ * (design record R2).
149
+ */
150
+ export function deriveEffectiveTriggers(outcome, record) {
151
+ const triggers = new Set([outcome]);
152
+ if (outcome === 'complete' && record.failed_steps.length > 0) {
153
+ triggers.add('completed_with_failed_steps');
154
+ }
155
+ return triggers;
156
+ }
109
157
  // ---------------------------------------------------------------------------
110
158
  // §4 mintFresh (terminal false→true edge only; same atomic write)
111
159
  // ---------------------------------------------------------------------------
@@ -123,7 +171,10 @@ export function selectFinalizers(definition, settledStepNames, outcome) {
123
171
  */
124
172
  function mintFresh(record, definition, outcome) {
125
173
  const settledStepNames = new Set([...record.completed_steps, ...record.failed_steps]);
126
- const selected = selectFinalizers(definition, settledStepNames, outcome);
174
+ // issue #302: derive the FULL effective trigger set (chokepoint 1 of 2) — record already
175
+ // reflects this settlement's own membership effects (a 'complete' outcome's failed_steps here
176
+ // is PRIOR failures only), so this is the correct read point for the mixed-complete predicate.
177
+ const selected = selectFinalizers(definition, settledStepNames, deriveEffectiveTriggers(outcome, record));
127
178
  if (selected.length === 0)
128
179
  return record.finalizer_ledger;
129
180
  const ledger = { ...record.finalizer_ledger };
@@ -137,6 +188,41 @@ function mintFresh(record, definition, outcome) {
137
188
  return ledger;
138
189
  }
139
190
  // ---------------------------------------------------------------------------
191
+ // §4 shared APPLY postconditions (design record design-d5-increment2.md §4, hoisted — lens-2 F4:
192
+ // "one implementation, every kind routes through it"). Every kind whose APPLY can terminalize
193
+ // (settle_step complete/fail/abort [shipped]; settle_gate resolution-complete; settle_guard
194
+ // pass/resolution_error/abort [increment 2, PR-C]) calls this ONE function to (1) mint fresh
195
+ // finalizers on a genuine terminal false→true edge (§4.1), (2) stamp `defaulted_steps` on a
196
+ // COMPLETE-terminal edge only (§4.2), and (3) derive `run_phase` uniformly (§4.5) — regardless of
197
+ // whether this particular APPLY actually transitioned.
198
+ // ---------------------------------------------------------------------------
199
+ /**
200
+ * `record.terminal_state` must already reflect the kind-specific terminal decision (each arm
201
+ * computes its own `isComplete`/unconditional-abort logic BEFORE calling this) — every in-contract
202
+ * caller has already refused `run_terminal` earlier in its own arm, so `record.terminal_state` can
203
+ * only be transitioning `false → true` here, never `true → true`; `transitioned` is therefore
204
+ * simply the post-write value, read back explicitly (not assumed) so a future caller that ever
205
+ * violates that precondition fails loudly via a wrong `transitioned` value rather than silently.
206
+ */
207
+ function applyTerminalPostconditions(record, definition, mintOutcome, stampDefaulted) {
208
+ const transitioned = record.terminal_state === true;
209
+ let sealed = record;
210
+ if (transitioned) {
211
+ // On terminal false→true edge: mintFresh (§4.1), same atomic write.
212
+ const ledger = mintFresh(record, definition, mintOutcome);
213
+ sealed = { ...record, ...(ledger !== undefined ? { finalizer_ledger: ledger } : {}) };
214
+ // defaulted_steps stamped IFF a COMPLETE-terminal edge (§4.2; the FM-5/#232 guard) — never on
215
+ // a fail/abort seal, even one that terminalizes.
216
+ if (stampDefaulted) {
217
+ const defaultedSteps = deriveDefaultedSteps(sealed.evidence);
218
+ if (defaultedSteps.length > 0)
219
+ sealed = { ...sealed, defaulted_steps: defaultedSteps };
220
+ }
221
+ }
222
+ const withPhase = { ...sealed, run_phase: deriveRunPhase(sealed) };
223
+ return { run: withPhase, transitioned };
224
+ }
225
+ // ---------------------------------------------------------------------------
140
226
  // §3 settleStepArms
141
227
  // ---------------------------------------------------------------------------
142
228
  function applySettleStep(fresh, delta, definition, now) {
@@ -209,6 +295,7 @@ function applyAbortEdge(fresh, withMembership, step, abort, definition, now) {
209
295
  // nothing above has touched it.
210
296
  if (fresh.pending_gate !== undefined && fresh.pending_gate.step_name !== step) {
211
297
  const gateStepName = fresh.pending_gate.step_name;
298
+ const cancelledGateId = fresh.pending_gate.gate_id;
212
299
  const { pending_gate: _droppedGate, ...withoutGate } = aborted;
213
300
  aborted = {
214
301
  ...withoutGate,
@@ -217,7 +304,9 @@ function applyAbortEdge(fresh, withMembership, step, abort, definition, now) {
217
304
  skipped_steps: [...withoutGate.skipped_steps, gateStepName],
218
305
  skip_details: {
219
306
  ...withoutGate.skip_details,
220
- [gateStepName]: { kind: 'gate_cancelled_by_abort' },
307
+ // gate_id additive (design record §5 D-4) — the settle_gate run_terminal envelope's
308
+ // cancelled-variant discriminator binds by this once populated.
309
+ [gateStepName]: { kind: 'gate_cancelled_by_abort', gate_id: cancelledGateId },
221
310
  },
222
311
  evidence: [
223
312
  ...withoutGate.evidence,
@@ -226,24 +315,20 @@ function applyAbortEdge(fresh, withMembership, step, abort, definition, now) {
226
315
  startedAt: now,
227
316
  completedAt: now,
228
317
  input: {},
229
- output: { gate_cancelled_by_abort: true, aborted_by: step },
318
+ output: { gate_cancelled_by_abort: true, aborted_by: step, gate_id: cancelledGateId },
230
319
  }),
231
320
  ],
232
321
  };
233
322
  }
234
- // On terminal false→true edge: mintFresh (§4), same atomic write. abort NEVER stamps
235
- // defaulted_steps (the FM-5/#232 guard only the complete edge does).
236
- const ledger = mintFresh(aborted, definition, 'abort');
237
- const finalRun = {
238
- ...aborted,
239
- ...(ledger !== undefined ? { finalizer_ledger: ledger } : {}),
240
- };
241
- const withPhase = { ...finalRun, run_phase: deriveRunPhase(finalRun) };
323
+ // §4 shared postconditions: abort NEVER stamps defaulted_steps (the FM-5/#232 guard only the
324
+ // complete edge does); `transitioned` is always true here (isTerminal(fresh) was already
325
+ // refused above, and `aborted.terminal_state` is unconditionally true).
326
+ const { run, transitioned } = applyTerminalPostconditions(aborted, definition, 'abort', false);
242
327
  return {
243
328
  applied: true,
244
- run: withPhase,
245
- transitioned: true, // isTerminal(fresh) was already refused above — abort is always false→true
246
- pendingFinalizers: pendingFinalizerNames(withPhase.finalizer_ledger),
329
+ run,
330
+ transitioned,
331
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
247
332
  };
248
333
  }
249
334
  /** complete / fail: the TWO-DISJUNCT `isComplete` predicate (execution-loop.ts :2579-2583 /
@@ -270,25 +355,562 @@ function applyCompleteOrFailEdge(withMembership, step, outcome, failureMessage,
270
355
  }
271
356
  : {}),
272
357
  };
273
- let sealed = draft;
274
- if (isComplete) {
275
- // On terminal edge: mintFresh (§4), same atomic write.
276
- const ledger = mintFresh(draft, definition, outcome);
277
- sealed = { ...draft, ...(ledger !== undefined ? { finalizer_ledger: ledger } : {}) };
278
- // defaulted_steps stamped IFF complete-terminal edge (FM-5/#232 guard) — never on a fail seal,
279
- // even one that terminalizes.
280
- if (outcome === 'complete') {
281
- const defaultedSteps = deriveDefaultedSteps(sealed.evidence);
282
- if (defaultedSteps.length > 0)
283
- sealed = { ...sealed, defaulted_steps: defaultedSteps };
358
+ // §4 shared postconditions: defaulted_steps stamps IFF this is a COMPLETE-terminal edge — never
359
+ // on a fail seal, even one that terminalizes.
360
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, outcome, outcome === 'complete' && isComplete);
361
+ return {
362
+ applied: true,
363
+ run,
364
+ transitioned,
365
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
366
+ };
367
+ }
368
+ // ---------------------------------------------------------------------------
369
+ // §3 openGateArms (issue #279, increment 2, PR-C) — fence = claimToken; entry lookup FIRST
370
+ // (design record lens-2 F1).
371
+ // ---------------------------------------------------------------------------
372
+ function applyOpenGate(fresh, delta) {
373
+ const { step, claimToken, pendingGate, evidence } = delta;
374
+ // Idempotence arm BEFORE terminal/claim (mirrors settleStepArms's own ordering, L21 ii).
375
+ const existing = entryOf(fresh, step);
376
+ if (existing !== undefined) {
377
+ if (existing.outcome === 'gate' && existing.token === pendingGate.gate_id) {
378
+ // Exact-delta replay AFTER the gate already resolved (BU F6) — the gate this delta is
379
+ // trying to open is the SAME one already committed as resolved.
380
+ return { applied: false, reason: 'already_settled', run: fresh };
284
381
  }
382
+ // Envelope text stays neutral (N1 — no "by_other" amplification) at the caller (PR-D).
383
+ return { applied: false, reason: 'already_settled_by_other', run: fresh };
285
384
  }
286
- const withPhase = { ...sealed, run_phase: deriveRunPhase(sealed) };
385
+ if (isTerminal(fresh)) {
386
+ return { applied: false, reason: 'run_terminal', run: fresh };
387
+ }
388
+ if (fresh.pending_gate !== undefined) {
389
+ if (fresh.pending_gate.step_name === step) {
390
+ if (fresh.pending_gate.gate_id === pendingGate.gate_id) {
391
+ // Exact-delta replay, gate still open (e.g. a retried gate-open write).
392
+ return { applied: false, reason: 'already_settled', run: fresh };
393
+ }
394
+ const claim = fresh.claims?.[step];
395
+ if (claim !== undefined && tokensEqual(claim.token, claimToken)) {
396
+ // D-1: the LIVE gate wins, rendered VERBATIM. In-contract UNREACHABLE (claimStep's
397
+ // in-flight guard + reclaim's own open-gate refusal both prevent a second open_gate
398
+ // attempt from ever reaching here with a live claim) — defensive.
399
+ return { applied: false, reason: 'already_open', run: fresh, gate: fresh.pending_gate };
400
+ }
401
+ // Same step, different claimant — defensive (a claim can't be re-acquired under an open
402
+ // gate; findEligibleSteps returns [] while a gate is open).
403
+ return { applied: false, reason: 'claim_lost', run: fresh };
404
+ }
405
+ // A gate open on ANOTHER step — serialization; the step named here STAYS claimed (L13
406
+ // asserts this — the caller's recovery path is to wait for the live gate to resolve).
407
+ return { applied: false, reason: 'gate_mismatch', run: fresh };
408
+ }
409
+ const claim = fresh.claims?.[step];
410
+ if (claim === undefined || !tokensEqual(claim.token, claimToken)) {
411
+ return { applied: false, reason: 'claim_lost', run: fresh };
412
+ }
413
+ // APPLY OPEN: pending_gate set (delta-carried verbatim); evidence append; CLAIM RETAINED + step
414
+ // stays in_progress (execution-loop.ts:2958 — retention keeps isComplete sound, G-1). Never
415
+ // terminalizes (design record §4.1) — run_phase still derives (§4.5: transform-owned uniformly).
416
+ const withGate = {
417
+ ...fresh,
418
+ evidence: [...fresh.evidence, ...evidence],
419
+ pending_gate: pendingGate,
420
+ };
421
+ const run = { ...withGate, run_phase: deriveRunPhase(withGate) };
422
+ return {
423
+ applied: true,
424
+ run,
425
+ transitioned: false,
426
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
427
+ };
428
+ }
429
+ // ---------------------------------------------------------------------------
430
+ // §3 settleGateArms (issue #279, increment 2, PR-C) — fence = gateId ONLY (L20). ZERO claim arms.
431
+ // ---------------------------------------------------------------------------
432
+ /**
433
+ * Finds the (at most one, per G-2) `settled` entry recording a resolved gate matching `gateId` —
434
+ * searched by gateId (the settle_gate fence), not by a known step name, since a gate submission
435
+ * carries only the gate_id. `first` (design record §3): lookup runs FIRST for fail-safety under
436
+ * corruption (D3 §0.2) — soundness of both the lookup and the "first" quantifier rests on G-2
437
+ * (TERMINAL_GATE_EXCLUSION) plus per-attempt gate_id uniqueness plus the membership conjunct (the
438
+ * orphan rule, generalized): a G-2-violating corrupt both-match record makes iteration order
439
+ * store-dependent, which is exactly why the fail-safe direction (NOOP, never RESOLVE) is pinned
440
+ * at the CALLER (this function returns whichever match Object.entries visits first — a real store
441
+ * never produces two, so this never matters in-contract).
442
+ */
443
+ function findSettledGateEntry(fresh, gateId) {
444
+ for (const [step, entry] of Object.entries(fresh.settled ?? {})) {
445
+ if (entry.outcome !== 'gate' || entry.token !== gateId)
446
+ continue;
447
+ if (!membershipFor(fresh, entry.outcome).includes(step))
448
+ continue; // orphan rule
449
+ return { step, choice: entry.choice };
450
+ }
451
+ return undefined;
452
+ }
453
+ function applySettleGate(fresh, delta, definition,
454
+ // issue #291 ([F3] shape c, lane-1 authorized): injectable `now` threaded through, mirroring
455
+ // every other `now`-consuming arm (`applySettleStep`'s abort branch, `applyExpireGate` below).
456
+ // The ONE authorized addition to this function — every other line is byte-unchanged from PR-C.
457
+ now) {
458
+ const { gateId, choice, evidence } = delta;
459
+ // Lookup FIRST (D3 §0.2 fail-safer-under-corruption; L21 ii: the own-commit may have already
460
+ // flipped terminal).
461
+ const hit = findSettledGateEntry(fresh, gateId);
462
+ if (hit !== undefined) {
463
+ if (hit.choice === choice) {
464
+ // Double-submit / two-gates delayed retry (TD F1) — same choice, idempotent no-op.
465
+ return { applied: false, reason: 'already_settled', run: fresh };
466
+ }
467
+ return {
468
+ applied: false,
469
+ reason: 'gate_choice_conflict',
470
+ run: fresh,
471
+ ...(hit.choice !== undefined ? { winningChoice: hit.choice } : {}),
472
+ };
473
+ }
474
+ // Zombie / stale submit — BEFORE the live-gate arm (matches the shipped `applySettleStep`
475
+ // terminal-first order `:215-217`, AND the live `submitHumanResponse` site's own terminal-first
476
+ // check `:3431`): a grandfathered terminal∧pending_gate record refuses run_terminal instead of
477
+ // resurrecting the run or falsely completing it.
478
+ if (isTerminal(fresh)) {
479
+ return { applied: false, reason: 'run_terminal', run: fresh };
480
+ }
481
+ if (fresh.pending_gate !== undefined && fresh.pending_gate.gate_id === gateId) {
482
+ // issue #291 ([F3] shape c, the expiry-WINS mechanism): a WRITE-FREE refusal when the live
483
+ // gate has expired unresolved AND has an enactable disposition (`on_expiry` frozen) —
484
+ // checked under the lock, with the injectable `now`, BEFORE choice_not_eligible. The caller
485
+ // (submitHumanResponse) reacts to this refusal by issuing its OWN `expire_gate` settleStep
486
+ // (this function never enacts anything itself — that stays applyExpireGate's job) and
487
+ // composing the honest envelope from the enactment result. This is exact-at-the-
488
+ // serialization-point-once-observed, uniform-fleet-only: a gate minted by an old binary (no
489
+ // `expires_at` frozen) can never trip this arm. The `on_expiry !== undefined` conjunct is
490
+ // load-bearing: a FINDING-ONLY gate (expires_at present, on_expiry absent) has NOTHING that
491
+ // could ever win this race — refusing the human here with no enactable disposition to
492
+ // compose an envelope from would strand the human's response forever (the exact
493
+ // undisposable dead-end this feature exists to cure), since expire_gate would just refuse
494
+ // `no_disposition` right back. A finding-only gate's human response always resolves
495
+ // normally, however overdue.
496
+ if (fresh.pending_gate.expires_at !== undefined &&
497
+ fresh.pending_gate.on_expiry !== undefined &&
498
+ now.getTime() >= new Date(fresh.pending_gate.expires_at).getTime()) {
499
+ return { applied: false, reason: 'gate_expired_pending', run: fresh };
500
+ }
501
+ if (!fresh.pending_gate.choices.includes(choice)) {
502
+ return {
503
+ applied: false,
504
+ reason: 'choice_not_eligible',
505
+ run: fresh,
506
+ choices: fresh.pending_gate.choices,
507
+ };
508
+ }
509
+ // APPLY RESOLVE: clear pending_gate; completed_steps += step_name; release claim +
510
+ // in_progress (execution-loop.ts:3519-3520 parity); settled[step] = {token: gateId,
511
+ // outcome:'gate', choice} — 'gate' LITERAL here, toSettledOutcome's own SettleStepOutcome
512
+ // domain stays untouched (§2).
513
+ const stepName = fresh.pending_gate.step_name;
514
+ const { pending_gate: _pg, ...rest } = fresh;
515
+ const withMembership = {
516
+ ...rest,
517
+ in_progress_steps: rest.in_progress_steps.filter((s) => s !== stepName),
518
+ claims: omitClaim(rest.claims, stepName),
519
+ completed_steps: [...rest.completed_steps, stepName],
520
+ evidence: [...rest.evidence, ...evidence],
521
+ settled: { ...rest.settled, [stepName]: { token: gateId, outcome: 'gate', choice } },
522
+ };
523
+ const propagated = propagateSkips(withMembership, definition);
524
+ const withSkipped = {
525
+ ...withMembership,
526
+ skipped_steps: propagated.skipped,
527
+ skip_details: propagated.details,
528
+ };
529
+ const isComplete = isWorkflowComplete(withSkipped, definition) ||
530
+ (withSkipped.in_progress_steps.length === 0 &&
531
+ findEligibleSteps(definition, withSkipped).length === 0 &&
532
+ findEligibleGuardSteps(definition, withSkipped).length === 0);
533
+ const draft = {
534
+ ...withSkipped,
535
+ terminal_state: isComplete,
536
+ // eligibility.ts:47 keys deriveRunPhase's 'completed' on this exact string.
537
+ ...(isComplete ? { terminal_reason: 'Workflow completed.' } : {}),
538
+ };
539
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, 'complete', isComplete);
540
+ return {
541
+ applied: true,
542
+ run,
543
+ transitioned,
544
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
545
+ };
546
+ }
547
+ // Superseded/unknown gateId on a live run.
548
+ return { applied: false, reason: 'gate_mismatch', run: fresh };
549
+ }
550
+ // ---------------------------------------------------------------------------
551
+ // §3 expireGateArms (issue #291; design record `plans/issue-291/design-d2.md` [F1]/[F2]/[F3]/[F9])
552
+ // — fence = gateId ONLY (mirrors settleGateArms — L20). Enacts a gate's FROZEN enforce-clock
553
+ // disposition once expired-unresolved. Reads the RECORD only, never the workflow definition
554
+ // (F2's whole point — kills the definition-drift livelock: a re-registered workflow's changed
555
+ // `choices`/`default_choice` can never make an already-open gate's expiry refuse forever).
556
+ // ---------------------------------------------------------------------------
557
+ function applyExpireGate(fresh, delta, definition, now) {
558
+ const { gateId } = delta;
559
+ // Lookup FIRST ([F1] i; D3 §0.2 fail-safer-under-corruption — same order as settleGateArms).
560
+ const hit = findSettledGateEntry(fresh, gateId);
561
+ if (hit !== undefined) {
562
+ return { applied: false, reason: 'already_settled', run: fresh };
563
+ }
564
+ // Terminal split ([F1] iv, the replay/crash-recovery arm): a prior expire-abort enactment for
565
+ // THIS gateId already sealed the run — NOOP (idempotent replay). Any OTHER terminal cause
566
+ // (a different disposition, a concurrent human resolve that raced ahead, a handler-abort on a
567
+ // sibling) REFUSES run_terminal — never silently resurrect or re-terminalize.
568
+ if (isTerminal(fresh)) {
569
+ const expiredEntry = Object.entries(fresh.skip_details ?? {}).find(([, d]) => d.kind === 'gate_expired' && d.gate_id === gateId);
570
+ if (expiredEntry !== undefined) {
571
+ return { applied: false, reason: 'already_settled', run: fresh };
572
+ }
573
+ return { applied: false, reason: 'run_terminal', run: fresh };
574
+ }
575
+ // No/other pending_gate ([F1] iii) — superseded or unknown gateId on a live run. Mirrors
576
+ // settleGateArms's own final fallthrough exactly.
577
+ if (fresh.pending_gate === undefined || fresh.pending_gate.gate_id !== gateId) {
578
+ return { applied: false, reason: 'gate_mismatch', run: fresh };
579
+ }
580
+ const gate = fresh.pending_gate;
581
+ // now < expires_at ([F1] the new arm-verified refusal, NEVER trusting the caller's own clock):
582
+ // premature enactment attempt. Absent expires_at (a grandfathered/old-binary-minted gate, R-d)
583
+ // falls into this same refusal — it can never legitimately expire, so no in-contract caller
584
+ // should ever construct this delta for one; defensive rather than a crash either way.
585
+ if (gate.expires_at === undefined || now.getTime() < new Date(gate.expires_at).getTime()) {
586
+ return { applied: false, reason: 'not_expired', run: fresh };
587
+ }
588
+ // Finding-only mode (the prompt's own addendum, MA-ratified): timeout_seconds is present
589
+ // (expires_at exists) but on_expiry is absent — REFUSE no_disposition, arm-level, BEFORE APPLY.
590
+ // Never enacted; disclosed only via the run-health finding + the notifier's finding-only wording.
591
+ if (gate.on_expiry === undefined) {
592
+ return { applied: false, reason: 'no_disposition', run: fresh };
593
+ }
594
+ const respondedAt = now;
595
+ const stepName = gate.step_name;
596
+ if (gate.on_expiry === 'settle_default') {
597
+ // APPLY settle_default: reuses settleGateArms' own RESOLVE shape end-to-end (choice =
598
+ // FROZEN default_choice, clear gate, complete step, release claim, isComplete/terminalize) —
599
+ // written as its OWN independent implementation (settleGateArms itself stays byte-untouched
600
+ // beyond the F3 addition above), attributed `resolved_by: 'timeout'`.
601
+ const choice = gate.default_choice; // E2/[F10]-enforced at load: required-iff-settle_default.
602
+ const evidence = captureEvidence({
603
+ stepId: stepName,
604
+ startedAt: new Date(gate.opened_at),
605
+ completedAt: respondedAt,
606
+ input: { choice },
607
+ output: { ...gate.preview, choice },
608
+ });
609
+ const gateResponseSnapshot = {
610
+ ...evidence,
611
+ kind: 'gate_response',
612
+ ...(gate.resolved_message !== undefined ? { gate_message: gate.resolved_message } : {}),
613
+ responded_by: 'timeout',
614
+ resolution: 'expired_default',
615
+ };
616
+ const { pending_gate: _pg, ...rest } = fresh;
617
+ const withMembership = {
618
+ ...rest,
619
+ in_progress_steps: rest.in_progress_steps.filter((s) => s !== stepName),
620
+ claims: omitClaim(rest.claims, stepName),
621
+ completed_steps: [...rest.completed_steps, stepName],
622
+ evidence: [...rest.evidence, gateResponseSnapshot],
623
+ settled: {
624
+ ...rest.settled,
625
+ [stepName]: { token: gateId, outcome: 'gate', choice, resolved_by: 'timeout' },
626
+ },
627
+ };
628
+ const propagated = propagateSkips(withMembership, definition);
629
+ const withSkipped = {
630
+ ...withMembership,
631
+ skipped_steps: propagated.skipped,
632
+ skip_details: propagated.details,
633
+ };
634
+ const isComplete = isWorkflowComplete(withSkipped, definition) ||
635
+ (withSkipped.in_progress_steps.length === 0 &&
636
+ findEligibleSteps(definition, withSkipped).length === 0 &&
637
+ findEligibleGuardSteps(definition, withSkipped).length === 0);
638
+ const draft = {
639
+ ...withSkipped,
640
+ terminal_state: isComplete,
641
+ ...(isComplete ? { terminal_reason: 'Workflow completed.' } : {}),
642
+ };
643
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, 'complete', isComplete);
644
+ return {
645
+ applied: true,
646
+ run,
647
+ transitioned,
648
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
649
+ };
650
+ }
651
+ // APPLY abort (on_expiry === 'abort'): a NEW own-step arm — applyAbortEdge is sibling-only
652
+ // (its cancel-gate branch only fires for `pending_gate.step_name !== step`), so aborting the
653
+ // GATE'S OWN step needs its own shape: clear pending_gate + aborted_at + skip_details
654
+ // {kind:'gate_expired', gate_id} (day-one, F9) + mintFresh — in ONE write (TERMINAL_GATE_
655
+ // EXCLUSION: never leave BOTH a live pending_gate AND a settled 'gate' entry — this branch
656
+ // writes NEITHER a settled entry NOR a completed_steps membership; the step lands in
657
+ // skipped_steps instead, mirroring every other abort disposition in this file).
658
+ const evidence = captureEvidence({
659
+ stepId: stepName,
660
+ startedAt: new Date(gate.opened_at),
661
+ completedAt: respondedAt,
662
+ input: {},
663
+ output: { gate_expired: true, disposition: 'abort' },
664
+ });
665
+ const gateResponseSnapshot = {
666
+ ...evidence,
667
+ kind: 'gate_response',
668
+ ...(gate.resolved_message !== undefined ? { gate_message: gate.resolved_message } : {}),
669
+ responded_by: 'timeout',
670
+ resolution: 'expired_abort',
671
+ };
672
+ const { pending_gate: _pg2, ...withoutGate } = fresh;
673
+ const withSkippedSelf = {
674
+ ...withoutGate,
675
+ in_progress_steps: withoutGate.in_progress_steps.filter((s) => s !== stepName),
676
+ claims: omitClaim(withoutGate.claims, stepName),
677
+ skipped_steps: [...withoutGate.skipped_steps, stepName],
678
+ skip_details: {
679
+ ...withoutGate.skip_details,
680
+ [stepName]: { kind: 'gate_expired', gate_id: gateId },
681
+ },
682
+ evidence: [...withoutGate.evidence, gateResponseSnapshot],
683
+ };
684
+ const propagated = propagateSkips(withSkippedSelf, definition);
685
+ const withSkipped = {
686
+ ...withSkippedSelf,
687
+ skipped_steps: propagated.skipped,
688
+ skip_details: { ...propagated.details, [stepName]: { kind: 'gate_expired', gate_id: gateId } },
689
+ };
690
+ const aborted = {
691
+ ...withSkipped,
692
+ terminal_state: true,
693
+ terminal_reason: `Gate '${stepName}' expired and the run aborted per the workflow's declared on_expiry.`,
694
+ aborted_at: {
695
+ step_id: stepName,
696
+ abort_message: `Gate expired (timeout_seconds elapsed with no human response); on_expiry: 'abort'.`,
697
+ },
698
+ };
699
+ // §4 shared postconditions: abort NEVER stamps defaulted_steps (the FM-5/#232 guard);
700
+ // `transitioned` is always true (isTerminal(fresh) already refused above).
701
+ const { run, transitioned } = applyTerminalPostconditions(aborted, definition, 'abort', false);
702
+ return {
703
+ applied: true,
704
+ run,
705
+ transitioned,
706
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
707
+ };
708
+ }
709
+ // ---------------------------------------------------------------------------
710
+ // §3 settleGuardArms (issue #279, increment 2, PR-C) — fence = ⊥ (guards are never claimed,
711
+ // eligibility.ts:418); writes NO settled entry (SE-4).
712
+ // ---------------------------------------------------------------------------
713
+ function ownMembershipFor(fresh, outcome) {
714
+ switch (outcome) {
715
+ case 'pass':
716
+ return fresh.completed_steps;
717
+ case 'resolution_error':
718
+ return fresh.failed_steps;
719
+ case 'abort':
720
+ return fresh.skipped_steps;
721
+ }
722
+ }
723
+ function applySettleGuard(fresh, delta, definition) {
724
+ const { step, outcome, evidence, resolutionError, abort } = delta;
725
+ if (outcome === 'resolution_error' && resolutionError === undefined) {
726
+ // Caller-programming-error, not a predicate outcome (the SettleStepDelta abort precedent).
727
+ throw new Error(`applySettlement contract violation: settle_guard delta for step '${step}' has ` +
728
+ `outcome:'resolution_error' but no 'resolutionError' payload`);
729
+ }
730
+ if (outcome === 'abort' && abort === undefined) {
731
+ throw new Error(`applySettlement contract violation: settle_guard delta for step '${step}' has ` +
732
+ `outcome:'abort' but no 'abort' payload`);
733
+ }
734
+ // A := {pass: completed_steps, resolution_error: failed_steps, abort: skipped_steps} (lens-1 F8).
735
+ if (ownMembershipFor(fresh, outcome).includes(step)) {
736
+ if (outcome === 'abort' && fresh.skip_details?.[step]?.kind !== 'guard_abort') {
737
+ // In skipped_steps, but NOT via a prior guard_abort (e.g. when_false/trigger_rule_
738
+ // unsatisfiable instead) — a genuine divergence, not this guard's own convergent retry.
739
+ return {
740
+ applied: false,
741
+ reason: 'settled_outcome_divergence',
742
+ run: fresh,
743
+ persisted: 'skip-non-abort',
744
+ };
745
+ }
746
+ // Convergence on own-APPLY coordinates (L21) — idempotent retry.
747
+ return { applied: false, reason: 'already_settled', run: fresh };
748
+ }
749
+ // Any OTHER membership array already containing this step is a genuine divergence — a
750
+ // different settle already committed a DIFFERENT outcome for the same guard.
751
+ if (fresh.completed_steps.includes(step)) {
752
+ return {
753
+ applied: false,
754
+ reason: 'settled_outcome_divergence',
755
+ run: fresh,
756
+ persisted: 'complete',
757
+ };
758
+ }
759
+ if (fresh.failed_steps.includes(step)) {
760
+ return { applied: false, reason: 'settled_outcome_divergence', run: fresh, persisted: 'fail' };
761
+ }
762
+ if (fresh.skipped_steps.includes(step)) {
763
+ return { applied: false, reason: 'settled_outcome_divergence', run: fresh, persisted: 'skip' };
764
+ }
765
+ if (isTerminal(fresh)) {
766
+ return { applied: false, reason: 'run_terminal', run: fresh }; // terminal by OTHER
767
+ }
768
+ if (fresh.pending_gate !== undefined && outcome !== 'pass') {
769
+ // D-2: the GATE WINS; quiet end-of-pass — the guard re-evaluates at the NEXT drive (N8).
770
+ return { applied: false, reason: 'gate_open_wait', run: fresh };
771
+ }
772
+ // APPLY GUARD.
773
+ if (outcome === 'resolution_error') {
774
+ const withFailed = {
775
+ ...fresh,
776
+ evidence: [...fresh.evidence, evidence],
777
+ failed_steps: [...fresh.failed_steps, step],
778
+ };
779
+ const propagated = propagateSkips(withFailed, definition);
780
+ const withSkipped = {
781
+ ...withFailed,
782
+ skipped_steps: propagated.skipped,
783
+ skip_details: propagated.details,
784
+ };
785
+ const draft = {
786
+ ...withSkipped,
787
+ terminal_state: true,
788
+ // execution-loop.ts:3671 parity.
789
+ terminal_reason: `Guard step '${step}' failed: unresolvable path '${resolutionError.unresolvable_path}'`,
790
+ };
791
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, 'fail', false);
792
+ return {
793
+ applied: true,
794
+ run,
795
+ transitioned,
796
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
797
+ };
798
+ }
799
+ if (outcome === 'abort') {
800
+ const withSkippedSelf = {
801
+ ...fresh,
802
+ evidence: [...fresh.evidence, evidence],
803
+ skipped_steps: [...fresh.skipped_steps, step],
804
+ };
805
+ const propagated = propagateSkips(withSkippedSelf, definition);
806
+ const withSkipped = {
807
+ ...withSkippedSelf,
808
+ skipped_steps: propagated.skipped,
809
+ // #111: the merge preserves any cascade details for OTHER now-unreachable steps alongside
810
+ // this guard's own guard_abort tag (execution-loop.ts:3728-3735 parity).
811
+ skip_details: { ...propagated.details, [step]: { kind: 'guard_abort' } },
812
+ };
813
+ const draft = {
814
+ ...withSkipped,
815
+ terminal_state: true,
816
+ // terminal_reason ABSENT — phase 'aborted' derives from aborted_at (§4 table).
817
+ aborted_at: {
818
+ step_id: step,
819
+ conditions: abort.conditions,
820
+ ...(abort.abort_message !== undefined ? { abort_message: abort.abort_message } : {}),
821
+ },
822
+ };
823
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, 'abort', false);
824
+ return {
825
+ applied: true,
826
+ run,
827
+ transitioned,
828
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
829
+ };
830
+ }
831
+ // pass: two-disjunct isComplete predicate (same shape as settleStepArms's own).
832
+ const withCompleted = {
833
+ ...fresh,
834
+ evidence: [...fresh.evidence, evidence],
835
+ completed_steps: [...fresh.completed_steps, step],
836
+ };
837
+ const propagated = propagateSkips(withCompleted, definition);
838
+ const withSkipped = {
839
+ ...withCompleted,
840
+ skipped_steps: propagated.skipped,
841
+ skip_details: propagated.details,
842
+ };
843
+ const isComplete = isWorkflowComplete(withSkipped, definition) ||
844
+ (withSkipped.in_progress_steps.length === 0 &&
845
+ findEligibleSteps(definition, withSkipped).length === 0 &&
846
+ findEligibleGuardSteps(definition, withSkipped).length === 0);
847
+ const draft = {
848
+ ...withSkipped,
849
+ terminal_state: isComplete,
850
+ // execution-loop.ts:3675-3705 parity; eligibility.ts:47 keys deriveRunPhase's 'completed' on
851
+ // this exact string.
852
+ ...(isComplete ? { terminal_reason: 'Workflow completed.' } : {}),
853
+ };
854
+ const { run, transitioned } = applyTerminalPostconditions(draft, definition, 'complete', isComplete);
855
+ return {
856
+ applied: true,
857
+ run,
858
+ transitioned,
859
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
860
+ };
861
+ }
862
+ // ---------------------------------------------------------------------------
863
+ // §3 releaseStepArms (issue #279, increment 2, PR-C) — fence = claimToken. NEVER terminal, writes
864
+ // NO settled entry — the step returns to eligible.
865
+ // ---------------------------------------------------------------------------
866
+ function applyReleaseStep(fresh, delta, now) {
867
+ const { step, claimToken, capabilityBlock, evidence } = delta;
868
+ if (entryOf(fresh, step) !== undefined) {
869
+ return { applied: false, reason: 'already_settled_by_other', run: fresh };
870
+ }
871
+ if (isTerminal(fresh)) {
872
+ return { applied: false, reason: 'run_terminal', run: fresh };
873
+ }
874
+ const claim = fresh.claims?.[step];
875
+ if (claim === undefined) {
876
+ // TD F10: the claim is already gone — the RELEASE intent already holds. Idempotent no-op.
877
+ return { applied: false, reason: 'already_released', run: fresh };
878
+ }
879
+ if (!tokensEqual(claim.token, claimToken)) {
880
+ // Never stomp a successor's claim (execution-loop.ts:660-671 parity).
881
+ return { applied: false, reason: 'claim_lost', run: fresh };
882
+ }
883
+ if (fresh.pending_gate?.step_name === step) {
884
+ // reclaim-step.ts:389 parity — a claim pinned by an open gate is never released this way.
885
+ return { applied: false, reason: 'gate_mismatch', run: fresh };
886
+ }
887
+ // APPLY RELEASE: release claim + in_progress; optional capability_blocks merge
888
+ // (execution-loop.ts:2461-2475 semantics); optional evidence append (execution-loop.ts:679
889
+ // semantics — the compensating un-claim's own audit snapshot). NEVER terminal, NO settled entry.
890
+ const withRelease = {
891
+ ...fresh,
892
+ in_progress_steps: fresh.in_progress_steps.filter((s) => s !== step),
893
+ claims: omitClaim(fresh.claims, step),
894
+ ...(capabilityBlock !== undefined
895
+ ? {
896
+ capability_blocks: {
897
+ ...fresh.capability_blocks,
898
+ [step]: {
899
+ requirement: capabilityBlock.requirement,
900
+ code: capabilityBlock.code,
901
+ at: now.toISOString(),
902
+ },
903
+ },
904
+ }
905
+ : {}),
906
+ ...(evidence !== undefined ? { evidence: [...fresh.evidence, ...evidence] } : {}),
907
+ };
908
+ const run = { ...withRelease, run_phase: deriveRunPhase(withRelease) };
287
909
  return {
288
910
  applied: true,
289
- run: withPhase,
290
- transitioned: isComplete,
291
- pendingFinalizers: pendingFinalizerNames(withPhase.finalizer_ledger),
911
+ run,
912
+ transitioned: false,
913
+ pendingFinalizers: pendingFinalizerNames(run.finalizer_ledger),
292
914
  };
293
915
  }
294
916
  // ---------------------------------------------------------------------------
@@ -409,6 +1031,37 @@ export function applySettlement(fresh, delta, definition, options) {
409
1031
  return applyLeaseFinalizer(fresh, delta, now);
410
1032
  case 'mark_finalizer':
411
1033
  return applyMarkFinalizer(fresh, delta);
1034
+ case 'open_gate':
1035
+ return applyOpenGate(fresh, delta);
1036
+ case 'settle_gate':
1037
+ return applySettleGate(fresh, delta, definition, now);
1038
+ case 'settle_guard':
1039
+ return applySettleGuard(fresh, delta, definition);
1040
+ case 'release_step':
1041
+ return applyReleaseStep(fresh, delta, now);
1042
+ case 'expire_gate':
1043
+ return applyExpireGate(fresh, delta, definition, now);
412
1044
  }
413
1045
  }
1046
+ /**
1047
+ * Named constant (issue #279, increment 2, PR-C; design record §4.3) tying reclaim-step.ts's own
1048
+ * open-gate refusal (`reclaimStep`, ~line 389: "the claim is legitimately pinned by a human gate")
1049
+ * to this design's "unfenced-release soundness rests on reclaim" premise. **Reworded (issue #291,
1050
+ * [F6]):** the ORIGINAL text claimed `applyAbortEdge`'s cancel-gate write was "the ONLY path that
1051
+ * may release an open gate's claim" — issue #291's `applyExpireGate` (both dispositions) ALSO
1052
+ * releases it now, making that literal claim false. The invariant this constant actually protects
1053
+ * — `reclaimStep`/`isAutoReclaimable` must NEVER release an open gate's claim, or a concurrent
1054
+ * release could race and double-release the same claim — still holds exactly; the closed set of
1055
+ * paths that MAY release it is now: `settle_step` abort's cancel-gate write (another step's
1056
+ * handler-abort), and `expire_gate`'s settle_default/abort dispositions (the gate's OWN step,
1057
+ * enforce-clock-driven) — all three live inside `applySettlement`, under the SAME store lock,
1058
+ * which is exactly WHY they can never race `reclaimStep`'s own (separate) CAS write. Referenced
1059
+ * (not asserted via) by this invariant's two existing pinning tests (`reclaim-step.test.ts` +
1060
+ * the CLI's `reclaim.test.ts`) so a future reader can grep this name to find both, and
1061
+ * reclaim-step.ts's own SOURCE stays untouched by this PR.
1062
+ */
1063
+ export const RECLAIM_REFUSES_GATE_STEP = 'reclaim never releases the open-gate claim (design record design-d5-increment2.md §4.3 + ' +
1064
+ 'issue #291 design-d2.md [F6], unfenced-release soundness — the closed set: settle_step abort ' +
1065
+ "(another step), expire_gate settle_default/abort (the gate's own step), all inside " +
1066
+ 'applySettlement under the same lock)';
414
1067
  //# sourceMappingURL=settlement.js.map