@sensigo/realm-testing 0.31.2 → 0.32.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.
@@ -0,0 +1,1692 @@
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).
6
+ //
7
+ // Pure case descriptors, NOT describe/it/expect — mirrors run-store-fidelity-contract.ts's and
8
+ // fenced-trace-buffer-contract.ts's own precedent (importing vitest here would make it a runtime
9
+ // dependency of this published package). Each calling test file supplies an adapter and wires the
10
+ // returned descriptors into ITS OWN test framework.
11
+ //
12
+ // Wiring note (divergence from the issue #183/#188 precedent, deliberate): the #183/#188 pattern
13
+ // keeps a JsonFileStore conformance test in @sensigo/realm-cli (cli depends on both core and
14
+ // testing; a testing→cli dependency would be circular). That constraint does NOT apply here:
15
+ // JsonFileStore is exported directly from @sensigo/realm's own index (core), and
16
+ // @sensigo/realm-testing already depends on @sensigo/realm — so BOTH stores' settlement
17
+ // conformance can live in NEW test files right here in packages/testing/src/store/, with no
18
+ // circular-package hazard. See this module's own calling test files for the actual wiring.
19
+ import { applySettlement, } from '@sensigo/realm';
20
+ // ---------------------------------------------------------------------------
21
+ // Fixture builders
22
+ // ---------------------------------------------------------------------------
23
+ let seq = 0;
24
+ /** A unique-per-call identifier — used for workflowId strings, which need only be locally unique
25
+ * (RunStore never validates them against a registry). */
26
+ function uid(prefix) {
27
+ seq += 1;
28
+ return `${prefix}-${seq}-${Math.random().toString(36).slice(2, 8)}`;
29
+ }
30
+ /** A minimal workflow definition with the named agent steps, all immediately eligible (no deps). */
31
+ function minimalDefinition(stepNames) {
32
+ const steps = {};
33
+ for (const name of stepNames) {
34
+ steps[name] = { description: name, execution: 'agent', depends_on: [] };
35
+ }
36
+ return { id: uid('settlement-wf'), name: 'Settlement TCK fixture', version: 1, steps };
37
+ }
38
+ /** Adds one finalizer step to a definition. */
39
+ function withFinalizer(def, finalizerName, onOutcome) {
40
+ return {
41
+ ...def,
42
+ steps: {
43
+ ...def.steps,
44
+ [finalizerName]: {
45
+ description: finalizerName,
46
+ execution: 'finalizer',
47
+ on_outcome: onOutcome,
48
+ },
49
+ },
50
+ };
51
+ }
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 };
57
+ function makeEvidence(stepId, overrides = {}) {
58
+ return {
59
+ step_id: stepId,
60
+ started_at: '2026-01-01T00:00:00.000Z',
61
+ completed_at: '2026-01-01T00:00:01.000Z',
62
+ duration_ms: 1,
63
+ input_summary: {},
64
+ output_summary: {},
65
+ status: 'success',
66
+ evidence_hash: 'tck-evidence',
67
+ ...overrides,
68
+ };
69
+ }
70
+ /** Creates a fresh run and claims `stepName` — returns the claimed run plus the REAL,
71
+ * store-minted fencing token (never hand-constructed — the law-3 mint-forcing rule). */
72
+ async function createClaimed(store, def, stepName) {
73
+ const { run } = await store.create({
74
+ workflowId: def.id,
75
+ workflowVersion: def.version,
76
+ params: {},
77
+ });
78
+ const claimed = await store.claimStep(run.id, stepName, def);
79
+ const token = claimed.claims?.[stepName]?.token;
80
+ if (token === undefined) {
81
+ throw new Error(`fixture setup failed: claimStep did not mint a token for step '${stepName}'`);
82
+ }
83
+ return { run: claimed, token };
84
+ }
85
+ function requireSettleStep(store) {
86
+ if (store.settleStep === undefined) {
87
+ throw new Error('settlementContract requires an adapter.store that declares settleStep');
88
+ }
89
+ return store.settleStep.bind(store);
90
+ }
91
+ function assertApplied(result, context) {
92
+ if (!result.applied) {
93
+ throw new Error(`${context}: expected applied:true, got refusal reason '${result.reason}'`);
94
+ }
95
+ }
96
+ function assertRefused(result, expectedReason, context) {
97
+ if (result.applied) {
98
+ throw new Error(`${context}: expected a refusal (reason '${expectedReason}'), got applied:true`);
99
+ }
100
+ if (result.reason !== expectedReason) {
101
+ throw new Error(`${context}: expected reason '${expectedReason}', got '${result.reason}'`);
102
+ }
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // L1 FRESH_APPLICATION
106
+ // ---------------------------------------------------------------------------
107
+ function freshApplicationCases(adapter) {
108
+ const { minimalDefinition } = adapter.settlementFixture;
109
+ const settleStep = requireSettleStep(adapter.store);
110
+ return [
111
+ {
112
+ law: 'FRESH_APPLICATION',
113
+ name: `[${adapter.storeName}] routine same-run fan-out (2 disjoint steps settling concurrently) produces ZERO refusals — disjoint deltas compose in-CS`,
114
+ run: async () => {
115
+ const def = minimalDefinition(['a', 'b']);
116
+ const { run } = await adapter.store.create({
117
+ workflowId: def.id,
118
+ workflowVersion: 1,
119
+ params: {},
120
+ });
121
+ const claimedA = await adapter.store.claimStep(run.id, 'a', def);
122
+ const tokenA = claimedA.claims['a'].token;
123
+ const claimedB = await adapter.store.claimStep(run.id, 'b', def);
124
+ const tokenB = claimedB.claims['b'].token;
125
+ const [resultA, resultB] = await Promise.all([
126
+ settleStep(run.id, {
127
+ kind: 'settle_step',
128
+ step: 'a',
129
+ outcome: 'complete',
130
+ claimToken: tokenA,
131
+ evidence: [makeEvidence('a')],
132
+ }, def),
133
+ settleStep(run.id, {
134
+ kind: 'settle_step',
135
+ step: 'b',
136
+ outcome: 'complete',
137
+ claimToken: tokenB,
138
+ evidence: [makeEvidence('b')],
139
+ }, def),
140
+ ]);
141
+ assertApplied(resultA, 'fan-out settle of step a');
142
+ assertApplied(resultB, 'fan-out settle of step b');
143
+ const final = await adapter.store.get(run.id);
144
+ if (!final.completed_steps.includes('a') || !final.completed_steps.includes('b')) {
145
+ throw new Error(`expected both 'a' and 'b' in completed_steps after concurrent settle, got: ${JSON.stringify(final.completed_steps)}`);
146
+ }
147
+ if (final.terminal_state !== true) {
148
+ throw new Error('expected the run to terminalize once both disjoint steps settled');
149
+ }
150
+ },
151
+ },
152
+ ];
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // L2 CONDITIONAL_NOOP (+ abort variant) + CONDITIONAL_NOOP_GRANDFATHERED
156
+ // ---------------------------------------------------------------------------
157
+ function conditionalNoopCases(adapter) {
158
+ const { minimalDefinition } = adapter.settlementFixture;
159
+ const settleStep = requireSettleStep(adapter.store);
160
+ return [
161
+ {
162
+ law: 'CONDITIONAL_NOOP',
163
+ name: `[${adapter.storeName}] a same-token, same-outcome (complete) retry NOOPs as already_settled — version unchanged`,
164
+ run: async () => {
165
+ const def = minimalDefinition(['a', 'b']);
166
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
167
+ const delta = {
168
+ kind: 'settle_step',
169
+ step: 'a',
170
+ outcome: 'complete',
171
+ claimToken: token,
172
+ evidence: [makeEvidence('a')],
173
+ };
174
+ const first = await settleStep(run.id, delta, def);
175
+ assertApplied(first, 'first settle of step a');
176
+ const second = await settleStep(run.id, delta, def);
177
+ assertRefused(second, 'already_settled', 'retry settle of step a (same token, same outcome)');
178
+ if (second.run.version !== first.run.version) {
179
+ throw new Error(`expected version unchanged on a NOOP (${first.run.version}), got ${second.run.version}`);
180
+ }
181
+ },
182
+ },
183
+ {
184
+ law: 'CONDITIONAL_NOOP',
185
+ name: `[${adapter.storeName}] a same-token, same-outcome (abort) retry NOOPs as already_settled — the mapped-space abort variant`,
186
+ run: async () => {
187
+ const def = minimalDefinition(['a', 'b']);
188
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
189
+ const delta = {
190
+ kind: 'settle_step',
191
+ step: 'a',
192
+ outcome: 'abort',
193
+ claimToken: token,
194
+ evidence: [makeEvidence('a')],
195
+ abort: { stepId: 'a', abortMessage: 'tck-abort' },
196
+ };
197
+ const first = await settleStep(run.id, delta, def);
198
+ assertApplied(first, 'first abort-settle of step a');
199
+ const second = await settleStep(run.id, delta, def);
200
+ assertRefused(second, 'already_settled', 'retry abort-settle of step a (same token, same outcome)');
201
+ },
202
+ },
203
+ {
204
+ law: 'CONDITIONAL_NOOP_GRANDFATHERED',
205
+ name: `[${adapter.storeName}] a grandfathered (token-less) claim settles with no claimToken presented, and absent≡absent NOOPs on retry`,
206
+ run: async () => {
207
+ const def = minimalDefinition(['a']);
208
+ const { run } = await adapter.store.create({
209
+ workflowId: def.id,
210
+ workflowVersion: 1,
211
+ params: {},
212
+ });
213
+ // Hand-seed a pre-#279-shaped claim (no token) — simulates a grandfathered claim record.
214
+ // This is the ONE deliberate exception to "tokens MUST come from store-returned claim
215
+ // records" (law-3's mint-forcing sentence) — the whole point of this fixture is to prove
216
+ // the ABSENCE of a token is handled correctly, so it must be genuinely absent here.
217
+ const seeded = await adapter.store.update({
218
+ ...run,
219
+ in_progress_steps: ['a'],
220
+ claims: { a: { deadline: null } },
221
+ });
222
+ const delta = {
223
+ kind: 'settle_step',
224
+ step: 'a',
225
+ outcome: 'complete',
226
+ evidence: [makeEvidence('a')],
227
+ // claimToken deliberately omitted
228
+ };
229
+ const first = await settleStep(seeded.id, delta, def);
230
+ assertApplied(first, 'grandfathered (token-less) settle');
231
+ const second = await settleStep(seeded.id, delta, def);
232
+ assertRefused(second, 'already_settled', 'grandfathered retry (absent≡absent)');
233
+ },
234
+ },
235
+ ];
236
+ }
237
+ // ---------------------------------------------------------------------------
238
+ // L3 OWNERSHIP_REFUSAL
239
+ // ---------------------------------------------------------------------------
240
+ function ownershipRefusalCases(adapter) {
241
+ const { minimalDefinition } = adapter.settlementFixture;
242
+ const settleStep = requireSettleStep(adapter.store);
243
+ return [
244
+ {
245
+ law: 'OWNERSHIP_REFUSAL',
246
+ name: `[${adapter.storeName}] a settle_step with a WRONG claimToken refuses claim_lost — outcome NOT recorded`,
247
+ run: async () => {
248
+ const def = minimalDefinition(['a']);
249
+ const { run } = await createClaimed(adapter.store, def, 'a');
250
+ const result = await settleStep(run.id, {
251
+ kind: 'settle_step',
252
+ step: 'a',
253
+ outcome: 'complete',
254
+ claimToken: 'wrong-token',
255
+ evidence: [],
256
+ }, def);
257
+ assertRefused(result, 'claim_lost', 'settle with a wrong token');
258
+ if (result.run.completed_steps.includes('a')) {
259
+ throw new Error('a claim_lost refusal must never record the outcome');
260
+ }
261
+ },
262
+ },
263
+ ];
264
+ }
265
+ // ---------------------------------------------------------------------------
266
+ // L4 LEDGER_MINT_ATOMICITY (+ cross-backend caveat — documented, not testable in-repo beyond this)
267
+ // ---------------------------------------------------------------------------
268
+ function ledgerMintAtomicityCases(adapter) {
269
+ const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
270
+ const settleStep = requireSettleStep(adapter.store);
271
+ return [
272
+ {
273
+ law: 'LEDGER_MINT_ATOMICITY',
274
+ name: `[${adapter.storeName}] a terminal edge mints the ledger in the SAME write as membership/terminal_state — never a torn intermediate state (single version bump)`,
275
+ run: async () => {
276
+ // CROSS-BACKEND CAVEAT (design record §8/L4): this in-repo store's atomicity comes from
277
+ // ITS OWN single lock+read+write critical section (JsonFileStore) or its documented
278
+ // no-await synchronous stretch (InMemoryStore) — verified structurally elsewhere in this
279
+ // suite. A MULTI-STATEMENT backend (e.g. a future Postgres store) MUST prove its own
280
+ // atomicity in ITS OWN conformance suite; a green run here does not and cannot verify
281
+ // that (mirrors claimStep's own cross-host caveat). This case's own assertion is a
282
+ // same-process proxy: exactly ONE version bump carries both the membership AND the ledger
283
+ // mint — never two separate writes.
284
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'complete');
285
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
286
+ const before = run.version;
287
+ const result = await settleStep(run.id, {
288
+ kind: 'settle_step',
289
+ step: 'a',
290
+ outcome: 'complete',
291
+ claimToken: token,
292
+ evidence: [makeEvidence('a')],
293
+ }, def);
294
+ assertApplied(result, 'terminal settle minting a finalizer');
295
+ if (result.run.version !== before + 1) {
296
+ throw new Error(`expected exactly one version bump (${before} -> ${before + 1}) carrying both membership and the mint, got version ${result.run.version}`);
297
+ }
298
+ if (result.run.finalizer_ledger?.['fin']?.status !== 'pending') {
299
+ throw new Error('expected the finalizer to be minted pending in the SAME write');
300
+ }
301
+ },
302
+ },
303
+ ];
304
+ }
305
+ // ---------------------------------------------------------------------------
306
+ // L5 DRAIN_MARK_DEDUP
307
+ // ---------------------------------------------------------------------------
308
+ function drainMarkDedupCases(adapter) {
309
+ const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
310
+ const settleStep = requireSettleStep(adapter.store);
311
+ return [
312
+ {
313
+ law: 'DRAIN_MARK_DEDUP',
314
+ name: `[${adapter.storeName}] a same-token, same-result mark_finalizer retry NOOPs as already_marked — completed_steps not double-appended`,
315
+ run: async () => {
316
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'complete');
317
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
318
+ const settled = await settleStep(run.id, {
319
+ kind: 'settle_step',
320
+ step: 'a',
321
+ outcome: 'complete',
322
+ claimToken: token,
323
+ evidence: [makeEvidence('a')],
324
+ }, def);
325
+ assertApplied(settled, 'terminal settle minting the finalizer');
326
+ const leaseToken = uid('lease');
327
+ const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
328
+ assertApplied(leased, 'lease the pending finalizer');
329
+ const markDelta = {
330
+ kind: 'mark_finalizer',
331
+ finalizer: 'fin',
332
+ leaseToken,
333
+ result: 'completed',
334
+ evidence: makeEvidence('fin'),
335
+ };
336
+ const firstMark = await settleStep(run.id, markDelta, def);
337
+ assertApplied(firstMark, 'first mark');
338
+ const secondMark = await settleStep(run.id, markDelta, def);
339
+ assertRefused(secondMark, 'already_marked', 'retry mark (same token, same result)');
340
+ const occurrences = secondMark.run.completed_steps.filter((s) => s === 'fin').length;
341
+ if (occurrences !== 1) {
342
+ throw new Error(`expected 'fin' to appear exactly once in completed_steps, got ${occurrences}`);
343
+ }
344
+ },
345
+ },
346
+ ];
347
+ }
348
+ // ---------------------------------------------------------------------------
349
+ // L6 TERMINAL_REFUSAL
350
+ // ---------------------------------------------------------------------------
351
+ function terminalRefusalCases(adapter) {
352
+ const { minimalDefinition } = adapter.settlementFixture;
353
+ const settleStep = requireSettleStep(adapter.store);
354
+ return [
355
+ {
356
+ law: 'TERMINAL_REFUSAL',
357
+ name: `[${adapter.storeName}] settle_step on an ALREADY-terminal run (a different, never-settled step) refuses run_terminal`,
358
+ run: async () => {
359
+ const def = minimalDefinition(['a', 'b']);
360
+ const { run } = await adapter.store.create({
361
+ workflowId: def.id,
362
+ workflowVersion: 1,
363
+ params: {},
364
+ });
365
+ // Terminalize the run directly (no need to route through a real settle for this fixture).
366
+ await adapter.store.update({
367
+ ...run,
368
+ terminal_state: true,
369
+ terminal_reason: 'tck-terminal',
370
+ });
371
+ const result = await settleStep(run.id, {
372
+ kind: 'settle_step',
373
+ step: 'b',
374
+ outcome: 'complete',
375
+ claimToken: 'irrelevant',
376
+ evidence: [],
377
+ }, def);
378
+ assertRefused(result, 'run_terminal', 'settle on an already-terminal run');
379
+ },
380
+ },
381
+ ];
382
+ }
383
+ // ---------------------------------------------------------------------------
384
+ // TERMINAL_STATE_ONLY (PR-A correction, atomic-settle-279-pr-a-pin-correction.md) — pins
385
+ // `isTerminal := terminal_state === true` EXACTLY, refuting the earlier trio adjudication
386
+ // (`terminal_state === true || abandoned_at !== undefined || aborted_at !== undefined`), which
387
+ // the restart's bottom-up Finding 1 identified as the deterministic resumed-abandoned wedge.
388
+ // terminal_state-only is load-bearing TODAY: `resume` (packages/cli/src/commands/resume.ts:71)
389
+ // strips only `terminal_reason`, never `abandoned_at` — so `abandoned_at ∧ terminal_state:false`
390
+ // is a LIVE, in-contract record shape on main right now. Under the trio, every settle on such a
391
+ // run would refuse `run_terminal` forever.
392
+ //
393
+ // Reintroducing either trio disjunct into `isTerminal` reds this law; genuine terminal refusal is
394
+ // TERMINAL_REFUSAL's job (`terminal_state: true` — which abandon/abort ALWAYS set atomically,
395
+ // abandon-run.ts:66-71 / execution-loop.ts:1803-1811).
396
+ // ---------------------------------------------------------------------------
397
+ function terminalStateOnlyCases(adapter) {
398
+ const { minimalDefinition } = adapter.settlementFixture;
399
+ const settleStep = requireSettleStep(adapter.store);
400
+ return [
401
+ {
402
+ law: 'TERMINAL_STATE_ONLY',
403
+ name: `[${adapter.storeName}] a resumed-abandoned shape (abandoned_at SET, terminal_state:false) — settle_step APPLIES, never run_terminal`,
404
+ run: async () => {
405
+ const def = minimalDefinition(['a']);
406
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
407
+ // Hand-authored fixture via update() (mirrors MINT_FRESH's own pattern) — a LIVE,
408
+ // in-contract shape today, not a hypothetical: resume never strips abandoned_at.
409
+ const seeded = await adapter.store.update({
410
+ ...run,
411
+ abandoned_at: '2026-01-01T00:00:00.000Z',
412
+ });
413
+ const result = await settleStep(seeded.id, {
414
+ kind: 'settle_step',
415
+ step: 'a',
416
+ outcome: 'complete',
417
+ claimToken: token,
418
+ evidence: [makeEvidence('a')],
419
+ }, def);
420
+ assertApplied(result, 'settle on a resumed-abandoned (terminal_state:false) run');
421
+ if (!result.run.completed_steps.includes('a')) {
422
+ throw new Error(`expected 'a' to land in completed_steps, got: ${JSON.stringify(result.run.completed_steps)}`);
423
+ }
424
+ },
425
+ },
426
+ {
427
+ law: 'TERMINAL_STATE_ONLY',
428
+ name: `[${adapter.storeName}] an aborted-marker shape (aborted_at SET, terminal_state:false) — settle_step APPLIES, never run_terminal`,
429
+ run: async () => {
430
+ const def = minimalDefinition(['a']);
431
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
432
+ // Fixture-legal via update() regardless of whether the real engine can currently reach
433
+ // this exact combination — the law pins the PREDICATE itself, so it must make ANY trio
434
+ // disjunct reintroduction red, not just the abandoned one.
435
+ const seeded = await adapter.store.update({
436
+ ...run,
437
+ aborted_at: { step_id: 'a', abort_message: 'tck-aborted-marker' },
438
+ });
439
+ const result = await settleStep(seeded.id, {
440
+ kind: 'settle_step',
441
+ step: 'a',
442
+ outcome: 'complete',
443
+ claimToken: token,
444
+ evidence: [makeEvidence('a')],
445
+ }, def);
446
+ assertApplied(result, 'settle on an aborted-marker (terminal_state:false) run');
447
+ if (!result.run.completed_steps.includes('a')) {
448
+ throw new Error(`expected 'a' to land in completed_steps, got: ${JSON.stringify(result.run.completed_steps)}`);
449
+ }
450
+ },
451
+ },
452
+ ];
453
+ }
454
+ // ---------------------------------------------------------------------------
455
+ // L7 CS-purity — structural: `options` carries VALUES only ({now}); no callback, no registry.
456
+ // In-repo source-text guard (the calling test file greps applySettlement's own signature).
457
+ // ---------------------------------------------------------------------------
458
+ function csPurityCases(_adapter) {
459
+ return [
460
+ {
461
+ law: 'CS_PURITY',
462
+ name: 'applySettlement is callable with ONLY {now?: Date} as its options — no callback, no registry parameter exists to pass',
463
+ run: async () => {
464
+ // A structural proof, not a source-text grep (that lives in the calling test file, which
465
+ // can read its own source — this module ships compiled and has no access to its own
466
+ // source text at runtime). Calling applySettlement with a bare {now} object and nothing
467
+ // else demonstrates the FULL options surface is exhausted by that one field — TypeScript
468
+ // itself would reject an extra property on a literal passed here if the type carried one.
469
+ const def = minimalDefinition(['a']);
470
+ const fresh = {
471
+ id: 'tck-cs-purity',
472
+ workflow_id: def.id,
473
+ workflow_version: 1,
474
+ completed_steps: [],
475
+ in_progress_steps: ['a'],
476
+ failed_steps: [],
477
+ skipped_steps: [],
478
+ run_phase: 'running',
479
+ version: 0,
480
+ params: {},
481
+ evidence: [],
482
+ created_at: '2026-01-01T00:00:00.000Z',
483
+ updated_at: '2026-01-01T00:00:00.000Z',
484
+ terminal_state: false,
485
+ claims: { a: { deadline: null, token: 'tck-token' } },
486
+ };
487
+ const result = applySettlement(fresh, {
488
+ kind: 'settle_step',
489
+ step: 'a',
490
+ outcome: 'complete',
491
+ claimToken: 'tck-token',
492
+ evidence: [],
493
+ }, def, { now: new Date('2026-01-01T00:00:00.000Z') });
494
+ if (!result.applied) {
495
+ throw new Error(`expected applied:true from a purity-check call, got refusal: ${result.reason}`);
496
+ }
497
+ },
498
+ },
499
+ ];
500
+ }
501
+ // ---------------------------------------------------------------------------
502
+ // L8 never-downgrade — completed/failed ledger entries are immutable across a re-mint.
503
+ // ---------------------------------------------------------------------------
504
+ function neverDowngradeCases(_adapter) {
505
+ return [
506
+ {
507
+ law: 'NEVER_DOWNGRADE',
508
+ name: 'mintFresh (via a hand-authored fresh state) never downgrades an already-completed finalizer ledger entry back to pending',
509
+ run: async () => {
510
+ // Transform-level (not store-level): hand-author a fresh state where 'fin' is selected by
511
+ // selectFinalizers (not yet in completed_steps) but its ledger entry ALREADY shows
512
+ // 'completed' — the defensive guard mintFresh's own doc names ("membership-skip SHOULD
513
+ // exclude this; the guard is defensive"). Exercised directly against applySettlement so
514
+ // the terminal false→true edge fires mintFresh in a single, controlled call.
515
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'complete');
516
+ const fresh = {
517
+ id: 'tck-never-downgrade',
518
+ workflow_id: def.id,
519
+ workflow_version: 1,
520
+ completed_steps: [],
521
+ in_progress_steps: ['a'],
522
+ failed_steps: [],
523
+ skipped_steps: [],
524
+ run_phase: 'running',
525
+ version: 0,
526
+ params: {},
527
+ evidence: [],
528
+ created_at: '2026-01-01T00:00:00.000Z',
529
+ updated_at: '2026-01-01T00:00:00.000Z',
530
+ terminal_state: false,
531
+ claims: { a: { deadline: null, token: 'tck-token' } },
532
+ finalizer_ledger: { fin: { status: 'completed', rank: 0 } },
533
+ };
534
+ const result = applySettlement(fresh, {
535
+ kind: 'settle_step',
536
+ step: 'a',
537
+ outcome: 'complete',
538
+ claimToken: 'tck-token',
539
+ evidence: [makeEvidence('a')],
540
+ }, def, { now: new Date('2026-01-01T00:00:00.000Z') });
541
+ if (!result.applied)
542
+ throw new Error(`expected applied:true, got refusal: ${result.reason}`);
543
+ if (result.run.finalizer_ledger?.['fin']?.status !== 'completed') {
544
+ throw new Error(`expected the already-completed finalizer to stay 'completed', got '${result.run.finalizer_ledger?.['fin']?.status}'`);
545
+ }
546
+ },
547
+ },
548
+ ];
549
+ }
550
+ // ---------------------------------------------------------------------------
551
+ // L9 SETTLE_OUTCOME_INTEGRITY
552
+ // ---------------------------------------------------------------------------
553
+ function settleOutcomeIntegrityCases(adapter) {
554
+ const { minimalDefinition } = adapter.settlementFixture;
555
+ const settleStep = requireSettleStep(adapter.store);
556
+ return [
557
+ {
558
+ law: 'SETTLE_OUTCOME_INTEGRITY',
559
+ name: `[${adapter.storeName}] the SAME token settling the SAME step with a DIFFERENT outcome refuses settled_outcome_divergence`,
560
+ run: async () => {
561
+ const def = minimalDefinition(['a', 'b']);
562
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
563
+ const first = await settleStep(run.id, {
564
+ kind: 'settle_step',
565
+ step: 'a',
566
+ outcome: 'complete',
567
+ claimToken: token,
568
+ evidence: [makeEvidence('a')],
569
+ }, def);
570
+ assertApplied(first, 'first settle (complete)');
571
+ const second = await settleStep(run.id, {
572
+ kind: 'settle_step',
573
+ step: 'a',
574
+ outcome: 'fail',
575
+ claimToken: token,
576
+ evidence: [],
577
+ failureMessage: 'tck-divergent',
578
+ }, def);
579
+ assertRefused(second, 'settled_outcome_divergence', 'same token, divergent outcome');
580
+ },
581
+ },
582
+ ];
583
+ }
584
+ // ---------------------------------------------------------------------------
585
+ // L10 SETTLED_ORPHAN_OVERWRITE (+ wrong-set fixture, lens-3 F7)
586
+ // ---------------------------------------------------------------------------
587
+ function settledOrphanOverwriteCases(adapter) {
588
+ const { minimalDefinition } = adapter.settlementFixture;
589
+ const settleStep = requireSettleStep(adapter.store);
590
+ return [
591
+ {
592
+ law: 'SETTLED_ORPHAN_OVERWRITE',
593
+ name: `[${adapter.storeName}] a settled entry {outcome:'fail'} while the step is ACTUALLY in completed_steps (wrong-set) is treated as ABSENT (orphan) — never a false already_settled_by_other`,
594
+ run: async () => {
595
+ const def = minimalDefinition(['a']);
596
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
597
+ // Hand-author the wrong-set orphan: 'a' is in completed_steps, but its settled entry
598
+ // claims 'fail' — a genuinely inconsistent state that only a hand-authored fixture (or an
599
+ // external store's own divergent history) could produce; settleStep itself always writes
600
+ // both atomically and could never reach this state on its own.
601
+ const seeded = await adapter.store.update({
602
+ ...run,
603
+ completed_steps: ['a'],
604
+ in_progress_steps: [],
605
+ settled: { a: { token: 'stale-different-token', outcome: 'fail' } },
606
+ });
607
+ const result = await settleStep(seeded.id, {
608
+ kind: 'settle_step',
609
+ step: 'a',
610
+ outcome: 'complete',
611
+ claimToken: token,
612
+ evidence: [makeEvidence('a')],
613
+ }, def);
614
+ // The orphan rule's own scope: the predicate must NOT treat the stale entry as a real
615
+ // idempotence match (which would incorrectly refuse already_settled_by_other, since the
616
+ // stale entry's token differs from the real claim token). It is explicitly out of THIS
617
+ // law's scope whether the resulting membership arrays stay duplicate-free when fed a
618
+ // deliberately-inconsistent hand-authored input.
619
+ if (result.applied === false && result.reason === 'already_settled_by_other') {
620
+ throw new Error('the orphaned settled entry incorrectly wedged the predicate into already_settled_by_other — entryOf must treat it as absent');
621
+ }
622
+ },
623
+ },
624
+ ];
625
+ }
626
+ // ---------------------------------------------------------------------------
627
+ // L11 TRANSFORM_FIDELITY (+ TERMINAL_GATE_EXCLUSION asserted across the same fixtures)
628
+ // ---------------------------------------------------------------------------
629
+ function transformFidelityCases(adapter) {
630
+ const { minimalDefinition } = adapter.settlementFixture;
631
+ const settleStep = requireSettleStep(adapter.store);
632
+ async function fidelityRun(outcome) {
633
+ const def = minimalDefinition(['a', 'b']);
634
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
635
+ const now = new Date('2026-06-15T12:00:00.000Z');
636
+ const delta = outcome === 'abort'
637
+ ? {
638
+ kind: 'settle_step',
639
+ step: 'a',
640
+ outcome: 'abort',
641
+ claimToken: token,
642
+ evidence: [makeEvidence('a')],
643
+ abort: { stepId: 'a', abortMessage: 'tck-fidelity-abort' },
644
+ }
645
+ : {
646
+ kind: 'settle_step',
647
+ step: 'a',
648
+ outcome,
649
+ claimToken: token,
650
+ evidence: [makeEvidence('a')],
651
+ ...(outcome === 'fail' ? { failureMessage: 'tck-fidelity-fail' } : {}),
652
+ };
653
+ // Harness-controlled fresh read — the SAME state settleStep's own internal fresh read will see
654
+ // (nothing else mutates this run between the two reads in a single-threaded test).
655
+ const freshRead = await adapter.store.get(run.id);
656
+ const expected = applySettlement(freshRead, delta, def, { now });
657
+ const actual = await settleStep(run.id, delta, def, { now });
658
+ if (expected.applied !== actual.applied) {
659
+ throw new Error(`transform/store fidelity mismatch: harness applySettlement applied=${expected.applied}, store settleStep applied=${actual.applied}`);
660
+ }
661
+ if (expected.applied && actual.applied) {
662
+ const { version: _ev, updated_at: _eu, ...expectedRest } = expected.run;
663
+ const { version: _av, updated_at: _au, ...actualRest } = actual.run;
664
+ const expectedJson = JSON.stringify(expectedRest);
665
+ const actualJson = JSON.stringify(actualRest);
666
+ if (expectedJson !== actualJson) {
667
+ throw new Error(`transform fidelity mismatch (modulo version/updated_at):\nharness: ${expectedJson}\nstore: ${actualJson}`);
668
+ }
669
+ // TERMINAL_GATE_EXCLUSION, asserted across this fixture: no settleStep APPLY output ever
670
+ // carries BOTH terminal_state:true AND a defined pending_gate.
671
+ if (actual.run.terminal_state === true && actual.run.pending_gate !== undefined) {
672
+ throw new Error('TERMINAL_GATE_EXCLUSION violated: terminal AND pending_gate both present');
673
+ }
674
+ }
675
+ }
676
+ return [
677
+ {
678
+ law: 'TRANSFORM_FIDELITY',
679
+ name: `[${adapter.storeName}] settleStep's persisted output matches applySettlement's own output exactly (modulo version/updated_at) — complete outcome, {now} injected`,
680
+ run: () => fidelityRun('complete'),
681
+ },
682
+ {
683
+ law: 'TRANSFORM_FIDELITY',
684
+ name: `[${adapter.storeName}] settleStep's persisted output matches applySettlement's own output exactly (modulo version/updated_at) — fail outcome, {now} injected`,
685
+ run: () => fidelityRun('fail'),
686
+ },
687
+ {
688
+ law: 'TRANSFORM_FIDELITY',
689
+ name: `[${adapter.storeName}] settleStep's persisted output matches applySettlement's own output exactly (modulo version/updated_at) — abort outcome, {now} injected`,
690
+ run: () => fidelityRun('abort'),
691
+ },
692
+ {
693
+ law: 'TERMINAL_GATE_EXCLUSION',
694
+ name: `[${adapter.storeName}] no settleStep APPLY output ever carries BOTH terminal_state:true and a defined pending_gate (asserted across the TRANSFORM_FIDELITY fixtures)`,
695
+ run: () => fidelityRun('complete'),
696
+ },
697
+ ];
698
+ }
699
+ // ---------------------------------------------------------------------------
700
+ // RESULT_AS_APPLIED (a fidelity-dropping fixture store — final-gate F5)
701
+ // ---------------------------------------------------------------------------
702
+ /** A deliberately LOSSY store (issue #279 TCK only) — its `get()` strips `defaulted_steps` on
703
+ * every read, but its internal storage and its OWN `settleStep` never do. Proves
704
+ * `SettlementResult.run` on `applied:true` is the AS-APPLIED transform output, NEVER a re-read —
705
+ * a store whose round-trip drops a field must still return that field's TRUE value directly. */
706
+ class LossyFixtureStore {
707
+ runs = new Map();
708
+ persistsClaims = true;
709
+ // Deliberately dishonest — never declares anything (`persistedRunRecordFields` omitted, not set
710
+ // to `undefined`: exactOptionalPropertyTypes forbids assigning `undefined` to an optional field).
711
+ async create(options) {
712
+ const now = new Date().toISOString();
713
+ const run = {
714
+ id: uid('lossy-run'),
715
+ workflow_id: options.workflowId,
716
+ workflow_version: options.workflowVersion,
717
+ completed_steps: [],
718
+ in_progress_steps: [],
719
+ failed_steps: [],
720
+ skipped_steps: [],
721
+ run_phase: 'running',
722
+ version: 0,
723
+ params: options.params,
724
+ evidence: [],
725
+ created_at: now,
726
+ updated_at: now,
727
+ terminal_state: false,
728
+ };
729
+ this.runs.set(run.id, run);
730
+ return { run, created: true };
731
+ }
732
+ async get(runId) {
733
+ const run = this.runs.get(runId);
734
+ if (run === undefined)
735
+ throw new Error(`lossy fixture store: run '${runId}' not found`);
736
+ // LOSSY: strips defaulted_steps on every read — the whole point of this fixture.
737
+ const { defaulted_steps: _dropped, ...rest } = run;
738
+ return rest;
739
+ }
740
+ async update(record) {
741
+ const updated = {
742
+ ...record,
743
+ version: record.version + 1,
744
+ updated_at: new Date().toISOString(),
745
+ };
746
+ this.runs.set(updated.id, updated);
747
+ return updated;
748
+ }
749
+ async list() {
750
+ return [...this.runs.values()];
751
+ }
752
+ async claimStep(runId, stepName, _definition) {
753
+ const run = this.runs.get(runId);
754
+ if (run === undefined)
755
+ throw new Error(`lossy fixture store: run '${runId}' not found`);
756
+ const claimed = {
757
+ ...run,
758
+ in_progress_steps: [...run.in_progress_steps, stepName],
759
+ claims: { ...run.claims, [stepName]: { deadline: null, token: uid('lossy-token') } },
760
+ version: run.version + 1,
761
+ updated_at: new Date().toISOString(),
762
+ };
763
+ this.runs.set(claimed.id, claimed);
764
+ return claimed;
765
+ }
766
+ async settleStep(runId, delta, definition, options) {
767
+ // Reads its OWN internal map directly (never through the lossy `get()` above).
768
+ const fresh = this.runs.get(runId);
769
+ if (fresh === undefined)
770
+ throw new Error(`lossy fixture store: run '${runId}' not found`);
771
+ const outcome = applySettlement(fresh, delta, definition, options);
772
+ if (!outcome.applied)
773
+ return outcome;
774
+ const updated = {
775
+ ...outcome.run,
776
+ version: fresh.version + 1,
777
+ updated_at: new Date().toISOString(),
778
+ };
779
+ this.runs.set(runId, updated);
780
+ return { ...outcome, run: updated }; // the AS-APPLIED output — never routed through get()
781
+ }
782
+ }
783
+ function resultAsAppliedCases() {
784
+ return [
785
+ {
786
+ law: 'RESULT_AS_APPLIED',
787
+ name: "a fidelity-dropping store (its own get() strips defaulted_steps) still returns the TRUE stamped defaulted_steps directly on settleStep's result — never a re-read",
788
+ run: async () => {
789
+ const store = new LossyFixtureStore();
790
+ const def = minimalDefinition(['a']);
791
+ const { run } = await store.create({ workflowId: def.id, workflowVersion: 1, params: {} });
792
+ const claimed = await store.claimStep(run.id, 'a', def);
793
+ const token = claimed.claims['a'].token;
794
+ const result = await store.settleStep(run.id, {
795
+ kind: 'settle_step',
796
+ step: 'a',
797
+ outcome: 'complete',
798
+ claimToken: token,
799
+ evidence: [
800
+ makeEvidence('a', {
801
+ diagnostics: {
802
+ input_token_estimate: 1,
803
+ precondition_trace: [],
804
+ settled_by_default: true,
805
+ },
806
+ }),
807
+ ],
808
+ }, def);
809
+ assertApplied(result, 'settle on the lossy fixture store');
810
+ if (!result.run.defaulted_steps?.includes('a')) {
811
+ throw new Error(`expected settleStep's OWN result to carry defaulted_steps:['a'] directly, got: ${JSON.stringify(result.run.defaulted_steps)}`);
812
+ }
813
+ // Prove the store really IS lossy on round-trip (the premise the whole test depends on).
814
+ const reread = await store.get(run.id);
815
+ if (reread.defaulted_steps !== undefined) {
816
+ throw new Error('fixture premise violated: the lossy store did not actually drop defaulted_steps on get()');
817
+ }
818
+ },
819
+ },
820
+ ];
821
+ }
822
+ // ---------------------------------------------------------------------------
823
+ // L12 MARK_MEMBERSHIP
824
+ // ---------------------------------------------------------------------------
825
+ function markMembershipCases(adapter) {
826
+ const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
827
+ const settleStep = requireSettleStep(adapter.store);
828
+ async function markRun(result, expectedArray) {
829
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
830
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
831
+ const settled = await settleStep(run.id, {
832
+ kind: 'settle_step',
833
+ step: 'a',
834
+ outcome: 'complete',
835
+ claimToken: token,
836
+ evidence: [makeEvidence('a')],
837
+ }, def);
838
+ assertApplied(settled, 'terminal settle minting the finalizer');
839
+ const leaseToken = uid('lease');
840
+ const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
841
+ assertApplied(leased, 'lease the pending finalizer');
842
+ const marked = await settleStep(run.id, {
843
+ kind: 'mark_finalizer',
844
+ finalizer: 'fin',
845
+ leaseToken,
846
+ result,
847
+ evidence: makeEvidence('fin'),
848
+ }, def);
849
+ assertApplied(marked, `mark the finalizer ${result}`);
850
+ if (!marked.run[expectedArray].includes('fin')) {
851
+ throw new Error(`expected 'fin' to join ${expectedArray} on a '${result}' mark, got: ${JSON.stringify(marked.run[expectedArray])}`);
852
+ }
853
+ }
854
+ return [
855
+ {
856
+ law: 'MARK_MEMBERSHIP',
857
+ name: `[${adapter.storeName}] mark_finalizer result:'completed' adds the finalizer name to completed_steps`,
858
+ run: () => markRun('completed', 'completed_steps'),
859
+ },
860
+ {
861
+ law: 'MARK_MEMBERSHIP',
862
+ name: `[${adapter.storeName}] mark_finalizer result:'failed' adds the finalizer name to failed_steps`,
863
+ run: () => markRun('failed', 'failed_steps'),
864
+ },
865
+ ];
866
+ }
867
+ // ---------------------------------------------------------------------------
868
+ // L13 REFUSAL_SWEEP — one fixture per REFUSE/NOOP line of §3 (17 total: 6 settle_step,
869
+ // 6 lease_finalizer, 5 mark_finalizer). Every fixture asserts the typed literal reason AND that
870
+ // the record/version are unchanged (a refusal/noop never writes).
871
+ // ---------------------------------------------------------------------------
872
+ function refusalSweepCases(adapter) {
873
+ const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
874
+ const settleStep = requireSettleStep(adapter.store);
875
+ async function expectRefusalUnchanged(runId, delta, def, expectedReason, versionBefore, label) {
876
+ const result = await settleStep(runId, delta, def);
877
+ if (result.applied) {
878
+ throw new Error(`${label}: expected a refusal/noop ('${expectedReason}'), got applied:true`);
879
+ }
880
+ if (result.reason !== expectedReason) {
881
+ throw new Error(`${label}: expected reason '${expectedReason}', got '${result.reason}'`);
882
+ }
883
+ if (result.run.version !== versionBefore) {
884
+ throw new Error(`${label}: expected version unchanged (${versionBefore}), got ${result.run.version}`);
885
+ }
886
+ }
887
+ const cases = [];
888
+ // --- settle_step (6) ---
889
+ cases.push({
890
+ law: 'REFUSAL_SWEEP',
891
+ name: `[${adapter.storeName}] settle_step: already_settled_by_other (different token on a settled step)`,
892
+ run: async () => {
893
+ const def = minimalDefinition(['a']);
894
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
895
+ const settled = await settleStep(run.id, {
896
+ kind: 'settle_step',
897
+ step: 'a',
898
+ outcome: 'complete',
899
+ claimToken: token,
900
+ evidence: [makeEvidence('a')],
901
+ }, def);
902
+ assertApplied(settled, 'setup settle');
903
+ await expectRefusalUnchanged(run.id, {
904
+ kind: 'settle_step',
905
+ step: 'a',
906
+ outcome: 'complete',
907
+ claimToken: 'a-different-token',
908
+ evidence: [],
909
+ }, def, 'already_settled_by_other', settled.run.version, 'already_settled_by_other');
910
+ },
911
+ });
912
+ cases.push({
913
+ law: 'REFUSAL_SWEEP',
914
+ name: `[${adapter.storeName}] settle_step: already_settled (NOOP — same token, same outcome)`,
915
+ run: async () => {
916
+ const def = minimalDefinition(['a']);
917
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
918
+ const delta = {
919
+ kind: 'settle_step',
920
+ step: 'a',
921
+ outcome: 'complete',
922
+ claimToken: token,
923
+ evidence: [makeEvidence('a')],
924
+ };
925
+ const settled = await settleStep(run.id, delta, def);
926
+ assertApplied(settled, 'setup settle');
927
+ await expectRefusalUnchanged(run.id, delta, def, 'already_settled', settled.run.version, 'already_settled');
928
+ },
929
+ });
930
+ cases.push({
931
+ law: 'REFUSAL_SWEEP',
932
+ name: `[${adapter.storeName}] settle_step: settled_outcome_divergence (same token, different outcome)`,
933
+ run: async () => {
934
+ const def = minimalDefinition(['a']);
935
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
936
+ const settled = await settleStep(run.id, {
937
+ kind: 'settle_step',
938
+ step: 'a',
939
+ outcome: 'complete',
940
+ claimToken: token,
941
+ evidence: [makeEvidence('a')],
942
+ }, def);
943
+ assertApplied(settled, 'setup settle');
944
+ await expectRefusalUnchanged(run.id, {
945
+ kind: 'settle_step',
946
+ step: 'a',
947
+ outcome: 'fail',
948
+ claimToken: token,
949
+ evidence: [],
950
+ failureMessage: 'x',
951
+ }, def, 'settled_outcome_divergence', settled.run.version, 'settled_outcome_divergence');
952
+ },
953
+ });
954
+ cases.push({
955
+ law: 'REFUSAL_SWEEP',
956
+ name: `[${adapter.storeName}] settle_step: run_terminal (a different, never-settled step on an already-terminal run)`,
957
+ run: async () => {
958
+ const def = minimalDefinition(['a', 'b']);
959
+ const { run } = await adapter.store.create({
960
+ workflowId: def.id,
961
+ workflowVersion: 1,
962
+ params: {},
963
+ });
964
+ const terminal = await adapter.store.update({
965
+ ...run,
966
+ terminal_state: true,
967
+ terminal_reason: 'tck',
968
+ });
969
+ await expectRefusalUnchanged(run.id, { kind: 'settle_step', step: 'b', outcome: 'complete', claimToken: 'x', evidence: [] }, def, 'run_terminal', terminal.version, 'run_terminal');
970
+ },
971
+ });
972
+ cases.push({
973
+ law: 'REFUSAL_SWEEP',
974
+ name: `[${adapter.storeName}] settle_step: claim_lost (no claim record at all for the step)`,
975
+ run: async () => {
976
+ const def = minimalDefinition(['a']);
977
+ const { run } = await adapter.store.create({
978
+ workflowId: def.id,
979
+ workflowVersion: 1,
980
+ params: {},
981
+ });
982
+ await expectRefusalUnchanged(run.id, { kind: 'settle_step', step: 'a', outcome: 'complete', claimToken: 'x', evidence: [] }, def, 'claim_lost', run.version, 'claim_lost');
983
+ },
984
+ });
985
+ cases.push({
986
+ law: 'REFUSAL_SWEEP',
987
+ name: `[${adapter.storeName}] settle_step: gate_mismatch (the step IS the currently-open gate)`,
988
+ run: async () => {
989
+ const def = minimalDefinition(['a']);
990
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
991
+ const gated = await adapter.store.update({
992
+ ...run,
993
+ pending_gate: {
994
+ gate_id: 'tck-gate',
995
+ step_name: 'a',
996
+ preview: {},
997
+ choices: ['approve'],
998
+ opened_at: '2026-01-01T00:00:00.000Z',
999
+ },
1000
+ });
1001
+ await expectRefusalUnchanged(run.id, { kind: 'settle_step', step: 'a', outcome: 'complete', claimToken: token, evidence: [] }, def, 'gate_mismatch', gated.version, 'gate_mismatch');
1002
+ },
1003
+ });
1004
+ // --- lease_finalizer (6) ---
1005
+ cases.push({
1006
+ law: 'REFUSAL_SWEEP',
1007
+ name: `[${adapter.storeName}] lease_finalizer: run_not_terminal (defensive; a non-terminal run somehow carrying a ledger entry)`,
1008
+ run: async () => {
1009
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1010
+ const { run } = await adapter.store.create({
1011
+ workflowId: def.id,
1012
+ workflowVersion: 1,
1013
+ params: {},
1014
+ });
1015
+ const seeded = await adapter.store.update({
1016
+ ...run,
1017
+ finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
1018
+ });
1019
+ await expectRefusalUnchanged(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken: uid('lease'), leaseSeconds: 30 }, def, 'run_not_terminal', seeded.version, 'lease run_not_terminal');
1020
+ },
1021
+ });
1022
+ cases.push({
1023
+ law: 'REFUSAL_SWEEP',
1024
+ name: `[${adapter.storeName}] lease_finalizer: not_eligible (unknown finalizer id)`,
1025
+ run: async () => {
1026
+ const def = minimalDefinition(['a']);
1027
+ const { run } = await adapter.store.create({
1028
+ workflowId: def.id,
1029
+ workflowVersion: 1,
1030
+ params: {},
1031
+ });
1032
+ const terminal = await adapter.store.update({
1033
+ ...run,
1034
+ terminal_state: true,
1035
+ terminal_reason: 'tck',
1036
+ });
1037
+ await expectRefusalUnchanged(run.id, {
1038
+ kind: 'lease_finalizer',
1039
+ finalizer: 'nonexistent',
1040
+ leaseToken: uid('lease'),
1041
+ leaseSeconds: 30,
1042
+ }, def, 'not_eligible', terminal.version, 'lease not_eligible');
1043
+ },
1044
+ });
1045
+ cases.push({
1046
+ law: 'REFUSAL_SWEEP',
1047
+ name: `[${adapter.storeName}] lease_finalizer: ledger_not_pending (entry already completed)`,
1048
+ run: async () => {
1049
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1050
+ const { run } = await adapter.store.create({
1051
+ workflowId: def.id,
1052
+ workflowVersion: 1,
1053
+ params: {},
1054
+ });
1055
+ const seeded = await adapter.store.update({
1056
+ ...run,
1057
+ terminal_state: true,
1058
+ terminal_reason: 'tck',
1059
+ finalizer_ledger: { fin: { status: 'completed', rank: 0 } },
1060
+ });
1061
+ await expectRefusalUnchanged(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken: uid('lease'), leaseSeconds: 30 }, def, 'ledger_not_pending', seeded.version, 'lease ledger_not_pending');
1062
+ },
1063
+ });
1064
+ cases.push({
1065
+ law: 'REFUSAL_SWEEP',
1066
+ name: `[${adapter.storeName}] lease_finalizer: already_leased (NOOP — same token, unexpired)`,
1067
+ run: async () => {
1068
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1069
+ const { run } = await adapter.store.create({
1070
+ workflowId: def.id,
1071
+ workflowVersion: 1,
1072
+ params: {},
1073
+ });
1074
+ await adapter.store.update({
1075
+ ...run,
1076
+ terminal_state: true,
1077
+ terminal_reason: 'tck',
1078
+ finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
1079
+ });
1080
+ const leaseToken = uid('lease');
1081
+ const first = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
1082
+ assertApplied(first, 'setup lease');
1083
+ await expectRefusalUnchanged(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def, 'already_leased', first.run.version, 'lease already_leased');
1084
+ },
1085
+ });
1086
+ cases.push({
1087
+ law: 'REFUSAL_SWEEP',
1088
+ name: `[${adapter.storeName}] lease_finalizer: rank_blocked (a lower-ranked pending entry still unleased)`,
1089
+ run: async () => {
1090
+ const def = withFinalizer(withFinalizer(minimalDefinition(['a']), 'first', 'always'), 'second', 'always');
1091
+ const { run } = await adapter.store.create({
1092
+ workflowId: def.id,
1093
+ workflowVersion: 1,
1094
+ params: {},
1095
+ });
1096
+ const seeded = await adapter.store.update({
1097
+ ...run,
1098
+ terminal_state: true,
1099
+ terminal_reason: 'tck',
1100
+ finalizer_ledger: {
1101
+ first: { status: 'pending', rank: 0 },
1102
+ second: { status: 'pending', rank: 1 },
1103
+ },
1104
+ });
1105
+ await expectRefusalUnchanged(run.id, {
1106
+ kind: 'lease_finalizer',
1107
+ finalizer: 'second',
1108
+ leaseToken: uid('lease'),
1109
+ leaseSeconds: 30,
1110
+ }, def, 'rank_blocked', seeded.version, 'lease rank_blocked');
1111
+ },
1112
+ });
1113
+ cases.push({
1114
+ law: 'REFUSAL_SWEEP',
1115
+ name: `[${adapter.storeName}] lease_finalizer: lease_held (a DIFFERENT token's unexpired lease)`,
1116
+ run: async () => {
1117
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1118
+ const { run } = await adapter.store.create({
1119
+ workflowId: def.id,
1120
+ workflowVersion: 1,
1121
+ params: {},
1122
+ });
1123
+ await adapter.store.update({
1124
+ ...run,
1125
+ terminal_state: true,
1126
+ terminal_reason: 'tck',
1127
+ finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
1128
+ });
1129
+ const first = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken: uid('lease-a'), leaseSeconds: 60 }, def);
1130
+ assertApplied(first, 'setup lease');
1131
+ await expectRefusalUnchanged(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken: uid('lease-b'), leaseSeconds: 60 }, def, 'lease_held', first.run.version, 'lease lease_held');
1132
+ },
1133
+ });
1134
+ // --- mark_finalizer (5) ---
1135
+ cases.push({
1136
+ law: 'REFUSAL_SWEEP',
1137
+ name: `[${adapter.storeName}] mark_finalizer: not_eligible (unknown finalizer id)`,
1138
+ run: async () => {
1139
+ const def = minimalDefinition(['a']);
1140
+ const { run } = await adapter.store.create({
1141
+ workflowId: def.id,
1142
+ workflowVersion: 1,
1143
+ params: {},
1144
+ });
1145
+ const terminal = await adapter.store.update({
1146
+ ...run,
1147
+ terminal_state: true,
1148
+ terminal_reason: 'tck',
1149
+ });
1150
+ await expectRefusalUnchanged(run.id, {
1151
+ kind: 'mark_finalizer',
1152
+ finalizer: 'nonexistent',
1153
+ leaseToken: uid('lease'),
1154
+ result: 'completed',
1155
+ evidence: makeEvidence('nonexistent'),
1156
+ }, def, 'not_eligible', terminal.version, 'mark not_eligible');
1157
+ },
1158
+ });
1159
+ cases.push({
1160
+ law: 'REFUSAL_SWEEP',
1161
+ name: `[${adapter.storeName}] mark_finalizer: already_marked (NOOP — same token, same result)`,
1162
+ run: async () => {
1163
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1164
+ const { run } = await adapter.store.create({
1165
+ workflowId: def.id,
1166
+ workflowVersion: 1,
1167
+ params: {},
1168
+ });
1169
+ await adapter.store.update({
1170
+ ...run,
1171
+ terminal_state: true,
1172
+ terminal_reason: 'tck',
1173
+ finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
1174
+ });
1175
+ const leaseToken = uid('lease');
1176
+ const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
1177
+ assertApplied(leased, 'setup lease');
1178
+ const markDelta = {
1179
+ kind: 'mark_finalizer',
1180
+ finalizer: 'fin',
1181
+ leaseToken,
1182
+ result: 'completed',
1183
+ evidence: makeEvidence('fin'),
1184
+ };
1185
+ const marked = await settleStep(run.id, markDelta, def);
1186
+ assertApplied(marked, 'setup mark');
1187
+ await expectRefusalUnchanged(run.id, markDelta, def, 'already_marked', marked.run.version, 'mark already_marked');
1188
+ },
1189
+ });
1190
+ cases.push({
1191
+ law: 'REFUSAL_SWEEP',
1192
+ name: `[${adapter.storeName}] mark_finalizer: ledger_not_pending (entry already voided)`,
1193
+ run: async () => {
1194
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1195
+ const { run } = await adapter.store.create({
1196
+ workflowId: def.id,
1197
+ workflowVersion: 1,
1198
+ params: {},
1199
+ });
1200
+ const seeded = await adapter.store.update({
1201
+ ...run,
1202
+ terminal_state: true,
1203
+ terminal_reason: 'tck',
1204
+ finalizer_ledger: { fin: { status: 'voided', rank: 0 } },
1205
+ });
1206
+ await expectRefusalUnchanged(run.id, {
1207
+ kind: 'mark_finalizer',
1208
+ finalizer: 'fin',
1209
+ leaseToken: uid('lease'),
1210
+ result: 'completed',
1211
+ evidence: makeEvidence('fin'),
1212
+ }, def, 'ledger_not_pending', seeded.version, 'mark ledger_not_pending');
1213
+ },
1214
+ });
1215
+ cases.push({
1216
+ law: 'REFUSAL_SWEEP',
1217
+ name: `[${adapter.storeName}] mark_finalizer: lease_lost (a WRONG token on a genuinely-leased entry)`,
1218
+ run: async () => {
1219
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1220
+ const { run } = await adapter.store.create({
1221
+ workflowId: def.id,
1222
+ workflowVersion: 1,
1223
+ params: {},
1224
+ });
1225
+ await adapter.store.update({
1226
+ ...run,
1227
+ terminal_state: true,
1228
+ terminal_reason: 'tck',
1229
+ finalizer_ledger: { fin: { status: 'pending', rank: 0 } },
1230
+ });
1231
+ const leaseToken = uid('lease');
1232
+ const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
1233
+ assertApplied(leased, 'setup lease');
1234
+ await expectRefusalUnchanged(run.id, {
1235
+ kind: 'mark_finalizer',
1236
+ finalizer: 'fin',
1237
+ leaseToken: 'a-wrong-token',
1238
+ result: 'completed',
1239
+ evidence: makeEvidence('fin'),
1240
+ }, def, 'lease_lost', leased.run.version, 'mark lease_lost');
1241
+ },
1242
+ });
1243
+ cases.push({
1244
+ law: 'REFUSAL_SWEEP',
1245
+ name: `[${adapter.storeName}] mark_finalizer: run_not_terminal (defensive; a leased-but-non-terminal run)`,
1246
+ run: async () => {
1247
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1248
+ const { run } = await adapter.store.create({
1249
+ workflowId: def.id,
1250
+ workflowVersion: 1,
1251
+ params: {},
1252
+ });
1253
+ const leaseToken = uid('lease');
1254
+ const seeded = await adapter.store.update({
1255
+ ...run,
1256
+ finalizer_ledger: {
1257
+ fin: {
1258
+ status: 'pending',
1259
+ rank: 0,
1260
+ lease_token: leaseToken,
1261
+ lease_deadline: '2099-01-01T00:00:00.000Z',
1262
+ },
1263
+ },
1264
+ });
1265
+ await expectRefusalUnchanged(run.id, {
1266
+ kind: 'mark_finalizer',
1267
+ finalizer: 'fin',
1268
+ leaseToken,
1269
+ result: 'completed',
1270
+ evidence: makeEvidence('fin'),
1271
+ }, def, 'run_not_terminal', seeded.version, 'mark run_not_terminal');
1272
+ },
1273
+ });
1274
+ return cases;
1275
+ }
1276
+ // ---------------------------------------------------------------------------
1277
+ // L14 MINT_FRESH — PR-A form (hand-author the post-resume state as a FIXTURE via update(); do NOT
1278
+ // implement applyResume, which is PR-B's job).
1279
+ // ---------------------------------------------------------------------------
1280
+ function mintFreshCases(adapter) {
1281
+ const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
1282
+ const settleStep = requireSettleStep(adapter.store);
1283
+ return [
1284
+ {
1285
+ law: 'MINT_FRESH',
1286
+ name: `[${adapter.storeName}] a re-fail edge, after a hand-authored post-resume-void state, re-mints a FRESH pending entry for the still-selected finalizer; a re-complete edge does not select it; completed/failed entries are never rewritten`,
1287
+ run: async () => {
1288
+ // Two finalizers: 'onFail' fires on fail+always is absent (fail-only), 'onComplete' fires
1289
+ // on complete only — lets the test distinguish "re-selected" from "not selected".
1290
+ const def = withFinalizer(withFinalizer(minimalDefinition(['a', 'b']), 'onFail', 'fail'), 'onComplete', 'complete');
1291
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
1292
+ // First terminal edge: fail 'a' (the only step — a 1-step-visible workflow) to mint 'onFail'.
1293
+ // 'a' alone won't terminalize a 2-step def, so also settle 'b' the same way to reach terminal.
1294
+ const failA = await settleStep(run.id, {
1295
+ kind: 'settle_step',
1296
+ step: 'a',
1297
+ outcome: 'fail',
1298
+ claimToken: token,
1299
+ evidence: [],
1300
+ failureMessage: 'x',
1301
+ }, def);
1302
+ assertApplied(failA, 'fail step a');
1303
+ const claimedB = await adapter.store.claimStep(run.id, 'b', def);
1304
+ const tokenB = claimedB.claims['b'].token;
1305
+ const failB = await settleStep(run.id, {
1306
+ kind: 'settle_step',
1307
+ step: 'b',
1308
+ outcome: 'fail',
1309
+ claimToken: tokenB,
1310
+ evidence: [],
1311
+ failureMessage: 'x',
1312
+ }, def);
1313
+ assertApplied(failB, 'fail step b (terminalizes)');
1314
+ if (failB.run.finalizer_ledger?.['onFail']?.status !== 'pending') {
1315
+ throw new Error('expected onFail minted pending after the first fail-terminal edge');
1316
+ }
1317
+ if (failB.run.finalizer_ledger?.['onComplete'] !== undefined) {
1318
+ throw new Error('onComplete must NOT be selected on a fail edge');
1319
+ }
1320
+ // Mark onFail completed (so the never-rewritten assertion has teeth later).
1321
+ const leaseToken = uid('lease');
1322
+ const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'onFail', leaseToken, leaseSeconds: 60 }, def);
1323
+ assertApplied(leased, 'lease onFail');
1324
+ const marked = await settleStep(run.id, {
1325
+ kind: 'mark_finalizer',
1326
+ finalizer: 'onFail',
1327
+ leaseToken,
1328
+ result: 'completed',
1329
+ evidence: makeEvidence('onFail'),
1330
+ }, def);
1331
+ assertApplied(marked, 'mark onFail completed');
1332
+ // Hand-author the post-resume-void state a real `applyResume` (PR-B) would produce:
1333
+ // terminal_state:false, failed_steps cleared, settled-map entries for the re-opened steps
1334
+ // dropped, and the STILL-PENDING... there are none pending now (onFail was already
1335
+ // marked) — this fixture specifically exercises re-mint on a run with NO pending entries
1336
+ // left post-void, proving mintFresh re-arms fresh regardless of prior history.
1337
+ const postResumeVoid = await adapter.store.update({
1338
+ ...marked.run,
1339
+ terminal_state: false,
1340
+ in_progress_steps: [],
1341
+ failed_steps: [],
1342
+ settled: {},
1343
+ });
1344
+ if (postResumeVoid.finalizer_ledger?.['onFail']?.status !== 'completed') {
1345
+ throw new Error('fixture setup: onFail must still read completed after the hand-authored void');
1346
+ }
1347
+ // Re-fail edge: re-claim + re-fail 'a' and 'b' — a SECOND terminal fail edge.
1348
+ const reclaimedA = await adapter.store.claimStep(run.id, 'a', def);
1349
+ const reTokenA = reclaimedA.claims['a'].token;
1350
+ const reFailA = await settleStep(run.id, {
1351
+ kind: 'settle_step',
1352
+ step: 'a',
1353
+ outcome: 'fail',
1354
+ claimToken: reTokenA,
1355
+ evidence: [],
1356
+ failureMessage: 'y',
1357
+ }, def);
1358
+ assertApplied(reFailA, 're-fail step a');
1359
+ const reclaimedB = await adapter.store.claimStep(run.id, 'b', def);
1360
+ const reTokenB = reclaimedB.claims['b'].token;
1361
+ const reFailB = await settleStep(run.id, {
1362
+ kind: 'settle_step',
1363
+ step: 'b',
1364
+ outcome: 'fail',
1365
+ claimToken: reTokenB,
1366
+ evidence: [],
1367
+ failureMessage: 'y',
1368
+ }, def);
1369
+ assertApplied(reFailB, 're-fail step b (re-terminalizes)');
1370
+ // onFail is selected again by selectFinalizers (fail outcome), but it's already
1371
+ // 'completed' in the ledger — the never-downgrade guard means it STAYS completed, never
1372
+ // rewritten back to pending. onComplete is still not selected (this is a fail edge).
1373
+ if (reFailB.run.finalizer_ledger?.['onFail']?.status !== 'completed') {
1374
+ throw new Error(`expected onFail to STAY 'completed' (never rewritten) on the re-fail edge, got '${reFailB.run.finalizer_ledger?.['onFail']?.status}'`);
1375
+ }
1376
+ if (reFailB.run.finalizer_ledger?.['onComplete'] !== undefined) {
1377
+ throw new Error('onComplete must not be selected on a re-fail edge either');
1378
+ }
1379
+ },
1380
+ },
1381
+ ];
1382
+ }
1383
+ // ---------------------------------------------------------------------------
1384
+ // L21 SELF_IMAGE_IDEMPOTENCE — store-level matrix (apply→re-apply per kind)
1385
+ // ---------------------------------------------------------------------------
1386
+ function selfImageIdempotenceCases(adapter) {
1387
+ const { minimalDefinition, withFinalizer } = adapter.settlementFixture;
1388
+ const settleStep = requireSettleStep(adapter.store);
1389
+ return [
1390
+ {
1391
+ law: 'SELF_IMAGE_IDEMPOTENCE',
1392
+ name: `[${adapter.storeName}] settle_step: predicate(S', d) is ok-shaped after predicate(S, d) applied — re-applying the SAME delta NOOPs`,
1393
+ run: async () => {
1394
+ const def = minimalDefinition(['a']);
1395
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
1396
+ const delta = {
1397
+ kind: 'settle_step',
1398
+ step: 'a',
1399
+ outcome: 'complete',
1400
+ claimToken: token,
1401
+ evidence: [makeEvidence('a')],
1402
+ };
1403
+ const first = await settleStep(run.id, delta, def);
1404
+ assertApplied(first, 'first apply');
1405
+ const second = await settleStep(run.id, delta, def);
1406
+ if (second.applied)
1407
+ throw new Error('expected the re-apply to be ok-shaped NOOP, not applied:true');
1408
+ },
1409
+ },
1410
+ {
1411
+ law: 'SELF_IMAGE_IDEMPOTENCE',
1412
+ name: `[${adapter.storeName}] lease_finalizer: predicate(S', d) is ok-shaped after predicate(S, d) applied — re-leasing with the SAME token NOOPs`,
1413
+ run: async () => {
1414
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1415
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
1416
+ const settled = await settleStep(run.id, {
1417
+ kind: 'settle_step',
1418
+ step: 'a',
1419
+ outcome: 'complete',
1420
+ claimToken: token,
1421
+ evidence: [makeEvidence('a')],
1422
+ }, def);
1423
+ assertApplied(settled, 'setup settle');
1424
+ const leaseToken = uid('lease');
1425
+ const delta = {
1426
+ kind: 'lease_finalizer',
1427
+ finalizer: 'fin',
1428
+ leaseToken,
1429
+ leaseSeconds: 60,
1430
+ };
1431
+ const first = await settleStep(run.id, delta, def);
1432
+ assertApplied(first, 'first lease');
1433
+ const second = await settleStep(run.id, delta, def);
1434
+ if (second.applied)
1435
+ throw new Error('expected the re-lease to be ok-shaped NOOP, not applied:true');
1436
+ },
1437
+ },
1438
+ {
1439
+ law: 'SELF_IMAGE_IDEMPOTENCE',
1440
+ name: `[${adapter.storeName}] mark_finalizer: predicate(S', d) is ok-shaped after predicate(S, d) applied — re-marking with the SAME token+result NOOPs`,
1441
+ run: async () => {
1442
+ const def = withFinalizer(minimalDefinition(['a']), 'fin', 'always');
1443
+ const { run, token } = await createClaimed(adapter.store, def, 'a');
1444
+ const settled = await settleStep(run.id, {
1445
+ kind: 'settle_step',
1446
+ step: 'a',
1447
+ outcome: 'complete',
1448
+ claimToken: token,
1449
+ evidence: [makeEvidence('a')],
1450
+ }, def);
1451
+ assertApplied(settled, 'setup settle');
1452
+ const leaseToken = uid('lease');
1453
+ const leased = await settleStep(run.id, { kind: 'lease_finalizer', finalizer: 'fin', leaseToken, leaseSeconds: 60 }, def);
1454
+ assertApplied(leased, 'setup lease');
1455
+ const delta = {
1456
+ kind: 'mark_finalizer',
1457
+ finalizer: 'fin',
1458
+ leaseToken,
1459
+ result: 'completed',
1460
+ evidence: makeEvidence('fin'),
1461
+ };
1462
+ const first = await settleStep(run.id, delta, def);
1463
+ assertApplied(first, 'first mark');
1464
+ const second = await settleStep(run.id, delta, def);
1465
+ if (second.applied)
1466
+ throw new Error('expected the re-mark to be ok-shaped NOOP, not applied:true');
1467
+ },
1468
+ },
1469
+ ];
1470
+ }
1471
+ // ---------------------------------------------------------------------------
1472
+ // COMPLETE_SEAL_PHASE + WHEN_ROUTED_TERMINALIZATION (hand-authored transform pins, non-circular)
1473
+ // ---------------------------------------------------------------------------
1474
+ function completeSealPhaseCases(_adapter) {
1475
+ return [
1476
+ {
1477
+ law: 'COMPLETE_SEAL_PHASE',
1478
+ name: "a complete-terminal edge sets run_phase 'completed' AND terminal_reason 'Workflow completed.' together (hand-authored, non-circular)",
1479
+ run: async () => {
1480
+ const def = minimalDefinition(['a']);
1481
+ const fresh = {
1482
+ id: 'tck-complete-seal-phase',
1483
+ workflow_id: def.id,
1484
+ workflow_version: 1,
1485
+ completed_steps: [],
1486
+ in_progress_steps: ['a'],
1487
+ failed_steps: [],
1488
+ skipped_steps: [],
1489
+ run_phase: 'running',
1490
+ version: 0,
1491
+ params: {},
1492
+ evidence: [],
1493
+ created_at: '2026-01-01T00:00:00.000Z',
1494
+ updated_at: '2026-01-01T00:00:00.000Z',
1495
+ terminal_state: false,
1496
+ claims: { a: { deadline: null, token: 'tck-token' } },
1497
+ };
1498
+ const result = applySettlement(fresh, {
1499
+ kind: 'settle_step',
1500
+ step: 'a',
1501
+ outcome: 'complete',
1502
+ claimToken: 'tck-token',
1503
+ evidence: [makeEvidence('a')],
1504
+ }, def, { now: new Date('2026-01-01T00:00:00.000Z') });
1505
+ if (!result.applied)
1506
+ throw new Error(`expected applied:true, got: ${result.reason}`);
1507
+ if (result.run.run_phase !== 'completed' ||
1508
+ result.run.terminal_reason !== 'Workflow completed.') {
1509
+ throw new Error(`expected run_phase:'completed' + terminal_reason:'Workflow completed.', got run_phase:'${result.run.run_phase}' terminal_reason:'${result.run.terminal_reason}'`);
1510
+ }
1511
+ },
1512
+ },
1513
+ ];
1514
+ }
1515
+ function whenRoutedTerminalizationCases(_adapter) {
1516
+ return [
1517
+ {
1518
+ law: 'WHEN_ROUTED_TERMINALIZATION',
1519
+ name: 'the safety-net-disjunct-only case (isWorkflowComplete false, but in_progress empty + zero eligible steps/guards) terminalizes and mints (hand-authored, non-circular)',
1520
+ run: async () => {
1521
+ // 'b' is permanently skipped by an unsatisfiable when-clause against 'a's own output —
1522
+ // isWorkflowComplete is FALSE at the settle-of-'a' instant (propagateSkips runs INSIDE
1523
+ // the same apply and marks 'b' skipped only as a RESULT of this very settle), so the
1524
+ // safety-net second disjunct (in_progress empty + zero eligible steps/guards) is what
1525
+ // fires terminalization here, not the first disjunct evaluated against the PRE-apply state.
1526
+ const def = {
1527
+ id: uid('when-routed-wf'),
1528
+ name: 'When-routed TCK fixture',
1529
+ version: 1,
1530
+ steps: {
1531
+ a: { description: 'a', execution: 'agent', depends_on: [] },
1532
+ b: {
1533
+ description: 'b',
1534
+ execution: 'agent',
1535
+ depends_on: ['a'],
1536
+ when: ["evidence.a.output.category == 'never-matches'"],
1537
+ },
1538
+ },
1539
+ };
1540
+ const fresh = {
1541
+ id: 'tck-when-routed',
1542
+ workflow_id: def.id,
1543
+ workflow_version: 1,
1544
+ completed_steps: [],
1545
+ in_progress_steps: ['a'],
1546
+ failed_steps: [],
1547
+ skipped_steps: [],
1548
+ run_phase: 'running',
1549
+ version: 0,
1550
+ params: {},
1551
+ evidence: [],
1552
+ created_at: '2026-01-01T00:00:00.000Z',
1553
+ updated_at: '2026-01-01T00:00:00.000Z',
1554
+ terminal_state: false,
1555
+ claims: { a: { deadline: null, token: 'tck-token' } },
1556
+ };
1557
+ const result = applySettlement(fresh, {
1558
+ kind: 'settle_step',
1559
+ step: 'a',
1560
+ outcome: 'complete',
1561
+ claimToken: 'tck-token',
1562
+ evidence: [makeEvidence('a', { output_summary: { category: 'something-else' } })],
1563
+ }, def, { now: new Date('2026-01-01T00:00:00.000Z') });
1564
+ if (!result.applied)
1565
+ throw new Error(`expected applied:true, got: ${result.reason}`);
1566
+ if (!result.run.skipped_steps.includes('b')) {
1567
+ throw new Error("fixture premise violated: 'b' must be propagated-skipped by its own when-clause");
1568
+ }
1569
+ if (!result.transitioned || result.run.terminal_state !== true) {
1570
+ throw new Error('expected the safety-net disjunct to terminalize the run');
1571
+ }
1572
+ },
1573
+ },
1574
+ ];
1575
+ }
1576
+ // ---------------------------------------------------------------------------
1577
+ // G-1 transform pin (sibling settle under an open LEGACY gate ⇒ transitioned:false)
1578
+ // ---------------------------------------------------------------------------
1579
+ function g1GateCoexistenceCases(_adapter) {
1580
+ return [
1581
+ {
1582
+ law: 'G1_GATE_COEXISTENCE',
1583
+ name: 'settling a sibling step while a LEGACY gate is open on another step never transitions the run — the gate step itself keeps in_progress non-empty (hand-authored, non-circular)',
1584
+ run: async () => {
1585
+ const def = {
1586
+ id: uid('g1-wf'),
1587
+ name: 'G-1 TCK fixture',
1588
+ version: 1,
1589
+ steps: {
1590
+ gated: { description: 'g', execution: 'agent', depends_on: [] },
1591
+ sibling: { description: 's', execution: 'agent', depends_on: [] },
1592
+ },
1593
+ };
1594
+ const fresh = {
1595
+ id: 'tck-g1',
1596
+ workflow_id: def.id,
1597
+ workflow_version: 1,
1598
+ completed_steps: [],
1599
+ in_progress_steps: ['gated', 'sibling'],
1600
+ failed_steps: [],
1601
+ skipped_steps: [],
1602
+ run_phase: 'gate_waiting',
1603
+ version: 0,
1604
+ params: {},
1605
+ evidence: [],
1606
+ created_at: '2026-01-01T00:00:00.000Z',
1607
+ updated_at: '2026-01-01T00:00:00.000Z',
1608
+ terminal_state: false,
1609
+ claims: { sibling: { deadline: null, token: 'tck-token' } },
1610
+ pending_gate: {
1611
+ gate_id: 'tck-gate',
1612
+ step_name: 'gated',
1613
+ preview: {},
1614
+ choices: ['approve'],
1615
+ opened_at: '2026-01-01T00:00:00.000Z',
1616
+ },
1617
+ };
1618
+ const result = applySettlement(fresh, {
1619
+ kind: 'settle_step',
1620
+ step: 'sibling',
1621
+ outcome: 'complete',
1622
+ claimToken: 'tck-token',
1623
+ evidence: [makeEvidence('sibling')],
1624
+ }, def, { now: new Date('2026-01-01T00:00:00.000Z') });
1625
+ if (!result.applied)
1626
+ throw new Error(`expected applied:true, got: ${result.reason}`);
1627
+ if (result.transitioned !== false || result.run.terminal_state !== false) {
1628
+ throw new Error('expected transitioned:false — the open gate step keeps in_progress non-empty');
1629
+ }
1630
+ if (result.run.pending_gate === undefined) {
1631
+ throw new Error('the legacy open gate must survive a sibling settle untouched');
1632
+ }
1633
+ },
1634
+ },
1635
+ ];
1636
+ }
1637
+ // ---------------------------------------------------------------------------
1638
+ // Assembly
1639
+ // ---------------------------------------------------------------------------
1640
+ /**
1641
+ * Builds every PR-A-runnable settlement contract case for `adapter`.
1642
+ *
1643
+ * - `adapter.store.settleStep` undeclared ⇒ **zero cases** (vacuous pass — mirrors the
1644
+ * established optional-capability idiom, e.g. `runStoreFidelityContract`'s own
1645
+ * zero-cases-on-no-declaration precedent for a store that opts out entirely).
1646
+ * - `settleStep` declared but `adapter.settlementFixture` absent ⇒ **one `ADAPTER_WIRING`
1647
+ * failing case**, never a silent zero-cases pass — a store that opts INTO settlement
1648
+ * conformance must not be able to pass this TCK by accident of an unwired adapter.
1649
+ * - Both present ⇒ the full law set below.
1650
+ */
1651
+ export function settlementContract(adapter) {
1652
+ if (adapter.store.settleStep === undefined) {
1653
+ return [];
1654
+ }
1655
+ if (adapter.settlementFixture === undefined) {
1656
+ return [
1657
+ {
1658
+ law: 'ADAPTER_WIRING',
1659
+ name: `[${adapter.storeName}] declares RunStore.settleStep but no settlementFixture was supplied to the adapter — wire in 'defaultSettlementFixture' (or a store-specific SettlementFixture) to run real conformance coverage`,
1660
+ run: async () => {
1661
+ throw new Error(`[${adapter.storeName}] settlementContract: adapter.store declares settleStep, but ` +
1662
+ `adapter.settlementFixture is undefined — this is a WIRING GAP in the calling test ` +
1663
+ `file, not a store defect. Pass 'defaultSettlementFixture' from this module (or a ` +
1664
+ 'store-specific SettlementFixture) to exercise the real conformance cases.');
1665
+ },
1666
+ },
1667
+ ];
1668
+ }
1669
+ return [
1670
+ ...freshApplicationCases(adapter),
1671
+ ...conditionalNoopCases(adapter),
1672
+ ...ownershipRefusalCases(adapter),
1673
+ ...ledgerMintAtomicityCases(adapter),
1674
+ ...drainMarkDedupCases(adapter),
1675
+ ...terminalRefusalCases(adapter),
1676
+ ...terminalStateOnlyCases(adapter),
1677
+ ...csPurityCases(adapter),
1678
+ ...neverDowngradeCases(adapter),
1679
+ ...settleOutcomeIntegrityCases(adapter),
1680
+ ...settledOrphanOverwriteCases(adapter),
1681
+ ...transformFidelityCases(adapter),
1682
+ ...resultAsAppliedCases(),
1683
+ ...markMembershipCases(adapter),
1684
+ ...refusalSweepCases(adapter),
1685
+ ...mintFreshCases(adapter),
1686
+ ...selfImageIdempotenceCases(adapter),
1687
+ ...completeSealPhaseCases(adapter),
1688
+ ...whenRoutedTerminalizationCases(adapter),
1689
+ ...g1GateCoexistenceCases(adapter),
1690
+ ];
1691
+ }
1692
+ //# sourceMappingURL=settlement-contract.js.map