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