@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.
@@ -1,8 +1,10 @@
1
1
  // settlement-contract.ts — framework-agnostic Test Compatibility Kit (TCK) for RunStore.settleStep
2
- // (issue #279, increment 1, PR-A). Normative spec: plans/issue-279/design-d4-increment1.md this
3
- // file implements the PR-A-runnable law subset named in the hand-off prompt's D4 section (a subset
4
- // of the design record's §8 the PR-B-only laws, e.g. RESUME_CLEARS_SETTLED / DRAIN_REREADS_LEDGER,
5
- // need `applyResume`/the drain verb and are deliberately NOT here).
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
- /** Default settlement fixture builds minimal agent-step / finalizer-step definitions with no
53
- * store-specific requirements beyond `RunStore.create` never validating `workflowId` against an
54
- * external registry. Suitable for JsonFileStore and InMemoryStore; both this package's own
55
- * conformance test files wire this in directly. */
56
- export const defaultSettlementFixture = { minimalDefinition, withFinalizer };
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