@sensigo/realm-testing 0.33.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.
@@ -88,6 +88,9 @@ function makePendingGate(stepName, opts) {
88
88
  preview: {},
89
89
  choices: opts?.choices ?? ['approve', 'reject'],
90
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 } : {}),
91
94
  };
92
95
  }
93
96
  function makeEvidence(stepId, overrides = {}) {
@@ -2444,6 +2447,771 @@ function phaseIsGeneratedCases(adapter) {
2444
2447
  ];
2445
2448
  }
2446
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
+ // ---------------------------------------------------------------------------
2447
3215
  // Assembly
2448
3216
  // ---------------------------------------------------------------------------
2449
3217
  /**
@@ -2503,6 +3271,16 @@ export function settlementContract(adapter) {
2503
3271
  ...guardCases(adapter),
2504
3272
  ...releaseIdempotentCases(adapter),
2505
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),
2506
3284
  ];
2507
3285
  }
2508
3286
  //# sourceMappingURL=settlement-contract.js.map