@sensigo/realm-testing 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/store/in-memory-store.d.ts.map +1 -1
- package/dist/store/in-memory-store.js +10 -3
- package/dist/store/in-memory-store.js.map +1 -1
- package/dist/store/settlement-contract.d.ts +59 -5
- package/dist/store/settlement-contract.d.ts.map +1 -1
- package/dist/store/settlement-contract.js +1604 -10
- package/dist/store/settlement-contract.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// settlement-contract.ts — framework-agnostic Test Compatibility Kit (TCK) for RunStore.settleStep
|
|
2
|
-
// (issue #279
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
6
8
|
//
|
|
7
9
|
// Pure case descriptors, NOT describe/it/expect — mirrors run-store-fidelity-contract.ts's and
|
|
8
10
|
// fenced-trace-buffer-contract.ts's own precedent (importing vitest here would make it a runtime
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
// @sensigo/realm-testing already depends on @sensigo/realm — so BOTH stores' settlement
|
|
17
19
|
// conformance can live in NEW test files right here in packages/testing/src/store/, with no
|
|
18
20
|
// circular-package hazard. See this module's own calling test files for the actual wiring.
|
|
19
|
-
import { applySettlement, } from '@sensigo/realm';
|
|
21
|
+
import { applySettlement, deriveRunPhase, } from '@sensigo/realm';
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Fixture builders
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
@@ -49,11 +51,48 @@ function withFinalizer(def, finalizerName, onOutcome) {
|
|
|
49
51
|
},
|
|
50
52
|
};
|
|
51
53
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
...(opts?.expiresAt !== undefined ? { expires_at: opts.expiresAt } : {}),
|
|
92
|
+
...(opts?.onExpiry !== undefined ? { on_expiry: opts.onExpiry } : {}),
|
|
93
|
+
...(opts?.defaultChoice !== undefined ? { default_choice: opts.defaultChoice } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
57
96
|
function makeEvidence(stepId, overrides = {}) {
|
|
58
97
|
return {
|
|
59
98
|
step_id: stepId,
|
|
@@ -1635,6 +1674,1544 @@ function g1GateCoexistenceCases(_adapter) {
|
|
|
1635
1674
|
];
|
|
1636
1675
|
}
|
|
1637
1676
|
// ---------------------------------------------------------------------------
|
|
1677
|
+
// GATE_OPEN_IDEMPOTENT (issue #279, increment 2, PR-C — design record §8)
|
|
1678
|
+
// ---------------------------------------------------------------------------
|
|
1679
|
+
function gateOpenIdempotentCases(adapter) {
|
|
1680
|
+
const fixture = adapter.settlementFixture;
|
|
1681
|
+
const { minimalDefinition } = fixture;
|
|
1682
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
1683
|
+
return [
|
|
1684
|
+
{
|
|
1685
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1686
|
+
name: `[${adapter.storeName}] an exact-delta open_gate replay NOOPs as already_settled — version unchanged`,
|
|
1687
|
+
run: async () => {
|
|
1688
|
+
const def = minimalDefinition(['gated']);
|
|
1689
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
1690
|
+
const gate = makePendingGate('gated');
|
|
1691
|
+
const delta = {
|
|
1692
|
+
kind: 'open_gate',
|
|
1693
|
+
step: 'gated',
|
|
1694
|
+
claimToken: token,
|
|
1695
|
+
pendingGate: gate,
|
|
1696
|
+
evidence: [],
|
|
1697
|
+
};
|
|
1698
|
+
const first = await settleStep(run.id, delta, def);
|
|
1699
|
+
assertApplied(first, 'first open_gate');
|
|
1700
|
+
const second = await settleStep(run.id, delta, def);
|
|
1701
|
+
assertRefused(second, 'already_settled', 'exact-delta open_gate replay');
|
|
1702
|
+
if (second.run.version !== first.run.version) {
|
|
1703
|
+
throw new Error(`expected version unchanged on a NOOP (${first.run.version}), got ${second.run.version}`);
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
},
|
|
1707
|
+
{
|
|
1708
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1709
|
+
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`,
|
|
1710
|
+
run: async () => {
|
|
1711
|
+
const def = minimalDefinition(['a']);
|
|
1712
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
1713
|
+
const completed = await settleStep(run.id, {
|
|
1714
|
+
kind: 'settle_step',
|
|
1715
|
+
step: 'a',
|
|
1716
|
+
outcome: 'complete',
|
|
1717
|
+
claimToken: token,
|
|
1718
|
+
evidence: [makeEvidence('a')],
|
|
1719
|
+
}, def);
|
|
1720
|
+
assertApplied(completed, 'settle_step complete');
|
|
1721
|
+
const result = await settleStep(run.id, {
|
|
1722
|
+
kind: 'open_gate',
|
|
1723
|
+
step: 'a',
|
|
1724
|
+
claimToken: token,
|
|
1725
|
+
pendingGate: makePendingGate('a'),
|
|
1726
|
+
evidence: [],
|
|
1727
|
+
}, def);
|
|
1728
|
+
assertRefused(result, 'already_settled_by_other', 'open_gate on a step already settled via a different route');
|
|
1729
|
+
},
|
|
1730
|
+
},
|
|
1731
|
+
{
|
|
1732
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1733
|
+
name: `[${adapter.storeName}] a re-submitted open_gate AFTER the gate already resolved (BU F6) NOOPs as already_settled`,
|
|
1734
|
+
run: async () => {
|
|
1735
|
+
const def = minimalDefinition(['gated']);
|
|
1736
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
1737
|
+
const gate = makePendingGate('gated');
|
|
1738
|
+
const openDelta = {
|
|
1739
|
+
kind: 'open_gate',
|
|
1740
|
+
step: 'gated',
|
|
1741
|
+
claimToken: token,
|
|
1742
|
+
pendingGate: gate,
|
|
1743
|
+
evidence: [],
|
|
1744
|
+
};
|
|
1745
|
+
const opened = await settleStep(run.id, openDelta, def);
|
|
1746
|
+
assertApplied(opened, 'open_gate');
|
|
1747
|
+
const resolved = await settleStep(run.id, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'approve', evidence: [] }, def);
|
|
1748
|
+
assertApplied(resolved, 'settle_gate resolve');
|
|
1749
|
+
// A delayed/retried open_gate for the SAME gate_id, arriving after resolution.
|
|
1750
|
+
const replay = await settleStep(run.id, openDelta, def);
|
|
1751
|
+
assertRefused(replay, 'already_settled', 'open_gate replay after resolution');
|
|
1752
|
+
},
|
|
1753
|
+
},
|
|
1754
|
+
{
|
|
1755
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1756
|
+
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)`,
|
|
1757
|
+
run: async () => {
|
|
1758
|
+
const def = minimalDefinition(['gated']);
|
|
1759
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
1760
|
+
const liveGate = makePendingGate('gated', { gateId: 'live-gate' });
|
|
1761
|
+
const opened = await settleStep(run.id, {
|
|
1762
|
+
kind: 'open_gate',
|
|
1763
|
+
step: 'gated',
|
|
1764
|
+
claimToken: token,
|
|
1765
|
+
pendingGate: liveGate,
|
|
1766
|
+
evidence: [],
|
|
1767
|
+
}, def);
|
|
1768
|
+
assertApplied(opened, 'open_gate (live)');
|
|
1769
|
+
const other = makePendingGate('gated', { gateId: 'other-gate' });
|
|
1770
|
+
const result = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: other, evidence: [] }, def);
|
|
1771
|
+
assertRefused(result, 'already_open', 'same-claimant re-open with a different gate_id');
|
|
1772
|
+
if (result.gate?.gate_id !== 'live-gate') {
|
|
1773
|
+
throw new Error(`already_open must return the LIVE gate verbatim, got: ${JSON.stringify(result.gate)}`);
|
|
1774
|
+
}
|
|
1775
|
+
},
|
|
1776
|
+
},
|
|
1777
|
+
{
|
|
1778
|
+
law: 'GATE_OPEN_IDEMPOTENT',
|
|
1779
|
+
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)`,
|
|
1780
|
+
run: async () => {
|
|
1781
|
+
const def = minimalDefinition(['gated', 'successor']);
|
|
1782
|
+
const { run, token: gatedToken } = await createClaimed(adapter.store, def, 'gated');
|
|
1783
|
+
const gate = makePendingGate('gated');
|
|
1784
|
+
const opened = await settleStep(run.id, {
|
|
1785
|
+
kind: 'open_gate',
|
|
1786
|
+
step: 'gated',
|
|
1787
|
+
claimToken: gatedToken,
|
|
1788
|
+
pendingGate: gate,
|
|
1789
|
+
evidence: [],
|
|
1790
|
+
}, def);
|
|
1791
|
+
assertApplied(opened, 'open_gate on gated');
|
|
1792
|
+
// 'successor' can never be claimed while a gate is open (findEligibleSteps returns []),
|
|
1793
|
+
// so this hand-constructs the claim to isolate the ARM being tested (open_gate's OWN
|
|
1794
|
+
// serialization refusal), matching the fixture-mechanics precedent set by
|
|
1795
|
+
// g1GateCoexistenceCases above.
|
|
1796
|
+
const seeded = await adapter.store.update({
|
|
1797
|
+
...opened.run,
|
|
1798
|
+
in_progress_steps: [...opened.run.in_progress_steps, 'successor'],
|
|
1799
|
+
claims: { ...opened.run.claims, successor: { deadline: null, token: 'successor-token' } },
|
|
1800
|
+
});
|
|
1801
|
+
const result = await settleStep(seeded.id, {
|
|
1802
|
+
kind: 'open_gate',
|
|
1803
|
+
step: 'successor',
|
|
1804
|
+
claimToken: 'successor-token',
|
|
1805
|
+
pendingGate: makePendingGate('successor'),
|
|
1806
|
+
evidence: [],
|
|
1807
|
+
}, def);
|
|
1808
|
+
assertRefused(result, 'gate_mismatch', 'open_gate on another step while a gate is open');
|
|
1809
|
+
if (!result.run.in_progress_steps.includes('successor')) {
|
|
1810
|
+
throw new Error('gate_mismatch must leave the target step STILL claimed (L13)');
|
|
1811
|
+
}
|
|
1812
|
+
},
|
|
1813
|
+
},
|
|
1814
|
+
];
|
|
1815
|
+
}
|
|
1816
|
+
// ---------------------------------------------------------------------------
|
|
1817
|
+
// GATE_RESOLUTION_CONFLICT (issue #279, increment 2, PR-C)
|
|
1818
|
+
// ---------------------------------------------------------------------------
|
|
1819
|
+
function gateResolutionConflictCases(adapter) {
|
|
1820
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
1821
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
1822
|
+
async function openGate(def, stepName) {
|
|
1823
|
+
const { run, token } = await createClaimed(adapter.store, def, stepName);
|
|
1824
|
+
const gate = makePendingGate(stepName);
|
|
1825
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: stepName, claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
1826
|
+
assertApplied(opened, `open_gate on ${stepName}`);
|
|
1827
|
+
return { runId: run.id, gate };
|
|
1828
|
+
}
|
|
1829
|
+
return [
|
|
1830
|
+
{
|
|
1831
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1832
|
+
name: `[${adapter.storeName}] a same-choice settle_gate retry (double-submit) NOOPs as already_settled`,
|
|
1833
|
+
run: async () => {
|
|
1834
|
+
const def = minimalDefinition(['gated']);
|
|
1835
|
+
const { runId, gate } = await openGate(def, 'gated');
|
|
1836
|
+
const delta = {
|
|
1837
|
+
kind: 'settle_gate',
|
|
1838
|
+
gateId: gate.gate_id,
|
|
1839
|
+
choice: 'approve',
|
|
1840
|
+
evidence: [],
|
|
1841
|
+
};
|
|
1842
|
+
const first = await settleStep(runId, delta, def);
|
|
1843
|
+
assertApplied(first, 'first settle_gate');
|
|
1844
|
+
const second = await settleStep(runId, delta, def);
|
|
1845
|
+
assertRefused(second, 'already_settled', 'same-choice settle_gate retry');
|
|
1846
|
+
},
|
|
1847
|
+
},
|
|
1848
|
+
{
|
|
1849
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1850
|
+
name: `[${adapter.storeName}] a choice NOT among the gate's own choices refuses choice_not_eligible, gate stays open`,
|
|
1851
|
+
run: async () => {
|
|
1852
|
+
const def = minimalDefinition(['gated']);
|
|
1853
|
+
const { runId, gate } = await openGate(def, 'gated');
|
|
1854
|
+
const result = await settleStep(runId, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'not-a-real-choice', evidence: [] }, def);
|
|
1855
|
+
assertRefused(result, 'choice_not_eligible', 'settle_gate with an ineligible choice');
|
|
1856
|
+
if (result.choices?.join(',') !== gate.choices.join(',')) {
|
|
1857
|
+
throw new Error(`expected choices:${JSON.stringify(gate.choices)}, got: ${JSON.stringify(result.choices)}`);
|
|
1858
|
+
}
|
|
1859
|
+
if (result.run.pending_gate?.gate_id !== gate.gate_id) {
|
|
1860
|
+
throw new Error('choice_not_eligible must leave the gate open, unchanged');
|
|
1861
|
+
}
|
|
1862
|
+
},
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1866
|
+
name: `[${adapter.storeName}] a DIFFERENT-choice settle_gate after resolution refuses gate_choice_conflict with the winning choice`,
|
|
1867
|
+
run: async () => {
|
|
1868
|
+
const def = minimalDefinition(['gated']);
|
|
1869
|
+
const { runId, gate } = await openGate(def, 'gated');
|
|
1870
|
+
const first = await settleStep(runId, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'approve', evidence: [] }, def);
|
|
1871
|
+
assertApplied(first, 'first settle_gate (approve)');
|
|
1872
|
+
const second = await settleStep(runId, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'reject', evidence: [] }, def);
|
|
1873
|
+
assertRefused(second, 'gate_choice_conflict', 'conflicting-choice settle_gate');
|
|
1874
|
+
if (second.winningChoice !== 'approve') {
|
|
1875
|
+
throw new Error(`expected winningChoice:'approve', got: ${second.winningChoice}`);
|
|
1876
|
+
}
|
|
1877
|
+
},
|
|
1878
|
+
},
|
|
1879
|
+
{
|
|
1880
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1881
|
+
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)`,
|
|
1882
|
+
run: async () => {
|
|
1883
|
+
const def = minimalDefinition(['gate1', 'gate2']);
|
|
1884
|
+
const { runId, gate: gate1 } = await openGate(def, 'gate1');
|
|
1885
|
+
const resolve1 = {
|
|
1886
|
+
kind: 'settle_gate',
|
|
1887
|
+
gateId: gate1.gate_id,
|
|
1888
|
+
choice: 'approve',
|
|
1889
|
+
evidence: [],
|
|
1890
|
+
};
|
|
1891
|
+
const first = await settleStep(runId, resolve1, def);
|
|
1892
|
+
assertApplied(first, 'resolve gate1');
|
|
1893
|
+
// A second, unrelated gate opens on a different step.
|
|
1894
|
+
const { token: gate2Token } = { token: 'gate2-token' };
|
|
1895
|
+
const seeded = await adapter.store.update({
|
|
1896
|
+
...first.run,
|
|
1897
|
+
in_progress_steps: [...first.run.in_progress_steps, 'gate2'],
|
|
1898
|
+
claims: { ...first.run.claims, gate2: { deadline: null, token: gate2Token } },
|
|
1899
|
+
});
|
|
1900
|
+
const gate2 = makePendingGate('gate2');
|
|
1901
|
+
const opened2 = await settleStep(seeded.id, {
|
|
1902
|
+
kind: 'open_gate',
|
|
1903
|
+
step: 'gate2',
|
|
1904
|
+
claimToken: gate2Token,
|
|
1905
|
+
pendingGate: gate2,
|
|
1906
|
+
evidence: [],
|
|
1907
|
+
}, def);
|
|
1908
|
+
assertApplied(opened2, 'open_gate on gate2');
|
|
1909
|
+
// The delayed retry of gate1's ORIGINAL resolution — found by gateId lookup regardless
|
|
1910
|
+
// of what is currently open.
|
|
1911
|
+
const delayedRetry = await settleStep(runId, resolve1, def);
|
|
1912
|
+
assertRefused(delayedRetry, 'already_settled', 'delayed retry of gate1 resolution');
|
|
1913
|
+
},
|
|
1914
|
+
},
|
|
1915
|
+
{
|
|
1916
|
+
law: 'GATE_RESOLUTION_CONFLICT',
|
|
1917
|
+
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)`,
|
|
1918
|
+
run: async () => {
|
|
1919
|
+
const def = minimalDefinition(['gated']);
|
|
1920
|
+
const { run } = await adapter.store.create({
|
|
1921
|
+
workflowId: def.id,
|
|
1922
|
+
workflowVersion: def.version,
|
|
1923
|
+
params: {},
|
|
1924
|
+
});
|
|
1925
|
+
const gateId = 'corrupt-gate';
|
|
1926
|
+
// Hand-shaped: an entry ALREADY records this gate as resolved with choice 'approve', but
|
|
1927
|
+
// pending_gate is STILL live with the SAME gate_id — never producible by settleStep
|
|
1928
|
+
// itself (APPLY always clears pending_gate in the SAME write it settles); only a
|
|
1929
|
+
// hand-authored fixture or an external store's divergent history can produce this.
|
|
1930
|
+
const corrupted = await adapter.store.update({
|
|
1931
|
+
...run,
|
|
1932
|
+
completed_steps: ['gated'],
|
|
1933
|
+
settled: { gated: { token: gateId, outcome: 'gate', choice: 'approve' } },
|
|
1934
|
+
pending_gate: {
|
|
1935
|
+
gate_id: gateId,
|
|
1936
|
+
step_name: 'gated',
|
|
1937
|
+
preview: {},
|
|
1938
|
+
choices: ['approve', 'reject'],
|
|
1939
|
+
opened_at: '2026-01-01T00:00:00.000Z',
|
|
1940
|
+
},
|
|
1941
|
+
});
|
|
1942
|
+
const result = await settleStep(corrupted.id, { kind: 'settle_gate', gateId, choice: 'approve', evidence: [] }, def);
|
|
1943
|
+
// The lookup-first ordering means this is ALWAYS a NOOP against the settled entry — the
|
|
1944
|
+
// live-gate RESOLVE branch is structurally unreachable once a matching settled entry
|
|
1945
|
+
// exists, regardless of corruption.
|
|
1946
|
+
assertRefused(result, 'already_settled', 'G2-corrupted both-match record');
|
|
1947
|
+
},
|
|
1948
|
+
},
|
|
1949
|
+
];
|
|
1950
|
+
}
|
|
1951
|
+
// ---------------------------------------------------------------------------
|
|
1952
|
+
// GATE_MISMATCH (issue #279, increment 2, PR-C)
|
|
1953
|
+
// ---------------------------------------------------------------------------
|
|
1954
|
+
function gateMismatchCases(adapter) {
|
|
1955
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
1956
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
1957
|
+
return [
|
|
1958
|
+
{
|
|
1959
|
+
law: 'GATE_MISMATCH',
|
|
1960
|
+
name: `[${adapter.storeName}] settle_gate with an UNKNOWN gateId on a live, gate-free run refuses gate_mismatch`,
|
|
1961
|
+
run: async () => {
|
|
1962
|
+
const def = minimalDefinition(['a']);
|
|
1963
|
+
const { run } = await adapter.store.create({
|
|
1964
|
+
workflowId: def.id,
|
|
1965
|
+
workflowVersion: def.version,
|
|
1966
|
+
params: {},
|
|
1967
|
+
});
|
|
1968
|
+
const result = await settleStep(run.id, { kind: 'settle_gate', gateId: 'nonexistent-gate', choice: 'approve', evidence: [] }, def);
|
|
1969
|
+
assertRefused(result, 'gate_mismatch', 'settle_gate with an unknown gateId');
|
|
1970
|
+
},
|
|
1971
|
+
},
|
|
1972
|
+
{
|
|
1973
|
+
law: 'GATE_MISMATCH',
|
|
1974
|
+
name: `[${adapter.storeName}] settle_gate with a SUPERSEDED gateId (a DIFFERENT gate is now open) refuses gate_mismatch`,
|
|
1975
|
+
run: async () => {
|
|
1976
|
+
const def = minimalDefinition(['a']);
|
|
1977
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
1978
|
+
const liveGate = makePendingGate('a', { gateId: 'live-gate' });
|
|
1979
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'a', claimToken: token, pendingGate: liveGate, evidence: [] }, def);
|
|
1980
|
+
assertApplied(opened, 'open_gate');
|
|
1981
|
+
const result = await settleStep(run.id, { kind: 'settle_gate', gateId: 'stale-superseded-gate', choice: 'approve', evidence: [] }, def);
|
|
1982
|
+
assertRefused(result, 'gate_mismatch', 'settle_gate with a superseded gateId');
|
|
1983
|
+
},
|
|
1984
|
+
},
|
|
1985
|
+
{
|
|
1986
|
+
law: 'GATE_MISMATCH',
|
|
1987
|
+
name: `[${adapter.storeName}] zombie submit: a hand-shaped terminal ∧ pending_gate record refuses run_terminal on a matching gateId submit, record/version UNCHANGED`,
|
|
1988
|
+
run: async () => {
|
|
1989
|
+
const def = minimalDefinition(['a']);
|
|
1990
|
+
const { run } = await adapter.store.create({
|
|
1991
|
+
workflowId: def.id,
|
|
1992
|
+
workflowVersion: def.version,
|
|
1993
|
+
params: {},
|
|
1994
|
+
});
|
|
1995
|
+
const gateId = 'zombie-gate';
|
|
1996
|
+
// Hand-shaped grandfathered/#282-class record: terminal AND still carrying a pending_gate
|
|
1997
|
+
// — the exact class D-3 exists to close on the RENDER side; this pins the TRANSFORM's own
|
|
1998
|
+
// zombie-submit refusal independent of that closure.
|
|
1999
|
+
const zombie = await adapter.store.update({
|
|
2000
|
+
...run,
|
|
2001
|
+
completed_steps: ['a'],
|
|
2002
|
+
terminal_state: true,
|
|
2003
|
+
terminal_reason: 'Workflow completed.',
|
|
2004
|
+
pending_gate: {
|
|
2005
|
+
gate_id: gateId,
|
|
2006
|
+
step_name: 'a',
|
|
2007
|
+
preview: {},
|
|
2008
|
+
choices: ['approve', 'reject'],
|
|
2009
|
+
opened_at: '2026-01-01T00:00:00.000Z',
|
|
2010
|
+
},
|
|
2011
|
+
});
|
|
2012
|
+
const result = await settleStep(zombie.id, { kind: 'settle_gate', gateId, choice: 'approve', evidence: [] }, def);
|
|
2013
|
+
assertRefused(result, 'run_terminal', 'zombie gate submit on a terminal record');
|
|
2014
|
+
if (result.run.version !== zombie.version) {
|
|
2015
|
+
throw new Error('a zombie-submit refusal must leave the record/version UNCHANGED');
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
},
|
|
2019
|
+
];
|
|
2020
|
+
}
|
|
2021
|
+
// ---------------------------------------------------------------------------
|
|
2022
|
+
// GUARD_OUTCOME_DIVERGENCE / GUARD_WAITS_ON_OPEN_GATE / GUARD_PASS_COMPLETE_OUTCOME /
|
|
2023
|
+
// GUARD_ABORT_CASCADE / GUARD_NO_ENTRY (issue #279, increment 2, PR-C)
|
|
2024
|
+
// ---------------------------------------------------------------------------
|
|
2025
|
+
function guardCases(adapter) {
|
|
2026
|
+
const fixture = adapter.settlementFixture;
|
|
2027
|
+
if (fixture.withGuard === undefined) {
|
|
2028
|
+
return [
|
|
2029
|
+
{
|
|
2030
|
+
law: 'ADAPTER_WIRING',
|
|
2031
|
+
name: `[${adapter.storeName}] declares RunStore.settleStep but the supplied settlementFixture has no withGuard — guard-law coverage is a WIRING GAP, not a vacuous pass`,
|
|
2032
|
+
run: async () => {
|
|
2033
|
+
throw new Error(`[${adapter.storeName}] settlementContract: adapter.settlementFixture.withGuard is ` +
|
|
2034
|
+
"undefined — pass 'defaultSettlementFixture' (or a store-specific fixture " +
|
|
2035
|
+
'implementing withGuard) to exercise guard conformance coverage.');
|
|
2036
|
+
},
|
|
2037
|
+
},
|
|
2038
|
+
];
|
|
2039
|
+
}
|
|
2040
|
+
const { minimalDefinition } = fixture;
|
|
2041
|
+
const withGuard = fixture.withGuard;
|
|
2042
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2043
|
+
return [
|
|
2044
|
+
// --- GUARD_OUTCOME_DIVERGENCE ---
|
|
2045
|
+
{
|
|
2046
|
+
law: 'GUARD_OUTCOME_DIVERGENCE',
|
|
2047
|
+
name: `[${adapter.storeName}] a same-outcome (pass) settle_guard retry NOOPs as already_settled`,
|
|
2048
|
+
run: async () => {
|
|
2049
|
+
const def = withGuard(minimalDefinition([]), 'g', []);
|
|
2050
|
+
const { run } = await adapter.store.create({
|
|
2051
|
+
workflowId: def.id,
|
|
2052
|
+
workflowVersion: def.version,
|
|
2053
|
+
params: {},
|
|
2054
|
+
});
|
|
2055
|
+
const delta = {
|
|
2056
|
+
kind: 'settle_guard',
|
|
2057
|
+
step: 'g',
|
|
2058
|
+
outcome: 'pass',
|
|
2059
|
+
evidence: makeEvidence('g'),
|
|
2060
|
+
};
|
|
2061
|
+
const first = await settleStep(run.id, delta, def);
|
|
2062
|
+
assertApplied(first, 'first settle_guard pass');
|
|
2063
|
+
const second = await settleStep(run.id, delta, def);
|
|
2064
|
+
assertRefused(second, 'already_settled', 'same-outcome settle_guard retry');
|
|
2065
|
+
},
|
|
2066
|
+
},
|
|
2067
|
+
{
|
|
2068
|
+
law: 'GUARD_OUTCOME_DIVERGENCE',
|
|
2069
|
+
name: `[${adapter.storeName}] a CROSS-outcome settle_guard retry (pass, then resolution_error) refuses settled_outcome_divergence`,
|
|
2070
|
+
run: async () => {
|
|
2071
|
+
const def = withGuard(minimalDefinition([]), 'g', []);
|
|
2072
|
+
const { run } = await adapter.store.create({
|
|
2073
|
+
workflowId: def.id,
|
|
2074
|
+
workflowVersion: def.version,
|
|
2075
|
+
params: {},
|
|
2076
|
+
});
|
|
2077
|
+
const first = await settleStep(run.id, { kind: 'settle_guard', step: 'g', outcome: 'pass', evidence: makeEvidence('g') }, def);
|
|
2078
|
+
assertApplied(first, 'first settle_guard pass');
|
|
2079
|
+
const second = await settleStep(run.id, {
|
|
2080
|
+
kind: 'settle_guard',
|
|
2081
|
+
step: 'g',
|
|
2082
|
+
outcome: 'resolution_error',
|
|
2083
|
+
evidence: makeEvidence('g'),
|
|
2084
|
+
resolutionError: { condition: 'x > 1', unresolvable_path: 'x' },
|
|
2085
|
+
}, def);
|
|
2086
|
+
assertRefused(second, 'settled_outcome_divergence', 'cross-outcome settle_guard retry');
|
|
2087
|
+
},
|
|
2088
|
+
},
|
|
2089
|
+
{
|
|
2090
|
+
law: 'GUARD_OUTCOME_DIVERGENCE',
|
|
2091
|
+
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`,
|
|
2092
|
+
run: async () => {
|
|
2093
|
+
// 'g' skipped via an unsatisfiable trigger_rule (its own dep 'never' fails), never via a
|
|
2094
|
+
// guard_abort — a settle_guard abort retry for 'g' must diverge, not converge.
|
|
2095
|
+
const def = {
|
|
2096
|
+
id: uid('guard-divergence-wf'),
|
|
2097
|
+
name: 'Guard divergence TCK fixture',
|
|
2098
|
+
version: 1,
|
|
2099
|
+
steps: {
|
|
2100
|
+
never: { description: 'n', execution: 'agent', depends_on: [] },
|
|
2101
|
+
g: {
|
|
2102
|
+
description: 'g',
|
|
2103
|
+
execution: 'guard',
|
|
2104
|
+
abort_unless: [],
|
|
2105
|
+
depends_on: ['never'],
|
|
2106
|
+
trigger_rule: 'all_failed',
|
|
2107
|
+
},
|
|
2108
|
+
},
|
|
2109
|
+
};
|
|
2110
|
+
const { run } = await adapter.store.create({
|
|
2111
|
+
workflowId: def.id,
|
|
2112
|
+
workflowVersion: def.version,
|
|
2113
|
+
params: {},
|
|
2114
|
+
});
|
|
2115
|
+
const seeded = await adapter.store.update({ ...run, skipped_steps: ['never'] });
|
|
2116
|
+
// propagateSkips (run via any settle_step on 'never'-adjacent... simpler: hand-seed 'g'
|
|
2117
|
+
// as skipped via trigger_rule_unsatisfiable directly, matching what propagateSkips itself
|
|
2118
|
+
// would produce.
|
|
2119
|
+
const skipped = await adapter.store.update({
|
|
2120
|
+
...seeded,
|
|
2121
|
+
skipped_steps: ['never', 'g'],
|
|
2122
|
+
skip_details: {
|
|
2123
|
+
g: { kind: 'trigger_rule_unsatisfiable', rule: 'all_failed', blocking_deps: [] },
|
|
2124
|
+
},
|
|
2125
|
+
});
|
|
2126
|
+
const result = await settleStep(skipped.id, {
|
|
2127
|
+
kind: 'settle_guard',
|
|
2128
|
+
step: 'g',
|
|
2129
|
+
outcome: 'abort',
|
|
2130
|
+
evidence: makeEvidence('g'),
|
|
2131
|
+
abort: { conditions: [] },
|
|
2132
|
+
}, def);
|
|
2133
|
+
assertRefused(result, 'settled_outcome_divergence', 'abort settle_guard vs non-guard_abort skip');
|
|
2134
|
+
if (result.persisted !== 'skip-non-abort') {
|
|
2135
|
+
throw new Error(`expected persisted:'skip-non-abort', got: ${result.persisted}`);
|
|
2136
|
+
}
|
|
2137
|
+
},
|
|
2138
|
+
},
|
|
2139
|
+
{
|
|
2140
|
+
law: 'GUARD_OUTCOME_DIVERGENCE',
|
|
2141
|
+
name: `[${adapter.storeName}] a terminalizing guard's own retry (same outcome, already terminal) still converges as already_settled`,
|
|
2142
|
+
run: async () => {
|
|
2143
|
+
const def = withGuard(minimalDefinition([]), 'g', ['1 == 2']); // always fails ⇒ abort
|
|
2144
|
+
const { run } = await adapter.store.create({
|
|
2145
|
+
workflowId: def.id,
|
|
2146
|
+
workflowVersion: def.version,
|
|
2147
|
+
params: {},
|
|
2148
|
+
});
|
|
2149
|
+
const delta = {
|
|
2150
|
+
kind: 'settle_guard',
|
|
2151
|
+
step: 'g',
|
|
2152
|
+
outcome: 'abort',
|
|
2153
|
+
evidence: makeEvidence('g'),
|
|
2154
|
+
abort: { conditions: [{ condition: '1 == 2', resolved_value: false, passed: false }] },
|
|
2155
|
+
};
|
|
2156
|
+
const first = await settleStep(run.id, delta, def);
|
|
2157
|
+
assertApplied(first, 'first settle_guard abort');
|
|
2158
|
+
if (first.run.terminal_state !== true) {
|
|
2159
|
+
throw new Error('fixture premise violated: abort must terminalize');
|
|
2160
|
+
}
|
|
2161
|
+
const second = await settleStep(run.id, delta, def);
|
|
2162
|
+
assertRefused(second, 'already_settled', 'terminalizing guard retry');
|
|
2163
|
+
},
|
|
2164
|
+
},
|
|
2165
|
+
// --- GUARD_WAITS_ON_OPEN_GATE ---
|
|
2166
|
+
{
|
|
2167
|
+
law: 'GUARD_WAITS_ON_OPEN_GATE',
|
|
2168
|
+
name: `[${adapter.storeName}] a non-pass settle_guard under an open gate refuses gate_open_wait, record UNCHANGED`,
|
|
2169
|
+
run: async () => {
|
|
2170
|
+
let def = minimalDefinition(['gated']);
|
|
2171
|
+
def = withGuard(def, 'g', ['1 == 2']);
|
|
2172
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2173
|
+
const gate = makePendingGate('gated');
|
|
2174
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2175
|
+
assertApplied(opened, 'open_gate');
|
|
2176
|
+
const result = await settleStep(run.id, {
|
|
2177
|
+
kind: 'settle_guard',
|
|
2178
|
+
step: 'g',
|
|
2179
|
+
outcome: 'abort',
|
|
2180
|
+
evidence: makeEvidence('g'),
|
|
2181
|
+
abort: { conditions: [{ condition: '1 == 2', resolved_value: false, passed: false }] },
|
|
2182
|
+
}, def);
|
|
2183
|
+
assertRefused(result, 'gate_open_wait', 'non-pass settle_guard under an open gate');
|
|
2184
|
+
if (result.run.version !== opened.run.version) {
|
|
2185
|
+
throw new Error('gate_open_wait must leave the record UNCHANGED (quiet end-of-pass)');
|
|
2186
|
+
}
|
|
2187
|
+
},
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
law: 'GUARD_WAITS_ON_OPEN_GATE',
|
|
2191
|
+
name: `[${adapter.storeName}] re-applying the SAME guard after the gate resolves now APPLIES`,
|
|
2192
|
+
run: async () => {
|
|
2193
|
+
let def = minimalDefinition(['gated']);
|
|
2194
|
+
def = withGuard(def, 'g', ['1 == 2']);
|
|
2195
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2196
|
+
const gate = makePendingGate('gated');
|
|
2197
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2198
|
+
assertApplied(opened, 'open_gate');
|
|
2199
|
+
const guardDelta = {
|
|
2200
|
+
kind: 'settle_guard',
|
|
2201
|
+
step: 'g',
|
|
2202
|
+
outcome: 'abort',
|
|
2203
|
+
evidence: makeEvidence('g'),
|
|
2204
|
+
abort: { conditions: [{ condition: '1 == 2', resolved_value: false, passed: false }] },
|
|
2205
|
+
};
|
|
2206
|
+
const waited = await settleStep(run.id, guardDelta, def);
|
|
2207
|
+
assertRefused(waited, 'gate_open_wait', 'first attempt, gate open');
|
|
2208
|
+
const resolved = await settleStep(run.id, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'approve', evidence: [] }, def);
|
|
2209
|
+
assertApplied(resolved, 'settle_gate resolve');
|
|
2210
|
+
const retried = await settleStep(run.id, guardDelta, def);
|
|
2211
|
+
assertApplied(retried, 'settle_guard retry, gate resolved');
|
|
2212
|
+
},
|
|
2213
|
+
},
|
|
2214
|
+
{
|
|
2215
|
+
law: 'GUARD_WAITS_ON_OPEN_GATE',
|
|
2216
|
+
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)`,
|
|
2217
|
+
run: async () => {
|
|
2218
|
+
let def = minimalDefinition(['gated']);
|
|
2219
|
+
def = withGuard(def, 'g', []); // trivially passes (zero conditions)
|
|
2220
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2221
|
+
const gate = makePendingGate('gated');
|
|
2222
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2223
|
+
assertApplied(opened, 'open_gate');
|
|
2224
|
+
const result = await settleStep(run.id, { kind: 'settle_guard', step: 'g', outcome: 'pass', evidence: makeEvidence('g') }, def);
|
|
2225
|
+
assertApplied(result, 'pass settle_guard under an open gate');
|
|
2226
|
+
if (result.transitioned !== false) {
|
|
2227
|
+
throw new Error('a pass-under-gate settle_guard must never terminalize (G-1)');
|
|
2228
|
+
}
|
|
2229
|
+
},
|
|
2230
|
+
},
|
|
2231
|
+
// --- GUARD_PASS_COMPLETE_OUTCOME ---
|
|
2232
|
+
{
|
|
2233
|
+
law: 'GUARD_PASS_COMPLETE_OUTCOME',
|
|
2234
|
+
name: `[${adapter.storeName}] a guard pass that completes the run sets terminal_reason 'Workflow completed.' and phase 'completed'`,
|
|
2235
|
+
run: async () => {
|
|
2236
|
+
const def = withGuard(minimalDefinition([]), 'g', []);
|
|
2237
|
+
const { run } = await adapter.store.create({
|
|
2238
|
+
workflowId: def.id,
|
|
2239
|
+
workflowVersion: def.version,
|
|
2240
|
+
params: {},
|
|
2241
|
+
});
|
|
2242
|
+
const result = await settleStep(run.id, { kind: 'settle_guard', step: 'g', outcome: 'pass', evidence: makeEvidence('g') }, def);
|
|
2243
|
+
assertApplied(result, 'guard pass, sole step');
|
|
2244
|
+
if (result.run.terminal_state !== true ||
|
|
2245
|
+
result.run.terminal_reason !== 'Workflow completed.' ||
|
|
2246
|
+
result.run.run_phase !== 'completed') {
|
|
2247
|
+
throw new Error(`expected terminal 'completed' seal, got terminal_state:${result.run.terminal_state} ` +
|
|
2248
|
+
`terminal_reason:${result.run.terminal_reason} run_phase:${result.run.run_phase}`);
|
|
2249
|
+
}
|
|
2250
|
+
},
|
|
2251
|
+
},
|
|
2252
|
+
// --- GUARD_ABORT_CASCADE ---
|
|
2253
|
+
{
|
|
2254
|
+
law: 'GUARD_ABORT_CASCADE',
|
|
2255
|
+
name: `[${adapter.storeName}] a guard abort skips the guard (object-array aborted_at, terminal_reason ABSENT) AND cascades propagateSkips onto a dependent step`,
|
|
2256
|
+
run: async () => {
|
|
2257
|
+
let def = minimalDefinition([]);
|
|
2258
|
+
def = withGuard(def, 'g', ['1 == 2'], { dependents: ['downstream'] });
|
|
2259
|
+
const { run } = await adapter.store.create({
|
|
2260
|
+
workflowId: def.id,
|
|
2261
|
+
workflowVersion: def.version,
|
|
2262
|
+
params: {},
|
|
2263
|
+
});
|
|
2264
|
+
const conditions = [{ condition: '1 == 2', resolved_value: false, passed: false }];
|
|
2265
|
+
const result = await settleStep(run.id, {
|
|
2266
|
+
kind: 'settle_guard',
|
|
2267
|
+
step: 'g',
|
|
2268
|
+
outcome: 'abort',
|
|
2269
|
+
evidence: makeEvidence('g'),
|
|
2270
|
+
abort: { conditions, abort_message: 'tck guard abort' },
|
|
2271
|
+
}, def);
|
|
2272
|
+
assertApplied(result, 'guard abort');
|
|
2273
|
+
if (!result.run.skipped_steps.includes('g')) {
|
|
2274
|
+
throw new Error('the guard itself must land in skipped_steps');
|
|
2275
|
+
}
|
|
2276
|
+
if (result.run.skip_details?.['g']?.kind !== 'guard_abort') {
|
|
2277
|
+
throw new Error('skip_details for the guard must carry kind:guard_abort');
|
|
2278
|
+
}
|
|
2279
|
+
if (!result.run.skipped_steps.includes('downstream')) {
|
|
2280
|
+
throw new Error('propagateSkips must cascade onto the dependent step');
|
|
2281
|
+
}
|
|
2282
|
+
if (result.run.aborted_at === undefined ||
|
|
2283
|
+
result.run.aborted_at.step_id !== 'g' ||
|
|
2284
|
+
!Array.isArray(result.run.aborted_at.conditions) ||
|
|
2285
|
+
result.run.aborted_at.conditions[0]?.condition !== '1 == 2') {
|
|
2286
|
+
throw new Error(`expected aborted_at with the object-array conditions shape, got: ${JSON.stringify(result.run.aborted_at)}`);
|
|
2287
|
+
}
|
|
2288
|
+
if (result.run.terminal_reason !== undefined) {
|
|
2289
|
+
throw new Error(`terminal_reason must be ABSENT on a guard-abort seal (phase derives from aborted_at), got: '${result.run.terminal_reason}'`);
|
|
2290
|
+
}
|
|
2291
|
+
if (result.run.terminal_state !== true) {
|
|
2292
|
+
throw new Error('a guard abort must terminalize the run');
|
|
2293
|
+
}
|
|
2294
|
+
},
|
|
2295
|
+
},
|
|
2296
|
+
// --- GUARD_NO_ENTRY ---
|
|
2297
|
+
{
|
|
2298
|
+
law: 'GUARD_NO_ENTRY',
|
|
2299
|
+
name: `[${adapter.storeName}] settle_guard NEVER writes a settled-map entry, regardless of outcome (SE-4)`,
|
|
2300
|
+
run: async () => {
|
|
2301
|
+
const def = withGuard(minimalDefinition([]), 'g', []);
|
|
2302
|
+
const { run } = await adapter.store.create({
|
|
2303
|
+
workflowId: def.id,
|
|
2304
|
+
workflowVersion: def.version,
|
|
2305
|
+
params: {},
|
|
2306
|
+
});
|
|
2307
|
+
const result = await settleStep(run.id, { kind: 'settle_guard', step: 'g', outcome: 'pass', evidence: makeEvidence('g') }, def);
|
|
2308
|
+
assertApplied(result, 'guard pass');
|
|
2309
|
+
if (result.run.settled?.['g'] !== undefined) {
|
|
2310
|
+
throw new Error('settle_guard must never write a settled-map entry (SE-4)');
|
|
2311
|
+
}
|
|
2312
|
+
},
|
|
2313
|
+
},
|
|
2314
|
+
];
|
|
2315
|
+
}
|
|
2316
|
+
// ---------------------------------------------------------------------------
|
|
2317
|
+
// RELEASE_IDEMPOTENT (issue #279, increment 2, PR-C)
|
|
2318
|
+
// ---------------------------------------------------------------------------
|
|
2319
|
+
function releaseIdempotentCases(adapter) {
|
|
2320
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
2321
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2322
|
+
return [
|
|
2323
|
+
{
|
|
2324
|
+
law: 'RELEASE_IDEMPOTENT',
|
|
2325
|
+
name: `[${adapter.storeName}] release_step on a claim-absent step NOOPs as already_released (TD F10)`,
|
|
2326
|
+
run: async () => {
|
|
2327
|
+
const def = minimalDefinition(['a']);
|
|
2328
|
+
const { run } = await adapter.store.create({
|
|
2329
|
+
workflowId: def.id,
|
|
2330
|
+
workflowVersion: def.version,
|
|
2331
|
+
params: {},
|
|
2332
|
+
});
|
|
2333
|
+
const result = await settleStep(run.id, { kind: 'release_step', step: 'a', claimToken: 'anything' }, def);
|
|
2334
|
+
assertRefused(result, 'already_released', 'release_step with no claim outstanding');
|
|
2335
|
+
},
|
|
2336
|
+
},
|
|
2337
|
+
{
|
|
2338
|
+
law: 'RELEASE_IDEMPOTENT',
|
|
2339
|
+
name: `[${adapter.storeName}] release_step with the WRONG token refuses claim_lost — never stomps a successor`,
|
|
2340
|
+
run: async () => {
|
|
2341
|
+
const def = minimalDefinition(['a']);
|
|
2342
|
+
const { run } = await createClaimed(adapter.store, def, 'a');
|
|
2343
|
+
const result = await settleStep(run.id, { kind: 'release_step', step: 'a', claimToken: 'wrong-token' }, def);
|
|
2344
|
+
assertRefused(result, 'claim_lost', 'release_step with a wrong token');
|
|
2345
|
+
},
|
|
2346
|
+
},
|
|
2347
|
+
{
|
|
2348
|
+
law: 'RELEASE_IDEMPOTENT',
|
|
2349
|
+
name: `[${adapter.storeName}] release_step on the currently-open gate step refuses gate_mismatch (reclaim-step.ts:389 parity)`,
|
|
2350
|
+
run: async () => {
|
|
2351
|
+
const def = minimalDefinition(['gated']);
|
|
2352
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2353
|
+
const gate = makePendingGate('gated');
|
|
2354
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2355
|
+
assertApplied(opened, 'open_gate');
|
|
2356
|
+
const result = await settleStep(run.id, { kind: 'release_step', step: 'gated', claimToken: token }, def);
|
|
2357
|
+
assertRefused(result, 'gate_mismatch', 'release_step on the open-gate step');
|
|
2358
|
+
},
|
|
2359
|
+
},
|
|
2360
|
+
{
|
|
2361
|
+
law: 'RELEASE_IDEMPOTENT',
|
|
2362
|
+
name: `[${adapter.storeName}] a successful release_step frees the claim (never terminal, no settled entry) — the step is eligible again`,
|
|
2363
|
+
run: async () => {
|
|
2364
|
+
const def = minimalDefinition(['a']);
|
|
2365
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
2366
|
+
const result = await settleStep(run.id, { kind: 'release_step', step: 'a', claimToken: token }, def);
|
|
2367
|
+
assertApplied(result, 'release_step');
|
|
2368
|
+
if (result.transitioned !== false || result.run.terminal_state !== false) {
|
|
2369
|
+
throw new Error('release_step must never terminalize');
|
|
2370
|
+
}
|
|
2371
|
+
if (result.run.in_progress_steps.includes('a') || result.run.claims?.['a'] !== undefined) {
|
|
2372
|
+
throw new Error('release_step must clear both in_progress_steps and claims');
|
|
2373
|
+
}
|
|
2374
|
+
if (result.run.settled?.['a'] !== undefined) {
|
|
2375
|
+
throw new Error('release_step must never write a settled-map entry');
|
|
2376
|
+
}
|
|
2377
|
+
},
|
|
2378
|
+
},
|
|
2379
|
+
];
|
|
2380
|
+
}
|
|
2381
|
+
// ---------------------------------------------------------------------------
|
|
2382
|
+
// PHASE_IS_GENERATED (issue #279, increment 2, PR-C, lane-C steal 1 — a NEW universal law: after
|
|
2383
|
+
// EVERY store mutation op, persisted run_phase ≡ deriveRunPhase(record)).
|
|
2384
|
+
// ---------------------------------------------------------------------------
|
|
2385
|
+
function phaseIsGeneratedCases(adapter) {
|
|
2386
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
2387
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2388
|
+
function assertGenerated(record, context) {
|
|
2389
|
+
const expected = deriveRunPhase(record);
|
|
2390
|
+
if (record.run_phase !== expected) {
|
|
2391
|
+
throw new Error(`PHASE_IS_GENERATED violated at ${context}: persisted run_phase '${record.run_phase}' ` +
|
|
2392
|
+
`!== derived '${expected}'`);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
return [
|
|
2396
|
+
{
|
|
2397
|
+
law: 'PHASE_IS_GENERATED',
|
|
2398
|
+
name: `[${adapter.storeName}] persisted run_phase ≡ deriveRunPhase(record) after settleStep (all four kinds exercised) / update / claimStep`,
|
|
2399
|
+
run: async () => {
|
|
2400
|
+
const def = minimalDefinition(['a', 'b']);
|
|
2401
|
+
const { run } = await adapter.store.create({
|
|
2402
|
+
workflowId: def.id,
|
|
2403
|
+
workflowVersion: def.version,
|
|
2404
|
+
params: {},
|
|
2405
|
+
});
|
|
2406
|
+
assertGenerated(run, 'create()');
|
|
2407
|
+
const claimed = await adapter.store.claimStep(run.id, 'a', def);
|
|
2408
|
+
assertGenerated(claimed, 'claimStep()');
|
|
2409
|
+
const token = claimed.claims['a'].token;
|
|
2410
|
+
const settled = await settleStep(run.id, {
|
|
2411
|
+
kind: 'settle_step',
|
|
2412
|
+
step: 'a',
|
|
2413
|
+
outcome: 'complete',
|
|
2414
|
+
claimToken: token,
|
|
2415
|
+
evidence: [makeEvidence('a')],
|
|
2416
|
+
}, def);
|
|
2417
|
+
assertApplied(settled, 'settle_step complete');
|
|
2418
|
+
assertGenerated(settled.run, 'settleStep(settle_step)');
|
|
2419
|
+
const updated = await adapter.store.update({ ...settled.run });
|
|
2420
|
+
assertGenerated(updated, 'update()');
|
|
2421
|
+
},
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
law: 'PHASE_IS_GENERATED',
|
|
2425
|
+
name: `[${adapter.storeName}] the claimStep leg's discriminating fixture: abandoned_at ∧ terminal_state:false ⇒ claimStep persists the DERIVED 'abandoned' phase, not a hardcoded 'running'`,
|
|
2426
|
+
run: async () => {
|
|
2427
|
+
const def = minimalDefinition(['a']);
|
|
2428
|
+
const { run } = await adapter.store.create({
|
|
2429
|
+
workflowId: def.id,
|
|
2430
|
+
workflowVersion: def.version,
|
|
2431
|
+
params: {},
|
|
2432
|
+
});
|
|
2433
|
+
// eligibility.ts's findEligibleSteps does NOT check abandoned_at (only terminal_state /
|
|
2434
|
+
// pending_gate) — so this hand-shaped record is still claimable, while deriveRunPhase
|
|
2435
|
+
// (which DOES check abandoned_at first) derives 'abandoned' for it. A store that still
|
|
2436
|
+
// hardcodes 'running' on claim (rather than deriving) would persist the wrong phase here.
|
|
2437
|
+
const seeded = await adapter.store.update({
|
|
2438
|
+
...run,
|
|
2439
|
+
abandoned_at: '2026-01-01T00:00:00.000Z',
|
|
2440
|
+
});
|
|
2441
|
+
const claimed = await adapter.store.claimStep(seeded.id, 'a', def);
|
|
2442
|
+
if (claimed.run_phase !== 'abandoned') {
|
|
2443
|
+
throw new Error(`PHASE_IS_GENERATED (claimStep leg) violated: expected persisted 'abandoned', got '${claimed.run_phase}'`);
|
|
2444
|
+
}
|
|
2445
|
+
},
|
|
2446
|
+
},
|
|
2447
|
+
];
|
|
2448
|
+
}
|
|
2449
|
+
// ---------------------------------------------------------------------------
|
|
2450
|
+
// issue #302 (finalizer outcome×trigger matrix) — completed_with_failed_steps laws.
|
|
2451
|
+
// ---------------------------------------------------------------------------
|
|
2452
|
+
/** CWFS_FIRES_PER_ARM — mixed-complete mints the declared completed_with_failed_steps finalizer,
|
|
2453
|
+
* discriminated per settlement ARM (S3): VIA settle_step, VIA settle_gate resolution, VIA
|
|
2454
|
+
* settle_guard pass. Each case independently fails a step FIRST (claimed, then settled), THEN
|
|
2455
|
+
* drives the arm under test as the run's LAST remaining piece — so failed_steps is non-empty at
|
|
2456
|
+
* the moment that arm's own settle terminalizes the run to 'complete'. */
|
|
2457
|
+
function cwfsFiresPerArmCases(adapter) {
|
|
2458
|
+
const fixture = adapter.settlementFixture;
|
|
2459
|
+
const { minimalDefinition, withFinalizer } = fixture;
|
|
2460
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2461
|
+
const cases = [
|
|
2462
|
+
{
|
|
2463
|
+
law: 'CWFS_FIRES_PER_ARM',
|
|
2464
|
+
name: `[${adapter.storeName}] mixed-complete VIA settle_step mints the declared completed_with_failed_steps finalizer`,
|
|
2465
|
+
run: async () => {
|
|
2466
|
+
const def = withFinalizer(minimalDefinition(['fail_step', 'complete_step']), 'fin', 'completed_with_failed_steps');
|
|
2467
|
+
const { run, token: failToken } = await createClaimed(adapter.store, def, 'fail_step');
|
|
2468
|
+
const claimedComplete = await adapter.store.claimStep(run.id, 'complete_step', def);
|
|
2469
|
+
const completeToken = claimedComplete.claims['complete_step'].token;
|
|
2470
|
+
const failed = await settleStep(run.id, {
|
|
2471
|
+
kind: 'settle_step',
|
|
2472
|
+
step: 'fail_step',
|
|
2473
|
+
outcome: 'fail',
|
|
2474
|
+
claimToken: failToken,
|
|
2475
|
+
evidence: [],
|
|
2476
|
+
failureMessage: 'x',
|
|
2477
|
+
}, def);
|
|
2478
|
+
assertApplied(failed, 'fail fail_step');
|
|
2479
|
+
if (failed.run.terminal_state) {
|
|
2480
|
+
throw new Error('fixture setup: run must NOT terminalize while complete_step is still claimed');
|
|
2481
|
+
}
|
|
2482
|
+
const completed = await settleStep(run.id, {
|
|
2483
|
+
kind: 'settle_step',
|
|
2484
|
+
step: 'complete_step',
|
|
2485
|
+
outcome: 'complete',
|
|
2486
|
+
claimToken: completeToken,
|
|
2487
|
+
evidence: [makeEvidence('complete_step')],
|
|
2488
|
+
}, def);
|
|
2489
|
+
assertApplied(completed, 'complete complete_step (terminalizes mixed-complete)');
|
|
2490
|
+
if (completed.run.finalizer_ledger?.['fin']?.status !== 'pending') {
|
|
2491
|
+
throw new Error(`expected 'fin' minted pending on the settle_step-driven mixed-complete seal, got: ${JSON.stringify(completed.run.finalizer_ledger)}`);
|
|
2492
|
+
}
|
|
2493
|
+
},
|
|
2494
|
+
},
|
|
2495
|
+
{
|
|
2496
|
+
law: 'CWFS_FIRES_PER_ARM',
|
|
2497
|
+
name: `[${adapter.storeName}] mixed-complete VIA settle_gate resolution mints the declared completed_with_failed_steps finalizer`,
|
|
2498
|
+
run: async () => {
|
|
2499
|
+
const def = withFinalizer(minimalDefinition(['fail_step', 'gated_step']), 'fin', 'completed_with_failed_steps');
|
|
2500
|
+
// PR-C ordering rule: claim BOTH steps before any gate opens — once a gate is open,
|
|
2501
|
+
// findEligibleSteps returns [] globally, so a fresh claim for 'gated_step' after opening a
|
|
2502
|
+
// gate on it would be the wrong sequencing (this claims first, opens second).
|
|
2503
|
+
const { run, token: failToken } = await createClaimed(adapter.store, def, 'fail_step');
|
|
2504
|
+
const claimedGated = await adapter.store.claimStep(run.id, 'gated_step', def);
|
|
2505
|
+
const gatedToken = claimedGated.claims['gated_step'].token;
|
|
2506
|
+
const failed = await settleStep(run.id, {
|
|
2507
|
+
kind: 'settle_step',
|
|
2508
|
+
step: 'fail_step',
|
|
2509
|
+
outcome: 'fail',
|
|
2510
|
+
claimToken: failToken,
|
|
2511
|
+
evidence: [],
|
|
2512
|
+
failureMessage: 'x',
|
|
2513
|
+
}, def);
|
|
2514
|
+
assertApplied(failed, 'fail fail_step');
|
|
2515
|
+
const gate = makePendingGate('gated_step');
|
|
2516
|
+
const opened = await settleStep(run.id, {
|
|
2517
|
+
kind: 'open_gate',
|
|
2518
|
+
step: 'gated_step',
|
|
2519
|
+
claimToken: gatedToken,
|
|
2520
|
+
pendingGate: gate,
|
|
2521
|
+
evidence: [],
|
|
2522
|
+
}, def);
|
|
2523
|
+
assertApplied(opened, 'open_gate on gated_step');
|
|
2524
|
+
if (opened.run.terminal_state) {
|
|
2525
|
+
throw new Error('fixture setup: run must not be terminal merely from opening the gate');
|
|
2526
|
+
}
|
|
2527
|
+
const resolved = await settleStep(run.id, { kind: 'settle_gate', gateId: gate.gate_id, choice: 'approve', evidence: [] }, def);
|
|
2528
|
+
assertApplied(resolved, 'settle_gate resolve (terminalizes mixed-complete)');
|
|
2529
|
+
if (resolved.run.finalizer_ledger?.['fin']?.status !== 'pending') {
|
|
2530
|
+
throw new Error(`expected 'fin' minted pending on the settle_gate-driven mixed-complete seal, got: ${JSON.stringify(resolved.run.finalizer_ledger)}`);
|
|
2531
|
+
}
|
|
2532
|
+
},
|
|
2533
|
+
},
|
|
2534
|
+
];
|
|
2535
|
+
if (fixture.withGuard === undefined) {
|
|
2536
|
+
cases.push({
|
|
2537
|
+
law: 'ADAPTER_WIRING',
|
|
2538
|
+
name: `[${adapter.storeName}] declares RunStore.settleStep but the supplied settlementFixture has no withGuard — CWFS_FIRES_PER_ARM's guard leg is a WIRING GAP, not a vacuous pass`,
|
|
2539
|
+
run: async () => {
|
|
2540
|
+
throw new Error(`[${adapter.storeName}] settlementContract: adapter.settlementFixture.withGuard is ` +
|
|
2541
|
+
"undefined — pass 'defaultSettlementFixture' (or a store-specific fixture " +
|
|
2542
|
+
'implementing withGuard) to exercise the CWFS_FIRES_PER_ARM guard-leg coverage.');
|
|
2543
|
+
},
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
else {
|
|
2547
|
+
const withGuard = fixture.withGuard;
|
|
2548
|
+
cases.push({
|
|
2549
|
+
law: 'CWFS_FIRES_PER_ARM',
|
|
2550
|
+
name: `[${adapter.storeName}] mixed-complete VIA settle_guard pass mints the declared completed_with_failed_steps finalizer`,
|
|
2551
|
+
run: async () => {
|
|
2552
|
+
const def = withFinalizer(withGuard(minimalDefinition(['fail_step']), 'g', []), // empty abort_unless ⇒ always passes
|
|
2553
|
+
'fin', 'completed_with_failed_steps');
|
|
2554
|
+
const { run, token: failToken } = await createClaimed(adapter.store, def, 'fail_step');
|
|
2555
|
+
const failed = await settleStep(run.id, {
|
|
2556
|
+
kind: 'settle_step',
|
|
2557
|
+
step: 'fail_step',
|
|
2558
|
+
outcome: 'fail',
|
|
2559
|
+
claimToken: failToken,
|
|
2560
|
+
evidence: [],
|
|
2561
|
+
failureMessage: 'x',
|
|
2562
|
+
}, def);
|
|
2563
|
+
assertApplied(failed, 'fail fail_step');
|
|
2564
|
+
if (failed.run.terminal_state) {
|
|
2565
|
+
throw new Error("fixture setup: run must NOT terminalize while guard 'g' is still eligible");
|
|
2566
|
+
}
|
|
2567
|
+
const passed = await settleStep(run.id, { kind: 'settle_guard', step: 'g', outcome: 'pass', evidence: makeEvidence('g') }, def);
|
|
2568
|
+
assertApplied(passed, 'settle_guard pass (terminalizes mixed-complete)');
|
|
2569
|
+
if (passed.run.finalizer_ledger?.['fin']?.status !== 'pending') {
|
|
2570
|
+
throw new Error(`expected 'fin' minted pending on the settle_guard-driven mixed-complete seal, got: ${JSON.stringify(passed.run.finalizer_ledger)}`);
|
|
2571
|
+
}
|
|
2572
|
+
},
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
return cases;
|
|
2576
|
+
}
|
|
2577
|
+
/** CWFS_NEGATIVES — the new trigger never over-fires: a clean complete (no failed_steps) does not
|
|
2578
|
+
* select it; a pure fail seal (never reaches complete) does not select it either. */
|
|
2579
|
+
function cwfsNegativesCases(adapter) {
|
|
2580
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
2581
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2582
|
+
return [
|
|
2583
|
+
{
|
|
2584
|
+
law: 'CWFS_NEGATIVES',
|
|
2585
|
+
name: `[${adapter.storeName}] a CLEAN complete seal (no failed_steps) does NOT select completed_with_failed_steps`,
|
|
2586
|
+
run: async () => {
|
|
2587
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'completed_with_failed_steps');
|
|
2588
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
2589
|
+
const completed = await settleStep(run.id, {
|
|
2590
|
+
kind: 'settle_step',
|
|
2591
|
+
step: 'a',
|
|
2592
|
+
outcome: 'complete',
|
|
2593
|
+
claimToken: token,
|
|
2594
|
+
evidence: [makeEvidence('a')],
|
|
2595
|
+
}, def);
|
|
2596
|
+
assertApplied(completed, 'clean complete of a');
|
|
2597
|
+
if (completed.run.finalizer_ledger?.['fin'] !== undefined) {
|
|
2598
|
+
throw new Error(`expected 'fin' NOT selected on a clean complete seal, got: ${JSON.stringify(completed.run.finalizer_ledger)}`);
|
|
2599
|
+
}
|
|
2600
|
+
},
|
|
2601
|
+
},
|
|
2602
|
+
{
|
|
2603
|
+
law: 'CWFS_NEGATIVES',
|
|
2604
|
+
name: `[${adapter.storeName}] a PURE fail seal does NOT select completed_with_failed_steps (it requires a complete seal, never a fail seal)`,
|
|
2605
|
+
run: async () => {
|
|
2606
|
+
const def = withFinalizer(minimalDefinition(['a']), 'fin', 'completed_with_failed_steps');
|
|
2607
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
2608
|
+
const failed = await settleStep(run.id, {
|
|
2609
|
+
kind: 'settle_step',
|
|
2610
|
+
step: 'a',
|
|
2611
|
+
outcome: 'fail',
|
|
2612
|
+
claimToken: token,
|
|
2613
|
+
evidence: [],
|
|
2614
|
+
failureMessage: 'x',
|
|
2615
|
+
}, def);
|
|
2616
|
+
assertApplied(failed, 'pure fail of a');
|
|
2617
|
+
if (failed.run.finalizer_ledger?.['fin'] !== undefined) {
|
|
2618
|
+
throw new Error(`expected 'fin' NOT selected on a pure fail seal, got: ${JSON.stringify(failed.run.finalizer_ledger)}`);
|
|
2619
|
+
}
|
|
2620
|
+
},
|
|
2621
|
+
},
|
|
2622
|
+
];
|
|
2623
|
+
}
|
|
2624
|
+
/** CWFS_SECOND_EPOCH — the M1 uniform-predicate pin: a second-epoch complete seal whose ONLY
|
|
2625
|
+
* failed_steps scar is a PRIOR epoch's finalizer self-failure (unresumable, so it never leaves
|
|
2626
|
+
* failed_steps) still fires completed_with_failed_steps. The post-resume state is HAND-AUTHORED
|
|
2627
|
+
* via update() (the mintFreshCases/L14 precedent) — this TCK case does not drive real
|
|
2628
|
+
* applyResume; a core-homed witness through the REAL applyResume lives in core's own test suite
|
|
2629
|
+
* (finalizer-matrix-302.test.ts), per the hand-off prompt's "Core tests" bullet. */
|
|
2630
|
+
function cwfsSecondEpochCases(adapter) {
|
|
2631
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
2632
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2633
|
+
return [
|
|
2634
|
+
{
|
|
2635
|
+
law: 'CWFS_SECOND_EPOCH',
|
|
2636
|
+
name: `[${adapter.storeName}] a second-epoch complete seal driven SOLELY by a prior epoch's finalizer self-failure still fires completed_with_failed_steps`,
|
|
2637
|
+
run: async () => {
|
|
2638
|
+
const def = withFinalizer(withFinalizer(minimalDefinition(['a']), 'onFail', 'fail'), 'fin', 'completed_with_failed_steps');
|
|
2639
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
2640
|
+
const failedA = await settleStep(run.id, {
|
|
2641
|
+
kind: 'settle_step',
|
|
2642
|
+
step: 'a',
|
|
2643
|
+
outcome: 'fail',
|
|
2644
|
+
claimToken: token,
|
|
2645
|
+
evidence: [],
|
|
2646
|
+
failureMessage: 'x',
|
|
2647
|
+
}, def);
|
|
2648
|
+
assertApplied(failedA, 'fail step a (terminalizes fail, mints onFail)');
|
|
2649
|
+
if (failedA.run.finalizer_ledger?.['onFail']?.status !== 'pending') {
|
|
2650
|
+
throw new Error('fixture setup: expected onFail minted pending on the fail-terminal edge');
|
|
2651
|
+
}
|
|
2652
|
+
if (failedA.run.finalizer_ledger?.['fin'] !== undefined) {
|
|
2653
|
+
throw new Error("fixture setup: 'fin' must NOT be selected on a pure fail seal");
|
|
2654
|
+
}
|
|
2655
|
+
// The prior epoch's finalizer self-failure: onFail itself FAILS (unresumable — its name
|
|
2656
|
+
// enters failed_steps and stays there forever).
|
|
2657
|
+
const leaseToken = uid('lease');
|
|
2658
|
+
const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'onFail', leaseToken, leaseSeconds: 60 }, def);
|
|
2659
|
+
assertApplied(leased, 'lease onFail');
|
|
2660
|
+
const markedFailed = await settleStep(run.id, {
|
|
2661
|
+
kind: 'mark_finalizer',
|
|
2662
|
+
finalizer: 'onFail',
|
|
2663
|
+
leaseToken,
|
|
2664
|
+
result: 'failed',
|
|
2665
|
+
evidence: makeEvidence('onFail'),
|
|
2666
|
+
}, def);
|
|
2667
|
+
assertApplied(markedFailed, 'mark onFail failed');
|
|
2668
|
+
if (!markedFailed.run.failed_steps.includes('onFail')) {
|
|
2669
|
+
throw new Error("fixture setup: expected 'onFail' in failed_steps after its own self-failure");
|
|
2670
|
+
}
|
|
2671
|
+
// Hand-author the post-resume state: 'a' is resumed/cleared (eligible again), but
|
|
2672
|
+
// 'onFail' — a FINALIZER, unresumable — stays in failed_steps (the M1 scenario's own
|
|
2673
|
+
// premise). terminal_state:false / in_progress_steps:[] / settled:{} mirror the
|
|
2674
|
+
// mintFreshCases precedent's own void shape.
|
|
2675
|
+
const postResumeVoid = await adapter.store.update({
|
|
2676
|
+
...markedFailed.run,
|
|
2677
|
+
terminal_state: false,
|
|
2678
|
+
in_progress_steps: [],
|
|
2679
|
+
failed_steps: ['onFail'],
|
|
2680
|
+
settled: {},
|
|
2681
|
+
});
|
|
2682
|
+
if (postResumeVoid.finalizer_ledger?.['onFail']?.status !== 'failed') {
|
|
2683
|
+
throw new Error("fixture setup: onFail must still read 'failed' after the hand-authored void");
|
|
2684
|
+
}
|
|
2685
|
+
// Second epoch: re-claim + complete 'a'. Its own failed_steps has NOTHING from this
|
|
2686
|
+
// epoch — only onFail's own prior-epoch scar.
|
|
2687
|
+
const reclaimed = await adapter.store.claimStep(run.id, 'a', def);
|
|
2688
|
+
const reToken = reclaimed.claims['a'].token;
|
|
2689
|
+
const reCompleted = await settleStep(run.id, {
|
|
2690
|
+
kind: 'settle_step',
|
|
2691
|
+
step: 'a',
|
|
2692
|
+
outcome: 'complete',
|
|
2693
|
+
claimToken: reToken,
|
|
2694
|
+
evidence: [makeEvidence('a')],
|
|
2695
|
+
}, def);
|
|
2696
|
+
assertApplied(reCompleted, 're-complete step a on its second epoch');
|
|
2697
|
+
if (reCompleted.run.finalizer_ledger?.['fin']?.status !== 'pending') {
|
|
2698
|
+
throw new Error(`expected 'fin' minted pending on the second-epoch complete seal (driven solely by ` +
|
|
2699
|
+
`onFail's prior-epoch self-failure), got: ${JSON.stringify(reCompleted.run.finalizer_ledger)}`);
|
|
2700
|
+
}
|
|
2701
|
+
// onFail itself must NOT be re-selected (its own trigger is 'fail', not 'complete'/
|
|
2702
|
+
// 'completed_with_failed_steps') — it stays 'failed', never rewritten.
|
|
2703
|
+
if (reCompleted.run.finalizer_ledger?.['onFail']?.status !== 'failed') {
|
|
2704
|
+
throw new Error(`expected onFail to STAY 'failed' (never re-selected on a complete seal), got: '${reCompleted.run.finalizer_ledger?.['onFail']?.status}'`);
|
|
2705
|
+
}
|
|
2706
|
+
},
|
|
2707
|
+
},
|
|
2708
|
+
];
|
|
2709
|
+
}
|
|
2710
|
+
/** CWFS_ARRAY_ONCE — a finalizer never fires twice for one seal: the array form fires exactly
|
|
2711
|
+
* once on mixed-complete (Group A, a single push despite a multi-element intersection); `always`
|
|
2712
|
+
* fires exactly once on mixed-complete too (Group B). */
|
|
2713
|
+
function cwfsArrayOnceCases(adapter) {
|
|
2714
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
2715
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2716
|
+
/** Builds a mixed-complete seal (fail 'fail_step' first, then complete 'complete_step' last)
|
|
2717
|
+
* over whatever finalizer(s) `withFin` has already added to the definition. */
|
|
2718
|
+
async function driveMixedComplete(def) {
|
|
2719
|
+
const { run, token: failToken } = await createClaimed(adapter.store, def, 'fail_step');
|
|
2720
|
+
const claimedComplete = await adapter.store.claimStep(run.id, 'complete_step', def);
|
|
2721
|
+
const completeToken = claimedComplete.claims['complete_step'].token;
|
|
2722
|
+
const failed = await settleStep(run.id, {
|
|
2723
|
+
kind: 'settle_step',
|
|
2724
|
+
step: 'fail_step',
|
|
2725
|
+
outcome: 'fail',
|
|
2726
|
+
claimToken: failToken,
|
|
2727
|
+
evidence: [],
|
|
2728
|
+
failureMessage: 'x',
|
|
2729
|
+
}, def);
|
|
2730
|
+
assertApplied(failed, 'fail fail_step');
|
|
2731
|
+
return settleStep(run.id, {
|
|
2732
|
+
kind: 'settle_step',
|
|
2733
|
+
step: 'complete_step',
|
|
2734
|
+
outcome: 'complete',
|
|
2735
|
+
claimToken: completeToken,
|
|
2736
|
+
evidence: [makeEvidence('complete_step')],
|
|
2737
|
+
}, def);
|
|
2738
|
+
}
|
|
2739
|
+
return [
|
|
2740
|
+
{
|
|
2741
|
+
law: 'CWFS_ARRAY_ONCE',
|
|
2742
|
+
name: `[${adapter.storeName}] on_outcome: [complete, completed_with_failed_steps] fires EXACTLY ONCE on a mixed-complete seal (Group A, one push despite a two-element intersection)`,
|
|
2743
|
+
run: async () => {
|
|
2744
|
+
const def = withFinalizer(minimalDefinition(['fail_step', 'complete_step']), 'fin', [
|
|
2745
|
+
'complete',
|
|
2746
|
+
'completed_with_failed_steps',
|
|
2747
|
+
]);
|
|
2748
|
+
const result = await driveMixedComplete(def);
|
|
2749
|
+
assertApplied(result, 'array-form mixed-complete seal');
|
|
2750
|
+
const entry = result.run.finalizer_ledger?.['fin'];
|
|
2751
|
+
if (entry?.status !== 'pending' || entry.rank !== 0) {
|
|
2752
|
+
throw new Error(`expected exactly one 'fin' entry (rank 0, pending), got: ${JSON.stringify(entry)}`);
|
|
2753
|
+
}
|
|
2754
|
+
if (result.run.completed_steps.filter((s) => s === 'fin').length > 1) {
|
|
2755
|
+
throw new Error("'fin' must not appear more than once in completed_steps");
|
|
2756
|
+
}
|
|
2757
|
+
},
|
|
2758
|
+
},
|
|
2759
|
+
{
|
|
2760
|
+
law: 'CWFS_ARRAY_ONCE',
|
|
2761
|
+
name: `[${adapter.storeName}] on_outcome: 'always' fires EXACTLY ONCE on a mixed-complete seal (Group B, never double-counted alongside a Group A hit)`,
|
|
2762
|
+
run: async () => {
|
|
2763
|
+
const def = withFinalizer(minimalDefinition(['fail_step', 'complete_step']), 'fin', 'always');
|
|
2764
|
+
const result = await driveMixedComplete(def);
|
|
2765
|
+
assertApplied(result, 'always-form mixed-complete seal');
|
|
2766
|
+
const entry = result.run.finalizer_ledger?.['fin'];
|
|
2767
|
+
if (entry?.status !== 'pending' || entry.rank !== 0) {
|
|
2768
|
+
throw new Error(`expected exactly one 'fin' entry (rank 0, pending), got: ${JSON.stringify(entry)}`);
|
|
2769
|
+
}
|
|
2770
|
+
},
|
|
2771
|
+
},
|
|
2772
|
+
];
|
|
2773
|
+
}
|
|
2774
|
+
/** CURRENT_BEHAVIOR_PINNED — the REJECTED design alternative (D-C: auto-firing 'fail' on
|
|
2775
|
+
* mixed-complete) never happens, pinned both ways. */
|
|
2776
|
+
function currentBehaviorPinnedCases(adapter) {
|
|
2777
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
2778
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2779
|
+
return [
|
|
2780
|
+
{
|
|
2781
|
+
law: 'CURRENT_BEHAVIOR_PINNED',
|
|
2782
|
+
name: `[${adapter.storeName}] a clean complete seal does NOT select a 'fail'-only finalizer`,
|
|
2783
|
+
run: async () => {
|
|
2784
|
+
const def = withFinalizer(minimalDefinition(['a']), 'onFail', 'fail');
|
|
2785
|
+
const { run, token } = await createClaimed(adapter.store, def, 'a');
|
|
2786
|
+
const completed = await settleStep(run.id, {
|
|
2787
|
+
kind: 'settle_step',
|
|
2788
|
+
step: 'a',
|
|
2789
|
+
outcome: 'complete',
|
|
2790
|
+
claimToken: token,
|
|
2791
|
+
evidence: [makeEvidence('a')],
|
|
2792
|
+
}, def);
|
|
2793
|
+
assertApplied(completed, 'clean complete of a');
|
|
2794
|
+
if (completed.run.finalizer_ledger?.['onFail'] !== undefined) {
|
|
2795
|
+
throw new Error("expected 'onFail' NOT selected on a clean complete seal");
|
|
2796
|
+
}
|
|
2797
|
+
},
|
|
2798
|
+
},
|
|
2799
|
+
{
|
|
2800
|
+
law: 'CURRENT_BEHAVIOR_PINNED',
|
|
2801
|
+
name: `[${adapter.storeName}] a MIXED-complete seal (failed_steps ≠ ∅) still does NOT select a 'fail'-only finalizer — D-C's rejected auto-fire alternative is contract, not merely untested`,
|
|
2802
|
+
run: async () => {
|
|
2803
|
+
const def = withFinalizer(minimalDefinition(['fail_step', 'complete_step']), 'onFail', 'fail');
|
|
2804
|
+
const { run, token: failToken } = await createClaimed(adapter.store, def, 'fail_step');
|
|
2805
|
+
const claimedComplete = await adapter.store.claimStep(run.id, 'complete_step', def);
|
|
2806
|
+
const completeToken = claimedComplete.claims['complete_step'].token;
|
|
2807
|
+
const failed = await settleStep(run.id, {
|
|
2808
|
+
kind: 'settle_step',
|
|
2809
|
+
step: 'fail_step',
|
|
2810
|
+
outcome: 'fail',
|
|
2811
|
+
claimToken: failToken,
|
|
2812
|
+
evidence: [],
|
|
2813
|
+
failureMessage: 'x',
|
|
2814
|
+
}, def);
|
|
2815
|
+
assertApplied(failed, 'fail fail_step');
|
|
2816
|
+
const completed = await settleStep(run.id, {
|
|
2817
|
+
kind: 'settle_step',
|
|
2818
|
+
step: 'complete_step',
|
|
2819
|
+
outcome: 'complete',
|
|
2820
|
+
claimToken: completeToken,
|
|
2821
|
+
evidence: [makeEvidence('complete_step')],
|
|
2822
|
+
}, def);
|
|
2823
|
+
assertApplied(completed, 'complete complete_step (terminalizes mixed-complete)');
|
|
2824
|
+
if (completed.run.finalizer_ledger?.['onFail'] !== undefined) {
|
|
2825
|
+
throw new Error(`expected 'onFail' (fail-only trigger) NOT selected on a mixed-complete seal — the ` +
|
|
2826
|
+
`rejected auto-fire-fail-on-mixed-complete alternative must not happen; got: ` +
|
|
2827
|
+
`${JSON.stringify(completed.run.finalizer_ledger)}`);
|
|
2828
|
+
}
|
|
2829
|
+
},
|
|
2830
|
+
},
|
|
2831
|
+
];
|
|
2832
|
+
}
|
|
2833
|
+
// ---------------------------------------------------------------------------
|
|
2834
|
+
// EXPIRE_ARM_MATRIX / EXPIRE_ABORT_CASCADE / EXPIRE_DEFAULT_RESOLVE (issue #291,
|
|
2835
|
+
// gate-timeout-291-correction Leg 2 — ported from the dedicated core-only
|
|
2836
|
+
// gate-expiry-tck-laws.test.ts per the PR-C precedent: new delta kinds' laws join this shared,
|
|
2837
|
+
// cross-store conformance kit rather than staying core-internal only). The dedicated core file
|
|
2838
|
+
// stays (see its own SCOPE NOTE, amended) as fast unit-level coverage exercising `applySettlement`
|
|
2839
|
+
// directly with hand-rolled fixtures — this is the STORE-EXERCISING cross-conformance layer,
|
|
2840
|
+
// proving BOTH JsonFileStore and InMemoryStore's `settleStep` reach the identical, correct
|
|
2841
|
+
// `applyExpireGate` behavior through the real store surface, matching every other delta kind's
|
|
2842
|
+
// own dual coverage (a dedicated engine-level test file + a settlement-contract.ts law family).
|
|
2843
|
+
// ---------------------------------------------------------------------------
|
|
2844
|
+
function expireArmMatrixCases(adapter) {
|
|
2845
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
2846
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2847
|
+
return [
|
|
2848
|
+
{
|
|
2849
|
+
law: 'EXPIRE_ARM_MATRIX',
|
|
2850
|
+
name: `[${adapter.storeName}] expire_gate refuses not_expired with the injectable now, never the caller-implied clock`,
|
|
2851
|
+
run: async () => {
|
|
2852
|
+
const def = minimalDefinition(['gated']);
|
|
2853
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2854
|
+
const gate = makePendingGate('gated', {
|
|
2855
|
+
gateId: 'gate-1',
|
|
2856
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
2857
|
+
onExpiry: 'abort',
|
|
2858
|
+
});
|
|
2859
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2860
|
+
assertApplied(opened, 'open_gate');
|
|
2861
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
2862
|
+
now: new Date('2026-01-01T00:00:30.000Z'),
|
|
2863
|
+
});
|
|
2864
|
+
assertRefused(result, 'not_expired', 'expire_gate before expires_at');
|
|
2865
|
+
},
|
|
2866
|
+
},
|
|
2867
|
+
{
|
|
2868
|
+
law: 'EXPIRE_ARM_MATRIX',
|
|
2869
|
+
name: `[${adapter.storeName}] expire_gate replay ×2 (settle_default): the SECOND attempt NOOPs already_settled — version-safe idempotence`,
|
|
2870
|
+
run: async () => {
|
|
2871
|
+
const def = minimalDefinition(['gated']);
|
|
2872
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2873
|
+
const gate = makePendingGate('gated', {
|
|
2874
|
+
gateId: 'gate-1',
|
|
2875
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
2876
|
+
onExpiry: 'settle_default',
|
|
2877
|
+
defaultChoice: 'approve',
|
|
2878
|
+
});
|
|
2879
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2880
|
+
assertApplied(opened, 'open_gate');
|
|
2881
|
+
const now = new Date('2026-01-01T00:10:00.000Z');
|
|
2882
|
+
const first = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
2883
|
+
now,
|
|
2884
|
+
});
|
|
2885
|
+
assertApplied(first, 'first expire_gate (settle_default)');
|
|
2886
|
+
const second = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
2887
|
+
now,
|
|
2888
|
+
});
|
|
2889
|
+
assertRefused(second, 'already_settled', 'replay expire_gate for an already-settled settle_default gate');
|
|
2890
|
+
},
|
|
2891
|
+
},
|
|
2892
|
+
{
|
|
2893
|
+
law: 'EXPIRE_ARM_MATRIX',
|
|
2894
|
+
name: `[${adapter.storeName}] expire_gate replay ×2 (abort): the SECOND attempt NOOPs already_settled (terminal split)`,
|
|
2895
|
+
run: async () => {
|
|
2896
|
+
const def = minimalDefinition(['gated']);
|
|
2897
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2898
|
+
const gate = makePendingGate('gated', {
|
|
2899
|
+
gateId: 'gate-1',
|
|
2900
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
2901
|
+
onExpiry: 'abort',
|
|
2902
|
+
});
|
|
2903
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2904
|
+
assertApplied(opened, 'open_gate');
|
|
2905
|
+
const now = new Date('2026-01-01T00:10:00.000Z');
|
|
2906
|
+
const first = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
2907
|
+
now,
|
|
2908
|
+
});
|
|
2909
|
+
assertApplied(first, 'first expire_gate (abort)');
|
|
2910
|
+
const second = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
2911
|
+
now,
|
|
2912
|
+
});
|
|
2913
|
+
assertRefused(second, 'already_settled', 'replay expire_gate for an already-aborted gate (terminal-split arm)');
|
|
2914
|
+
},
|
|
2915
|
+
},
|
|
2916
|
+
{
|
|
2917
|
+
law: 'EXPIRE_ARM_MATRIX',
|
|
2918
|
+
name: `[${adapter.storeName}] expire_gate against a run terminalized by a DIFFERENT cause refuses run_terminal — never resurrecting or re-terminalizing`,
|
|
2919
|
+
run: async () => {
|
|
2920
|
+
const def = minimalDefinition(['gated']);
|
|
2921
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2922
|
+
const completed = await settleStep(run.id, {
|
|
2923
|
+
kind: 'settle_step',
|
|
2924
|
+
step: 'gated',
|
|
2925
|
+
outcome: 'complete',
|
|
2926
|
+
claimToken: token,
|
|
2927
|
+
evidence: [makeEvidence('gated')],
|
|
2928
|
+
}, def);
|
|
2929
|
+
assertApplied(completed, 'complete gated (terminalizes — sole step, unrelated to any gate)');
|
|
2930
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
2931
|
+
now: new Date('2026-01-01T00:10:00.000Z'),
|
|
2932
|
+
});
|
|
2933
|
+
assertRefused(result, 'run_terminal', 'expire_gate against a run terminalized by an unrelated cause');
|
|
2934
|
+
},
|
|
2935
|
+
},
|
|
2936
|
+
{
|
|
2937
|
+
law: 'EXPIRE_ARM_MATRIX',
|
|
2938
|
+
name: `[${adapter.storeName}] expire_gate with an unknown/superseded gateId refuses gate_mismatch`,
|
|
2939
|
+
run: async () => {
|
|
2940
|
+
const def = minimalDefinition(['gated']);
|
|
2941
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2942
|
+
const gate = makePendingGate('gated', {
|
|
2943
|
+
gateId: 'live-gate',
|
|
2944
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
2945
|
+
onExpiry: 'abort',
|
|
2946
|
+
});
|
|
2947
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2948
|
+
assertApplied(opened, 'open_gate');
|
|
2949
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'stale-unknown-gate' }, def, { now: new Date('2026-01-01T00:10:00.000Z') });
|
|
2950
|
+
assertRefused(result, 'gate_mismatch', 'expire_gate with an unknown gateId');
|
|
2951
|
+
},
|
|
2952
|
+
},
|
|
2953
|
+
{
|
|
2954
|
+
law: 'EXPIRE_ARM_MATRIX',
|
|
2955
|
+
name: `[${adapter.storeName}] expire_gate on a finding-only gate (expires_at present, on_expiry absent) refuses no_disposition, arm-level, before APPLY — never cleared`,
|
|
2956
|
+
run: async () => {
|
|
2957
|
+
const def = minimalDefinition(['gated']);
|
|
2958
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2959
|
+
const gate = makePendingGate('gated', {
|
|
2960
|
+
gateId: 'gate-1',
|
|
2961
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
2962
|
+
// no onExpiry — finding-only.
|
|
2963
|
+
});
|
|
2964
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2965
|
+
assertApplied(opened, 'open_gate');
|
|
2966
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
2967
|
+
now: new Date('2026-01-01T00:10:00.000Z'),
|
|
2968
|
+
});
|
|
2969
|
+
assertRefused(result, 'no_disposition', 'expire_gate on a finding-only gate');
|
|
2970
|
+
if (result.run.pending_gate === undefined) {
|
|
2971
|
+
throw new Error('a finding-only refusal must NEVER clear pending_gate');
|
|
2972
|
+
}
|
|
2973
|
+
},
|
|
2974
|
+
},
|
|
2975
|
+
];
|
|
2976
|
+
}
|
|
2977
|
+
function expireAbortCascadeCases(adapter) {
|
|
2978
|
+
const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
|
|
2979
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
2980
|
+
return [
|
|
2981
|
+
{
|
|
2982
|
+
law: 'EXPIRE_ABORT_CASCADE',
|
|
2983
|
+
name: `[${adapter.storeName}] expire_gate (abort) clears pending_gate + claim, writes aborted_at + skip_details {gate_expired, gate_id} (day-one), mints finalizers — all in ONE write`,
|
|
2984
|
+
run: async () => {
|
|
2985
|
+
const def = withFinalizer(minimalDefinition(['gated']), 'fin', 'abort');
|
|
2986
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
2987
|
+
const gate = makePendingGate('gated', {
|
|
2988
|
+
gateId: 'gate-1',
|
|
2989
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
2990
|
+
onExpiry: 'abort',
|
|
2991
|
+
});
|
|
2992
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
2993
|
+
assertApplied(opened, 'open_gate');
|
|
2994
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
2995
|
+
now: new Date('2026-01-01T00:10:00.000Z'),
|
|
2996
|
+
});
|
|
2997
|
+
assertApplied(result, 'expire_gate (abort)');
|
|
2998
|
+
if (result.run.pending_gate !== undefined) {
|
|
2999
|
+
throw new Error('expected pending_gate cleared');
|
|
3000
|
+
}
|
|
3001
|
+
if (result.run.claims?.['gated'] !== undefined) {
|
|
3002
|
+
throw new Error('expected the claim cleared');
|
|
3003
|
+
}
|
|
3004
|
+
if (result.run.in_progress_steps.includes('gated')) {
|
|
3005
|
+
throw new Error("expected 'gated' removed from in_progress_steps");
|
|
3006
|
+
}
|
|
3007
|
+
if (result.run.terminal_state !== true) {
|
|
3008
|
+
throw new Error('expected the run to terminalize');
|
|
3009
|
+
}
|
|
3010
|
+
if (result.run.aborted_at?.step_id !== 'gated') {
|
|
3011
|
+
throw new Error(`expected aborted_at.step_id === 'gated', got ${JSON.stringify(result.run.aborted_at)}`);
|
|
3012
|
+
}
|
|
3013
|
+
const detail = result.run.skip_details?.['gated'];
|
|
3014
|
+
if (detail === undefined || detail.kind !== 'gate_expired' || detail.gate_id !== 'gate-1') {
|
|
3015
|
+
throw new Error(`expected skip_details.gated = {kind:'gate_expired', gate_id:'gate-1'}, got ${JSON.stringify(detail)}`);
|
|
3016
|
+
}
|
|
3017
|
+
if (result.run.settled?.['gated'] !== undefined) {
|
|
3018
|
+
throw new Error('TERMINAL_GATE_EXCLUSION: the abort disposition must never ALSO write a settled gate entry');
|
|
3019
|
+
}
|
|
3020
|
+
if (result.run.finalizer_ledger?.['fin'] === undefined) {
|
|
3021
|
+
throw new Error("expected the 'abort'-triggered finalizer minted on the abort edge");
|
|
3022
|
+
}
|
|
3023
|
+
},
|
|
3024
|
+
},
|
|
3025
|
+
{
|
|
3026
|
+
law: 'EXPIRE_ABORT_CASCADE',
|
|
3027
|
+
name: `[${adapter.storeName}] the D-4 discriminator: a gate_expired skip_detail is NEVER mistaken for gate_cancelled_by_abort`,
|
|
3028
|
+
run: async () => {
|
|
3029
|
+
const def = minimalDefinition(['gated']);
|
|
3030
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
3031
|
+
const gate = makePendingGate('gated', {
|
|
3032
|
+
gateId: 'gate-1',
|
|
3033
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
3034
|
+
onExpiry: 'abort',
|
|
3035
|
+
});
|
|
3036
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
3037
|
+
assertApplied(opened, 'open_gate');
|
|
3038
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
3039
|
+
now: new Date('2026-01-01T00:10:00.000Z'),
|
|
3040
|
+
});
|
|
3041
|
+
assertApplied(result, 'expire_gate (abort)');
|
|
3042
|
+
const kind = result.run.skip_details?.['gated']?.kind;
|
|
3043
|
+
if (kind !== 'gate_expired') {
|
|
3044
|
+
throw new Error(`expected skip_details.gated.kind === 'gate_expired', got '${kind}'`);
|
|
3045
|
+
}
|
|
3046
|
+
// TypeScript narrows `kind` to the literal above; the discriminator being tested is that
|
|
3047
|
+
// it is NEVER 'gate_cancelled_by_abort' — asserted by construction (the union excludes it
|
|
3048
|
+
// once narrowed) and restated here as an explicit, readable pin.
|
|
3049
|
+
},
|
|
3050
|
+
},
|
|
3051
|
+
{
|
|
3052
|
+
law: 'EXPIRE_ABORT_CASCADE',
|
|
3053
|
+
name: `[${adapter.storeName}] expire_gate (abort) never stamps defaulted_steps, even though it terminalizes (the FM-5/#232 guard)`,
|
|
3054
|
+
run: async () => {
|
|
3055
|
+
const def = minimalDefinition(['gated']);
|
|
3056
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
3057
|
+
const gate = makePendingGate('gated', {
|
|
3058
|
+
gateId: 'gate-1',
|
|
3059
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
3060
|
+
onExpiry: 'abort',
|
|
3061
|
+
});
|
|
3062
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
3063
|
+
assertApplied(opened, 'open_gate');
|
|
3064
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
3065
|
+
now: new Date('2026-01-01T00:10:00.000Z'),
|
|
3066
|
+
});
|
|
3067
|
+
assertApplied(result, 'expire_gate (abort)');
|
|
3068
|
+
if (result.run.defaulted_steps !== undefined) {
|
|
3069
|
+
throw new Error('expire_gate (abort) must never stamp defaulted_steps');
|
|
3070
|
+
}
|
|
3071
|
+
},
|
|
3072
|
+
},
|
|
3073
|
+
];
|
|
3074
|
+
}
|
|
3075
|
+
function expireDefaultResolveCases(adapter) {
|
|
3076
|
+
const { minimalDefinition } = adapter.settlementFixture;
|
|
3077
|
+
const settleStep = requireSettleStep(adapter.store);
|
|
3078
|
+
return [
|
|
3079
|
+
{
|
|
3080
|
+
law: 'EXPIRE_DEFAULT_RESOLVE',
|
|
3081
|
+
name: `[${adapter.storeName}] expire_gate (settle_default) resolves with resolved_by:'timeout' attribution + terminalizes (sole step)`,
|
|
3082
|
+
run: async () => {
|
|
3083
|
+
const def = minimalDefinition(['gated']);
|
|
3084
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
3085
|
+
const gate = makePendingGate('gated', {
|
|
3086
|
+
gateId: 'gate-1',
|
|
3087
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
3088
|
+
onExpiry: 'settle_default',
|
|
3089
|
+
defaultChoice: 'approve',
|
|
3090
|
+
});
|
|
3091
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
3092
|
+
assertApplied(opened, 'open_gate');
|
|
3093
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
3094
|
+
now: new Date('2026-01-01T00:10:00.000Z'),
|
|
3095
|
+
});
|
|
3096
|
+
assertApplied(result, 'expire_gate (settle_default)');
|
|
3097
|
+
const entry = result.run.settled?.['gated'];
|
|
3098
|
+
if (entry?.token !== 'gate-1' ||
|
|
3099
|
+
entry.outcome !== 'gate' ||
|
|
3100
|
+
entry.choice !== 'approve' ||
|
|
3101
|
+
entry.resolved_by !== 'timeout') {
|
|
3102
|
+
throw new Error(`expected settled.gated = {token:'gate-1', outcome:'gate', choice:'approve', ` +
|
|
3103
|
+
`resolved_by:'timeout'}, got ${JSON.stringify(entry)}`);
|
|
3104
|
+
}
|
|
3105
|
+
if (result.run.terminal_state !== true) {
|
|
3106
|
+
throw new Error('sole step ⇒ isComplete ⇒ the run must terminalize');
|
|
3107
|
+
}
|
|
3108
|
+
},
|
|
3109
|
+
},
|
|
3110
|
+
{
|
|
3111
|
+
law: 'EXPIRE_DEFAULT_RESOLVE',
|
|
3112
|
+
name: `[${adapter.storeName}] expire_gate (settle_default): a sibling step still in progress keeps the run non-terminal`,
|
|
3113
|
+
run: async () => {
|
|
3114
|
+
const def = minimalDefinition(['gated', 'sibling']);
|
|
3115
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
3116
|
+
await adapter.store.claimStep(run.id, 'sibling', def);
|
|
3117
|
+
const gate = makePendingGate('gated', {
|
|
3118
|
+
gateId: 'gate-1',
|
|
3119
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
3120
|
+
onExpiry: 'settle_default',
|
|
3121
|
+
defaultChoice: 'approve',
|
|
3122
|
+
});
|
|
3123
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
3124
|
+
assertApplied(opened, 'open_gate');
|
|
3125
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
3126
|
+
now: new Date('2026-01-01T00:10:00.000Z'),
|
|
3127
|
+
});
|
|
3128
|
+
assertApplied(result, 'expire_gate (settle_default)');
|
|
3129
|
+
if (result.run.terminal_state !== false) {
|
|
3130
|
+
throw new Error("'sibling' still in_progress ⇒ the run must stay non-terminal");
|
|
3131
|
+
}
|
|
3132
|
+
if (!result.run.completed_steps.includes('gated')) {
|
|
3133
|
+
throw new Error("expected 'gated' in completed_steps");
|
|
3134
|
+
}
|
|
3135
|
+
},
|
|
3136
|
+
},
|
|
3137
|
+
{
|
|
3138
|
+
law: 'EXPIRE_DEFAULT_RESOLVE',
|
|
3139
|
+
name: `[${adapter.storeName}] FROZEN-BEATS-DEFINITION: expire_gate reads the RECORD-frozen default_choice, NEVER a re-registered definition's drifted value`,
|
|
3140
|
+
run: async () => {
|
|
3141
|
+
const def = minimalDefinition(['gated']);
|
|
3142
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
3143
|
+
// The gate was minted under a definition whose gate.default_choice was 'approve' — frozen
|
|
3144
|
+
// into the record's own pending_gate.default_choice at mint (simulated directly here, as
|
|
3145
|
+
// the source test targets applyExpireGate's own read behavior, not the mint site).
|
|
3146
|
+
const gate = makePendingGate('gated', {
|
|
3147
|
+
gateId: 'gate-1',
|
|
3148
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
3149
|
+
onExpiry: 'settle_default',
|
|
3150
|
+
defaultChoice: 'approve',
|
|
3151
|
+
});
|
|
3152
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
3153
|
+
assertApplied(opened, 'open_gate');
|
|
3154
|
+
// A DIFFERENT definition — simulating a workflow re-registered with a changed
|
|
3155
|
+
// default_choice ('reject') and a different choice set entirely — passed only at
|
|
3156
|
+
// enactment time. applyExpireGate never reads step-def gate config (gate state lives
|
|
3157
|
+
// entirely on the RECORD's frozen pending_gate), so this must have zero effect.
|
|
3158
|
+
const driftedDef = {
|
|
3159
|
+
id: uid('settlement-wf-drifted'),
|
|
3160
|
+
name: 'Drifted default_choice',
|
|
3161
|
+
version: 1,
|
|
3162
|
+
steps: {
|
|
3163
|
+
gated: {
|
|
3164
|
+
description: 'gated',
|
|
3165
|
+
execution: 'agent',
|
|
3166
|
+
depends_on: [],
|
|
3167
|
+
trust: 'human_confirmed',
|
|
3168
|
+
gate: {
|
|
3169
|
+
choices: ['ship', 'hold'],
|
|
3170
|
+
on_expiry: 'settle_default',
|
|
3171
|
+
default_choice: 'reject',
|
|
3172
|
+
},
|
|
3173
|
+
},
|
|
3174
|
+
},
|
|
3175
|
+
};
|
|
3176
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, driftedDef, {
|
|
3177
|
+
now: new Date('2026-01-01T00:10:00.000Z'),
|
|
3178
|
+
});
|
|
3179
|
+
assertApplied(result, 'expire_gate against a drifted definition');
|
|
3180
|
+
if (result.run.settled?.['gated']?.choice !== 'approve') {
|
|
3181
|
+
throw new Error(`expected the FROZEN choice 'approve' to win, got '${result.run.settled?.['gated']?.choice}'`);
|
|
3182
|
+
}
|
|
3183
|
+
},
|
|
3184
|
+
},
|
|
3185
|
+
{
|
|
3186
|
+
law: 'EXPIRE_DEFAULT_RESOLVE',
|
|
3187
|
+
name: `[${adapter.storeName}] expire_gate (settle_default): the evidence gate_response snapshot carries responded_by:'timeout' + resolution:'expired_default'`,
|
|
3188
|
+
run: async () => {
|
|
3189
|
+
const def = minimalDefinition(['gated']);
|
|
3190
|
+
const { run, token } = await createClaimed(adapter.store, def, 'gated');
|
|
3191
|
+
const gate = makePendingGate('gated', {
|
|
3192
|
+
gateId: 'gate-1',
|
|
3193
|
+
expiresAt: '2026-01-01T00:05:00.000Z',
|
|
3194
|
+
onExpiry: 'settle_default',
|
|
3195
|
+
defaultChoice: 'approve',
|
|
3196
|
+
});
|
|
3197
|
+
const opened = await settleStep(run.id, { kind: 'open_gate', step: 'gated', claimToken: token, pendingGate: gate, evidence: [] }, def);
|
|
3198
|
+
assertApplied(opened, 'open_gate');
|
|
3199
|
+
const result = await settleStep(run.id, { kind: 'expire_gate', gateId: 'gate-1' }, def, {
|
|
3200
|
+
now: new Date('2026-01-01T00:10:00.000Z'),
|
|
3201
|
+
});
|
|
3202
|
+
assertApplied(result, 'expire_gate (settle_default)');
|
|
3203
|
+
const snapshot = result.run.evidence.at(-1);
|
|
3204
|
+
if (snapshot?.kind !== 'gate_response' ||
|
|
3205
|
+
snapshot.responded_by !== 'timeout' ||
|
|
3206
|
+
snapshot.resolution !== 'expired_default') {
|
|
3207
|
+
throw new Error(`expected the last evidence entry to be a gate_response with responded_by:'timeout'/` +
|
|
3208
|
+
`resolution:'expired_default', got ${JSON.stringify(snapshot)}`);
|
|
3209
|
+
}
|
|
3210
|
+
},
|
|
3211
|
+
},
|
|
3212
|
+
];
|
|
3213
|
+
}
|
|
3214
|
+
// ---------------------------------------------------------------------------
|
|
1638
3215
|
// Assembly
|
|
1639
3216
|
// ---------------------------------------------------------------------------
|
|
1640
3217
|
/**
|
|
@@ -1687,6 +3264,23 @@ export function settlementContract(adapter) {
|
|
|
1687
3264
|
...completeSealPhaseCases(adapter),
|
|
1688
3265
|
...whenRoutedTerminalizationCases(adapter),
|
|
1689
3266
|
...g1GateCoexistenceCases(adapter),
|
|
3267
|
+
// issue #279, increment 2 (PR-C).
|
|
3268
|
+
...gateOpenIdempotentCases(adapter),
|
|
3269
|
+
...gateResolutionConflictCases(adapter),
|
|
3270
|
+
...gateMismatchCases(adapter),
|
|
3271
|
+
...guardCases(adapter),
|
|
3272
|
+
...releaseIdempotentCases(adapter),
|
|
3273
|
+
...phaseIsGeneratedCases(adapter),
|
|
3274
|
+
// issue #302 (finalizer outcome×trigger matrix).
|
|
3275
|
+
...cwfsFiresPerArmCases(adapter),
|
|
3276
|
+
...cwfsNegativesCases(adapter),
|
|
3277
|
+
...cwfsSecondEpochCases(adapter),
|
|
3278
|
+
...cwfsArrayOnceCases(adapter),
|
|
3279
|
+
...currentBehaviorPinnedCases(adapter),
|
|
3280
|
+
// issue #291 (gate-timeout-291-correction, Leg 2).
|
|
3281
|
+
...expireArmMatrixCases(adapter),
|
|
3282
|
+
...expireAbortCascadeCases(adapter),
|
|
3283
|
+
...expireDefaultResolveCases(adapter),
|
|
1690
3284
|
];
|
|
1691
3285
|
}
|
|
1692
3286
|
//# sourceMappingURL=settlement-contract.js.map
|