@sensigo/realm-testing 0.32.0 → 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 +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 +20 -5
- package/dist/store/settlement-contract.d.ts.map +1 -1
- package/dist/store/settlement-contract.js +826 -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,45 @@ 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
|
+
};
|
|
92
|
+
}
|
|
57
93
|
function makeEvidence(stepId, overrides = {}) {
|
|
58
94
|
return {
|
|
59
95
|
step_id: stepId,
|
|
@@ -1635,6 +1671,779 @@ function g1GateCoexistenceCases(_adapter) {
|
|
|
1635
1671
|
];
|
|
1636
1672
|
}
|
|
1637
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
|
+
// ---------------------------------------------------------------------------
|
|
1638
2447
|
// Assembly
|
|
1639
2448
|
// ---------------------------------------------------------------------------
|
|
1640
2449
|
/**
|
|
@@ -1687,6 +2496,13 @@ export function settlementContract(adapter) {
|
|
|
1687
2496
|
...completeSealPhaseCases(adapter),
|
|
1688
2497
|
...whenRoutedTerminalizationCases(adapter),
|
|
1689
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),
|
|
1690
2506
|
];
|
|
1691
2507
|
}
|
|
1692
2508
|
//# sourceMappingURL=settlement-contract.js.map
|