@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.
- package/dist/engine/abandon-run.d.ts.map +1 -1
- package/dist/engine/abandon-run.js +17 -9
- package/dist/engine/abandon-run.js.map +1 -1
- package/dist/engine/apply-resume.d.ts +11 -2
- package/dist/engine/apply-resume.d.ts.map +1 -1
- package/dist/engine/apply-resume.js +15 -4
- package/dist/engine/apply-resume.js.map +1 -1
- package/dist/engine/eligibility.d.ts +20 -2
- package/dist/engine/eligibility.d.ts.map +1 -1
- package/dist/engine/eligibility.js +46 -24
- package/dist/engine/eligibility.js.map +1 -1
- package/dist/engine/execution-loop.d.ts +29 -5
- package/dist/engine/execution-loop.d.ts.map +1 -1
- package/dist/engine/execution-loop.js +1152 -90
- package/dist/engine/execution-loop.js.map +1 -1
- package/dist/engine/gate-timing.d.ts +17 -0
- package/dist/engine/gate-timing.d.ts.map +1 -0
- package/dist/engine/gate-timing.js +19 -0
- package/dist/engine/gate-timing.js.map +1 -0
- package/dist/engine/reclaim-step.d.ts +8 -0
- package/dist/engine/reclaim-step.d.ts.map +1 -1
- package/dist/engine/reclaim-step.js +48 -3
- package/dist/engine/reclaim-step.js.map +1 -1
- package/dist/engine/run-health.d.ts +1 -1
- package/dist/engine/run-health.d.ts.map +1 -1
- package/dist/engine/run-health.js +100 -2
- package/dist/engine/run-health.js.map +1 -1
- package/dist/engine/settlement.d.ts +56 -7
- package/dist/engine/settlement.d.ts.map +1 -1
- package/dist/engine/settlement.js +690 -37
- package/dist/engine/settlement.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/store/json-file-store.d.ts +6 -4
- package/dist/store/json-file-store.d.ts.map +1 -1
- package/dist/store/json-file-store.js +26 -9
- package/dist/store/json-file-store.js.map +1 -1
- package/dist/store/store-interface.d.ts +11 -8
- package/dist/store/store-interface.d.ts.map +1 -1
- package/dist/types/response-envelope.d.ts +16 -0
- package/dist/types/response-envelope.d.ts.map +1 -1
- package/dist/types/run-record.d.ts +132 -3
- package/dist/types/run-record.d.ts.map +1 -1
- package/dist/types/settlement.d.ts +164 -11
- package/dist/types/settlement.d.ts.map +1 -1
- package/dist/types/workflow-definition.d.ts +113 -32
- package/dist/types/workflow-definition.d.ts.map +1 -1
- package/dist/types/workflow-definition.js +20 -0
- package/dist/types/workflow-definition.js.map +1 -1
- package/dist/workflow/diagnostics.d.ts +1 -1
- package/dist/workflow/diagnostics.d.ts.map +1 -1
- package/dist/workflow/diagnostics.js +3 -0
- package/dist/workflow/diagnostics.js.map +1 -1
- package/dist/workflow/structured-output-eligibility.d.ts +81 -0
- package/dist/workflow/structured-output-eligibility.d.ts.map +1 -0
- package/dist/workflow/structured-output-eligibility.js +359 -0
- package/dist/workflow/structured-output-eligibility.js.map +1 -0
- package/dist/workflow/yaml-loader.d.ts.map +1 -1
- package/dist/workflow/yaml-loader.js +145 -2
- package/dist/workflow/yaml-loader.js.map +1 -1
- 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
|
|
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
|
|
85
|
-
* record §4/§6; extracted from `buildFinalizedSeal`'s selection,
|
|
86
|
-
* Group A (rank precedence) —
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
const
|
|
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
|
|
245
|
-
transitioned
|
|
246
|
-
pendingFinalizers: pendingFinalizerNames(
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
transitioned:
|
|
291
|
-
pendingFinalizers: pendingFinalizerNames(
|
|
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
|