@sensigo/realm-testing 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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/store/in-memory-store.d.ts +15 -1
- package/dist/store/in-memory-store.d.ts.map +1 -1
- package/dist/store/in-memory-store.js +52 -5
- package/dist/store/in-memory-store.js.map +1 -1
- package/dist/store/run-store-fidelity-contract.d.ts.map +1 -1
- package/dist/store/run-store-fidelity-contract.js +11 -0
- package/dist/store/run-store-fidelity-contract.js.map +1 -1
- package/dist/store/settlement-contract.d.ts +82 -0
- package/dist/store/settlement-contract.d.ts.map +1 -0
- package/dist/store/settlement-contract.js +2508 -0
- package/dist/store/settlement-contract.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,2508 @@
|
|
|
1
|
+
// settlement-contract.ts — framework-agnostic Test Compatibility Kit (TCK) for RunStore.settleStep
|
|
2
|
+
// (issue #279). Normative spec: plans/issue-279/design-d4-increment1.md (increment 1) +
|
|
3
|
+
// plans/issue-279/design-d5-increment2.md §8 (increment 2, PR-C — this file's own gate/guard/
|
|
4
|
+
// release law additions). This file implements the PR-C-runnable law subset (increment 1's own
|
|
5
|
+
// laws, plus increment 2's gate/guard/release laws + PHASE_IS_GENERATED); the PR-D-only laws
|
|
6
|
+
// (RESUME_CLEARS_SETTLED / DRAIN_REREADS_LEDGER, needing `applyResume`/the drain verb) are
|
|
7
|
+
// deliberately NOT here.
|
|
8
|
+
//
|
|
9
|
+
// Pure case descriptors, NOT describe/it/expect — mirrors run-store-fidelity-contract.ts's and
|
|
10
|
+
// fenced-trace-buffer-contract.ts's own precedent (importing vitest here would make it a runtime
|
|
11
|
+
// dependency of this published package). Each calling test file supplies an adapter and wires the
|
|
12
|
+
// returned descriptors into ITS OWN test framework.
|
|
13
|
+
//
|
|
14
|
+
// Wiring note (divergence from the issue #183/#188 precedent, deliberate): the #183/#188 pattern
|
|
15
|
+
// keeps a JsonFileStore conformance test in @sensigo/realm-cli (cli depends on both core and
|
|
16
|
+
// testing; a testing→cli dependency would be circular). That constraint does NOT apply here:
|
|
17
|
+
// JsonFileStore is exported directly from @sensigo/realm's own index (core), and
|
|
18
|
+
// @sensigo/realm-testing already depends on @sensigo/realm — so BOTH stores' settlement
|
|
19
|
+
// conformance can live in NEW test files right here in packages/testing/src/store/, with no
|
|
20
|
+
// circular-package hazard. See this module's own calling test files for the actual wiring.
|
|
21
|
+
import { applySettlement, deriveRunPhase, } from '@sensigo/realm';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Fixture builders
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
let seq = 0;
|
|
26
|
+
/** A unique-per-call identifier — used for workflowId strings, which need only be locally unique
|
|
27
|
+
* (RunStore never validates them against a registry). */
|
|
28
|
+
function uid(prefix) {
|
|
29
|
+
seq += 1;
|
|
30
|
+
return `${prefix}-${seq}-${Math.random().toString(36).slice(2, 8)}`;
|
|
31
|
+
}
|
|
32
|
+
/** A minimal workflow definition with the named agent steps, all immediately eligible (no deps). */
|
|
33
|
+
function minimalDefinition(stepNames) {
|
|
34
|
+
const steps = {};
|
|
35
|
+
for (const name of stepNames) {
|
|
36
|
+
steps[name] = { description: name, execution: 'agent', depends_on: [] };
|
|
37
|
+
}
|
|
38
|
+
return { id: uid('settlement-wf'), name: 'Settlement TCK fixture', version: 1, steps };
|
|
39
|
+
}
|
|
40
|
+
/** Adds one finalizer step to a definition. */
|
|
41
|
+
function withFinalizer(def, finalizerName, onOutcome) {
|
|
42
|
+
return {
|
|
43
|
+
...def,
|
|
44
|
+
steps: {
|
|
45
|
+
...def.steps,
|
|
46
|
+
[finalizerName]: {
|
|
47
|
+
description: finalizerName,
|
|
48
|
+
execution: 'finalizer',
|
|
49
|
+
on_outcome: onOutcome,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Adds one guard step (+ optional dependents) to a definition. */
|
|
55
|
+
function withGuard(def, guardName, abortUnless, opts) {
|
|
56
|
+
const dependentSteps = {};
|
|
57
|
+
for (const dep of opts?.dependents ?? []) {
|
|
58
|
+
dependentSteps[dep] = { description: dep, execution: 'agent', depends_on: [guardName] };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
...def,
|
|
62
|
+
steps: {
|
|
63
|
+
...def.steps,
|
|
64
|
+
[guardName]: { description: guardName, execution: 'guard', abort_unless: abortUnless },
|
|
65
|
+
...dependentSteps,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Default settlement fixture — builds minimal agent-step / finalizer-step / guard-step
|
|
70
|
+
* definitions with no store-specific requirements beyond `RunStore.create` never validating
|
|
71
|
+
* `workflowId` against an external registry. Suitable for JsonFileStore and InMemoryStore; both
|
|
72
|
+
* this package's own conformance test files wire this in directly. */
|
|
73
|
+
export const defaultSettlementFixture = {
|
|
74
|
+
minimalDefinition,
|
|
75
|
+
withFinalizer,
|
|
76
|
+
withGuard,
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Contract-INTERNAL helper (design record §8 fixture mechanics) — deliberately NOT a
|
|
80
|
+
* `SettlementFixture` member: gate state lives on the RUN RECORD side (`pending_gate`), not the
|
|
81
|
+
* step definition — the transform never reads step-def gate config at all, so there is nothing
|
|
82
|
+
* store-specific to inject here.
|
|
83
|
+
*/
|
|
84
|
+
function makePendingGate(stepName, opts) {
|
|
85
|
+
return {
|
|
86
|
+
gate_id: opts?.gateId ?? uid('tck-gate'),
|
|
87
|
+
step_name: stepName,
|
|
88
|
+
preview: {},
|
|
89
|
+
choices: opts?.choices ?? ['approve', 'reject'],
|
|
90
|
+
opened_at: '2026-01-01T00:00:00.000Z',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function makeEvidence(stepId, overrides = {}) {
|
|
94
|
+
return {
|
|
95
|
+
step_id: stepId,
|
|
96
|
+
started_at: '2026-01-01T00:00:00.000Z',
|
|
97
|
+
completed_at: '2026-01-01T00:00:01.000Z',
|
|
98
|
+
duration_ms: 1,
|
|
99
|
+
input_summary: {},
|
|
100
|
+
output_summary: {},
|
|
101
|
+
status: 'success',
|
|
102
|
+
evidence_hash: 'tck-evidence',
|
|
103
|
+
...overrides,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Creates a fresh run and claims `stepName` — returns the claimed run plus the REAL,
|
|
107
|
+
* store-minted fencing token (never hand-constructed — the law-3 mint-forcing rule). */
|
|
108
|
+
async function createClaimed(store, def, stepName) {
|
|
109
|
+
const { run } = await store.create({
|
|
110
|
+
workflowId: def.id,
|
|
111
|
+
workflowVersion: def.version,
|
|
112
|
+
params: {},
|
|
113
|
+
});
|
|
114
|
+
const claimed = await store.claimStep(run.id, stepName, def);
|
|
115
|
+
const token = claimed.claims?.[stepName]?.token;
|
|
116
|
+
if (token === undefined) {
|
|
117
|
+
throw new Error(`fixture setup failed: claimStep did not mint a token for step '${stepName}'`);
|
|
118
|
+
}
|
|
119
|
+
return { run: claimed, token };
|
|
120
|
+
}
|
|
121
|
+
function requireSettleStep(store) {
|
|
122
|
+
if (store.settleStep === undefined) {
|
|
123
|
+
throw new Error('settlementContract requires an adapter.store that declares settleStep');
|
|
124
|
+
}
|
|
125
|
+
return store.settleStep.bind(store);
|
|
126
|
+
}
|
|
127
|
+
function assertApplied(result, context) {
|
|
128
|
+
if (!result.applied) {
|
|
129
|
+
throw new Error(`${context}: expected applied:true, got refusal reason '${result.reason}'`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function assertRefused(result, expectedReason, context) {
|
|
133
|
+
if (result.applied) {
|
|
134
|
+
throw new Error(`${context}: expected a refusal (reason '${expectedReason}'), got applied:true`);
|
|
135
|
+
}
|
|
136
|
+
if (result.reason !== expectedReason) {
|
|
137
|
+
throw new Error(`${context}: expected reason '${expectedReason}', got '${result.reason}'`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// L1 FRESH_APPLICATION
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
function freshApplicationCases(adapter) {
|
|
144
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
145
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
146
|
+
return [
|
|
147
|
+
{
|
|
148
|
+
law: 'FRESH_APPLICATION',
|
|
149
|
+
name: `[${adapter.storeName}] routine same-run fan-out (2 disjoint steps settling concurrently) produces ZERO refusals — disjoint deltas compose in-CS`,
|
|
150
|
+
run: async () => {
|
|
151
|
+
const def = minimalDefinition(['a', 'b']);
|
|
152
|
+
const { run } = await adapter.store.create({
|
|
153
|
+
workflowId: def.id,
|
|
154
|
+
workflowVersion: 1,
|
|
155
|
+
params: {},
|
|
156
|
+
});
|
|
157
|
+
const claimedA = await adapter.store.claimStep(run.id, 'a', def);
|
|
158
|
+
const tokenA = claimedA.claims['a'].token;
|
|
159
|
+
const claimedB = await adapter.store.claimStep(run.id, 'b', def);
|
|
160
|
+
const tokenB = claimedB.claims['b'].token;
|
|
161
|
+
const [resultA, resultB] = await Promise.all([
|
|
162
|
+
settleStep(run.id, {
|
|
163
|
+
kind: 'settle_step',
|
|
164
|
+
step: 'a',
|
|
165
|
+
outcome: 'complete',
|
|
166
|
+
claimToken: tokenA,
|
|
167
|
+
evidence: [makeEvidence('a')],
|
|
168
|
+
}, def),
|
|
169
|
+
settleStep(run.id, {
|
|
170
|
+
kind: 'settle_step',
|
|
171
|
+
step: 'b',
|
|
172
|
+
outcome: 'complete',
|
|
173
|
+
claimToken: tokenB,
|
|
174
|
+
evidence: [makeEvidence('b')],
|
|
175
|
+
}, def),
|
|
176
|
+
]);
|
|
177
|
+
assertApplied(resultA, 'fan-out settle of step a');
|
|
178
|
+
assertApplied(resultB, 'fan-out settle of step b');
|
|
179
|
+
const final = await adapter.store.get(run.id);
|
|
180
|
+
if (!final.completed_steps.includes('a') || !final.completed_steps.includes('b')) {
|
|
181
|
+
throw new Error(`expected both 'a' and 'b' in completed_steps after concurrent settle, got: ${JSON.stringify(final.completed_steps)}`);
|
|
182
|
+
}
|
|
183
|
+
if (final.terminal_state !== true) {
|
|
184
|
+
throw new Error('expected the run to terminalize once both disjoint steps settled');
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// L2 CONDITIONAL_NOOP (+ abort variant) + CONDITIONAL_NOOP_GRANDFATHERED
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
function conditionalNoopCases(adapter) {
|
|
194
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
195
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
196
|
+
return [
|
|
197
|
+
{
|
|
198
|
+
law: 'CONDITIONAL_NOOP',
|
|
199
|
+
name: `[${adapter.storeName}] a same-token, same-outcome (complete) retry NOOPs as already_settled — version unchanged`,
|
|
200
|
+
run: async () => {
|
|
201
|
+
const def = minimalDefinition(['a', 'b']);
|
|
202
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
203
|
+
const delta = {
|
|
204
|
+
kind: 'settle_step',
|
|
205
|
+
step: 'a',
|
|
206
|
+
outcome: 'complete',
|
|
207
|
+
claimToken: token,
|
|
208
|
+
evidence: [makeEvidence('a')],
|
|
209
|
+
};
|
|
210
|
+
const first = await settleStep(run.id, delta, def);
|
|
211
|
+
assertApplied(first, 'first settle of step a');
|
|
212
|
+
const second = await settleStep(run.id, delta, def);
|
|
213
|
+
assertRefused(second, 'already_settled', 'retry settle of step a (same token, same outcome)');
|
|
214
|
+
if (second.run.version !== first.run.version) {
|
|
215
|
+
throw new Error(`expected version unchanged on a NOOP (${first.run.version}), got ${second.run.version}`);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
law: 'CONDITIONAL_NOOP',
|
|
221
|
+
name: `[${adapter.storeName}] a same-token, same-outcome (abort) retry NOOPs as already_settled — the mapped-space abort variant`,
|
|
222
|
+
run: async () => {
|
|
223
|
+
const def = minimalDefinition(['a', 'b']);
|
|
224
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
225
|
+
const delta = {
|
|
226
|
+
kind: 'settle_step',
|
|
227
|
+
step: 'a',
|
|
228
|
+
outcome: 'abort',
|
|
229
|
+
claimToken: token,
|
|
230
|
+
evidence: [makeEvidence('a')],
|
|
231
|
+
abort: { stepId: 'a', abortMessage: 'tck-abort' },
|
|
232
|
+
};
|
|
233
|
+
const first = await settleStep(run.id, delta, def);
|
|
234
|
+
assertApplied(first, 'first abort-settle of step a');
|
|
235
|
+
const second = await settleStep(run.id, delta, def);
|
|
236
|
+
assertRefused(second, 'already_settled', 'retry abort-settle of step a (same token, same outcome)');
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
law: 'CONDITIONAL_NOOP_GRANDFATHERED',
|
|
241
|
+
name: `[${adapter.storeName}] a grandfathered (token-less) claim settles with no claimToken presented, and absent≡absent NOOPs on retry`,
|
|
242
|
+
run: async () => {
|
|
243
|
+
const def = minimalDefinition(['a']);
|
|
244
|
+
const { run } = await adapter.store.create({
|
|
245
|
+
workflowId: def.id,
|
|
246
|
+
workflowVersion: 1,
|
|
247
|
+
params: {},
|
|
248
|
+
});
|
|
249
|
+
// Hand-seed a pre-#279-shaped claim (no token) — simulates a grandfathered claim record.
|
|
250
|
+
// This is the ONE deliberate exception to "tokens MUST come from store-returned claim
|
|
251
|
+
// records" (law-3's mint-forcing sentence) — the whole point of this fixture is to prove
|
|
252
|
+
// the ABSENCE of a token is handled correctly, so it must be genuinely absent here.
|
|
253
|
+
const seeded = await adapter.store.update({
|
|
254
|
+
...run,
|
|
255
|
+
in_progress_steps: ['a'],
|
|
256
|
+
claims: { a: { deadline: null } },
|
|
257
|
+
});
|
|
258
|
+
const delta = {
|
|
259
|
+
kind: 'settle_step',
|
|
260
|
+
step: 'a',
|
|
261
|
+
outcome: 'complete',
|
|
262
|
+
evidence: [makeEvidence('a')],
|
|
263
|
+
// claimToken deliberately omitted
|
|
264
|
+
};
|
|
265
|
+
const first = await settleStep(seeded.id, delta, def);
|
|
266
|
+
assertApplied(first, 'grandfathered (token-less) settle');
|
|
267
|
+
const second = await settleStep(seeded.id, delta, def);
|
|
268
|
+
assertRefused(second, 'already_settled', 'grandfathered retry (absent≡absent)');
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// L3 OWNERSHIP_REFUSAL
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
function ownershipRefusalCases(adapter) {
|
|
277
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
278
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
279
|
+
return [
|
|
280
|
+
{
|
|
281
|
+
law: 'OWNERSHIP_REFUSAL',
|
|
282
|
+
name: `[${adapter.storeName}] a settle_step with a WRONG claimToken refuses claim_lost — outcome NOT recorded`,
|
|
283
|
+
run: async () => {
|
|
284
|
+
const def = minimalDefinition(['a']);
|
|
285
|
+
const { run } = await createClaimed(adapter.store, def, 'a');
|
|
286
|
+
const result = await settleStep(run.id, {
|
|
287
|
+
kind: 'settle_step',
|
|
288
|
+
step: 'a',
|
|
289
|
+
outcome: 'complete',
|
|
290
|
+
claimToken: 'wrong-token',
|
|
291
|
+
evidence: [],
|
|
292
|
+
}, def);
|
|
293
|
+
assertRefused(result, 'claim_lost', 'settle with a wrong token');
|
|
294
|
+
if (result.run.completed_steps.includes('a')) {
|
|
295
|
+
throw new Error('a claim_lost refusal must never record the outcome');
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// L4 LEDGER_MINT_ATOMICITY (+ cross-backend caveat — documented, not testable in-repo beyond this)
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
function ledgerMintAtomicityCases(adapter) {
|
|
305
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
306
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
307
|
+
return [
|
|
308
|
+
{
|
|
309
|
+
law: 'LEDGER_MINT_ATOMICITY',
|
|
310
|
+
name: `[${adapter.storeName}] a terminal edge mints the ledger in the SAME write as membership/terminal_state — never a torn intermediate state (single version bump)`,
|
|
311
|
+
run: async () => {
|
|
312
|
+
// CROSS-BACKEND CAVEAT (design record §8/L4): this in-repo store's atomicity comes from
|
|
313
|
+
// ITS OWN single lock+read+write critical section (JsonFileStore) or its documented
|
|
314
|
+
// no-await synchronous stretch (InMemoryStore) — verified structurally elsewhere in this
|
|
315
|
+
// suite. A MULTI-STATEMENT backend (e.g. a future Postgres store) MUST prove its own
|
|
316
|
+
// atomicity in ITS OWN conformance suite; a green run here does not and cannot verify
|
|
317
|
+
// that (mirrors claimStep's own cross-host caveat). This case's own assertion is a
|
|
318
|
+
// same-process proxy: exactly ONE version bump carries both the membership AND the ledger
|
|
319
|
+
// mint — never two separate writes.
|
|
320
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'complete');
|
|
321
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
322
|
+
const before = run.version;
|
|
323
|
+
const result = await settleStep(run.id, {
|
|
324
|
+
kind: 'settle_step',
|
|
325
|
+
step: 'a',
|
|
326
|
+
outcome: 'complete',
|
|
327
|
+
claimToken: token,
|
|
328
|
+
evidence: [makeEvidence('a')],
|
|
329
|
+
}, def);
|
|
330
|
+
assertApplied(result, 'terminal settle minting a finalizer');
|
|
331
|
+
if (result.run.version !== before + 1) {
|
|
332
|
+
throw new Error(`expected exactly one version bump (${before} -> ${before + 1}) carrying both membership and the mint, got version ${result.run.version}`);
|
|
333
|
+
}
|
|
334
|
+
if (result.run.finalizer_ledger?.['fin']?.status !== 'pending') {
|
|
335
|
+
throw new Error('expected the finalizer to be minted pending in the SAME write');
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// L5 DRAIN_MARK_DEDUP
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
function drainMarkDedupCases(adapter) {
|
|
345
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
346
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
347
|
+
return [
|
|
348
|
+
{
|
|
349
|
+
law: 'DRAIN_MARK_DEDUP',
|
|
350
|
+
name: `[${adapter.storeName}] a same-token, same-result mark_finalizer retry NOOPs as already_marked — completed_steps not double-appended`,
|
|
351
|
+
run: async () => {
|
|
352
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'complete');
|
|
353
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
354
|
+
const settled = await settleStep(run.id, {
|
|
355
|
+
kind: 'settle_step',
|
|
356
|
+
step: 'a',
|
|
357
|
+
outcome: 'complete',
|
|
358
|
+
claimToken: token,
|
|
359
|
+
evidence: [makeEvidence('a')],
|
|
360
|
+
}, def);
|
|
361
|
+
assertApplied(settled, 'terminal settle minting the finalizer');
|
|
362
|
+
const leaseToken = uid('lease');
|
|
363
|
+
const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
|
|
364
|
+
assertApplied(leased, 'lease the pending finalizer');
|
|
365
|
+
const markDelta = {
|
|
366
|
+
kind: 'mark_finalizer',
|
|
367
|
+
finalizer: 'fin',
|
|
368
|
+
leaseToken,
|
|
369
|
+
result: 'completed',
|
|
370
|
+
evidence: makeEvidence('fin'),
|
|
371
|
+
};
|
|
372
|
+
const firstMark = await settleStep(run.id, markDelta, def);
|
|
373
|
+
assertApplied(firstMark, 'first mark');
|
|
374
|
+
const secondMark = await settleStep(run.id, markDelta, def);
|
|
375
|
+
assertRefused(secondMark, 'already_marked', 'retry mark (same token, same result)');
|
|
376
|
+
const occurrences = secondMark.run.completed_steps.filter((s) => s === 'fin').length;
|
|
377
|
+
if (occurrences !== 1) {
|
|
378
|
+
throw new Error(`expected 'fin' to appear exactly once in completed_steps, got ${occurrences}`);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// L6 TERMINAL_REFUSAL
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
function terminalRefusalCases(adapter) {
|
|
388
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
389
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
390
|
+
return [
|
|
391
|
+
{
|
|
392
|
+
law: 'TERMINAL_REFUSAL',
|
|
393
|
+
name: `[${adapter.storeName}] settle_step on an ALREADY-terminal run (a different, never-settled step) refuses run_terminal`,
|
|
394
|
+
run: async () => {
|
|
395
|
+
const def = minimalDefinition(['a', 'b']);
|
|
396
|
+
const { run } = await adapter.store.create({
|
|
397
|
+
workflowId: def.id,
|
|
398
|
+
workflowVersion: 1,
|
|
399
|
+
params: {},
|
|
400
|
+
});
|
|
401
|
+
// Terminalize the run directly (no need to route through a real settle for this fixture).
|
|
402
|
+
await adapter.store.update({
|
|
403
|
+
...run,
|
|
404
|
+
terminal_state: true,
|
|
405
|
+
terminal_reason: 'tck-terminal',
|
|
406
|
+
});
|
|
407
|
+
const result = await settleStep(run.id, {
|
|
408
|
+
kind: 'settle_step',
|
|
409
|
+
step: 'b',
|
|
410
|
+
outcome: 'complete',
|
|
411
|
+
claimToken: 'irrelevant',
|
|
412
|
+
evidence: [],
|
|
413
|
+
}, def);
|
|
414
|
+
assertRefused(result, 'run_terminal', 'settle on an already-terminal run');
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// TERMINAL_STATE_ONLY (PR-A correction, atomic-settle-279-pr-a-pin-correction.md) — pins
|
|
421
|
+
// `isTerminal := terminal_state === true` EXACTLY, refuting the earlier trio adjudication
|
|
422
|
+
// (`terminal_state === true || abandoned_at !== undefined || aborted_at !== undefined`), which
|
|
423
|
+
// the restart's bottom-up Finding 1 identified as the deterministic resumed-abandoned wedge.
|
|
424
|
+
// terminal_state-only is load-bearing TODAY: `resume` (packages/cli/src/commands/resume.ts:71)
|
|
425
|
+
// strips only `terminal_reason`, never `abandoned_at` — so `abandoned_at ∧ terminal_state:false`
|
|
426
|
+
// is a LIVE, in-contract record shape on main right now. Under the trio, every settle on such a
|
|
427
|
+
// run would refuse `run_terminal` forever.
|
|
428
|
+
//
|
|
429
|
+
// Reintroducing either trio disjunct into `isTerminal` reds this law; genuine terminal refusal is
|
|
430
|
+
// TERMINAL_REFUSAL's job (`terminal_state: true` — which abandon/abort ALWAYS set atomically,
|
|
431
|
+
// abandon-run.ts:66-71 / execution-loop.ts:1803-1811).
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
function terminalStateOnlyCases(adapter) {
|
|
434
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
435
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
436
|
+
return [
|
|
437
|
+
{
|
|
438
|
+
law: 'TERMINAL_STATE_ONLY',
|
|
439
|
+
name: `[${adapter.storeName}] a resumed-abandoned shape (abandoned_at SET, terminal_state:false) — settle_step APPLIES, never run_terminal`,
|
|
440
|
+
run: async () => {
|
|
441
|
+
const def = minimalDefinition(['a']);
|
|
442
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
443
|
+
// Hand-authored fixture via update() (mirrors MINT_FRESH's own pattern) — a LIVE,
|
|
444
|
+
// in-contract shape today, not a hypothetical: resume never strips abandoned_at.
|
|
445
|
+
const seeded = await adapter.store.update({
|
|
446
|
+
...run,
|
|
447
|
+
abandoned_at: '2026-01-01T00:00:00.000Z',
|
|
448
|
+
});
|
|
449
|
+
const result = await settleStep(seeded.id, {
|
|
450
|
+
kind: 'settle_step',
|
|
451
|
+
step: 'a',
|
|
452
|
+
outcome: 'complete',
|
|
453
|
+
claimToken: token,
|
|
454
|
+
evidence: [makeEvidence('a')],
|
|
455
|
+
}, def);
|
|
456
|
+
assertApplied(result, 'settle on a resumed-abandoned (terminal_state:false) run');
|
|
457
|
+
if (!result.run.completed_steps.includes('a')) {
|
|
458
|
+
throw new Error(`expected 'a' to land in completed_steps, got: ${JSON.stringify(result.run.completed_steps)}`);
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
law: 'TERMINAL_STATE_ONLY',
|
|
464
|
+
name: `[${adapter.storeName}] an aborted-marker shape (aborted_at SET, terminal_state:false) — settle_step APPLIES, never run_terminal`,
|
|
465
|
+
run: async () => {
|
|
466
|
+
const def = minimalDefinition(['a']);
|
|
467
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
468
|
+
// Fixture-legal via update() regardless of whether the real engine can currently reach
|
|
469
|
+
// this exact combination — the law pins the PREDICATE itself, so it must make ANY trio
|
|
470
|
+
// disjunct reintroduction red, not just the abandoned one.
|
|
471
|
+
const seeded = await adapter.store.update({
|
|
472
|
+
...run,
|
|
473
|
+
aborted_at: { step_id: 'a', abort_message: 'tck-aborted-marker' },
|
|
474
|
+
});
|
|
475
|
+
const result = await settleStep(seeded.id, {
|
|
476
|
+
kind: 'settle_step',
|
|
477
|
+
step: 'a',
|
|
478
|
+
outcome: 'complete',
|
|
479
|
+
claimToken: token,
|
|
480
|
+
evidence: [makeEvidence('a')],
|
|
481
|
+
}, def);
|
|
482
|
+
assertApplied(result, 'settle on an aborted-marker (terminal_state:false) run');
|
|
483
|
+
if (!result.run.completed_steps.includes('a')) {
|
|
484
|
+
throw new Error(`expected 'a' to land in completed_steps, got: ${JSON.stringify(result.run.completed_steps)}`);
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// L7 CS-purity — structural: `options` carries VALUES only ({now}); no callback, no registry.
|
|
492
|
+
// In-repo source-text guard (the calling test file greps applySettlement's own signature).
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
function csPurityCases(_adapter) {
|
|
495
|
+
return [
|
|
496
|
+
{
|
|
497
|
+
law: 'CS_PURITY',
|
|
498
|
+
name: 'applySettlement is callable with ONLY {now?: Date} as its options — no callback, no registry parameter exists to pass',
|
|
499
|
+
run: async () => {
|
|
500
|
+
// A structural proof, not a source-text grep (that lives in the calling test file, which
|
|
501
|
+
// can read its own source — this module ships compiled and has no access to its own
|
|
502
|
+
// source text at runtime). Calling applySettlement with a bare {now} object and nothing
|
|
503
|
+
// else demonstrates the FULL options surface is exhausted by that one field — TypeScript
|
|
504
|
+
// itself would reject an extra property on a literal passed here if the type carried one.
|
|
505
|
+
const def = minimalDefinition(['a']);
|
|
506
|
+
const fresh = {
|
|
507
|
+
id: 'tck-cs-purity',
|
|
508
|
+
workflow_id: def.id,
|
|
509
|
+
workflow_version: 1,
|
|
510
|
+
completed_steps: [],
|
|
511
|
+
in_progress_steps: ['a'],
|
|
512
|
+
failed_steps: [],
|
|
513
|
+
skipped_steps: [],
|
|
514
|
+
run_phase: 'running',
|
|
515
|
+
version: 0,
|
|
516
|
+
params: {},
|
|
517
|
+
evidence: [],
|
|
518
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
519
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
520
|
+
terminal_state: false,
|
|
521
|
+
claims: { a: { deadline: null, token: 'tck-token' } },
|
|
522
|
+
};
|
|
523
|
+
const result = applySettlement(fresh, {
|
|
524
|
+
kind: 'settle_step',
|
|
525
|
+
step: 'a',
|
|
526
|
+
outcome: 'complete',
|
|
527
|
+
claimToken: 'tck-token',
|
|
528
|
+
evidence: [],
|
|
529
|
+
}, def, { now: new Date('2026-01-01T00:00:00.000Z') });
|
|
530
|
+
if (!result.applied) {
|
|
531
|
+
throw new Error(`expected applied:true from a purity-check call, got refusal: ${result.reason}`);
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
];
|
|
536
|
+
}
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// L8 never-downgrade — completed/failed ledger entries are immutable across a re-mint.
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
function neverDowngradeCases(_adapter) {
|
|
541
|
+
return [
|
|
542
|
+
{
|
|
543
|
+
law: 'NEVER_DOWNGRADE',
|
|
544
|
+
name: 'mintFresh (via a hand-authored fresh state) never downgrades an already-completed finalizer ledger entry back to pending',
|
|
545
|
+
run: async () => {
|
|
546
|
+
// Transform-level (not store-level): hand-author a fresh state where 'fin' is selected by
|
|
547
|
+
// selectFinalizers (not yet in completed_steps) but its ledger entry ALREADY shows
|
|
548
|
+
// 'completed' — the defensive guard mintFresh's own doc names ("membership-skip SHOULD
|
|
549
|
+
// exclude this; the guard is defensive"). Exercised directly against applySettlement so
|
|
550
|
+
// the terminal false→true edge fires mintFresh in a single, controlled call.
|
|
551
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'complete');
|
|
552
|
+
const fresh = {
|
|
553
|
+
id: 'tck-never-downgrade',
|
|
554
|
+
workflow_id: def.id,
|
|
555
|
+
workflow_version: 1,
|
|
556
|
+
completed_steps: [],
|
|
557
|
+
in_progress_steps: ['a'],
|
|
558
|
+
failed_steps: [],
|
|
559
|
+
skipped_steps: [],
|
|
560
|
+
run_phase: 'running',
|
|
561
|
+
version: 0,
|
|
562
|
+
params: {},
|
|
563
|
+
evidence: [],
|
|
564
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
565
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
566
|
+
terminal_state: false,
|
|
567
|
+
claims: { a: { deadline: null, token: 'tck-token' } },
|
|
568
|
+
finalizer_ledger: { fin: { status: 'completed', rank: 0 } },
|
|
569
|
+
};
|
|
570
|
+
const result = applySettlement(fresh, {
|
|
571
|
+
kind: 'settle_step',
|
|
572
|
+
step: 'a',
|
|
573
|
+
outcome: 'complete',
|
|
574
|
+
claimToken: 'tck-token',
|
|
575
|
+
evidence: [makeEvidence('a')],
|
|
576
|
+
}, def, { now: new Date('2026-01-01T00:00:00.000Z') });
|
|
577
|
+
if (!result.applied)
|
|
578
|
+
throw new Error(`expected applied:true, got refusal: ${result.reason}`);
|
|
579
|
+
if (result.run.finalizer_ledger?.['fin']?.status !== 'completed') {
|
|
580
|
+
throw new Error(`expected the already-completed finalizer to stay 'completed', got '${result.run.finalizer_ledger?.['fin']?.status}'`);
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
];
|
|
585
|
+
}
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
// L9 SETTLE_OUTCOME_INTEGRITY
|
|
588
|
+
// ---------------------------------------------------------------------------
|
|
589
|
+
function settleOutcomeIntegrityCases(adapter) {
|
|
590
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
591
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
592
|
+
return [
|
|
593
|
+
{
|
|
594
|
+
law: 'SETTLE_OUTCOME_INTEGRITY',
|
|
595
|
+
name: `[${adapter.storeName}] the SAME token settling the SAME step with a DIFFERENT outcome refuses settled_outcome_divergence`,
|
|
596
|
+
run: async () => {
|
|
597
|
+
const def = minimalDefinition(['a', 'b']);
|
|
598
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
599
|
+
const first = await settleStep(run.id, {
|
|
600
|
+
kind: 'settle_step',
|
|
601
|
+
step: 'a',
|
|
602
|
+
outcome: 'complete',
|
|
603
|
+
claimToken: token,
|
|
604
|
+
evidence: [makeEvidence('a')],
|
|
605
|
+
}, def);
|
|
606
|
+
assertApplied(first, 'first settle (complete)');
|
|
607
|
+
const second = await settleStep(run.id, {
|
|
608
|
+
kind: 'settle_step',
|
|
609
|
+
step: 'a',
|
|
610
|
+
outcome: 'fail',
|
|
611
|
+
claimToken: token,
|
|
612
|
+
evidence: [],
|
|
613
|
+
failureMessage: 'tck-divergent',
|
|
614
|
+
}, def);
|
|
615
|
+
assertRefused(second, 'settled_outcome_divergence', 'same token, divergent outcome');
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
];
|
|
619
|
+
}
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// L10 SETTLED_ORPHAN_OVERWRITE (+ wrong-set fixture, lens-3 F7)
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
function settledOrphanOverwriteCases(adapter) {
|
|
624
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
625
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
626
|
+
return [
|
|
627
|
+
{
|
|
628
|
+
law: 'SETTLED_ORPHAN_OVERWRITE',
|
|
629
|
+
name: `[${adapter.storeName}] a settled entry {outcome:'fail'} while the step is ACTUALLY in completed_steps (wrong-set) is treated as ABSENT (orphan) — never a false already_settled_by_other`,
|
|
630
|
+
run: async () => {
|
|
631
|
+
const def = minimalDefinition(['a']);
|
|
632
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
633
|
+
// Hand-author the wrong-set orphan: 'a' is in completed_steps, but its settled entry
|
|
634
|
+
// claims 'fail' — a genuinely inconsistent state that only a hand-authored fixture (or an
|
|
635
|
+
// external store's own divergent history) could produce; settleStep itself always writes
|
|
636
|
+
// both atomically and could never reach this state on its own.
|
|
637
|
+
const seeded = await adapter.store.update({
|
|
638
|
+
...run,
|
|
639
|
+
completed_steps: ['a'],
|
|
640
|
+
in_progress_steps: [],
|
|
641
|
+
settled: { a: { token: 'stale-different-token', outcome: 'fail' } },
|
|
642
|
+
});
|
|
643
|
+
const result = await settleStep(seeded.id, {
|
|
644
|
+
kind: 'settle_step',
|
|
645
|
+
step: 'a',
|
|
646
|
+
outcome: 'complete',
|
|
647
|
+
claimToken: token,
|
|
648
|
+
evidence: [makeEvidence('a')],
|
|
649
|
+
}, def);
|
|
650
|
+
// The orphan rule's own scope: the predicate must NOT treat the stale entry as a real
|
|
651
|
+
// idempotence match (which would incorrectly refuse already_settled_by_other, since the
|
|
652
|
+
// stale entry's token differs from the real claim token). It is explicitly out of THIS
|
|
653
|
+
// law's scope whether the resulting membership arrays stay duplicate-free when fed a
|
|
654
|
+
// deliberately-inconsistent hand-authored input.
|
|
655
|
+
if (result.applied === false && result.reason === 'already_settled_by_other') {
|
|
656
|
+
throw new Error('the orphaned settled entry incorrectly wedged the predicate into already_settled_by_other — entryOf must treat it as absent');
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
];
|
|
661
|
+
}
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
// L11 TRANSFORM_FIDELITY (+ TERMINAL_GATE_EXCLUSION asserted across the same fixtures)
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
function transformFidelityCases(adapter) {
|
|
666
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
667
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
668
|
+
async function fidelityRun(outcome) {
|
|
669
|
+
const def = minimalDefinition(['a', 'b']);
|
|
670
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
671
|
+
const now = new Date('2026-06-15T12:00:00.000Z');
|
|
672
|
+
const delta = outcome === 'abort'
|
|
673
|
+
? {
|
|
674
|
+
kind: 'settle_step',
|
|
675
|
+
step: 'a',
|
|
676
|
+
outcome: 'abort',
|
|
677
|
+
claimToken: token,
|
|
678
|
+
evidence: [makeEvidence('a')],
|
|
679
|
+
abort: { stepId: 'a', abortMessage: 'tck-fidelity-abort' },
|
|
680
|
+
}
|
|
681
|
+
: {
|
|
682
|
+
kind: 'settle_step',
|
|
683
|
+
step: 'a',
|
|
684
|
+
outcome,
|
|
685
|
+
claimToken: token,
|
|
686
|
+
evidence: [makeEvidence('a')],
|
|
687
|
+
...(outcome === 'fail' ? { failureMessage: 'tck-fidelity-fail' } : {}),
|
|
688
|
+
};
|
|
689
|
+
// Harness-controlled fresh read — the SAME state settleStep's own internal fresh read will see
|
|
690
|
+
// (nothing else mutates this run between the two reads in a single-threaded test).
|
|
691
|
+
const freshRead = await adapter.store.get(run.id);
|
|
692
|
+
const expected = applySettlement(freshRead, delta, def, { now });
|
|
693
|
+
const actual = await settleStep(run.id, delta, def, { now });
|
|
694
|
+
if (expected.applied !== actual.applied) {
|
|
695
|
+
throw new Error(`transform/store fidelity mismatch: harness applySettlement applied=${expected.applied}, store settleStep applied=${actual.applied}`);
|
|
696
|
+
}
|
|
697
|
+
if (expected.applied && actual.applied) {
|
|
698
|
+
const { version: _ev, updated_at: _eu, ...expectedRest } = expected.run;
|
|
699
|
+
const { version: _av, updated_at: _au, ...actualRest } = actual.run;
|
|
700
|
+
const expectedJson = JSON.stringify(expectedRest);
|
|
701
|
+
const actualJson = JSON.stringify(actualRest);
|
|
702
|
+
if (expectedJson !== actualJson) {
|
|
703
|
+
throw new Error(`transform fidelity mismatch (modulo version/updated_at):\nharness: ${expectedJson}\nstore: ${actualJson}`);
|
|
704
|
+
}
|
|
705
|
+
// TERMINAL_GATE_EXCLUSION, asserted across this fixture: no settleStep APPLY output ever
|
|
706
|
+
// carries BOTH terminal_state:true AND a defined pending_gate.
|
|
707
|
+
if (actual.run.terminal_state === true && actual.run.pending_gate !== undefined) {
|
|
708
|
+
throw new Error('TERMINAL_GATE_EXCLUSION violated: terminal AND pending_gate both present');
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return [
|
|
713
|
+
{
|
|
714
|
+
law: 'TRANSFORM_FIDELITY',
|
|
715
|
+
name: `[${adapter.storeName}] settleStep's persisted output matches applySettlement's own output exactly (modulo version/updated_at) — complete outcome, {now} injected`,
|
|
716
|
+
run: () => fidelityRun('complete'),
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
law: 'TRANSFORM_FIDELITY',
|
|
720
|
+
name: `[${adapter.storeName}] settleStep's persisted output matches applySettlement's own output exactly (modulo version/updated_at) — fail outcome, {now} injected`,
|
|
721
|
+
run: () => fidelityRun('fail'),
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
law: 'TRANSFORM_FIDELITY',
|
|
725
|
+
name: `[${adapter.storeName}] settleStep's persisted output matches applySettlement's own output exactly (modulo version/updated_at) — abort outcome, {now} injected`,
|
|
726
|
+
run: () => fidelityRun('abort'),
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
law: 'TERMINAL_GATE_EXCLUSION',
|
|
730
|
+
name: `[${adapter.storeName}] no settleStep APPLY output ever carries BOTH terminal_state:true and a defined pending_gate (asserted across the TRANSFORM_FIDELITY fixtures)`,
|
|
731
|
+
run: () => fidelityRun('complete'),
|
|
732
|
+
},
|
|
733
|
+
];
|
|
734
|
+
}
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
// RESULT_AS_APPLIED (a fidelity-dropping fixture store — final-gate F5)
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
/** A deliberately LOSSY store (issue #279 TCK only) — its `get()` strips `defaulted_steps` on
|
|
739
|
+
* every read, but its internal storage and its OWN `settleStep` never do. Proves
|
|
740
|
+
* `SettlementResult.run` on `applied:true` is the AS-APPLIED transform output, NEVER a re-read —
|
|
741
|
+
* a store whose round-trip drops a field must still return that field's TRUE value directly. */
|
|
742
|
+
class LossyFixtureStore {
|
|
743
|
+
runs = new Map();
|
|
744
|
+
persistsClaims = true;
|
|
745
|
+
// Deliberately dishonest — never declares anything (`persistedRunRecordFields` omitted, not set
|
|
746
|
+
// to `undefined`: exactOptionalPropertyTypes forbids assigning `undefined` to an optional field).
|
|
747
|
+
async create(options) {
|
|
748
|
+
const now = new Date().toISOString();
|
|
749
|
+
const run = {
|
|
750
|
+
id: uid('lossy-run'),
|
|
751
|
+
workflow_id: options.workflowId,
|
|
752
|
+
workflow_version: options.workflowVersion,
|
|
753
|
+
completed_steps: [],
|
|
754
|
+
in_progress_steps: [],
|
|
755
|
+
failed_steps: [],
|
|
756
|
+
skipped_steps: [],
|
|
757
|
+
run_phase: 'running',
|
|
758
|
+
version: 0,
|
|
759
|
+
params: options.params,
|
|
760
|
+
evidence: [],
|
|
761
|
+
created_at: now,
|
|
762
|
+
updated_at: now,
|
|
763
|
+
terminal_state: false,
|
|
764
|
+
};
|
|
765
|
+
this.runs.set(run.id, run);
|
|
766
|
+
return { run, created: true };
|
|
767
|
+
}
|
|
768
|
+
async get(runId) {
|
|
769
|
+
const run = this.runs.get(runId);
|
|
770
|
+
if (run === undefined)
|
|
771
|
+
throw new Error(`lossy fixture store: run '${runId}' not found`);
|
|
772
|
+
// LOSSY: strips defaulted_steps on every read — the whole point of this fixture.
|
|
773
|
+
const { defaulted_steps: _dropped, ...rest } = run;
|
|
774
|
+
return rest;
|
|
775
|
+
}
|
|
776
|
+
async update(record) {
|
|
777
|
+
const updated = {
|
|
778
|
+
...record,
|
|
779
|
+
version: record.version + 1,
|
|
780
|
+
updated_at: new Date().toISOString(),
|
|
781
|
+
};
|
|
782
|
+
this.runs.set(updated.id, updated);
|
|
783
|
+
return updated;
|
|
784
|
+
}
|
|
785
|
+
async list() {
|
|
786
|
+
return [...this.runs.values()];
|
|
787
|
+
}
|
|
788
|
+
async claimStep(runId, stepName, _definition) {
|
|
789
|
+
const run = this.runs.get(runId);
|
|
790
|
+
if (run === undefined)
|
|
791
|
+
throw new Error(`lossy fixture store: run '${runId}' not found`);
|
|
792
|
+
const claimed = {
|
|
793
|
+
...run,
|
|
794
|
+
in_progress_steps: [...run.in_progress_steps, stepName],
|
|
795
|
+
claims: { ...run.claims, [stepName]: { deadline: null, token: uid('lossy-token') } },
|
|
796
|
+
version: run.version + 1,
|
|
797
|
+
updated_at: new Date().toISOString(),
|
|
798
|
+
};
|
|
799
|
+
this.runs.set(claimed.id, claimed);
|
|
800
|
+
return claimed;
|
|
801
|
+
}
|
|
802
|
+
async settleStep(runId, delta, definition, options) {
|
|
803
|
+
// Reads its OWN internal map directly (never through the lossy `get()` above).
|
|
804
|
+
const fresh = this.runs.get(runId);
|
|
805
|
+
if (fresh === undefined)
|
|
806
|
+
throw new Error(`lossy fixture store: run '${runId}' not found`);
|
|
807
|
+
const outcome = applySettlement(fresh, delta, definition, options);
|
|
808
|
+
if (!outcome.applied)
|
|
809
|
+
return outcome;
|
|
810
|
+
const updated = {
|
|
811
|
+
...outcome.run,
|
|
812
|
+
version: fresh.version + 1,
|
|
813
|
+
updated_at: new Date().toISOString(),
|
|
814
|
+
};
|
|
815
|
+
this.runs.set(runId, updated);
|
|
816
|
+
return { ...outcome, run: updated }; // the AS-APPLIED output — never routed through get()
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
function resultAsAppliedCases() {
|
|
820
|
+
return [
|
|
821
|
+
{
|
|
822
|
+
law: 'RESULT_AS_APPLIED',
|
|
823
|
+
name: "a fidelity-dropping store (its own get() strips defaulted_steps) still returns the TRUE stamped defaulted_steps directly on settleStep's result — never a re-read",
|
|
824
|
+
run: async () => {
|
|
825
|
+
const store = new LossyFixtureStore();
|
|
826
|
+
const def = minimalDefinition(['a']);
|
|
827
|
+
const { run } = await store.create({ workflowId: def.id, workflowVersion: 1, params: {} });
|
|
828
|
+
const claimed = await store.claimStep(run.id, 'a', def);
|
|
829
|
+
const token = claimed.claims['a'].token;
|
|
830
|
+
const result = await store.settleStep(run.id, {
|
|
831
|
+
kind: 'settle_step',
|
|
832
|
+
step: 'a',
|
|
833
|
+
outcome: 'complete',
|
|
834
|
+
claimToken: token,
|
|
835
|
+
evidence: [
|
|
836
|
+
makeEvidence('a', {
|
|
837
|
+
diagnostics: {
|
|
838
|
+
input_token_estimate: 1,
|
|
839
|
+
precondition_trace: [],
|
|
840
|
+
settled_by_default: true,
|
|
841
|
+
},
|
|
842
|
+
}),
|
|
843
|
+
],
|
|
844
|
+
}, def);
|
|
845
|
+
assertApplied(result, 'settle on the lossy fixture store');
|
|
846
|
+
if (!result.run.defaulted_steps?.includes('a')) {
|
|
847
|
+
throw new Error(`expected settleStep's OWN result to carry defaulted_steps:['a'] directly, got: ${JSON.stringify(result.run.defaulted_steps)}`);
|
|
848
|
+
}
|
|
849
|
+
// Prove the store really IS lossy on round-trip (the premise the whole test depends on).
|
|
850
|
+
const reread = await store.get(run.id);
|
|
851
|
+
if (reread.defaulted_steps !== undefined) {
|
|
852
|
+
throw new Error('fixture premise violated: the lossy store did not actually drop defaulted_steps on get()');
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
];
|
|
857
|
+
}
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
// L12 MARK_MEMBERSHIP
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
function markMembershipCases(adapter) {
|
|
862
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
863
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
864
|
+
async function markRun(result, expectedArray) {
|
|
865
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
866
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
867
|
+
const settled = await settleStep(run.id, {
|
|
868
|
+
kind: 'settle_step',
|
|
869
|
+
step: 'a',
|
|
870
|
+
outcome: 'complete',
|
|
871
|
+
claimToken: token,
|
|
872
|
+
evidence: [makeEvidence('a')],
|
|
873
|
+
}, def);
|
|
874
|
+
assertApplied(settled, 'terminal settle minting the finalizer');
|
|
875
|
+
const leaseToken = uid('lease');
|
|
876
|
+
const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
|
|
877
|
+
assertApplied(leased, 'lease the pending finalizer');
|
|
878
|
+
const marked = await settleStep(run.id, {
|
|
879
|
+
kind: 'mark_finalizer',
|
|
880
|
+
finalizer: 'fin',
|
|
881
|
+
leaseToken,
|
|
882
|
+
result,
|
|
883
|
+
evidence: makeEvidence('fin'),
|
|
884
|
+
}, def);
|
|
885
|
+
assertApplied(marked, `mark the finalizer ${result}`);
|
|
886
|
+
if (!marked.run[expectedArray].includes('fin')) {
|
|
887
|
+
throw new Error(`expected 'fin' to join ${expectedArray} on a '${result}' mark, got: ${JSON.stringify(marked.run[expectedArray])}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return [
|
|
891
|
+
{
|
|
892
|
+
law: 'MARK_MEMBERSHIP',
|
|
893
|
+
name: `[${adapter.storeName}] mark_finalizer result:'completed' adds the finalizer name to completed_steps`,
|
|
894
|
+
run: () => markRun('completed', 'completed_steps'),
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
law: 'MARK_MEMBERSHIP',
|
|
898
|
+
name: `[${adapter.storeName}] mark_finalizer result:'failed' adds the finalizer name to failed_steps`,
|
|
899
|
+
run: () => markRun('failed', 'failed_steps'),
|
|
900
|
+
},
|
|
901
|
+
];
|
|
902
|
+
}
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
// L13 REFUSAL_SWEEP — one fixture per REFUSE/NOOP line of §3 (17 total: 6 settle_step,
|
|
905
|
+
// 6 lease_finalizer, 5 mark_finalizer). Every fixture asserts the typed literal reason AND that
|
|
906
|
+
// the record/version are unchanged (a refusal/noop never writes).
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
function refusalSweepCases(adapter) {
|
|
909
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
910
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
911
|
+
async function expectRefusalUnchanged(runId, delta, def, expectedReason, versionBefore, label) {
|
|
912
|
+
const result = await settleStep(runId, delta, def);
|
|
913
|
+
if (result.applied) {
|
|
914
|
+
throw new Error(`${label}: expected a refusal/noop ('${expectedReason}'), got applied:true`);
|
|
915
|
+
}
|
|
916
|
+
if (result.reason !== expectedReason) {
|
|
917
|
+
throw new Error(`${label}: expected reason '${expectedReason}', got '${result.reason}'`);
|
|
918
|
+
}
|
|
919
|
+
if (result.run.version !== versionBefore) {
|
|
920
|
+
throw new Error(`${label}: expected version unchanged (${versionBefore}), got ${result.run.version}`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const cases = [];
|
|
924
|
+
// --- settle_step (6) ---
|
|
925
|
+
cases.push({
|
|
926
|
+
law: 'REFUSAL_SWEEP',
|
|
927
|
+
name: `[${adapter.storeName}] settle_step: already_settled_by_other (different token on a settled step)`,
|
|
928
|
+
run: async () => {
|
|
929
|
+
const def = minimalDefinition(['a']);
|
|
930
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
931
|
+
const settled = await settleStep(run.id, {
|
|
932
|
+
kind: 'settle_step',
|
|
933
|
+
step: 'a',
|
|
934
|
+
outcome: 'complete',
|
|
935
|
+
claimToken: token,
|
|
936
|
+
evidence: [makeEvidence('a')],
|
|
937
|
+
}, def);
|
|
938
|
+
assertApplied(settled, 'setup settle');
|
|
939
|
+
await expectRefusalUnchanged(run.id, {
|
|
940
|
+
kind: 'settle_step',
|
|
941
|
+
step: 'a',
|
|
942
|
+
outcome: 'complete',
|
|
943
|
+
claimToken: 'a-different-token',
|
|
944
|
+
evidence: [],
|
|
945
|
+
}, def, 'already_settled_by_other', settled.run.version, 'already_settled_by_other');
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
cases.push({
|
|
949
|
+
law: 'REFUSAL_SWEEP',
|
|
950
|
+
name: `[${adapter.storeName}] settle_step: already_settled (NOOP — same token, same outcome)`,
|
|
951
|
+
run: async () => {
|
|
952
|
+
const def = minimalDefinition(['a']);
|
|
953
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
954
|
+
const delta = {
|
|
955
|
+
kind: 'settle_step',
|
|
956
|
+
step: 'a',
|
|
957
|
+
outcome: 'complete',
|
|
958
|
+
claimToken: token,
|
|
959
|
+
evidence: [makeEvidence('a')],
|
|
960
|
+
};
|
|
961
|
+
const settled = await settleStep(run.id, delta, def);
|
|
962
|
+
assertApplied(settled, 'setup settle');
|
|
963
|
+
await expectRefusalUnchanged(run.id, delta, def, 'already_settled', settled.run.version, 'already_settled');
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
cases.push({
|
|
967
|
+
law: 'REFUSAL_SWEEP',
|
|
968
|
+
name: `[${adapter.storeName}] settle_step: settled_outcome_divergence (same token, different outcome)`,
|
|
969
|
+
run: async () => {
|
|
970
|
+
const def = minimalDefinition(['a']);
|
|
971
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
972
|
+
const settled = await settleStep(run.id, {
|
|
973
|
+
kind: 'settle_step',
|
|
974
|
+
step: 'a',
|
|
975
|
+
outcome: 'complete',
|
|
976
|
+
claimToken: token,
|
|
977
|
+
evidence: [makeEvidence('a')],
|
|
978
|
+
}, def);
|
|
979
|
+
assertApplied(settled, 'setup settle');
|
|
980
|
+
await expectRefusalUnchanged(run.id, {
|
|
981
|
+
kind: 'settle_step',
|
|
982
|
+
step: 'a',
|
|
983
|
+
outcome: 'fail',
|
|
984
|
+
claimToken: token,
|
|
985
|
+
evidence: [],
|
|
986
|
+
failureMessage: 'x',
|
|
987
|
+
}, def, 'settled_outcome_divergence', settled.run.version, 'settled_outcome_divergence');
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
cases.push({
|
|
991
|
+
law: 'REFUSAL_SWEEP',
|
|
992
|
+
name: `[${adapter.storeName}] settle_step: run_terminal (a different, never-settled step on an already-terminal run)`,
|
|
993
|
+
run: async () => {
|
|
994
|
+
const def = minimalDefinition(['a', 'b']);
|
|
995
|
+
const { run } = await adapter.store.create({
|
|
996
|
+
workflowId: def.id,
|
|
997
|
+
workflowVersion: 1,
|
|
998
|
+
params: {},
|
|
999
|
+
});
|
|
1000
|
+
const terminal = await adapter.store.update({
|
|
1001
|
+
...run,
|
|
1002
|
+
terminal_state: true,
|
|
1003
|
+
terminal_reason: 'tck',
|
|
1004
|
+
});
|
|
1005
|
+
await expectRefusalUnchanged(run.id, { kind: 'settle_step', step: 'b', outcome: 'complete', claimToken: 'x', evidence: [] }, def, 'run_terminal', terminal.version, 'run_terminal');
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
cases.push({
|
|
1009
|
+
law: 'REFUSAL_SWEEP',
|
|
1010
|
+
name: `[${adapter.storeName}] settle_step: claim_lost (no claim record at all for the step)`,
|
|
1011
|
+
run: async () => {
|
|
1012
|
+
const def = minimalDefinition(['a']);
|
|
1013
|
+
const { run } = await adapter.store.create({
|
|
1014
|
+
workflowId: def.id,
|
|
1015
|
+
workflowVersion: 1,
|
|
1016
|
+
params: {},
|
|
1017
|
+
});
|
|
1018
|
+
await expectRefusalUnchanged(run.id, { kind: 'settle_step', step: 'a', outcome: 'complete', claimToken: 'x', evidence: [] }, def, 'claim_lost', run.version, 'claim_lost');
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
cases.push({
|
|
1022
|
+
law: 'REFUSAL_SWEEP',
|
|
1023
|
+
name: `[${adapter.storeName}] settle_step: gate_mismatch (the step IS the currently-open gate)`,
|
|
1024
|
+
run: async () => {
|
|
1025
|
+
const def = minimalDefinition(['a']);
|
|
1026
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
1027
|
+
const gated = await adapter.store.update({
|
|
1028
|
+
...run,
|
|
1029
|
+
pending_gate: {
|
|
1030
|
+
gate_id: 'tck-gate',
|
|
1031
|
+
step_name: 'a',
|
|
1032
|
+
preview: {},
|
|
1033
|
+
choices: ['approve'],
|
|
1034
|
+
opened_at: '2026-01-01T00:00:00.000Z',
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
await expectRefusalUnchanged(run.id, { kind: 'settle_step', step: 'a', outcome: 'complete', claimToken: token, evidence: [] }, def, 'gate_mismatch', gated.version, 'gate_mismatch');
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
// --- lease_finalizer (6) ---
|
|
1041
|
+
cases.push({
|
|
1042
|
+
law: 'REFUSAL_SWEEP',
|
|
1043
|
+
name: `[${adapter.storeName}] lease_finalizer: run_not_terminal (defensive; a non-terminal run somehow carrying a ledger entry)`,
|
|
1044
|
+
run: async () => {
|
|
1045
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1046
|
+
const { run } = await adapter.store.create({
|
|
1047
|
+
workflowId: def.id,
|
|
1048
|
+
workflowVersion: 1,
|
|
1049
|
+
params: {},
|
|
1050
|
+
});
|
|
1051
|
+
const seeded = await adapter.store.update({
|
|
1052
|
+
...run,
|
|
1053
|
+
finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
|
|
1054
|
+
});
|
|
1055
|
+
await expectRefusalUnchanged(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken: uid('lease'), leaseSeconds: 30 }, def, 'run_not_terminal', seeded.version, 'lease run_not_terminal');
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
1058
|
+
cases.push({
|
|
1059
|
+
law: 'REFUSAL_SWEEP',
|
|
1060
|
+
name: `[${adapter.storeName}] lease_finalizer: not_eligible (unknown finalizer id)`,
|
|
1061
|
+
run: async () => {
|
|
1062
|
+
const def = minimalDefinition(['a']);
|
|
1063
|
+
const { run } = await adapter.store.create({
|
|
1064
|
+
workflowId: def.id,
|
|
1065
|
+
workflowVersion: 1,
|
|
1066
|
+
params: {},
|
|
1067
|
+
});
|
|
1068
|
+
const terminal = await adapter.store.update({
|
|
1069
|
+
...run,
|
|
1070
|
+
terminal_state: true,
|
|
1071
|
+
terminal_reason: 'tck',
|
|
1072
|
+
});
|
|
1073
|
+
await expectRefusalUnchanged(run.id, {
|
|
1074
|
+
kind: 'lease_finalizer',
|
|
1075
|
+
finalizer: 'nonexistent',
|
|
1076
|
+
leaseToken: uid('lease'),
|
|
1077
|
+
leaseSeconds: 30,
|
|
1078
|
+
}, def, 'not_eligible', terminal.version, 'lease not_eligible');
|
|
1079
|
+
},
|
|
1080
|
+
});
|
|
1081
|
+
cases.push({
|
|
1082
|
+
law: 'REFUSAL_SWEEP',
|
|
1083
|
+
name: `[${adapter.storeName}] lease_finalizer: ledger_not_pending (entry already completed)`,
|
|
1084
|
+
run: async () => {
|
|
1085
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1086
|
+
const { run } = await adapter.store.create({
|
|
1087
|
+
workflowId: def.id,
|
|
1088
|
+
workflowVersion: 1,
|
|
1089
|
+
params: {},
|
|
1090
|
+
});
|
|
1091
|
+
const seeded = await adapter.store.update({
|
|
1092
|
+
...run,
|
|
1093
|
+
terminal_state: true,
|
|
1094
|
+
terminal_reason: 'tck',
|
|
1095
|
+
finalizer_ledger: { fin: { status: 'completed', rank: 0 } },
|
|
1096
|
+
});
|
|
1097
|
+
await expectRefusalUnchanged(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken: uid('lease'), leaseSeconds: 30 }, def, 'ledger_not_pending', seeded.version, 'lease ledger_not_pending');
|
|
1098
|
+
},
|
|
1099
|
+
});
|
|
1100
|
+
cases.push({
|
|
1101
|
+
law: 'REFUSAL_SWEEP',
|
|
1102
|
+
name: `[${adapter.storeName}] lease_finalizer: already_leased (NOOP — same token, unexpired)`,
|
|
1103
|
+
run: async () => {
|
|
1104
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1105
|
+
const { run } = await adapter.store.create({
|
|
1106
|
+
workflowId: def.id,
|
|
1107
|
+
workflowVersion: 1,
|
|
1108
|
+
params: {},
|
|
1109
|
+
});
|
|
1110
|
+
await adapter.store.update({
|
|
1111
|
+
...run,
|
|
1112
|
+
terminal_state: true,
|
|
1113
|
+
terminal_reason: 'tck',
|
|
1114
|
+
finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
|
|
1115
|
+
});
|
|
1116
|
+
const leaseToken = uid('lease');
|
|
1117
|
+
const first = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
|
|
1118
|
+
assertApplied(first, 'setup lease');
|
|
1119
|
+
await expectRefusalUnchanged(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def, 'already_leased', first.run.version, 'lease already_leased');
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
cases.push({
|
|
1123
|
+
law: 'REFUSAL_SWEEP',
|
|
1124
|
+
name: `[${adapter.storeName}] lease_finalizer: rank_blocked (a lower-ranked pending entry still unleased)`,
|
|
1125
|
+
run: async () => {
|
|
1126
|
+
const def = withFinalizer(withFinalizer(minimalDefinition(['a']), 'first', 'always'), 'second', 'always');
|
|
1127
|
+
const { run } = await adapter.store.create({
|
|
1128
|
+
workflowId: def.id,
|
|
1129
|
+
workflowVersion: 1,
|
|
1130
|
+
params: {},
|
|
1131
|
+
});
|
|
1132
|
+
const seeded = await adapter.store.update({
|
|
1133
|
+
...run,
|
|
1134
|
+
terminal_state: true,
|
|
1135
|
+
terminal_reason: 'tck',
|
|
1136
|
+
finalizer_ledger: {
|
|
1137
|
+
first: { status: 'pending', rank: 0 },
|
|
1138
|
+
second: { status: 'pending', rank: 1 },
|
|
1139
|
+
},
|
|
1140
|
+
});
|
|
1141
|
+
await expectRefusalUnchanged(run.id, {
|
|
1142
|
+
kind: 'lease_finalizer',
|
|
1143
|
+
finalizer: 'second',
|
|
1144
|
+
leaseToken: uid('lease'),
|
|
1145
|
+
leaseSeconds: 30,
|
|
1146
|
+
}, def, 'rank_blocked', seeded.version, 'lease rank_blocked');
|
|
1147
|
+
},
|
|
1148
|
+
});
|
|
1149
|
+
cases.push({
|
|
1150
|
+
law: 'REFUSAL_SWEEP',
|
|
1151
|
+
name: `[${adapter.storeName}] lease_finalizer: lease_held (a DIFFERENT token's unexpired lease)`,
|
|
1152
|
+
run: async () => {
|
|
1153
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1154
|
+
const { run } = await adapter.store.create({
|
|
1155
|
+
workflowId: def.id,
|
|
1156
|
+
workflowVersion: 1,
|
|
1157
|
+
params: {},
|
|
1158
|
+
});
|
|
1159
|
+
await adapter.store.update({
|
|
1160
|
+
...run,
|
|
1161
|
+
terminal_state: true,
|
|
1162
|
+
terminal_reason: 'tck',
|
|
1163
|
+
finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
|
|
1164
|
+
});
|
|
1165
|
+
const first = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken: uid('lease-a'), leaseSeconds: 60 }, def);
|
|
1166
|
+
assertApplied(first, 'setup lease');
|
|
1167
|
+
await expectRefusalUnchanged(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken: uid('lease-b'), leaseSeconds: 60 }, def, 'lease_held', first.run.version, 'lease lease_held');
|
|
1168
|
+
},
|
|
1169
|
+
});
|
|
1170
|
+
// --- mark_finalizer (5) ---
|
|
1171
|
+
cases.push({
|
|
1172
|
+
law: 'REFUSAL_SWEEP',
|
|
1173
|
+
name: `[${adapter.storeName}] mark_finalizer: not_eligible (unknown finalizer id)`,
|
|
1174
|
+
run: async () => {
|
|
1175
|
+
const def = minimalDefinition(['a']);
|
|
1176
|
+
const { run } = await adapter.store.create({
|
|
1177
|
+
workflowId: def.id,
|
|
1178
|
+
workflowVersion: 1,
|
|
1179
|
+
params: {},
|
|
1180
|
+
});
|
|
1181
|
+
const terminal = await adapter.store.update({
|
|
1182
|
+
...run,
|
|
1183
|
+
terminal_state: true,
|
|
1184
|
+
terminal_reason: 'tck',
|
|
1185
|
+
});
|
|
1186
|
+
await expectRefusalUnchanged(run.id, {
|
|
1187
|
+
kind: 'mark_finalizer',
|
|
1188
|
+
finalizer: 'nonexistent',
|
|
1189
|
+
leaseToken: uid('lease'),
|
|
1190
|
+
result: 'completed',
|
|
1191
|
+
evidence: makeEvidence('nonexistent'),
|
|
1192
|
+
}, def, 'not_eligible', terminal.version, 'mark not_eligible');
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
cases.push({
|
|
1196
|
+
law: 'REFUSAL_SWEEP',
|
|
1197
|
+
name: `[${adapter.storeName}] mark_finalizer: already_marked (NOOP — same token, same result)`,
|
|
1198
|
+
run: async () => {
|
|
1199
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1200
|
+
const { run } = await adapter.store.create({
|
|
1201
|
+
workflowId: def.id,
|
|
1202
|
+
workflowVersion: 1,
|
|
1203
|
+
params: {},
|
|
1204
|
+
});
|
|
1205
|
+
await adapter.store.update({
|
|
1206
|
+
...run,
|
|
1207
|
+
terminal_state: true,
|
|
1208
|
+
terminal_reason: 'tck',
|
|
1209
|
+
finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
|
|
1210
|
+
});
|
|
1211
|
+
const leaseToken = uid('lease');
|
|
1212
|
+
const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
|
|
1213
|
+
assertApplied(leased, 'setup lease');
|
|
1214
|
+
const markDelta = {
|
|
1215
|
+
kind: 'mark_finalizer',
|
|
1216
|
+
finalizer: 'fin',
|
|
1217
|
+
leaseToken,
|
|
1218
|
+
result: 'completed',
|
|
1219
|
+
evidence: makeEvidence('fin'),
|
|
1220
|
+
};
|
|
1221
|
+
const marked = await settleStep(run.id, markDelta, def);
|
|
1222
|
+
assertApplied(marked, 'setup mark');
|
|
1223
|
+
await expectRefusalUnchanged(run.id, markDelta, def, 'already_marked', marked.run.version, 'mark already_marked');
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
cases.push({
|
|
1227
|
+
law: 'REFUSAL_SWEEP',
|
|
1228
|
+
name: `[${adapter.storeName}] mark_finalizer: ledger_not_pending (entry already voided)`,
|
|
1229
|
+
run: async () => {
|
|
1230
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1231
|
+
const { run } = await adapter.store.create({
|
|
1232
|
+
workflowId: def.id,
|
|
1233
|
+
workflowVersion: 1,
|
|
1234
|
+
params: {},
|
|
1235
|
+
});
|
|
1236
|
+
const seeded = await adapter.store.update({
|
|
1237
|
+
...run,
|
|
1238
|
+
terminal_state: true,
|
|
1239
|
+
terminal_reason: 'tck',
|
|
1240
|
+
finalizer_ledger: { fin: { status: 'voided', rank: 0 } },
|
|
1241
|
+
});
|
|
1242
|
+
await expectRefusalUnchanged(run.id, {
|
|
1243
|
+
kind: 'mark_finalizer',
|
|
1244
|
+
finalizer: 'fin',
|
|
1245
|
+
leaseToken: uid('lease'),
|
|
1246
|
+
result: 'completed',
|
|
1247
|
+
evidence: makeEvidence('fin'),
|
|
1248
|
+
}, def, 'ledger_not_pending', seeded.version, 'mark ledger_not_pending');
|
|
1249
|
+
},
|
|
1250
|
+
});
|
|
1251
|
+
cases.push({
|
|
1252
|
+
law: 'REFUSAL_SWEEP',
|
|
1253
|
+
name: `[${adapter.storeName}] mark_finalizer: lease_lost (a WRONG token on a genuinely-leased entry)`,
|
|
1254
|
+
run: async () => {
|
|
1255
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1256
|
+
const { run } = await adapter.store.create({
|
|
1257
|
+
workflowId: def.id,
|
|
1258
|
+
workflowVersion: 1,
|
|
1259
|
+
params: {},
|
|
1260
|
+
});
|
|
1261
|
+
await adapter.store.update({
|
|
1262
|
+
...run,
|
|
1263
|
+
terminal_state: true,
|
|
1264
|
+
terminal_reason: 'tck',
|
|
1265
|
+
finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
|
|
1266
|
+
});
|
|
1267
|
+
const leaseToken = uid('lease');
|
|
1268
|
+
const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
|
|
1269
|
+
assertApplied(leased, 'setup lease');
|
|
1270
|
+
await expectRefusalUnchanged(run.id, {
|
|
1271
|
+
kind: 'mark_finalizer',
|
|
1272
|
+
finalizer: 'fin',
|
|
1273
|
+
leaseToken: 'a-wrong-token',
|
|
1274
|
+
result: 'completed',
|
|
1275
|
+
evidence: makeEvidence('fin'),
|
|
1276
|
+
}, def, 'lease_lost', leased.run.version, 'mark lease_lost');
|
|
1277
|
+
},
|
|
1278
|
+
});
|
|
1279
|
+
cases.push({
|
|
1280
|
+
law: 'REFUSAL_SWEEP',
|
|
1281
|
+
name: `[${adapter.storeName}] mark_finalizer: run_not_terminal (defensive; a leased-but-non-terminal run)`,
|
|
1282
|
+
run: async () => {
|
|
1283
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1284
|
+
const { run } = await adapter.store.create({
|
|
1285
|
+
workflowId: def.id,
|
|
1286
|
+
workflowVersion: 1,
|
|
1287
|
+
params: {},
|
|
1288
|
+
});
|
|
1289
|
+
const leaseToken = uid('lease');
|
|
1290
|
+
const seeded = await adapter.store.update({
|
|
1291
|
+
...run,
|
|
1292
|
+
finalizer_ledger: {
|
|
1293
|
+
fin: {
|
|
1294
|
+
status: 'pending',
|
|
1295
|
+
rank: 0,
|
|
1296
|
+
lease_token: leaseToken,
|
|
1297
|
+
lease_deadline: '2099-01-01T00:00:00.000Z',
|
|
1298
|
+
},
|
|
1299
|
+
},
|
|
1300
|
+
});
|
|
1301
|
+
await expectRefusalUnchanged(run.id, {
|
|
1302
|
+
kind: 'mark_finalizer',
|
|
1303
|
+
finalizer: 'fin',
|
|
1304
|
+
leaseToken,
|
|
1305
|
+
result: 'completed',
|
|
1306
|
+
evidence: makeEvidence('fin'),
|
|
1307
|
+
}, def, 'run_not_terminal', seeded.version, 'mark run_not_terminal');
|
|
1308
|
+
},
|
|
1309
|
+
});
|
|
1310
|
+
return cases;
|
|
1311
|
+
}
|
|
1312
|
+
// ---------------------------------------------------------------------------
|
|
1313
|
+
// L14 MINT_FRESH — PR-A form (hand-author the post-resume state as a FIXTURE via update(); do NOT
|
|
1314
|
+
// implement applyResume, which is PR-B's job).
|
|
1315
|
+
// ---------------------------------------------------------------------------
|
|
1316
|
+
function mintFreshCases(adapter) {
|
|
1317
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
1318
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
1319
|
+
return [
|
|
1320
|
+
{
|
|
1321
|
+
law: 'MINT_FRESH',
|
|
1322
|
+
name: `[${adapter.storeName}] a re-fail edge, after a hand-authored post-resume-void state, re-mints a FRESH pending entry for the still-selected finalizer; a re-complete edge does not select it; completed/failed entries are never rewritten`,
|
|
1323
|
+
run: async () => {
|
|
1324
|
+
// Two finalizers: 'onFail' fires on fail+always is absent (fail-only), 'onComplete' fires
|
|
1325
|
+
// on complete only — lets the test distinguish "re-selected" from "not selected".
|
|
1326
|
+
const def = withFinalizer(withFinalizer(minimalDefinition(['a', 'b']), 'onFail', 'fail'), 'onComplete', 'complete');
|
|
1327
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
1328
|
+
// First terminal edge: fail 'a' (the only step — a 1-step-visible workflow) to mint 'onFail'.
|
|
1329
|
+
// 'a' alone won't terminalize a 2-step def, so also settle 'b' the same way to reach terminal.
|
|
1330
|
+
const failA = await settleStep(run.id, {
|
|
1331
|
+
kind: 'settle_step',
|
|
1332
|
+
step: 'a',
|
|
1333
|
+
outcome: 'fail',
|
|
1334
|
+
claimToken: token,
|
|
1335
|
+
evidence: [],
|
|
1336
|
+
failureMessage: 'x',
|
|
1337
|
+
}, def);
|
|
1338
|
+
assertApplied(failA, 'fail step a');
|
|
1339
|
+
const claimedB = await adapter.store.claimStep(run.id, 'b', def);
|
|
1340
|
+
const tokenB = claimedB.claims['b'].token;
|
|
1341
|
+
const failB = await settleStep(run.id, {
|
|
1342
|
+
kind: 'settle_step',
|
|
1343
|
+
step: 'b',
|
|
1344
|
+
outcome: 'fail',
|
|
1345
|
+
claimToken: tokenB,
|
|
1346
|
+
evidence: [],
|
|
1347
|
+
failureMessage: 'x',
|
|
1348
|
+
}, def);
|
|
1349
|
+
assertApplied(failB, 'fail step b (terminalizes)');
|
|
1350
|
+
if (failB.run.finalizer_ledger?.['onFail']?.status !== 'pending') {
|
|
1351
|
+
throw new Error('expected onFail minted pending after the first fail-terminal edge');
|
|
1352
|
+
}
|
|
1353
|
+
if (failB.run.finalizer_ledger?.['onComplete'] !== undefined) {
|
|
1354
|
+
throw new Error('onComplete must NOT be selected on a fail edge');
|
|
1355
|
+
}
|
|
1356
|
+
// Mark onFail completed (so the never-rewritten assertion has teeth later).
|
|
1357
|
+
const leaseToken = uid('lease');
|
|
1358
|
+
const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'onFail', leaseToken, leaseSeconds: 60 }, def);
|
|
1359
|
+
assertApplied(leased, 'lease onFail');
|
|
1360
|
+
const marked = await settleStep(run.id, {
|
|
1361
|
+
kind: 'mark_finalizer',
|
|
1362
|
+
finalizer: 'onFail',
|
|
1363
|
+
leaseToken,
|
|
1364
|
+
result: 'completed',
|
|
1365
|
+
evidence: makeEvidence('onFail'),
|
|
1366
|
+
}, def);
|
|
1367
|
+
assertApplied(marked, 'mark onFail completed');
|
|
1368
|
+
// Hand-author the post-resume-void state a real `applyResume` (PR-B) would produce:
|
|
1369
|
+
// terminal_state:false, failed_steps cleared, settled-map entries for the re-opened steps
|
|
1370
|
+
// dropped, and the STILL-PENDING... there are none pending now (onFail was already
|
|
1371
|
+
// marked) — this fixture specifically exercises re-mint on a run with NO pending entries
|
|
1372
|
+
// left post-void, proving mintFresh re-arms fresh regardless of prior history.
|
|
1373
|
+
const postResumeVoid = await adapter.store.update({
|
|
1374
|
+
...marked.run,
|
|
1375
|
+
terminal_state: false,
|
|
1376
|
+
in_progress_steps: [],
|
|
1377
|
+
failed_steps: [],
|
|
1378
|
+
settled: {},
|
|
1379
|
+
});
|
|
1380
|
+
if (postResumeVoid.finalizer_ledger?.['onFail']?.status !== 'completed') {
|
|
1381
|
+
throw new Error('fixture setup: onFail must still read completed after the hand-authored void');
|
|
1382
|
+
}
|
|
1383
|
+
// Re-fail edge: re-claim + re-fail 'a' and 'b' — a SECOND terminal fail edge.
|
|
1384
|
+
const reclaimedA = await adapter.store.claimStep(run.id, 'a', def);
|
|
1385
|
+
const reTokenA = reclaimedA.claims['a'].token;
|
|
1386
|
+
const reFailA = await settleStep(run.id, {
|
|
1387
|
+
kind: 'settle_step',
|
|
1388
|
+
step: 'a',
|
|
1389
|
+
outcome: 'fail',
|
|
1390
|
+
claimToken: reTokenA,
|
|
1391
|
+
evidence: [],
|
|
1392
|
+
failureMessage: 'y',
|
|
1393
|
+
}, def);
|
|
1394
|
+
assertApplied(reFailA, 're-fail step a');
|
|
1395
|
+
const reclaimedB = await adapter.store.claimStep(run.id, 'b', def);
|
|
1396
|
+
const reTokenB = reclaimedB.claims['b'].token;
|
|
1397
|
+
const reFailB = await settleStep(run.id, {
|
|
1398
|
+
kind: 'settle_step',
|
|
1399
|
+
step: 'b',
|
|
1400
|
+
outcome: 'fail',
|
|
1401
|
+
claimToken: reTokenB,
|
|
1402
|
+
evidence: [],
|
|
1403
|
+
failureMessage: 'y',
|
|
1404
|
+
}, def);
|
|
1405
|
+
assertApplied(reFailB, 're-fail step b (re-terminalizes)');
|
|
1406
|
+
// onFail is selected again by selectFinalizers (fail outcome), but it's already
|
|
1407
|
+
// 'completed' in the ledger — the never-downgrade guard means it STAYS completed, never
|
|
1408
|
+
// rewritten back to pending. onComplete is still not selected (this is a fail edge).
|
|
1409
|
+
if (reFailB.run.finalizer_ledger?.['onFail']?.status !== 'completed') {
|
|
1410
|
+
throw new Error(`expected onFail to STAY 'completed' (never rewritten) on the re-fail edge, got '${reFailB.run.finalizer_ledger?.['onFail']?.status}'`);
|
|
1411
|
+
}
|
|
1412
|
+
if (reFailB.run.finalizer_ledger?.['onComplete'] !== undefined) {
|
|
1413
|
+
throw new Error('onComplete must not be selected on a re-fail edge either');
|
|
1414
|
+
}
|
|
1415
|
+
},
|
|
1416
|
+
},
|
|
1417
|
+
];
|
|
1418
|
+
}
|
|
1419
|
+
// ---------------------------------------------------------------------------
|
|
1420
|
+
// L21 SELF_IMAGE_IDEMPOTENCE — store-level matrix (apply→re-apply per kind)
|
|
1421
|
+
// ---------------------------------------------------------------------------
|
|
1422
|
+
function selfImageIdempotenceCases(adapter) {
|
|
1423
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
1424
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
1425
|
+
return [
|
|
1426
|
+
{
|
|
1427
|
+
law: 'SELF_IMAGE_IDEMPOTENCE',
|
|
1428
|
+
name: `[${adapter.storeName}] settle_step: predicate(S', d) is ok-shaped after predicate(S, d) applied — re-applying the SAME delta NOOPs`,
|
|
1429
|
+
run: async () => {
|
|
1430
|
+
const def = minimalDefinition(['a']);
|
|
1431
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
1432
|
+
const delta = {
|
|
1433
|
+
kind: 'settle_step',
|
|
1434
|
+
step: 'a',
|
|
1435
|
+
outcome: 'complete',
|
|
1436
|
+
claimToken: token,
|
|
1437
|
+
evidence: [makeEvidence('a')],
|
|
1438
|
+
};
|
|
1439
|
+
const first = await settleStep(run.id, delta, def);
|
|
1440
|
+
assertApplied(first, 'first apply');
|
|
1441
|
+
const second = await settleStep(run.id, delta, def);
|
|
1442
|
+
if (second.applied)
|
|
1443
|
+
throw new Error('expected the re-apply to be ok-shaped NOOP, not applied:true');
|
|
1444
|
+
},
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
law: 'SELF_IMAGE_IDEMPOTENCE',
|
|
1448
|
+
name: `[${adapter.storeName}] lease_finalizer: predicate(S', d) is ok-shaped after predicate(S, d) applied — re-leasing with the SAME token NOOPs`,
|
|
1449
|
+
run: async () => {
|
|
1450
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1451
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
1452
|
+
const settled = await settleStep(run.id, {
|
|
1453
|
+
kind: 'settle_step',
|
|
1454
|
+
step: 'a',
|
|
1455
|
+
outcome: 'complete',
|
|
1456
|
+
claimToken: token,
|
|
1457
|
+
evidence: [makeEvidence('a')],
|
|
1458
|
+
}, def);
|
|
1459
|
+
assertApplied(settled, 'setup settle');
|
|
1460
|
+
const leaseToken = uid('lease');
|
|
1461
|
+
const delta = {
|
|
1462
|
+
kind: 'lease_finalizer',
|
|
1463
|
+
finalizer: 'fin',
|
|
1464
|
+
leaseToken,
|
|
1465
|
+
leaseSeconds: 60,
|
|
1466
|
+
};
|
|
1467
|
+
const first = await settleStep(run.id, delta, def);
|
|
1468
|
+
assertApplied(first, 'first lease');
|
|
1469
|
+
const second = await settleStep(run.id, delta, def);
|
|
1470
|
+
if (second.applied)
|
|
1471
|
+
throw new Error('expected the re-lease to be ok-shaped NOOP, not applied:true');
|
|
1472
|
+
},
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
law: 'SELF_IMAGE_IDEMPOTENCE',
|
|
1476
|
+
name: `[${adapter.storeName}] mark_finalizer: predicate(S', d) is ok-shaped after predicate(S, d) applied — re-marking with the SAME token+result NOOPs`,
|
|
1477
|
+
run: async () => {
|
|
1478
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
|
|
1479
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
1480
|
+
const settled = await settleStep(run.id, {
|
|
1481
|
+
kind: 'settle_step',
|
|
1482
|
+
step: 'a',
|
|
1483
|
+
outcome: 'complete',
|
|
1484
|
+
claimToken: token,
|
|
1485
|
+
evidence: [makeEvidence('a')],
|
|
1486
|
+
}, def);
|
|
1487
|
+
assertApplied(settled, 'setup settle');
|
|
1488
|
+
const leaseToken = uid('lease');
|
|
1489
|
+
const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
|
|
1490
|
+
assertApplied(leased, 'setup lease');
|
|
1491
|
+
const delta = {
|
|
1492
|
+
kind: 'mark_finalizer',
|
|
1493
|
+
finalizer: 'fin',
|
|
1494
|
+
leaseToken,
|
|
1495
|
+
result: 'completed',
|
|
1496
|
+
evidence: makeEvidence('fin'),
|
|
1497
|
+
};
|
|
1498
|
+
const first = await settleStep(run.id, delta, def);
|
|
1499
|
+
assertApplied(first, 'first mark');
|
|
1500
|
+
const second = await settleStep(run.id, delta, def);
|
|
1501
|
+
if (second.applied)
|
|
1502
|
+
throw new Error('expected the re-mark to be ok-shaped NOOP, not applied:true');
|
|
1503
|
+
},
|
|
1504
|
+
},
|
|
1505
|
+
];
|
|
1506
|
+
}
|
|
1507
|
+
// ---------------------------------------------------------------------------
|
|
1508
|
+
// COMPLETE_SEAL_PHASE + WHEN_ROUTED_TERMINALIZATION (hand-authored transform pins, non-circular)
|
|
1509
|
+
// ---------------------------------------------------------------------------
|
|
1510
|
+
function completeSealPhaseCases(_adapter) {
|
|
1511
|
+
return [
|
|
1512
|
+
{
|
|
1513
|
+
law: 'COMPLETE_SEAL_PHASE',
|
|
1514
|
+
name: "a complete-terminal edge sets run_phase 'completed' AND terminal_reason 'Workflow completed.' together (hand-authored, non-circular)",
|
|
1515
|
+
run: async () => {
|
|
1516
|
+
const def = minimalDefinition(['a']);
|
|
1517
|
+
const fresh = {
|
|
1518
|
+
id: 'tck-complete-seal-phase',
|
|
1519
|
+
workflow_id: def.id,
|
|
1520
|
+
workflow_version: 1,
|
|
1521
|
+
completed_steps: [],
|
|
1522
|
+
in_progress_steps: ['a'],
|
|
1523
|
+
failed_steps: [],
|
|
1524
|
+
skipped_steps: [],
|
|
1525
|
+
run_phase: 'running',
|
|
1526
|
+
version: 0,
|
|
1527
|
+
params: {},
|
|
1528
|
+
evidence: [],
|
|
1529
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
1530
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
1531
|
+
terminal_state: false,
|
|
1532
|
+
claims: { a: { deadline: null, token: 'tck-token' } },
|
|
1533
|
+
};
|
|
1534
|
+
const result = applySettlement(fresh, {
|
|
1535
|
+
kind: 'settle_step',
|
|
1536
|
+
step: 'a',
|
|
1537
|
+
outcome: 'complete',
|
|
1538
|
+
claimToken: 'tck-token',
|
|
1539
|
+
evidence: [makeEvidence('a')],
|
|
1540
|
+
}, def, { now: new Date('2026-01-01T00:00:00.000Z') });
|
|
1541
|
+
if (!result.applied)
|
|
1542
|
+
throw new Error(`expected applied:true, got: ${result.reason}`);
|
|
1543
|
+
if (result.run.run_phase !== 'completed' ||
|
|
1544
|
+
result.run.terminal_reason !== 'Workflow completed.') {
|
|
1545
|
+
throw new Error(`expected run_phase:'completed' + terminal_reason:'Workflow completed.', got run_phase:'${result.run.run_phase}' terminal_reason:'${result.run.terminal_reason}'`);
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1548
|
+
},
|
|
1549
|
+
];
|
|
1550
|
+
}
|
|
1551
|
+
function whenRoutedTerminalizationCases(_adapter) {
|
|
1552
|
+
return [
|
|
1553
|
+
{
|
|
1554
|
+
law: 'WHEN_ROUTED_TERMINALIZATION',
|
|
1555
|
+
name: 'the safety-net-disjunct-only case (isWorkflowComplete false, but in_progress empty + zero eligible steps/guards) terminalizes and mints (hand-authored, non-circular)',
|
|
1556
|
+
run: async () => {
|
|
1557
|
+
// 'b' is permanently skipped by an unsatisfiable when-clause against 'a's own output —
|
|
1558
|
+
// isWorkflowComplete is FALSE at the settle-of-'a' instant (propagateSkips runs INSIDE
|
|
1559
|
+
// the same apply and marks 'b' skipped only as a RESULT of this very settle), so the
|
|
1560
|
+
// safety-net second disjunct (in_progress empty + zero eligible steps/guards) is what
|
|
1561
|
+
// fires terminalization here, not the first disjunct evaluated against the PRE-apply state.
|
|
1562
|
+
const def = {
|
|
1563
|
+
id: uid('when-routed-wf'),
|
|
1564
|
+
name: 'When-routed TCK fixture',
|
|
1565
|
+
version: 1,
|
|
1566
|
+
steps: {
|
|
1567
|
+
a: { description: 'a', execution: 'agent', depends_on: [] },
|
|
1568
|
+
b: {
|
|
1569
|
+
description: 'b',
|
|
1570
|
+
execution: 'agent',
|
|
1571
|
+
depends_on: ['a'],
|
|
1572
|
+
when: ["evidence.a.output.category == 'never-matches'"],
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
};
|
|
1576
|
+
const fresh = {
|
|
1577
|
+
id: 'tck-when-routed',
|
|
1578
|
+
workflow_id: def.id,
|
|
1579
|
+
workflow_version: 1,
|
|
1580
|
+
completed_steps: [],
|
|
1581
|
+
in_progress_steps: ['a'],
|
|
1582
|
+
failed_steps: [],
|
|
1583
|
+
skipped_steps: [],
|
|
1584
|
+
run_phase: 'running',
|
|
1585
|
+
version: 0,
|
|
1586
|
+
params: {},
|
|
1587
|
+
evidence: [],
|
|
1588
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
1589
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
1590
|
+
terminal_state: false,
|
|
1591
|
+
claims: { a: { deadline: null, token: 'tck-token' } },
|
|
1592
|
+
};
|
|
1593
|
+
const result = applySettlement(fresh, {
|
|
1594
|
+
kind: 'settle_step',
|
|
1595
|
+
step: 'a',
|
|
1596
|
+
outcome: 'complete',
|
|
1597
|
+
claimToken: 'tck-token',
|
|
1598
|
+
evidence: [makeEvidence('a', { output_summary: { category: 'something-else' } })],
|
|
1599
|
+
}, def, { now: new Date('2026-01-01T00:00:00.000Z') });
|
|
1600
|
+
if (!result.applied)
|
|
1601
|
+
throw new Error(`expected applied:true, got: ${result.reason}`);
|
|
1602
|
+
if (!result.run.skipped_steps.includes('b')) {
|
|
1603
|
+
throw new Error("fixture premise violated: 'b' must be propagated-skipped by its own when-clause");
|
|
1604
|
+
}
|
|
1605
|
+
if (!result.transitioned || result.run.terminal_state !== true) {
|
|
1606
|
+
throw new Error('expected the safety-net disjunct to terminalize the run');
|
|
1607
|
+
}
|
|
1608
|
+
},
|
|
1609
|
+
},
|
|
1610
|
+
];
|
|
1611
|
+
}
|
|
1612
|
+
// ---------------------------------------------------------------------------
|
|
1613
|
+
// G-1 transform pin (sibling settle under an open LEGACY gate ⇒ transitioned:false)
|
|
1614
|
+
// ---------------------------------------------------------------------------
|
|
1615
|
+
function g1GateCoexistenceCases(_adapter) {
|
|
1616
|
+
return [
|
|
1617
|
+
{
|
|
1618
|
+
law: 'G1_GATE_COEXISTENCE',
|
|
1619
|
+
name: 'settling a sibling step while a LEGACY gate is open on another step never transitions the run — the gate step itself keeps in_progress non-empty (hand-authored, non-circular)',
|
|
1620
|
+
run: async () => {
|
|
1621
|
+
const def = {
|
|
1622
|
+
id: uid('g1-wf'),
|
|
1623
|
+
name: 'G-1 TCK fixture',
|
|
1624
|
+
version: 1,
|
|
1625
|
+
steps: {
|
|
1626
|
+
gated: { description: 'g', execution: 'agent', depends_on: [] },
|
|
1627
|
+
sibling: { description: 's', execution: 'agent', depends_on: [] },
|
|
1628
|
+
},
|
|
1629
|
+
};
|
|
1630
|
+
const fresh = {
|
|
1631
|
+
id: 'tck-g1',
|
|
1632
|
+
workflow_id: def.id,
|
|
1633
|
+
workflow_version: 1,
|
|
1634
|
+
completed_steps: [],
|
|
1635
|
+
in_progress_steps: ['gated', 'sibling'],
|
|
1636
|
+
failed_steps: [],
|
|
1637
|
+
skipped_steps: [],
|
|
1638
|
+
run_phase: 'gate_waiting',
|
|
1639
|
+
version: 0,
|
|
1640
|
+
params: {},
|
|
1641
|
+
evidence: [],
|
|
1642
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
1643
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
1644
|
+
terminal_state: false,
|
|
1645
|
+
claims: { sibling: { deadline: null, token: 'tck-token' } },
|
|
1646
|
+
pending_gate: {
|
|
1647
|
+
gate_id: 'tck-gate',
|
|
1648
|
+
step_name: 'gated',
|
|
1649
|
+
preview: {},
|
|
1650
|
+
choices: ['approve'],
|
|
1651
|
+
opened_at: '2026-01-01T00:00:00.000Z',
|
|
1652
|
+
},
|
|
1653
|
+
};
|
|
1654
|
+
const result = applySettlement(fresh, {
|
|
1655
|
+
kind: 'settle_step',
|
|
1656
|
+
step: 'sibling',
|
|
1657
|
+
outcome: 'complete',
|
|
1658
|
+
claimToken: 'tck-token',
|
|
1659
|
+
evidence: [makeEvidence('sibling')],
|
|
1660
|
+
}, def, { now: new Date('2026-01-01T00:00:00.000Z') });
|
|
1661
|
+
if (!result.applied)
|
|
1662
|
+
throw new Error(`expected applied:true, got: ${result.reason}`);
|
|
1663
|
+
if (result.transitioned !== false || result.run.terminal_state !== false) {
|
|
1664
|
+
throw new Error('expected transitioned:false — the open gate step keeps in_progress non-empty');
|
|
1665
|
+
}
|
|
1666
|
+
if (result.run.pending_gate === undefined) {
|
|
1667
|
+
throw new Error('the legacy open gate must survive a sibling settle untouched');
|
|
1668
|
+
}
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
];
|
|
1672
|
+
}
|
|
1673
|
+
// ---------------------------------------------------------------------------
|
|
1674
|
+
// GATE_OPEN_IDEMPOTENT (issue #279, increment 2, PR-C — design record §8)
|
|
1675
|
+
// ---------------------------------------------------------------------------
|
|
1676
|
+
function gateOpenIdempotentCases(adapter) {
|
|
1677
|
+
const fixture = adapter.settlementFixture;
|
|
1678
|
+
const { minimalDefinition } = fixture;
|
|
1679
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
1680
|
+
return [
|
|
1681
|
+
{
|
|
1682
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1683
|
+
name: `[${adapter.storeName}] an exact-delta open_gate replay NOOPs as already_settled — version unchanged`,
|
|
1684
|
+
run: async () => {
|
|
1685
|
+
const def = minimalDefinition(['gated']);
|
|
1686
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
1687
|
+
const gate = makePendingGate('gated');
|
|
1688
|
+
const delta = {
|
|
1689
|
+
kind: 'open_gate',
|
|
1690
|
+
step: 'gated',
|
|
1691
|
+
claimToken: token,
|
|
1692
|
+
pendingGate: gate,
|
|
1693
|
+
evidence: [],
|
|
1694
|
+
};
|
|
1695
|
+
const first = await settleStep(run.id, delta, def);
|
|
1696
|
+
assertApplied(first, 'first open_gate');
|
|
1697
|
+
const second = await settleStep(run.id, delta, def);
|
|
1698
|
+
assertRefused(second, 'already_settled', 'exact-delta open_gate replay');
|
|
1699
|
+
if (second.run.version !== first.run.version) {
|
|
1700
|
+
throw new Error(`expected version unchanged on a NOOP (${first.run.version}), got ${second.run.version}`);
|
|
1701
|
+
}
|
|
1702
|
+
},
|
|
1703
|
+
},
|
|
1704
|
+
{
|
|
1705
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1706
|
+
name: `[${adapter.storeName}] a re-submitted open_gate AFTER the step ALSO settled via a totally different route (settle_step complete) refuses already_settled_by_other`,
|
|
1707
|
+
run: async () => {
|
|
1708
|
+
const def = minimalDefinition(['a']);
|
|
1709
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
1710
|
+
const completed = await settleStep(run.id, {
|
|
1711
|
+
kind: 'settle_step',
|
|
1712
|
+
step: 'a',
|
|
1713
|
+
outcome: 'complete',
|
|
1714
|
+
claimToken: token,
|
|
1715
|
+
evidence: [makeEvidence('a')],
|
|
1716
|
+
}, def);
|
|
1717
|
+
assertApplied(completed, 'settle_step complete');
|
|
1718
|
+
const result = await settleStep(run.id, {
|
|
1719
|
+
kind: 'open_gate',
|
|
1720
|
+
step: 'a',
|
|
1721
|
+
claimToken: token,
|
|
1722
|
+
pendingGate: makePendingGate('a'),
|
|
1723
|
+
evidence: [],
|
|
1724
|
+
}, def);
|
|
1725
|
+
assertRefused(result, 'already_settled_by_other', 'open_gate on a step already settled via a different route');
|
|
1726
|
+
},
|
|
1727
|
+
},
|
|
1728
|
+
{
|
|
1729
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1730
|
+
name: `[${adapter.storeName}] a re-submitted open_gate AFTER the gate already resolved (BU F6) NOOPs as already_settled`,
|
|
1731
|
+
run: async () => {
|
|
1732
|
+
const def = minimalDefinition(['gated']);
|
|
1733
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
1734
|
+
const gate = makePendingGate('gated');
|
|
1735
|
+
const openDelta = {
|
|
1736
|
+
kind: 'open_gate',
|
|
1737
|
+
step: 'gated',
|
|
1738
|
+
claimToken: token,
|
|
1739
|
+
pendingGate: gate,
|
|
1740
|
+
evidence: [],
|
|
1741
|
+
};
|
|
1742
|
+
const opened = await settleStep(run.id, openDelta, def);
|
|
1743
|
+
assertApplied(opened, 'open_gate');
|
|
1744
|
+
const resolved = await settleStep(run.id, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'approve', evidence: [] }, def);
|
|
1745
|
+
assertApplied(resolved, 'settle_gate resolve');
|
|
1746
|
+
// A delayed/retried open_gate for the SAME gate_id, arriving after resolution.
|
|
1747
|
+
const replay = await settleStep(run.id, openDelta, def);
|
|
1748
|
+
assertRefused(replay, 'already_settled', 'open_gate replay after resolution');
|
|
1749
|
+
},
|
|
1750
|
+
},
|
|
1751
|
+
{
|
|
1752
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1753
|
+
name: `[${adapter.storeName}] fence-checked already_open: a matching claimant re-opening a DIFFERENT gate_id on the SAME step gets the LIVE gate back verbatim (defensive — in-contract unreachable)`,
|
|
1754
|
+
run: async () => {
|
|
1755
|
+
const def = minimalDefinition(['gated']);
|
|
1756
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
1757
|
+
const liveGate = makePendingGate('gated', { gateId: 'live-gate' });
|
|
1758
|
+
const opened = await settleStep(run.id, {
|
|
1759
|
+
kind: 'open_gate',
|
|
1760
|
+
step: 'gated',
|
|
1761
|
+
claimToken: token,
|
|
1762
|
+
pendingGate: liveGate,
|
|
1763
|
+
evidence: [],
|
|
1764
|
+
}, def);
|
|
1765
|
+
assertApplied(opened, 'open_gate (live)');
|
|
1766
|
+
const other = makePendingGate('gated', { gateId: 'other-gate' });
|
|
1767
|
+
const result = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: other, evidence: [] }, def);
|
|
1768
|
+
assertRefused(result, 'already_open', 'same-claimant re-open with a different gate_id');
|
|
1769
|
+
if (result.gate?.gate_id !== 'live-gate') {
|
|
1770
|
+
throw new Error(`already_open must return the LIVE gate verbatim, got: ${JSON.stringify(result.gate)}`);
|
|
1771
|
+
}
|
|
1772
|
+
},
|
|
1773
|
+
},
|
|
1774
|
+
{
|
|
1775
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1776
|
+
name: `[${adapter.storeName}] a successor gate-open attempt on a DIFFERENT step while a gate is open elsewhere refuses gate_mismatch, and the target step STAYS claimed (L13)`,
|
|
1777
|
+
run: async () => {
|
|
1778
|
+
const def = minimalDefinition(['gated', 'successor']);
|
|
1779
|
+
const { run, token: gatedToken } = await createClaimed(adapter.store, def, 'gated');
|
|
1780
|
+
const gate = makePendingGate('gated');
|
|
1781
|
+
const opened = await settleStep(run.id, {
|
|
1782
|
+
kind: 'open_gate',
|
|
1783
|
+
step: 'gated',
|
|
1784
|
+
claimToken: gatedToken,
|
|
1785
|
+
pendingGate: gate,
|
|
1786
|
+
evidence: [],
|
|
1787
|
+
}, def);
|
|
1788
|
+
assertApplied(opened, 'open_gate on gated');
|
|
1789
|
+
// 'successor' can never be claimed while a gate is open (findEligibleSteps returns []),
|
|
1790
|
+
// so this hand-constructs the claim to isolate the ARM being tested (open_gate's OWN
|
|
1791
|
+
// serialization refusal), matching the fixture-mechanics precedent set by
|
|
1792
|
+
// g1GateCoexistenceCases above.
|
|
1793
|
+
const seeded = await adapter.store.update({
|
|
1794
|
+
...opened.run,
|
|
1795
|
+
in_progress_steps: [...opened.run.in_progress_steps, 'successor'],
|
|
1796
|
+
claims: { ...opened.run.claims, successor: { deadline: null, token: 'successor-token' } },
|
|
1797
|
+
});
|
|
1798
|
+
const result = await settleStep(seeded.id, {
|
|
1799
|
+
kind: 'open_gate',
|
|
1800
|
+
step: 'successor',
|
|
1801
|
+
claimToken: 'successor-token',
|
|
1802
|
+
pendingGate: makePendingGate('successor'),
|
|
1803
|
+
evidence: [],
|
|
1804
|
+
}, def);
|
|
1805
|
+
assertRefused(result, 'gate_mismatch', 'open_gate on another step while a gate is open');
|
|
1806
|
+
if (!result.run.in_progress_steps.includes('successor')) {
|
|
1807
|
+
throw new Error('gate_mismatch must leave the target step STILL claimed (L13)');
|
|
1808
|
+
}
|
|
1809
|
+
},
|
|
1810
|
+
},
|
|
1811
|
+
];
|
|
1812
|
+
}
|
|
1813
|
+
// ---------------------------------------------------------------------------
|
|
1814
|
+
// GATE_RESOLUTION_CONFLICT (issue #279, increment 2, PR-C)
|
|
1815
|
+
// ---------------------------------------------------------------------------
|
|
1816
|
+
function gateResolutionConflictCases(adapter) {
|
|
1817
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
1818
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
1819
|
+
async function openGate(def, stepName) {
|
|
1820
|
+
const { run, token } = await createClaimed(adapter.store, def, stepName);
|
|
1821
|
+
const gate = makePendingGate(stepName);
|
|
1822
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: stepName, claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
1823
|
+
assertApplied(opened, `open_gate on ${stepName}`);
|
|
1824
|
+
return { runId: run.id, gate };
|
|
1825
|
+
}
|
|
1826
|
+
return [
|
|
1827
|
+
{
|
|
1828
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1829
|
+
name: `[${adapter.storeName}] a same-choice settle_gate retry (double-submit) NOOPs as already_settled`,
|
|
1830
|
+
run: async () => {
|
|
1831
|
+
const def = minimalDefinition(['gated']);
|
|
1832
|
+
const { runId, gate } = await openGate(def, 'gated');
|
|
1833
|
+
const delta = {
|
|
1834
|
+
kind: 'settle_gate',
|
|
1835
|
+
gateId: gate.gate_id,
|
|
1836
|
+
choice: 'approve',
|
|
1837
|
+
evidence: [],
|
|
1838
|
+
};
|
|
1839
|
+
const first = await settleStep(runId, delta, def);
|
|
1840
|
+
assertApplied(first, 'first settle_gate');
|
|
1841
|
+
const second = await settleStep(runId, delta, def);
|
|
1842
|
+
assertRefused(second, 'already_settled', 'same-choice settle_gate retry');
|
|
1843
|
+
},
|
|
1844
|
+
},
|
|
1845
|
+
{
|
|
1846
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1847
|
+
name: `[${adapter.storeName}] a choice NOT among the gate's own choices refuses choice_not_eligible, gate stays open`,
|
|
1848
|
+
run: async () => {
|
|
1849
|
+
const def = minimalDefinition(['gated']);
|
|
1850
|
+
const { runId, gate } = await openGate(def, 'gated');
|
|
1851
|
+
const result = await settleStep(runId, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'not-a-real-choice', evidence: [] }, def);
|
|
1852
|
+
assertRefused(result, 'choice_not_eligible', 'settle_gate with an ineligible choice');
|
|
1853
|
+
if (result.choices?.join(',') !== gate.choices.join(',')) {
|
|
1854
|
+
throw new Error(`expected choices:${JSON.stringify(gate.choices)}, got: ${JSON.stringify(result.choices)}`);
|
|
1855
|
+
}
|
|
1856
|
+
if (result.run.pending_gate?.gate_id !== gate.gate_id) {
|
|
1857
|
+
throw new Error('choice_not_eligible must leave the gate open, unchanged');
|
|
1858
|
+
}
|
|
1859
|
+
},
|
|
1860
|
+
},
|
|
1861
|
+
{
|
|
1862
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1863
|
+
name: `[${adapter.storeName}] a DIFFERENT-choice settle_gate after resolution refuses gate_choice_conflict with the winning choice`,
|
|
1864
|
+
run: async () => {
|
|
1865
|
+
const def = minimalDefinition(['gated']);
|
|
1866
|
+
const { runId, gate } = await openGate(def, 'gated');
|
|
1867
|
+
const first = await settleStep(runId, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'approve', evidence: [] }, def);
|
|
1868
|
+
assertApplied(first, 'first settle_gate (approve)');
|
|
1869
|
+
const second = await settleStep(runId, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'reject', evidence: [] }, def);
|
|
1870
|
+
assertRefused(second, 'gate_choice_conflict', 'conflicting-choice settle_gate');
|
|
1871
|
+
if (second.winningChoice !== 'approve') {
|
|
1872
|
+
throw new Error(`expected winningChoice:'approve', got: ${second.winningChoice}`);
|
|
1873
|
+
}
|
|
1874
|
+
},
|
|
1875
|
+
},
|
|
1876
|
+
{
|
|
1877
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1878
|
+
name: `[${adapter.storeName}] a delayed retry of an ALREADY-RESOLVED gate's choice still NOOPs, even after a SECOND gate has since opened on another step (TD F1 — lookup is by gateId alone)`,
|
|
1879
|
+
run: async () => {
|
|
1880
|
+
const def = minimalDefinition(['gate1', 'gate2']);
|
|
1881
|
+
const { runId, gate: gate1 } = await openGate(def, 'gate1');
|
|
1882
|
+
const resolve1 = {
|
|
1883
|
+
kind: 'settle_gate',
|
|
1884
|
+
gateId: gate1.gate_id,
|
|
1885
|
+
choice: 'approve',
|
|
1886
|
+
evidence: [],
|
|
1887
|
+
};
|
|
1888
|
+
const first = await settleStep(runId, resolve1, def);
|
|
1889
|
+
assertApplied(first, 'resolve gate1');
|
|
1890
|
+
// A second, unrelated gate opens on a different step.
|
|
1891
|
+
const { token: gate2Token } = { token: 'gate2-token' };
|
|
1892
|
+
const seeded = await adapter.store.update({
|
|
1893
|
+
...first.run,
|
|
1894
|
+
in_progress_steps: [...first.run.in_progress_steps, 'gate2'],
|
|
1895
|
+
claims: { ...first.run.claims, gate2: { deadline: null, token: gate2Token } },
|
|
1896
|
+
});
|
|
1897
|
+
const gate2 = makePendingGate('gate2');
|
|
1898
|
+
const opened2 = await settleStep(seeded.id, {
|
|
1899
|
+
kind: 'open_gate',
|
|
1900
|
+
step: 'gate2',
|
|
1901
|
+
claimToken: gate2Token,
|
|
1902
|
+
pendingGate: gate2,
|
|
1903
|
+
evidence: [],
|
|
1904
|
+
}, def);
|
|
1905
|
+
assertApplied(opened2, 'open_gate on gate2');
|
|
1906
|
+
// The delayed retry of gate1's ORIGINAL resolution — found by gateId lookup regardless
|
|
1907
|
+
// of what is currently open.
|
|
1908
|
+
const delayedRetry = await settleStep(runId, resolve1, def);
|
|
1909
|
+
assertRefused(delayedRetry, 'already_settled', 'delayed retry of gate1 resolution');
|
|
1910
|
+
},
|
|
1911
|
+
},
|
|
1912
|
+
{
|
|
1913
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1914
|
+
name: `[${adapter.storeName}] G2 corruption fixture: a hand-shaped record where BOTH a settled 'gate' entry and a live pending_gate share the same gate_id ⇒ the lookup-first NOOP wins, never a RESOLVE (fail-safe, lens-2 m3)`,
|
|
1915
|
+
run: async () => {
|
|
1916
|
+
const def = minimalDefinition(['gated']);
|
|
1917
|
+
const { run } = await adapter.store.create({
|
|
1918
|
+
workflowId: def.id,
|
|
1919
|
+
workflowVersion: def.version,
|
|
1920
|
+
params: {},
|
|
1921
|
+
});
|
|
1922
|
+
const gateId = 'corrupt-gate';
|
|
1923
|
+
// Hand-shaped: an entry ALREADY records this gate as resolved with choice 'approve', but
|
|
1924
|
+
// pending_gate is STILL live with the SAME gate_id — never producible by settleStep
|
|
1925
|
+
// itself (APPLY always clears pending_gate in the SAME write it settles); only a
|
|
1926
|
+
// hand-authored fixture or an external store's divergent history can produce this.
|
|
1927
|
+
const corrupted = await adapter.store.update({
|
|
1928
|
+
...run,
|
|
1929
|
+
completed_steps: ['gated'],
|
|
1930
|
+
settled: { gated: { token: gateId, outcome: 'gate', choice: 'approve' } },
|
|
1931
|
+
pending_gate: {
|
|
1932
|
+
gate_id: gateId,
|
|
1933
|
+
step_name: 'gated',
|
|
1934
|
+
preview: {},
|
|
1935
|
+
choices: ['approve', 'reject'],
|
|
1936
|
+
opened_at: '2026-01-01T00:00:00.000Z',
|
|
1937
|
+
},
|
|
1938
|
+
});
|
|
1939
|
+
const result = await settleStep(corrupted.id, { kind: 'settle_gate', gateId, choice: 'approve', evidence: [] }, def);
|
|
1940
|
+
// The lookup-first ordering means this is ALWAYS a NOOP against the settled entry — the
|
|
1941
|
+
// live-gate RESOLVE branch is structurally unreachable once a matching settled entry
|
|
1942
|
+
// exists, regardless of corruption.
|
|
1943
|
+
assertRefused(result, 'already_settled', 'G2-corrupted both-match record');
|
|
1944
|
+
},
|
|
1945
|
+
},
|
|
1946
|
+
];
|
|
1947
|
+
}
|
|
1948
|
+
// ---------------------------------------------------------------------------
|
|
1949
|
+
// GATE_MISMATCH (issue #279, increment 2, PR-C)
|
|
1950
|
+
// ---------------------------------------------------------------------------
|
|
1951
|
+
function gateMismatchCases(adapter) {
|
|
1952
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
1953
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
1954
|
+
return [
|
|
1955
|
+
{
|
|
1956
|
+
law: 'GATE_MISMATCH',
|
|
1957
|
+
name: `[${adapter.storeName}] settle_gate with an UNKNOWN gateId on a live, gate-free run refuses gate_mismatch`,
|
|
1958
|
+
run: async () => {
|
|
1959
|
+
const def = minimalDefinition(['a']);
|
|
1960
|
+
const { run } = await adapter.store.create({
|
|
1961
|
+
workflowId: def.id,
|
|
1962
|
+
workflowVersion: def.version,
|
|
1963
|
+
params: {},
|
|
1964
|
+
});
|
|
1965
|
+
const result = await settleStep(run.id, { kind: 'settle_gate', gateId: 'nonexistent-gate', choice: 'approve', evidence: [] }, def);
|
|
1966
|
+
assertRefused(result, 'gate_mismatch', 'settle_gate with an unknown gateId');
|
|
1967
|
+
},
|
|
1968
|
+
},
|
|
1969
|
+
{
|
|
1970
|
+
law: 'GATE_MISMATCH',
|
|
1971
|
+
name: `[${adapter.storeName}] settle_gate with a SUPERSEDED gateId (a DIFFERENT gate is now open) refuses gate_mismatch`,
|
|
1972
|
+
run: async () => {
|
|
1973
|
+
const def = minimalDefinition(['a']);
|
|
1974
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
1975
|
+
const liveGate = makePendingGate('a', { gateId: 'live-gate' });
|
|
1976
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'a', claimToken: token, pendingGate: liveGate, evidence: [] }, def);
|
|
1977
|
+
assertApplied(opened, 'open_gate');
|
|
1978
|
+
const result = await settleStep(run.id, { kind: 'settle_gate', gateId: 'stale-superseded-gate', choice: 'approve', evidence: [] }, def);
|
|
1979
|
+
assertRefused(result, 'gate_mismatch', 'settle_gate with a superseded gateId');
|
|
1980
|
+
},
|
|
1981
|
+
},
|
|
1982
|
+
{
|
|
1983
|
+
law: 'GATE_MISMATCH',
|
|
1984
|
+
name: `[${adapter.storeName}] zombie submit: a hand-shaped terminal ∧ pending_gate record refuses run_terminal on a matching gateId submit, record/version UNCHANGED`,
|
|
1985
|
+
run: async () => {
|
|
1986
|
+
const def = minimalDefinition(['a']);
|
|
1987
|
+
const { run } = await adapter.store.create({
|
|
1988
|
+
workflowId: def.id,
|
|
1989
|
+
workflowVersion: def.version,
|
|
1990
|
+
params: {},
|
|
1991
|
+
});
|
|
1992
|
+
const gateId = 'zombie-gate';
|
|
1993
|
+
// Hand-shaped grandfathered/#282-class record: terminal AND still carrying a pending_gate
|
|
1994
|
+
// — the exact class D-3 exists to close on the RENDER side; this pins the TRANSFORM's own
|
|
1995
|
+
// zombie-submit refusal independent of that closure.
|
|
1996
|
+
const zombie = await adapter.store.update({
|
|
1997
|
+
...run,
|
|
1998
|
+
completed_steps: ['a'],
|
|
1999
|
+
terminal_state: true,
|
|
2000
|
+
terminal_reason: 'Workflow completed.',
|
|
2001
|
+
pending_gate: {
|
|
2002
|
+
gate_id: gateId,
|
|
2003
|
+
step_name: 'a',
|
|
2004
|
+
preview: {},
|
|
2005
|
+
choices: ['approve', 'reject'],
|
|
2006
|
+
opened_at: '2026-01-01T00:00:00.000Z',
|
|
2007
|
+
},
|
|
2008
|
+
});
|
|
2009
|
+
const result = await settleStep(zombie.id, { kind: 'settle_gate', gateId, choice: 'approve', evidence: [] }, def);
|
|
2010
|
+
assertRefused(result, 'run_terminal', 'zombie gate submit on a terminal record');
|
|
2011
|
+
if (result.run.version !== zombie.version) {
|
|
2012
|
+
throw new Error('a zombie-submit refusal must leave the record/version UNCHANGED');
|
|
2013
|
+
}
|
|
2014
|
+
},
|
|
2015
|
+
},
|
|
2016
|
+
];
|
|
2017
|
+
}
|
|
2018
|
+
// ---------------------------------------------------------------------------
|
|
2019
|
+
// GUARD_OUTCOME_DIVERGENCE / GUARD_WAITS_ON_OPEN_GATE / GUARD_PASS_COMPLETE_OUTCOME /
|
|
2020
|
+
// GUARD_ABORT_CASCADE / GUARD_NO_ENTRY (issue #279, increment 2, PR-C)
|
|
2021
|
+
// ---------------------------------------------------------------------------
|
|
2022
|
+
function guardCases(adapter) {
|
|
2023
|
+
const fixture = adapter.settlementFixture;
|
|
2024
|
+
if (fixture.withGuard === undefined) {
|
|
2025
|
+
return [
|
|
2026
|
+
{
|
|
2027
|
+
law: 'ADAPTER_WIRING',
|
|
2028
|
+
name: `[${adapter.storeName}] declares RunStore.settleStep but the supplied settlementFixture has no withGuard — guard-law coverage is a WIRING GAP, not a vacuous pass`,
|
|
2029
|
+
run: async () => {
|
|
2030
|
+
throw new Error(`[${adapter.storeName}] settlementContract: adapter.settlementFixture.withGuard is ` +
|
|
2031
|
+
"undefined — pass 'defaultSettlementFixture' (or a store-specific fixture " +
|
|
2032
|
+
'implementing withGuard) to exercise guard conformance coverage.');
|
|
2033
|
+
},
|
|
2034
|
+
},
|
|
2035
|
+
];
|
|
2036
|
+
}
|
|
2037
|
+
const { minimalDefinition } = fixture;
|
|
2038
|
+
const withGuard = fixture.withGuard;
|
|
2039
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2040
|
+
return [
|
|
2041
|
+
// --- GUARD_OUTCOME_DIVERGENCE ---
|
|
2042
|
+
{
|
|
2043
|
+
law: 'GUARD_OUTCOME_DIVERGENCE',
|
|
2044
|
+
name: `[${adapter.storeName}] a same-outcome (pass) settle_guard retry NOOPs as already_settled`,
|
|
2045
|
+
run: async () => {
|
|
2046
|
+
const def = withGuard(minimalDefinition([]), 'g', []);
|
|
2047
|
+
const { run } = await adapter.store.create({
|
|
2048
|
+
workflowId: def.id,
|
|
2049
|
+
workflowVersion: def.version,
|
|
2050
|
+
params: {},
|
|
2051
|
+
});
|
|
2052
|
+
const delta = {
|
|
2053
|
+
kind: 'settle_guard',
|
|
2054
|
+
step: 'g',
|
|
2055
|
+
outcome: 'pass',
|
|
2056
|
+
evidence: makeEvidence('g'),
|
|
2057
|
+
};
|
|
2058
|
+
const first = await settleStep(run.id, delta, def);
|
|
2059
|
+
assertApplied(first, 'first settle_guard pass');
|
|
2060
|
+
const second = await settleStep(run.id, delta, def);
|
|
2061
|
+
assertRefused(second, 'already_settled', 'same-outcome settle_guard retry');
|
|
2062
|
+
},
|
|
2063
|
+
},
|
|
2064
|
+
{
|
|
2065
|
+
law: 'GUARD_OUTCOME_DIVERGENCE',
|
|
2066
|
+
name: `[${adapter.storeName}] a CROSS-outcome settle_guard retry (pass, then resolution_error) refuses settled_outcome_divergence`,
|
|
2067
|
+
run: async () => {
|
|
2068
|
+
const def = withGuard(minimalDefinition([]), 'g', []);
|
|
2069
|
+
const { run } = await adapter.store.create({
|
|
2070
|
+
workflowId: def.id,
|
|
2071
|
+
workflowVersion: def.version,
|
|
2072
|
+
params: {},
|
|
2073
|
+
});
|
|
2074
|
+
const first = await settleStep(run.id, { kind: 'settle_guard', step: 'g', outcome: 'pass', evidence: makeEvidence('g') }, def);
|
|
2075
|
+
assertApplied(first, 'first settle_guard pass');
|
|
2076
|
+
const second = await settleStep(run.id, {
|
|
2077
|
+
kind: 'settle_guard',
|
|
2078
|
+
step: 'g',
|
|
2079
|
+
outcome: 'resolution_error',
|
|
2080
|
+
evidence: makeEvidence('g'),
|
|
2081
|
+
resolutionError: { condition: 'x > 1', unresolvable_path: 'x' },
|
|
2082
|
+
}, def);
|
|
2083
|
+
assertRefused(second, 'settled_outcome_divergence', 'cross-outcome settle_guard retry');
|
|
2084
|
+
},
|
|
2085
|
+
},
|
|
2086
|
+
{
|
|
2087
|
+
law: 'GUARD_OUTCOME_DIVERGENCE',
|
|
2088
|
+
name: `[${adapter.storeName}] an abort settle_guard retry against a step skipped for a DIFFERENT reason (not guard_abort) refuses settled_outcome_divergence — the skip_details conjunct`,
|
|
2089
|
+
run: async () => {
|
|
2090
|
+
// 'g' skipped via an unsatisfiable trigger_rule (its own dep 'never' fails), never via a
|
|
2091
|
+
// guard_abort — a settle_guard abort retry for 'g' must diverge, not converge.
|
|
2092
|
+
const def = {
|
|
2093
|
+
id: uid('guard-divergence-wf'),
|
|
2094
|
+
name: 'Guard divergence TCK fixture',
|
|
2095
|
+
version: 1,
|
|
2096
|
+
steps: {
|
|
2097
|
+
never: { description: 'n', execution: 'agent', depends_on: [] },
|
|
2098
|
+
g: {
|
|
2099
|
+
description: 'g',
|
|
2100
|
+
execution: 'guard',
|
|
2101
|
+
abort_unless: [],
|
|
2102
|
+
depends_on: ['never'],
|
|
2103
|
+
trigger_rule: 'all_failed',
|
|
2104
|
+
},
|
|
2105
|
+
},
|
|
2106
|
+
};
|
|
2107
|
+
const { run } = await adapter.store.create({
|
|
2108
|
+
workflowId: def.id,
|
|
2109
|
+
workflowVersion: def.version,
|
|
2110
|
+
params: {},
|
|
2111
|
+
});
|
|
2112
|
+
const seeded = await adapter.store.update({ ...run, skipped_steps: ['never'] });
|
|
2113
|
+
// propagateSkips (run via any settle_step on 'never'-adjacent... simpler: hand-seed 'g'
|
|
2114
|
+
// as skipped via trigger_rule_unsatisfiable directly, matching what propagateSkips itself
|
|
2115
|
+
// would produce.
|
|
2116
|
+
const skipped = await adapter.store.update({
|
|
2117
|
+
...seeded,
|
|
2118
|
+
skipped_steps: ['never', 'g'],
|
|
2119
|
+
skip_details: {
|
|
2120
|
+
g: { kind: 'trigger_rule_unsatisfiable', rule: 'all_failed', blocking_deps: [] },
|
|
2121
|
+
},
|
|
2122
|
+
});
|
|
2123
|
+
const result = await settleStep(skipped.id, {
|
|
2124
|
+
kind: 'settle_guard',
|
|
2125
|
+
step: 'g',
|
|
2126
|
+
outcome: 'abort',
|
|
2127
|
+
evidence: makeEvidence('g'),
|
|
2128
|
+
abort: { conditions: [] },
|
|
2129
|
+
}, def);
|
|
2130
|
+
assertRefused(result, 'settled_outcome_divergence', 'abort settle_guard vs non-guard_abort skip');
|
|
2131
|
+
if (result.persisted !== 'skip-non-abort') {
|
|
2132
|
+
throw new Error(`expected persisted:'skip-non-abort', got: ${result.persisted}`);
|
|
2133
|
+
}
|
|
2134
|
+
},
|
|
2135
|
+
},
|
|
2136
|
+
{
|
|
2137
|
+
law: 'GUARD_OUTCOME_DIVERGENCE',
|
|
2138
|
+
name: `[${adapter.storeName}] a terminalizing guard's own retry (same outcome, already terminal) still converges as already_settled`,
|
|
2139
|
+
run: async () => {
|
|
2140
|
+
const def = withGuard(minimalDefinition([]), 'g', ['1 == 2']); // always fails ⇒ abort
|
|
2141
|
+
const { run } = await adapter.store.create({
|
|
2142
|
+
workflowId: def.id,
|
|
2143
|
+
workflowVersion: def.version,
|
|
2144
|
+
params: {},
|
|
2145
|
+
});
|
|
2146
|
+
const delta = {
|
|
2147
|
+
kind: 'settle_guard',
|
|
2148
|
+
step: 'g',
|
|
2149
|
+
outcome: 'abort',
|
|
2150
|
+
evidence: makeEvidence('g'),
|
|
2151
|
+
abort: { conditions: [{ condition: '1 == 2', resolved_value: false, passed: false }] },
|
|
2152
|
+
};
|
|
2153
|
+
const first = await settleStep(run.id, delta, def);
|
|
2154
|
+
assertApplied(first, 'first settle_guard abort');
|
|
2155
|
+
if (first.run.terminal_state !== true) {
|
|
2156
|
+
throw new Error('fixture premise violated: abort must terminalize');
|
|
2157
|
+
}
|
|
2158
|
+
const second = await settleStep(run.id, delta, def);
|
|
2159
|
+
assertRefused(second, 'already_settled', 'terminalizing guard retry');
|
|
2160
|
+
},
|
|
2161
|
+
},
|
|
2162
|
+
// --- GUARD_WAITS_ON_OPEN_GATE ---
|
|
2163
|
+
{
|
|
2164
|
+
law: 'GUARD_WAITS_ON_OPEN_GATE',
|
|
2165
|
+
name: `[${adapter.storeName}] a non-pass settle_guard under an open gate refuses gate_open_wait, record UNCHANGED`,
|
|
2166
|
+
run: async () => {
|
|
2167
|
+
let def = minimalDefinition(['gated']);
|
|
2168
|
+
def = withGuard(def, 'g', ['1 == 2']);
|
|
2169
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2170
|
+
const gate = makePendingGate('gated');
|
|
2171
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2172
|
+
assertApplied(opened, 'open_gate');
|
|
2173
|
+
const result = await settleStep(run.id, {
|
|
2174
|
+
kind: 'settle_guard',
|
|
2175
|
+
step: 'g',
|
|
2176
|
+
outcome: 'abort',
|
|
2177
|
+
evidence: makeEvidence('g'),
|
|
2178
|
+
abort: { conditions: [{ condition: '1 == 2', resolved_value: false, passed: false }] },
|
|
2179
|
+
}, def);
|
|
2180
|
+
assertRefused(result, 'gate_open_wait', 'non-pass settle_guard under an open gate');
|
|
2181
|
+
if (result.run.version !== opened.run.version) {
|
|
2182
|
+
throw new Error('gate_open_wait must leave the record UNCHANGED (quiet end-of-pass)');
|
|
2183
|
+
}
|
|
2184
|
+
},
|
|
2185
|
+
},
|
|
2186
|
+
{
|
|
2187
|
+
law: 'GUARD_WAITS_ON_OPEN_GATE',
|
|
2188
|
+
name: `[${adapter.storeName}] re-applying the SAME guard after the gate resolves now APPLIES`,
|
|
2189
|
+
run: async () => {
|
|
2190
|
+
let def = minimalDefinition(['gated']);
|
|
2191
|
+
def = withGuard(def, 'g', ['1 == 2']);
|
|
2192
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2193
|
+
const gate = makePendingGate('gated');
|
|
2194
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2195
|
+
assertApplied(opened, 'open_gate');
|
|
2196
|
+
const guardDelta = {
|
|
2197
|
+
kind: 'settle_guard',
|
|
2198
|
+
step: 'g',
|
|
2199
|
+
outcome: 'abort',
|
|
2200
|
+
evidence: makeEvidence('g'),
|
|
2201
|
+
abort: { conditions: [{ condition: '1 == 2', resolved_value: false, passed: false }] },
|
|
2202
|
+
};
|
|
2203
|
+
const waited = await settleStep(run.id, guardDelta, def);
|
|
2204
|
+
assertRefused(waited, 'gate_open_wait', 'first attempt, gate open');
|
|
2205
|
+
const resolved = await settleStep(run.id, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'approve', evidence: [] }, def);
|
|
2206
|
+
assertApplied(resolved, 'settle_gate resolve');
|
|
2207
|
+
const retried = await settleStep(run.id, guardDelta, def);
|
|
2208
|
+
assertApplied(retried, 'settle_guard retry, gate resolved');
|
|
2209
|
+
},
|
|
2210
|
+
},
|
|
2211
|
+
{
|
|
2212
|
+
law: 'GUARD_WAITS_ON_OPEN_GATE',
|
|
2213
|
+
name: `[${adapter.storeName}] a PASS settle_guard under an open gate APPLIES (non-terminal — the gate wins on any terminalizing attempt, but pass alone never terminalizes under it, G-1)`,
|
|
2214
|
+
run: async () => {
|
|
2215
|
+
let def = minimalDefinition(['gated']);
|
|
2216
|
+
def = withGuard(def, 'g', []); // trivially passes (zero conditions)
|
|
2217
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2218
|
+
const gate = makePendingGate('gated');
|
|
2219
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2220
|
+
assertApplied(opened, 'open_gate');
|
|
2221
|
+
const result = await settleStep(run.id, { kind: 'settle_guard', step: 'g', outcome: 'pass', evidence: makeEvidence('g') }, def);
|
|
2222
|
+
assertApplied(result, 'pass settle_guard under an open gate');
|
|
2223
|
+
if (result.transitioned !== false) {
|
|
2224
|
+
throw new Error('a pass-under-gate settle_guard must never terminalize (G-1)');
|
|
2225
|
+
}
|
|
2226
|
+
},
|
|
2227
|
+
},
|
|
2228
|
+
// --- GUARD_PASS_COMPLETE_OUTCOME ---
|
|
2229
|
+
{
|
|
2230
|
+
law: 'GUARD_PASS_COMPLETE_OUTCOME',
|
|
2231
|
+
name: `[${adapter.storeName}] a guard pass that completes the run sets terminal_reason 'Workflow completed.' and phase 'completed'`,
|
|
2232
|
+
run: async () => {
|
|
2233
|
+
const def = withGuard(minimalDefinition([]), 'g', []);
|
|
2234
|
+
const { run } = await adapter.store.create({
|
|
2235
|
+
workflowId: def.id,
|
|
2236
|
+
workflowVersion: def.version,
|
|
2237
|
+
params: {},
|
|
2238
|
+
});
|
|
2239
|
+
const result = await settleStep(run.id, { kind: 'settle_guard', step: 'g', outcome: 'pass', evidence: makeEvidence('g') }, def);
|
|
2240
|
+
assertApplied(result, 'guard pass, sole step');
|
|
2241
|
+
if (result.run.terminal_state !== true ||
|
|
2242
|
+
result.run.terminal_reason !== 'Workflow completed.' ||
|
|
2243
|
+
result.run.run_phase !== 'completed') {
|
|
2244
|
+
throw new Error(`expected terminal 'completed' seal, got terminal_state:${result.run.terminal_state} ` +
|
|
2245
|
+
`terminal_reason:${result.run.terminal_reason} run_phase:${result.run.run_phase}`);
|
|
2246
|
+
}
|
|
2247
|
+
},
|
|
2248
|
+
},
|
|
2249
|
+
// --- GUARD_ABORT_CASCADE ---
|
|
2250
|
+
{
|
|
2251
|
+
law: 'GUARD_ABORT_CASCADE',
|
|
2252
|
+
name: `[${adapter.storeName}] a guard abort skips the guard (object-array aborted_at, terminal_reason ABSENT) AND cascades propagateSkips onto a dependent step`,
|
|
2253
|
+
run: async () => {
|
|
2254
|
+
let def = minimalDefinition([]);
|
|
2255
|
+
def = withGuard(def, 'g', ['1 == 2'], { dependents: ['downstream'] });
|
|
2256
|
+
const { run } = await adapter.store.create({
|
|
2257
|
+
workflowId: def.id,
|
|
2258
|
+
workflowVersion: def.version,
|
|
2259
|
+
params: {},
|
|
2260
|
+
});
|
|
2261
|
+
const conditions = [{ condition: '1 == 2', resolved_value: false, passed: false }];
|
|
2262
|
+
const result = await settleStep(run.id, {
|
|
2263
|
+
kind: 'settle_guard',
|
|
2264
|
+
step: 'g',
|
|
2265
|
+
outcome: 'abort',
|
|
2266
|
+
evidence: makeEvidence('g'),
|
|
2267
|
+
abort: { conditions, abort_message: 'tck guard abort' },
|
|
2268
|
+
}, def);
|
|
2269
|
+
assertApplied(result, 'guard abort');
|
|
2270
|
+
if (!result.run.skipped_steps.includes('g')) {
|
|
2271
|
+
throw new Error('the guard itself must land in skipped_steps');
|
|
2272
|
+
}
|
|
2273
|
+
if (result.run.skip_details?.['g']?.kind !== 'guard_abort') {
|
|
2274
|
+
throw new Error('skip_details for the guard must carry kind:guard_abort');
|
|
2275
|
+
}
|
|
2276
|
+
if (!result.run.skipped_steps.includes('downstream')) {
|
|
2277
|
+
throw new Error('propagateSkips must cascade onto the dependent step');
|
|
2278
|
+
}
|
|
2279
|
+
if (result.run.aborted_at === undefined ||
|
|
2280
|
+
result.run.aborted_at.step_id !== 'g' ||
|
|
2281
|
+
!Array.isArray(result.run.aborted_at.conditions) ||
|
|
2282
|
+
result.run.aborted_at.conditions[0]?.condition !== '1 == 2') {
|
|
2283
|
+
throw new Error(`expected aborted_at with the object-array conditions shape, got: ${JSON.stringify(result.run.aborted_at)}`);
|
|
2284
|
+
}
|
|
2285
|
+
if (result.run.terminal_reason !== undefined) {
|
|
2286
|
+
throw new Error(`terminal_reason must be ABSENT on a guard-abort seal (phase derives from aborted_at), got: '${result.run.terminal_reason}'`);
|
|
2287
|
+
}
|
|
2288
|
+
if (result.run.terminal_state !== true) {
|
|
2289
|
+
throw new Error('a guard abort must terminalize the run');
|
|
2290
|
+
}
|
|
2291
|
+
},
|
|
2292
|
+
},
|
|
2293
|
+
// --- GUARD_NO_ENTRY ---
|
|
2294
|
+
{
|
|
2295
|
+
law: 'GUARD_NO_ENTRY',
|
|
2296
|
+
name: `[${adapter.storeName}] settle_guard NEVER writes a settled-map entry, regardless of outcome (SE-4)`,
|
|
2297
|
+
run: async () => {
|
|
2298
|
+
const def = withGuard(minimalDefinition([]), 'g', []);
|
|
2299
|
+
const { run } = await adapter.store.create({
|
|
2300
|
+
workflowId: def.id,
|
|
2301
|
+
workflowVersion: def.version,
|
|
2302
|
+
params: {},
|
|
2303
|
+
});
|
|
2304
|
+
const result = await settleStep(run.id, { kind: 'settle_guard', step: 'g', outcome: 'pass', evidence: makeEvidence('g') }, def);
|
|
2305
|
+
assertApplied(result, 'guard pass');
|
|
2306
|
+
if (result.run.settled?.['g'] !== undefined) {
|
|
2307
|
+
throw new Error('settle_guard must never write a settled-map entry (SE-4)');
|
|
2308
|
+
}
|
|
2309
|
+
},
|
|
2310
|
+
},
|
|
2311
|
+
];
|
|
2312
|
+
}
|
|
2313
|
+
// ---------------------------------------------------------------------------
|
|
2314
|
+
// RELEASE_IDEMPOTENT (issue #279, increment 2, PR-C)
|
|
2315
|
+
// ---------------------------------------------------------------------------
|
|
2316
|
+
function releaseIdempotentCases(adapter) {
|
|
2317
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
2318
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2319
|
+
return [
|
|
2320
|
+
{
|
|
2321
|
+
law: 'RELEASE_IDEMPOTENT',
|
|
2322
|
+
name: `[${adapter.storeName}] release_step on a claim-absent step NOOPs as already_released (TD F10)`,
|
|
2323
|
+
run: async () => {
|
|
2324
|
+
const def = minimalDefinition(['a']);
|
|
2325
|
+
const { run } = await adapter.store.create({
|
|
2326
|
+
workflowId: def.id,
|
|
2327
|
+
workflowVersion: def.version,
|
|
2328
|
+
params: {},
|
|
2329
|
+
});
|
|
2330
|
+
const result = await settleStep(run.id, { kind: 'release_step', step: 'a', claimToken: 'anything' }, def);
|
|
2331
|
+
assertRefused(result, 'already_released', 'release_step with no claim outstanding');
|
|
2332
|
+
},
|
|
2333
|
+
},
|
|
2334
|
+
{
|
|
2335
|
+
law: 'RELEASE_IDEMPOTENT',
|
|
2336
|
+
name: `[${adapter.storeName}] release_step with the WRONG token refuses claim_lost — never stomps a successor`,
|
|
2337
|
+
run: async () => {
|
|
2338
|
+
const def = minimalDefinition(['a']);
|
|
2339
|
+
const { run } = await createClaimed(adapter.store, def, 'a');
|
|
2340
|
+
const result = await settleStep(run.id, { kind: 'release_step', step: 'a', claimToken: 'wrong-token' }, def);
|
|
2341
|
+
assertRefused(result, 'claim_lost', 'release_step with a wrong token');
|
|
2342
|
+
},
|
|
2343
|
+
},
|
|
2344
|
+
{
|
|
2345
|
+
law: 'RELEASE_IDEMPOTENT',
|
|
2346
|
+
name: `[${adapter.storeName}] release_step on the currently-open gate step refuses gate_mismatch (reclaim-step.ts:389 parity)`,
|
|
2347
|
+
run: async () => {
|
|
2348
|
+
const def = minimalDefinition(['gated']);
|
|
2349
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2350
|
+
const gate = makePendingGate('gated');
|
|
2351
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2352
|
+
assertApplied(opened, 'open_gate');
|
|
2353
|
+
const result = await settleStep(run.id, { kind: 'release_step', step: 'gated', claimToken: token }, def);
|
|
2354
|
+
assertRefused(result, 'gate_mismatch', 'release_step on the open-gate step');
|
|
2355
|
+
},
|
|
2356
|
+
},
|
|
2357
|
+
{
|
|
2358
|
+
law: 'RELEASE_IDEMPOTENT',
|
|
2359
|
+
name: `[${adapter.storeName}] a successful release_step frees the claim (never terminal, no settled entry) — the step is eligible again`,
|
|
2360
|
+
run: async () => {
|
|
2361
|
+
const def = minimalDefinition(['a']);
|
|
2362
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
2363
|
+
const result = await settleStep(run.id, { kind: 'release_step', step: 'a', claimToken: token }, def);
|
|
2364
|
+
assertApplied(result, 'release_step');
|
|
2365
|
+
if (result.transitioned !== false || result.run.terminal_state !== false) {
|
|
2366
|
+
throw new Error('release_step must never terminalize');
|
|
2367
|
+
}
|
|
2368
|
+
if (result.run.in_progress_steps.includes('a') || result.run.claims?.['a'] !== undefined) {
|
|
2369
|
+
throw new Error('release_step must clear both in_progress_steps and claims');
|
|
2370
|
+
}
|
|
2371
|
+
if (result.run.settled?.['a'] !== undefined) {
|
|
2372
|
+
throw new Error('release_step must never write a settled-map entry');
|
|
2373
|
+
}
|
|
2374
|
+
},
|
|
2375
|
+
},
|
|
2376
|
+
];
|
|
2377
|
+
}
|
|
2378
|
+
// ---------------------------------------------------------------------------
|
|
2379
|
+
// PHASE_IS_GENERATED (issue #279, increment 2, PR-C, lane-C steal 1 — a NEW universal law: after
|
|
2380
|
+
// EVERY store mutation op, persisted run_phase ≡ deriveRunPhase(record)).
|
|
2381
|
+
// ---------------------------------------------------------------------------
|
|
2382
|
+
function phaseIsGeneratedCases(adapter) {
|
|
2383
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
2384
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2385
|
+
function assertGenerated(record, context) {
|
|
2386
|
+
const expected = deriveRunPhase(record);
|
|
2387
|
+
if (record.run_phase !== expected) {
|
|
2388
|
+
throw new Error(`PHASE_IS_GENERATED violated at ${context}: persisted run_phase '${record.run_phase}' ` +
|
|
2389
|
+
`!== derived '${expected}'`);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
return [
|
|
2393
|
+
{
|
|
2394
|
+
law: 'PHASE_IS_GENERATED',
|
|
2395
|
+
name: `[${adapter.storeName}] persisted run_phase ≡ deriveRunPhase(record) after settleStep (all four kinds exercised) / update / claimStep`,
|
|
2396
|
+
run: async () => {
|
|
2397
|
+
const def = minimalDefinition(['a', 'b']);
|
|
2398
|
+
const { run } = await adapter.store.create({
|
|
2399
|
+
workflowId: def.id,
|
|
2400
|
+
workflowVersion: def.version,
|
|
2401
|
+
params: {},
|
|
2402
|
+
});
|
|
2403
|
+
assertGenerated(run, 'create()');
|
|
2404
|
+
const claimed = await adapter.store.claimStep(run.id, 'a', def);
|
|
2405
|
+
assertGenerated(claimed, 'claimStep()');
|
|
2406
|
+
const token = claimed.claims['a'].token;
|
|
2407
|
+
const settled = await settleStep(run.id, {
|
|
2408
|
+
kind: 'settle_step',
|
|
2409
|
+
step: 'a',
|
|
2410
|
+
outcome: 'complete',
|
|
2411
|
+
claimToken: token,
|
|
2412
|
+
evidence: [makeEvidence('a')],
|
|
2413
|
+
}, def);
|
|
2414
|
+
assertApplied(settled, 'settle_step complete');
|
|
2415
|
+
assertGenerated(settled.run, 'settleStep(settle_step)');
|
|
2416
|
+
const updated = await adapter.store.update({ ...settled.run });
|
|
2417
|
+
assertGenerated(updated, 'update()');
|
|
2418
|
+
},
|
|
2419
|
+
},
|
|
2420
|
+
{
|
|
2421
|
+
law: 'PHASE_IS_GENERATED',
|
|
2422
|
+
name: `[${adapter.storeName}] the claimStep leg's discriminating fixture: abandoned_at ∧ terminal_state:false ⇒ claimStep persists the DERIVED 'abandoned' phase, not a hardcoded 'running'`,
|
|
2423
|
+
run: async () => {
|
|
2424
|
+
const def = minimalDefinition(['a']);
|
|
2425
|
+
const { run } = await adapter.store.create({
|
|
2426
|
+
workflowId: def.id,
|
|
2427
|
+
workflowVersion: def.version,
|
|
2428
|
+
params: {},
|
|
2429
|
+
});
|
|
2430
|
+
// eligibility.ts's findEligibleSteps does NOT check abandoned_at (only terminal_state /
|
|
2431
|
+
// pending_gate) — so this hand-shaped record is still claimable, while deriveRunPhase
|
|
2432
|
+
// (which DOES check abandoned_at first) derives 'abandoned' for it. A store that still
|
|
2433
|
+
// hardcodes 'running' on claim (rather than deriving) would persist the wrong phase here.
|
|
2434
|
+
const seeded = await adapter.store.update({
|
|
2435
|
+
...run,
|
|
2436
|
+
abandoned_at: '2026-01-01T00:00:00.000Z',
|
|
2437
|
+
});
|
|
2438
|
+
const claimed = await adapter.store.claimStep(seeded.id, 'a', def);
|
|
2439
|
+
if (claimed.run_phase !== 'abandoned') {
|
|
2440
|
+
throw new Error(`PHASE_IS_GENERATED (claimStep leg) violated: expected persisted 'abandoned', got '${claimed.run_phase}'`);
|
|
2441
|
+
}
|
|
2442
|
+
},
|
|
2443
|
+
},
|
|
2444
|
+
];
|
|
2445
|
+
}
|
|
2446
|
+
// ---------------------------------------------------------------------------
|
|
2447
|
+
// Assembly
|
|
2448
|
+
// ---------------------------------------------------------------------------
|
|
2449
|
+
/**
|
|
2450
|
+
* Builds every PR-A-runnable settlement contract case for `adapter`.
|
|
2451
|
+
*
|
|
2452
|
+
* - `adapter.store.settleStep` undeclared ⇒ **zero cases** (vacuous pass — mirrors the
|
|
2453
|
+
* established optional-capability idiom, e.g. `runStoreFidelityContract`'s own
|
|
2454
|
+
* zero-cases-on-no-declaration precedent for a store that opts out entirely).
|
|
2455
|
+
* - `settleStep` declared but `adapter.settlementFixture` absent ⇒ **one `ADAPTER_WIRING`
|
|
2456
|
+
* failing case**, never a silent zero-cases pass — a store that opts INTO settlement
|
|
2457
|
+
* conformance must not be able to pass this TCK by accident of an unwired adapter.
|
|
2458
|
+
* - Both present ⇒ the full law set below.
|
|
2459
|
+
*/
|
|
2460
|
+
export function settlementContract(adapter) {
|
|
2461
|
+
if (adapter.store.settleStep === undefined) {
|
|
2462
|
+
return [];
|
|
2463
|
+
}
|
|
2464
|
+
if (adapter.settlementFixture === undefined) {
|
|
2465
|
+
return [
|
|
2466
|
+
{
|
|
2467
|
+
law: 'ADAPTER_WIRING',
|
|
2468
|
+
name: `[${adapter.storeName}] declares RunStore.settleStep but no settlementFixture was supplied to the adapter — wire in 'defaultSettlementFixture' (or a store-specific SettlementFixture) to run real conformance coverage`,
|
|
2469
|
+
run: async () => {
|
|
2470
|
+
throw new Error(`[${adapter.storeName}] settlementContract: adapter.store declares settleStep, but ` +
|
|
2471
|
+
`adapter.settlementFixture is undefined — this is a WIRING GAP in the calling test ` +
|
|
2472
|
+
`file, not a store defect. Pass 'defaultSettlementFixture' from this module (or a ` +
|
|
2473
|
+
'store-specific SettlementFixture) to exercise the real conformance cases.');
|
|
2474
|
+
},
|
|
2475
|
+
},
|
|
2476
|
+
];
|
|
2477
|
+
}
|
|
2478
|
+
return [
|
|
2479
|
+
...freshApplicationCases(adapter),
|
|
2480
|
+
...conditionalNoopCases(adapter),
|
|
2481
|
+
...ownershipRefusalCases(adapter),
|
|
2482
|
+
...ledgerMintAtomicityCases(adapter),
|
|
2483
|
+
...drainMarkDedupCases(adapter),
|
|
2484
|
+
...terminalRefusalCases(adapter),
|
|
2485
|
+
...terminalStateOnlyCases(adapter),
|
|
2486
|
+
...csPurityCases(adapter),
|
|
2487
|
+
...neverDowngradeCases(adapter),
|
|
2488
|
+
...settleOutcomeIntegrityCases(adapter),
|
|
2489
|
+
...settledOrphanOverwriteCases(adapter),
|
|
2490
|
+
...transformFidelityCases(adapter),
|
|
2491
|
+
...resultAsAppliedCases(),
|
|
2492
|
+
...markMembershipCases(adapter),
|
|
2493
|
+
...refusalSweepCases(adapter),
|
|
2494
|
+
...mintFreshCases(adapter),
|
|
2495
|
+
...selfImageIdempotenceCases(adapter),
|
|
2496
|
+
...completeSealPhaseCases(adapter),
|
|
2497
|
+
...whenRoutedTerminalizationCases(adapter),
|
|
2498
|
+
...g1GateCoexistenceCases(adapter),
|
|
2499
|
+
// issue #279, increment 2 (PR-C).
|
|
2500
|
+
...gateOpenIdempotentCases(adapter),
|
|
2501
|
+
...gateResolutionConflictCases(adapter),
|
|
2502
|
+
...gateMismatchCases(adapter),
|
|
2503
|
+
...guardCases(adapter),
|
|
2504
|
+
...releaseIdempotentCases(adapter),
|
|
2505
|
+
...phaseIsGeneratedCases(adapter),
|
|
2506
|
+
];
|
|
2507
|
+
}
|
|
2508
|
+
//# sourceMappingURL=settlement-contract.js.map
|