From 62c3fa64c095079a8b93680d6a0c0d5cdb2b9887 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 13 Sep 2026 23:20:42 +0200 Subject: [PATCH 1/2] =?UTF-8?q?test:=20adopt=20the=20#1072=20mode-transiti?= =?UTF-8?q?on-engine=20RED=20test=20(stage=20=E2=86=92=20verify=20?= =?UTF-8?q?=E2=86=92=20commit,=20kill=20matrix,=20F2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surviving test from the interrupted cast — 740 lines, never green. It pins the two-phase engine contract exactly: the pre-stage snapshot semantics, the transient/structural verify split, the KillHookRegistry kill matrix (pre-commit / mid-commit × stop/throw/crash), the journaled commit-pending state + FakeClock cap + local force-resolve, the verify-entry serialization seam (P4a-3's drain point), and F2's zero-non-atomic-mode-switches assertion across the full matrix. Currently red: ModeTransitionEngine does not exist (25/25 failing). --- .../test/mode_transition_engine.test.ts | 740 ++++++++++++++++++ 1 file changed, 740 insertions(+) create mode 100644 packages/extension/test/mode_transition_engine.test.ts diff --git a/packages/extension/test/mode_transition_engine.test.ts b/packages/extension/test/mode_transition_engine.test.ts new file mode 100644 index 000000000..02f7777c6 --- /dev/null +++ b/packages/extension/test/mode_transition_engine.test.ts @@ -0,0 +1,740 @@ +// #1072 (fleet rearchitect P4a-2, ADR-0005 — the vocabulary law): the +// two-phase mode-transition engine GROWN ON the #1069 ModeMachine surface: +// stage → verify → commit, journalled + rollback-able (spec-20260913-114814 +// §2.2 row 2, invariant 1 as amended, §8 F2). +// +// The mechanics, asserted through the F-harness (#1049/#1051): +// · STAGE takes the pre-stage snapshot of MODE-SCOPED local state BEFORE +// any state writes — the snapshot pins the last human-confirmed mode +// config; staging itself writes NO attach-state field. +// · VERIFY is an injectable attach test bounded by an injectable budget +// (the #1034 60s patience is the FLOOR). TRANSIENT fail → return to +// staged with the snapshot retained; re-verify needs NO fresh confirm +// (the human confirmed the proposal, not the timing). STRUCTURAL fail → +// AUTOMATIC rollback to the pre-stage snapshot (restoration, not +// mutation: local-only, idempotent, hub-side untouched). +// · COMMIT is the ONLY mode writer, atomic + journalled, with the +// KillHookRegistry's named boundaries: a kill at "pre-commit" leaves +// nothing written; a kill at "mid-commit" leaves the journaled +// commit-pending state — the journal record restores or resumes, never +// half-applies. commit-pending renders as a labeled badge (the snapshot +// live), persisted in the MODE journal — NEVER in the posture field +// (invariant 7; the posture vocabulary stays closed). +// · The commit-pending wall-clock cap surfaces a staged abort/resume +// choice confirmable locally + a local force-resolve verb — FakeClock +// driven, zero wall-clock waits. +// · F2: n_non_atomic_mode_switches == 0 over the full kill/fail matrix; +// the mode field is written ONLY by human-confirmed commit paths. +// · The P4a-3 serialization seam: a verify-entry hook fired BEFORE the +// attach test — the future proposal queue drains there (D2); no queue +// implementation exists in this slice. +import { describe, it, expect, afterEach } from "vitest"; +import { + ATTACH_POSTURES, + ModeMachine, + ModeTransitionEngine, + DEFAULT_MODE_TRANSITION_CONFIG, + VERIFY_BUDGET_FLOOR_MS, + type AttachMode, + type AttachStateRecord, + type VerifyContext, + type VerifyOutcome, +} from "../src/amicode_service/attach_state"; +import { + KillHookRegistry, + SimulatedCrash, +} from "./fixtures/fleet_fault_harness/kill_hook"; +import { FakeClock } from "./fixtures/fleet_fault_harness/test_clock"; +import { + FaultProxy, + probeOnce, + startEchoBackend, +} from "./fixtures/fleet_fault_harness/fault_proxy"; + +const PROBE_DEADLINE_MS = 150; + +const disposers: (() => Promise | void)[] = []; +afterEach(async () => { + while (disposers.length) { + const d = disposers.pop(); + await d?.(); + } +}); + +async function startProxy(mode = "pass") { + const backend = await startEchoBackend(); + const proxy = new FaultProxy({ targetPort: backend.port }); + await proxy.listen(); + await proxy.setMode(mode as never); + disposers.push(async () => { + await proxy.close(); + await backend.close(); + }); + return proxy; +} + +/** An attach test that probes the REAL fault proxy and classifies the + * outcome: any no-response is a TRANSIENT fail (timing, not a world change); + * a response is a pass. */ +function proxyVerify(proxy: FaultProxy) { + return async (): Promise => { + const outcome = await probeOnce(proxy.port, PROBE_DEADLINE_MS); + return outcome.kind === "response" + ? { kind: "pass" } + : { kind: "transient-fail", detail: `attach test: ${outcome.kind}` }; + }; +} + +function rig(opts: { + initial?: AttachStateRecord; + mode?: AttachMode; + verify?: (ctx: VerifyContext) => Promise | VerifyOutcome; + config?: Record; + clock?: FakeClock; + hooks?: KillHookRegistry; + onVerifyEntry?: (ctx: VerifyContext) => Promise | void; +} = {}) { + const machine = new ModeMachine( + opts.initial !== undefined ? { initial: opts.initial } : {}, + ); + if (opts.mode !== undefined) machine.confirmMode(opts.mode, "fixture: prior human confirm"); + const engine = new ModeTransitionEngine({ + machine, + verify: opts.verify ?? (() => ({ kind: "pass" }) as VerifyOutcome), + ...(opts.config !== undefined ? { config: opts.config as never } : {}), + ...(opts.clock !== undefined ? { clock: opts.clock } : {}), + ...(opts.hooks !== undefined ? { hooks: opts.hooks } : {}), + ...(opts.onVerifyEntry !== undefined ? { onVerifyEntry: opts.onVerifyEntry } : {}), + }); + return { machine, engine }; +} + +/** The F2 counter: a mode value that is neither the pre-stage human-confirmed + * mode NOR backed by a completed journaled commit + a human-confirm mode + * write is a NON-ATOMIC mode switch. Zero across the whole matrix. */ +function nonAtomicModeSwitches(machine: ModeMachine, preStageMode: AttachMode): number { + const mode = machine.read().mode; + if (mode === preStageMode) return 0; // not switched — nothing half-applied + const journal = machine.journal(); + const commit = journal.find((e) => e.kind === "transition-commit" && e.to === mode); + const confirm = machine + .writeLog() + .find((w) => w.field === "mode" && w.writer === "human-confirm" && w.value === mode); + return commit !== undefined && confirm !== undefined ? 0 : 1; +} + +/** The mode-field provenance invariant: every mode-field write in the render + * log came from a human-confirmed commit path (rollback restores the + * identical confirmed value and therefore never writes mode). */ +function modeFieldWritesAllHumanConfirmed(machine: ModeMachine): boolean { + const modeWrites = machine.writeLog().filter((w) => w.field === "mode"); + return modeWrites.every((w) => w.writer === "human-confirm"); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// STAGE — the pre-stage snapshot, BEFORE any state writes (AC 1) +// ══════════════════════════════════════════════════════════════════════════════ + +describe("#1072 stage — pre-stage snapshot first, no state writes", () => { + it("stage snapshots the mode-scoped local state BEFORE any state writes; the snapshot pins the last human-confirmed mode config", () => { + const { machine, engine } = rig({ mode: "fleet" }); + const recordBefore = machine.read().record; + const writesBefore = machine.writeLog().length; + + const snapshot = engine.stage({ to: "standalone", reason: "detach: human-confirmed proposal" }); + + // the snapshot pins the LAST HUMAN-CONFIRMED mode config (raw fields + resolved) + expect(snapshot.mode).toBe("fleet"); + expect(snapshot.fields).toEqual({ mode: "fleet", state: "fleet" }); + // stage wrote NO attach-state field: the record, the render log, untouched + expect(machine.read().record).toEqual(recordBefore); + expect(machine.writeLog().length).toBe(writesBefore); + // the stage entry lives in the MODE journal with the snapshot + const journal = machine.journal(); + const stageEntry = journal[journal.length - 1]; + expect(stageEntry).toMatchObject({ + kind: "transition-stage", + from: "fleet", + to: "standalone", + }); + if (stageEntry.kind === "transition-stage") { + expect(stageEntry.snapshot).toEqual(snapshot); + } + expect(engine.state()).toBe("staged"); + }); + + it("apply fields are mode-scoped by construction — the snapshot covers everything commit could write (rollback's write set)", () => { + const { machine, engine } = rig({ + mode: "standalone", + initial: { fleet_program: "v1" }, + }); + const snapshot = engine.stage({ + to: "fleet", + apply: { fleet_program: "v2" }, + modeScopedFields: ["fleet_program"], + }); + expect(snapshot.fields).toEqual({ + mode: "standalone", + state: "standalone", + fleet_program: "v1", + }); + expect(engine.proposal()).toMatchObject({ + to: "fleet", + modeScopedFields: ["fleet_program"], + }); + }); + + it("stage without a prior confirm pins the base default (mode absent = standalone)", () => { + const { engine } = rig(); + const snapshot = engine.stage({ to: "fleet" }); + expect(snapshot.mode).toBe("standalone"); + }); + + it("the verbs refuse from wrong states: no second transition in flight, no commit without verify, no verify from idle, no transition to the already-confirmed mode", () => { + const { engine } = rig({ mode: "standalone" }); + expect(() => engine.verify()).toThrow(); + expect(() => engine.commit()).toThrow(); + engine.stage({ to: "fleet" }); + expect(() => engine.stage({ to: "standalone" })).toThrow(/in flight/); + expect(() => engine.commit()).toThrow(/verified/); + const { engine: sameMode } = rig({ mode: "fleet" }); + expect(() => sameMode.stage({ to: "fleet" })).toThrow(/already/); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// VERIFY — transient fail (AC 2, the FaultProxy's transient fault modes) +// ══════════════════════════════════════════════════════════════════════════════ + +describe("#1072 verify — transient fail: staged with the snapshot retained, re-verify without fresh confirm", () => { + it("every FaultProxy transient mode (drop, half-open, refuse, clean-close) lands a TRANSIENT verify-fail — staged, snapshot retained", async () => { + for (const mode of ["drop", "half-open", "refuse", "clean-close"] as const) { + const proxy = await startProxy(mode); + const { machine, engine } = rig({ mode: "standalone", verify: proxyVerify(proxy) }); + const snapshot = engine.stage({ to: "fleet" }); + const out = await engine.verify(); + expect(out.kind, `${mode} must land transient-fail`).toBe("transient-fail"); + expect(engine.state()).toBe("staged"); // return to staged — the snapshot retained + expect(engine.snapshot()).toEqual(snapshot); + expect(machine.read().mode).toBe("standalone"); // nothing was written + const verifyEntries = machine.journal().filter((e) => e.kind === "transition-verify"); + expect(verifyEntries.length).toBe(1); + expect(verifyEntries[0]).toMatchObject({ outcome: "transient-fail" }); + } + }); + + it("re-verify after a transient fail needs NO fresh confirm — the human confirmed the proposal, not the timing (the full path through the real proxy)", async () => { + const proxy = await startProxy("drop"); + const { machine, engine } = rig({ mode: "standalone", verify: proxyVerify(proxy) }); + engine.stage({ to: "fleet", reason: "enroll: human-confirmed proposal" }); + const humanConfirmsBefore = machine.journal().filter((e) => e.kind === "human-confirm").length; + + const fail = await engine.verify(); + expect(fail.kind).toBe("transient-fail"); + expect(machine.journal().filter((e) => e.kind === "human-confirm").length) + .toBe(humanConfirmsBefore); // the fail minted NO fresh confirm + + // the tunnel heals — re-verify, still no fresh confirm, then commit + await proxy.setMode("pass"); + const pass = await engine.verify(); + expect(pass.kind).toBe("pass"); + expect(machine.journal().filter((e) => e.kind === "human-confirm").length) + .toBe(humanConfirmsBefore); // the re-verify minted NO fresh confirm either + expect(engine.state()).toBe("verified"); + + engine.commit(); + expect(machine.read().mode).toBe("fleet"); + // the ONLY fresh human-confirm in the journal is the commit's own write + expect(machine.journal().filter((e) => e.kind === "human-confirm").length) + .toBe(humanConfirmsBefore + 1); + // the full journaled sequence: confirm, stage, verify-fail, verify-pass, commit-pending, confirm, commit + expect(machine.journal().map((e) => e.kind)).toEqual([ + "human-confirm", + "transition-stage", + "transition-verify", + "transition-verify", + "transition-commit-pending", + "human-confirm", + "transition-commit", + ]); + }); + + it("the verify budget expiring is a TRANSIENT fail (timing, not a world change) — FakeClock-driven, zero wall-clock waits", async () => { + const clock = new FakeClock(); + let impl: (ctx: VerifyContext) => Promise = () => + new Promise(() => {}); // an attach test that never settles + const { machine, engine } = rig({ + mode: "standalone", + clock, + verify: (ctx) => impl(ctx), + }); + const snapshot = engine.stage({ to: "fleet" }); + + const pending = engine.verify(); // NOT awaited before the advance — the clock drives the deadline + clock.advance(VERIFY_BUDGET_FLOOR_MS); + const out = await pending; + expect(out.kind).toBe("transient-fail"); + if (out.kind === "transient-fail") expect(out.detail).toContain("budget"); + expect(engine.state()).toBe("staged"); + expect(engine.snapshot()).toEqual(snapshot); // retained + + // the attach test then settles — re-verify works, no fresh confirm + impl = () => ({ kind: "pass" }); + const pass = await engine.verify(); + expect(pass.kind).toBe("pass"); + expect(engine.state()).toBe("verified"); + expect(machine.read().mode).toBe("standalone"); // still nothing written + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// VERIFY — structural fail: AUTOMATIC rollback (AC 3) +// ══════════════════════════════════════════════════════════════════════════════ + +describe("#1072 verify — structural fail: automatic rollback, local-only, idempotent, hub-side untouched", () => { + const structural: VerifyOutcome = { + kind: "structural-fail", + detail: "mode changed elsewhere: the fresh projection no longer lists this device", + }; + + it("structural fail rolls back to the pre-stage snapshot automatically: mode-scoped fields restored, hub-side state untouched", async () => { + const { machine, engine } = rig({ + mode: "standalone", + initial: { fleet_program: "v1", hub_proposal_receipt: "hub-apply-42" }, + verify: () => structural, + }); + engine.stage({ to: "fleet", modeScopedFields: ["fleet_program"] }); + // mid-transition local drift + a hub-side apply landing (NOT mode-scoped) + machine.overlayRewrite({ fleet_program: "drift-v9", hub_proposal_receipt: "hub-apply-99" }); + + const out = await engine.verify(); + expect(out.kind).toBe("structural-fail"); + expect(engine.state()).toBe("rolled-back"); + + const read = machine.read(); + expect(read.mode).toBe("standalone"); // the last human-confirmed mode — restored + expect(read.record.fleet_program).toBe("v1"); // mode-scoped: restored + expect(read.record.hub_proposal_receipt).toBe("hub-apply-99"); // hub-side: UNTOUCHED + // the journal records the automatic rollback (invariant 1's sole automatic action) + const rollbackEntries = machine.journal().filter((e) => e.kind === "transition-rollback"); + expect(rollbackEntries.length).toBe(1); + expect(rollbackEntries[0]).toMatchObject({ restored: "standalone" }); + // restoration of the identical confirmed mode is a NO-OP on the mode + // field: the only mode writes in the render log are human-confirms + expect(modeFieldWritesAllHumanConfirmed(machine)).toBe(true); + }); + + it("rollback is idempotent — a second rollback is a no-op", async () => { + const { machine, engine } = rig({ + mode: "standalone", + initial: { fleet_program: "v1" }, + verify: () => structural, + }); + engine.stage({ to: "fleet", modeScopedFields: ["fleet_program"] }); + machine.overlayRewrite({ fleet_program: "drift-v9" }); + await engine.verify(); // automatic rollback + + const recordAfterFirst = machine.read().record; + const journalAfterFirst = machine.journal().length; + + const second = engine.rollback("double rollback"); + expect(second).toEqual({ rolledBack: false, restoredFields: [] }); + expect(machine.read().record).toEqual(recordAfterFirst); // nothing re-written + expect(machine.journal().length).toBe(journalAfterFirst); // no second rollback entry + }); + + it("the machine's restore path refuses posture — transport-owned render-only state is never mode-scoped", () => { + const machine = new ModeMachine({ initial: { mode: "fleet", posture: "degraded" } }); + expect(() => machine.restoreModeScoped({ posture: "ok" })).toThrow(/posture/); + }); + + it("the machine's restore path CAN restore a diverged mode config (the machinery, proven at the machine level)", () => { + const machine = new ModeMachine({ initial: { mode: "fleet", state: "fleet" } }); + const { restored } = machine.restoreModeScoped({ mode: "standalone", state: "standalone" }); + expect(restored).toContain("mode"); + expect(machine.read().mode).toBe("standalone"); + expect(machine.read().record.state).toBe("standalone"); + // and restoring the identical config again is a no-op (idempotent) + const again = machine.restoreModeScoped({ mode: "standalone", state: "standalone" }); + expect(again.restored).toEqual([]); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// COMMIT — the kill matrix, journaled commit-pending, never torn (AC 4) +// ══════════════════════════════════════════════════════════════════════════════ + +describe("#1072 commit — the KillHookRegistry matrix: pre-commit / mid-commit, stop / throw / crash", () => { + it("kill at pre-commit (stop): halted at verified — NOTHING written, no journal record; resume completes the commit", async () => { + const hooks = new KillHookRegistry(); + hooks.arm("pre-commit", { kind: "stop" }); + const { machine, engine } = rig({ mode: "standalone", hooks }); + engine.stage({ to: "fleet", apply: { fleet_program: "v2" }, modeScopedFields: ["fleet_program"] }); + await engine.verify(); + const recordBefore = machine.read().record; + + engine.commit(); + expect(engine.state()).toBe("verified"); // still pre-commit + expect(engine.haltedAt()).toBe("pre-commit"); + expect(machine.read().record).toEqual(recordBefore); // nothing applied + expect(machine.journal().filter((e) => e.kind === "transition-commit-pending").length).toBe(0); + + hooks.disarm("pre-commit"); + engine.resume(); // the proposal was human-confirmed — no fresh confirm needed + expect(engine.state()).toBe("committed"); + expect(machine.read().mode).toBe("fleet"); + expect(machine.read().record.fleet_program).toBe("v2"); + expect(machine.journal().filter((e) => e.kind === "transition-commit").length).toBe(1); + }); + + it("kill at pre-commit (crash): the SimulatedCrash escapes — nothing written, no journal record; the transition resumes cleanly", async () => { + const hooks = new KillHookRegistry(); + hooks.arm("pre-commit", { kind: "crash" }); + const { machine, engine } = rig({ mode: "standalone", hooks }); + engine.stage({ to: "fleet" }); + await engine.verify(); + const recordBefore = machine.read().record; + + expect(() => engine.commit()).toThrow(SimulatedCrash); + expect(engine.state()).toBe("verified"); // pre-commit: the crash escaped before anything was written + expect(machine.read().record).toEqual(recordBefore); + expect(machine.journal().filter((e) => e.kind === "transition-commit-pending").length).toBe(0); + + hooks.disarm("pre-commit"); + engine.resume(); + expect(machine.read().mode).toBe("fleet"); + }); + + it("kill at mid-commit (stop): journaled commit-pending, NEVER torn — nothing applied, the badge renders the snapshot live, and the posture field never carries it (invariant 7)", async () => { + const hooks = new KillHookRegistry(); + hooks.arm("mid-commit", { kind: "stop" }); + const clock = new FakeClock(); + const { machine, engine } = rig({ mode: "standalone", hooks, clock }); + engine.stage({ + to: "fleet", + apply: { fleet_program: "v2" }, + modeScopedFields: ["fleet_program"], + }); + await engine.verify(); + const recordBefore = machine.read().record; + + engine.commit(); + expect(engine.state()).toBe("commit-pending"); + expect(engine.haltedAt()).toBe("mid-commit"); + expect(machine.read().record).toEqual(recordBefore); // NEVER torn: nothing half-applied + + // the journal record: commit-pending persisted in the MODE journal + const pendingEntries = machine.journal().filter((e) => e.kind === "transition-commit-pending"); + expect(pendingEntries.length).toBe(1); + expect(pendingEntries[0]).toMatchObject({ to: "fleet" }); + + // the labeled badge renders the pre-stage snapshot live + const view = engine.pending(); + expect(view).not.toBeNull(); + expect(view!.badge).toContain("commit-pending"); + expect(view!.badge).toContain("standalone"); // the last human-confirmed mode, rendered live + expect(view!.snapshot.mode).toBe("standalone"); + expect(view!.capElapsed).toBe(false); // the cap has not elapsed + expect(view!.choices).toBeNull(); // the abort/resume choice is not surfaced yet + + // invariant 7: commit-pending is a MODE-JOURNAL state — the posture field + // NEVER carries it (the vocabulary stays closed) + expect(machine.read().record.posture).not.toBe("commit-pending"); + expect(ATTACH_POSTURES).not.toContain("commit-pending"); + expect(ATTACH_POSTURES).toContain(machine.read().posture); + + hooks.disarm("mid-commit"); + engine.resume(); // the journal record RESUMES — replay to committed + expect(engine.state()).toBe("committed"); + expect(machine.read().mode).toBe("fleet"); + expect(machine.read().record.fleet_program).toBe("v2"); + expect(engine.pending()).toBeNull(); // resolved — no dangling pending + }); + + it("kill at mid-commit (crash): the journal record restores or resumes — a FRESH engine recovers from the journal and force-resolves", async () => { + const hooks = new KillHookRegistry(); + hooks.arm("mid-commit", { kind: "crash" }); + const { machine, engine } = rig({ mode: "standalone", hooks }); + engine.stage({ + to: "fleet", + apply: { fleet_program: "v2" }, + modeScopedFields: ["fleet_program"], + }); + await engine.verify(); + const recordBefore = machine.read().record; + + expect(() => engine.commit()).toThrow(SimulatedCrash); + expect(machine.read().record).toEqual(recordBefore); // never torn + expect(machine.journal().filter((e) => e.kind === "transition-commit-pending").length).toBe(1); + + // process-death story: a FRESH engine on the same machine recovers the + // pending transition from the MODE journal + const engine2 = new ModeTransitionEngine({ machine, verify: () => ({ kind: "pass" }) }); + expect(engine2.state()).toBe("commit-pending"); + const view = engine2.pending(); + expect(view).not.toBeNull(); + expect(view!.snapshot.mode).toBe("standalone"); + expect(view!.badge).toContain("commit-pending"); + + engine2.forceResolvePending("resume"); // the local force-resolve verb + expect(machine.read().mode).toBe("fleet"); + expect(machine.read().record.fleet_program).toBe("v2"); + const resolves = machine.journal().filter((e) => e.kind === "transition-resolve"); + expect(resolves.length).toBe(1); + expect(resolves[0]).toMatchObject({ action: "resume", via: "force" }); + expect(engine2.state()).toBe("committed"); + }); + + it("resume is idempotent once committed — a second resume refuses, the pending view is gone", async () => { + const hooks = new KillHookRegistry(); + hooks.arm("mid-commit", { kind: "stop" }); + const { machine, engine } = rig({ mode: "standalone", hooks }); + engine.stage({ to: "fleet" }); + await engine.verify(); + engine.commit(); // halted mid-commit + hooks.disarm("mid-commit"); + engine.resume(); + expect(engine.state()).toBe("committed"); + expect(() => engine.resume()).toThrow(); + expect(() => engine.forceResolvePending("resume")).toThrow(/no commit-pending/); + expect(engine.pending()).toBeNull(); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// The commit-pending wall-clock cap + the local force-resolve verb (AC 5) +// ══════════════════════════════════════════════════════════════════════════════ + +describe("#1072 the commit-pending wall-clock cap — FakeClock-driven, zero wall-clock waits", () => { + function haltedPending(capMs: number) { + const hooks = new KillHookRegistry(); + hooks.arm("mid-commit", { kind: "stop" }); + const clock = new FakeClock(); + const r = rig({ + mode: "standalone", + hooks, + clock, + config: { pendingCapMs: capMs }, + initial: { fleet_program: "v1" }, + }); + r.engine.stage({ to: "fleet", apply: { fleet_program: "v2" }, modeScopedFields: ["fleet_program"] }); + return { ...r, hooks, clock }; + } + + it("before the cap the choice is NOT surfaced and resolvePending refuses — but the local force-resolve verb works", async () => { + const { machine, engine, hooks, clock } = haltedPending(30_000); + void clock; + await engine.verify(); + engine.commit(); // halted mid-commit: commit-pending + + expect(engine.pending()!.capElapsed).toBe(false); + expect(() => engine.resolvePending("abort")).toThrow(/cap/); + + engine.forceResolvePending("abort"); // the local verb — no cap, no hub + expect(engine.state()).toBe("rolled-back"); + expect(machine.read().mode).toBe("standalone"); // restored to the pre-stage snapshot + expect(machine.read().record.fleet_program).toBe("v1"); + expect(machine.journal().filter((e) => e.kind === "transition-resolve")) + .toMatchObject([{ action: "abort", via: "force" }]); + expect(hooks.fired).toEqual([{ point: "mid-commit", kind: "stop" }]); + }); + + it("the cap surfacing the staged abort/resume choice, confirmable locally: RESUME completes the commit", async () => { + const { machine, engine, clock } = haltedPending(30_000); + await engine.verify(); + engine.commit(); // halted mid-commit + + clock.advance(30_000); // the wall-clock cap elapses — zero wall-clock waits + const view = engine.pending()!; + expect(view.capElapsed).toBe(true); + expect(view.choices).toEqual(["abort", "resume"]); + + engine.resolvePending("resume"); + expect(engine.state()).toBe("committed"); + expect(machine.read().mode).toBe("fleet"); + expect(machine.journal().filter((e) => e.kind === "transition-resolve")) + .toMatchObject([{ action: "resume", via: "cap-choice" }]); + }); + + it("the cap surfacing the staged abort/resume choice, confirmable locally: ABORT rolls back to the pre-stage snapshot", async () => { + const { machine, engine, clock } = haltedPending(30_000); + await engine.verify(); + engine.commit(); // halted mid-commit + + clock.advance(30_001); + expect(engine.pending()!.capElapsed).toBe(true); + + engine.resolvePending("abort"); + expect(engine.state()).toBe("rolled-back"); + expect(machine.read().mode).toBe("standalone"); + expect(machine.read().record.fleet_program).toBe("v1"); // the snapshot restored + const kinds = machine.journal().map((e) => e.kind); + expect(kinds).toContain("transition-resolve"); + expect(kinds).toContain("transition-rollback"); + // the posture field never carried any of it (invariant 7) + expect(machine.read().record.posture).not.toBe("commit-pending"); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// F2 — the full kill/fail matrix: n_non_atomic_mode_switches == 0 (AC 6/7) +// ══════════════════════════════════════════════════════════════════════════════ + +describe("#1072 F2 — n_non_atomic_mode_switches == 0 over the full kill/fail matrix", () => { + const PRE_STAGE: AttachMode = "standalone"; + + async function runScenario(kind: string): Promise { + if (kind === "transient-fail") { + const proxy = await startProxy("drop"); + const { machine, engine } = rig({ mode: PRE_STAGE, verify: proxyVerify(proxy) }); + engine.stage({ to: "fleet" }); + await engine.verify(); // transient fail — staged with the snapshot retained + return machine; + } + if (kind === "structural-fail") { + const { machine, engine } = rig({ + mode: PRE_STAGE, + verify: () => ({ kind: "structural-fail", detail: "mode changed elsewhere" }), + }); + engine.stage({ to: "fleet" }); + await engine.verify(); // automatic rollback + return machine; + } + // the kill scenarios: pre-commit and mid-commit, stop/throw/crash + const [point, fault] = kind.split(":") as ["pre-commit" | "mid-commit", "stop" | "throw" | "crash"]; + const hooks = new KillHookRegistry(); + hooks.arm(point, { kind: fault, ...(fault === "throw" ? { error: new Error("injected") } : {}) }); + const { machine, engine } = rig({ mode: PRE_STAGE, hooks }); + engine.stage({ to: "fleet" }); + await engine.verify(); + try { + engine.commit(); + } catch { + // throw/crash escape — the machine keeps whatever was journaled + } + return machine; + } + + it("every scenario in the matrix ends honest: mode either unswitched or atomically committed-and-journaled", async () => { + const matrix = [ + "pre-commit:stop", + "pre-commit:throw", + "pre-commit:crash", + "mid-commit:stop", + "mid-commit:throw", + "mid-commit:crash", + "transient-fail", + "structural-fail", + ]; + let nonAtomic = 0; + for (const kind of matrix) { + const machine = await runScenario(kind); + const violations = nonAtomicModeSwitches(machine, PRE_STAGE); + expect(violations, `${kind} must end honest`).toBe(0); + nonAtomic += violations; + // the mode field is written ONLY by human-confirmed commit paths + expect(modeFieldWritesAllHumanConfirmed(machine), `${kind}: mode writes all human-confirm`).toBe(true); + // no transport write ever named mode (the #1069 floor holds under the engine too) + expect(machine.transportModeWriteCount()).toBe(0); + // invariant 7: the posture field never carries commit-pending; the vocabulary stays closed + expect(machine.read().record.posture).not.toBe("commit-pending"); + expect(ATTACH_POSTURES).toContain(machine.read().posture); + // never torn: a journaled commit-pending without a completed commit left NOTHING applied + const pending = machine.journal().filter((e) => e.kind === "transition-commit-pending").length; + const committed = machine.journal().filter((e) => e.kind === "transition-commit").length; + if (committed === 0 && pending > 0) { + expect(machine.read().mode, `${kind}: journaled-pending but uncommitted means NOTHING applied`).toBe(PRE_STAGE); + } + } + expect(nonAtomic).toBe(0); // n_non_atomic_mode_switches == 0 + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// The serialization seam (P4a-3, D2) — the verify-entry hook (AC 8) +// ══════════════════════════════════════════════════════════════════════════════ + +describe("#1072 the verify-entry seam — the future proposal queue's drain point, no queue yet", () => { + it("the onVerifyEntry hook fires BEFORE the attach test, once per verify attempt — the D2 drain point (queue drains before verify)", async () => { + const order: string[] = []; + const seen: VerifyContext[] = []; + let impl: (ctx: VerifyContext) => Promise | VerifyOutcome = () => { + order.push("attach-test"); + return { kind: "pass" }; + }; + const { engine } = rig({ + mode: "standalone", + verify: (ctx) => impl(ctx), + onVerifyEntry: (ctx) => { + order.push("drain"); + seen.push(ctx); + }, + }); + engine.stage({ to: "fleet" }); + await engine.verify(); + expect(order).toEqual(["drain", "attach-test"]); // BEFORE the attach test + + // a transient fail then a re-verify: the seam fires again per attempt + impl = () => { + order.push("attach-test"); + return { kind: "transient-fail", detail: "timeout" }; + }; + await engine.verify(); + await engine.verify(); + expect(order).toEqual(["drain", "attach-test", "drain", "attach-test", "drain", "attach-test"]); + + // the seam receives the transition + the clamped budget (the P4b program composes it) + expect(seen[0].transition.from).toBe("standalone"); + expect(seen[0].transition.to).toBe("fleet"); + expect(typeof seen[0].transition.id).toBe("string"); + expect(seen[0].budgetMs).toBe(VERIFY_BUDGET_FLOOR_MS); + }); + + it("a throwing drain hook leaves the transition STAGED — nothing written, nothing journaled for the attempt", async () => { + const { machine, engine } = rig({ + mode: "standalone", + verify: () => ({ kind: "pass" }), + onVerifyEntry: () => { + throw new Error("queue drain failed"); + }, + }); + engine.stage({ to: "fleet" }); + const journalBefore = machine.journal().length; + await expect(engine.verify()).rejects.toThrow("queue drain failed"); + expect(engine.state()).toBe("staged"); // still staged — re-verify when the drain heals + expect(machine.journal().length).toBe(journalBefore); // no verify entry journaled + expect(machine.read().mode).toBe("standalone"); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Budgets + thresholds — injectable, base defaults ship (AC 9) +// ══════════════════════════════════════════════════════════════════════════════ + +describe("#1072 budgets — injectable, the 60s floor, base defaults ship", () => { + it("the base defaults: the #1034 60s verify floor + a commit-pending wall-clock cap", () => { + expect(VERIFY_BUDGET_FLOOR_MS).toBe(60_000); + expect(DEFAULT_MODE_TRANSITION_CONFIG).toEqual({ verifyBudgetMs: 60_000, pendingCapMs: 300_000 }); + const { engine } = rig(); + expect(engine.config()).toEqual(DEFAULT_MODE_TRANSITION_CONFIG); + }); + + it("the verify budget is injectable ABOVE the floor and CLAMPED to the floor below it", () => { + expect(rig({ config: { verifyBudgetMs: 90_000 } }).engine.config().verifyBudgetMs).toBe(90_000); + expect(rig({ config: { verifyBudgetMs: 1_000 } }).engine.config().verifyBudgetMs).toBe(60_000); + }); + + it("the pending cap is injectable (the P4b fleet program composes it) — and the seam's budget reflects the clamp", async () => { + expect(rig({ config: { pendingCapMs: 5_000 } }).engine.config().pendingCapMs).toBe(5_000); + const seen: number[] = []; + const { engine } = rig({ + config: { verifyBudgetMs: 500 }, + onVerifyEntry: (ctx) => { + seen.push(ctx.budgetMs); + }, + }); + engine.stage({ to: "fleet" }); + await engine.verify(); + expect(seen).toEqual([60_000]); // clamped UP to the floor — never below the #1034 patience + }); +}); From e3e4620c1abfda627bc9450696884f7588e4bd0e Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sun, 13 Sep 2026 23:20:49 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20#1072=20the=20two-phase=20mode-tran?= =?UTF-8?q?sition=20engine=20on=20the=20ModeMachine=20(stage=20=E2=86=92?= =?UTF-8?q?=20verify=20=E2=86=92=20commit,=20journalled=20+=20rollback-abl?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive evolution of the #1069/#1070 attach_state surface — the engine grows ON the ModeMachine, no renames or removals: - ModeTransitionEngine: stage (pre-stage snapshot of mode-scoped local state BEFORE any state writes; staging writes only the journal entry) → verify (injectable attach test, budget clamped to the #1034 60s floor, deadline armed before the first await so the FakeClock drives it with zero wall-clock waits) → commit (the only mode writer, through the machine's human-confirm path; the commit-pending journal record is written BEFORE any state write so a kill mid-commit leaves the transition journaled, never torn). - Transient verify-fail → staged with the snapshot retained, re-verify needs no fresh confirm (the human confirmed the proposal, not the timing); structural verify-fail → automatic rollback to the pre-stage snapshot (local-only, idempotent, hub-side untouched — invariant 1's sole automatic action, restores never advances). - KillHookRegistry boundaries pre-commit/mid-commit; process-death recovery from the MODE journal (a fresh engine resumes a journaled commit-pending); the wall-clock cap surfaces the abort/resume choice confirmable locally + a local force-resolve verb; commit-pending is a mode-JOURNAL state rendered as a labeled badge — never a posture value (invariant 7, ADR-0005 vocabulary law). - The P4a-3 serialization seam: the onVerifyEntry hook fires before the attach test, once per verify attempt — the future proposal queue drains there. No queue in this slice. - Machine additions: appendJournal (the engine's journal seam), restoreModeScoped (the restore path; refuses posture, keeps the legacy dual-write honest, compares the RESOLVED mode so restoration materializes nothing); ModeJournalEntry extended to the transition vocabulary; writeLog writer union extended with 'restore'. - Budgets injectable (verify budget, pending cap), clock injectable; base defaults ship (60s floor + 5min cap). mode_transition_engine.test.ts: 25/25 green; the #1069 machine + classifier suites untouched and green (37); typecheck clean; full suite at baseline parity (9 known environmental failures, family unchanged). --- .../src/amicode_service/attach_state.ts | 677 +++++++++++++++++- 1 file changed, 660 insertions(+), 17 deletions(-) diff --git a/packages/extension/src/amicode_service/attach_state.ts b/packages/extension/src/amicode_service/attach_state.ts index 4f1654019..1beb303ed 100644 --- a/packages/extension/src/amicode_service/attach_state.ts +++ b/packages/extension/src/amicode_service/attach_state.ts @@ -35,19 +35,26 @@ // THE RENDER-ONLY EXEMPTION (row 3, rearchitect spec §2.2): posture writes // are render-only and exempt from the D2 serialization rule and the mode // journal — the badge stays live exactly while the mode machine is stuck. -// This module therefore keeps TWO distinct logs and NO serialization -// machinery of its own (the engine is P4a-2, the queue is P4a-3 — neither is -// this slice): +// This module therefore keeps TWO distinct logs (the D2 serialization rule +// itself is P4a-3's queue; this slice ships only the verify-entry seam it +// will drain through): // · writeLog() — an in-memory render record (who wrote what, when) for // surfacing and the F3 fixture; NOT a journal, never persisted, exempt. -// · journal() — the MODE journal surface where P4a-2 will persist -// stage/verify/commit states and commit-pending. Posture writes NEVER -// land in it. Human confirms are the only entries this slice records. +// · journal() — the MODE journal surface where the P4a-2 engine (#1072) +// persists stage/verify/commit states and commit-pending, alongside +// human confirms. Posture writes NEVER land in it. // // Preserve-on-rewrite (the substrate's bidirectional invariant, carried // across the split unchanged): a base rewrite preserves overlay-written // fields it does not understand, and an overlay rewrite preserves // base-written fields — never clobbers in either direction. +// +// The TWO-PHASE TRANSITION ENGINE (P4a-2, #1072) grows at the bottom of +// this module: stage → verify → commit over the ModeMachine, journalled + +// rollback-able. The proposal QUEUE is P4a-3 (this module ships only the +// verify-entry seam it will drain through). +import { randomUUID } from "node:crypto"; + export type AttachMode = "standalone" | "fleet"; export type AttachPosture = "ok" | "degraded" | "hub-down"; export type LegacyFleetPostureState = "fleet" | "degraded" | "standalone"; @@ -147,7 +154,7 @@ export type StructuralSignalInput = Omit & { ki /** One attach-state write, as a RENDER record (exempt: never a journal). */ export interface AttachStateWriteEntry { - writer: "transport" | "human-confirm" | "overlay"; + writer: "transport" | "human-confirm" | "overlay" | "restore"; /** The machine-owned field the write named: "posture" | "mode" — or * "overlay-fields" for the overlay data path. */ field: "posture" | "mode" | "overlay-fields"; @@ -157,15 +164,69 @@ export interface AttachStateWriteEntry { reason?: string; } -/** A MODE journal entry (P4a-2's two-phase engine grows this surface; this - * slice records only human confirms — posture writes are exempt). */ -export interface ModeJournalEntry { - kind: "human-confirm"; +/** The pre-stage snapshot (#1072): the MODE-SCOPED local state captured + * BEFORE any state writes — mode + the dual-written legacy field + the + * proposal's mode-scoped overlay fields, pinning the last human-confirmed + * mode config. Rollback restores EXACTLY this (invariant 1, as amended: + * local-only restoration, hub-side applies untouched). */ +export interface TransitionSnapshot { mode: AttachMode; - at: string; + fields: Record; +} + +/** The confirmed proposal the engine executes (#1072): what the human + * confirmed (the `enrollFleet` propose step precedes; the engine never + * mints a confirm of its own — commit executes the confirmed proposal). */ +export interface TransitionProposalRecord { + id: string; + from: AttachMode; + to: AttachMode; reason?: string; + /** Mode-scoped overlay fields the commit applies (P4b program values). */ + apply?: Record; + modeScopedFields: string[]; } +/** A MODE journal entry. This slice's vocabulary per ADR-0005: human + * confirms + the P4a-2 transition lifecycle (stage / verify / + * commit-pending / commit / rollback / resolve). commit-pending lives + * HERE — a mode-journal state — and never in the posture field + * (invariant 7). Posture writes are exempt and never land in the journal. */ +export type ModeJournalEntry = + | { kind: "human-confirm"; mode: AttachMode; at: string; reason?: string } + | { + kind: "transition-stage"; + from: AttachMode; + to: AttachMode; + snapshot: TransitionSnapshot; + proposal: TransitionProposalRecord; + at: string; + reason?: string; + } + | { + kind: "transition-verify"; + outcome: "pass" | "transient-fail" | "structural-fail"; + detail?: string; + at: string; + } + | { + kind: "transition-commit-pending"; + to: AttachMode; + snapshot: TransitionSnapshot; + proposal: TransitionProposalRecord; + /** The wall-clock instant the pending state began (cap arithmetic). */ + sinceMs: number; + at: string; + } + | { kind: "transition-commit"; to: AttachMode; at: string } + | { kind: "transition-rollback"; restored: AttachMode; at: string; reason?: string } + | { + kind: "transition-resolve"; + action: "abort" | "resume"; + via: "force" | "cap-choice"; + at: string; + }; + /** The machine-owned fields — overlay rewrites never name them. */ const MACHINE_OWNED_FIELDS = new Set(["mode", "posture", "state"]); @@ -222,8 +283,8 @@ export class ModeMachine { } /** The overlay data rewrite (P4b program values): merges the patch's - * fields while preserving the machine-owned mode/posture/state — never - * clobbers in either direction. */ + * fields while preserving the machine-owned mode/posture/state — never + * clobbers in either direction. */ overlayRewrite(patch: Record): void { const dataOnly: Record = {}; for (const [k, v] of Object.entries(patch)) { @@ -234,6 +295,47 @@ export class ModeMachine { this.writes.push({ writer: "overlay", field: "overlay-fields", value: null, at: this.now() }); } + /** The RESTORE path (#1072): invariant 1's rollback exemption — the + * machine's only automatic write, and it RESTORES, never advances. Each + * named mode-scoped field goes back to its snapshotted value; identical + * values are no-ops (idempotent — a double rollback writes nothing). + * Posture is REFUSED: transport-owned render state is never mode-scoped. + * Restoring mode keeps the legacy dual-write honest (the frozen `state` + * projection follows the restored mode when the patch does not name it). */ + restoreModeScoped(fields: Record): { restored: string[] } { + if ("posture" in fields) { + throw new Error( + "restoreModeScoped: posture is transport-owned render state and is never mode-scoped (ADR-0005)", + ); + } + const restored: string[] = []; + const at = this.now(); + for (const [field, value] of Object.entries(fields)) { + if (field === "mode") { + // compare the RESOLVED mode: an absent field with the same semantic + // value is already-restored — restoration materializes nothing + if (this.resolve().mode === value) continue; + } else if (this.recordValue[field] === value) { + continue; // idempotent: identical value = no write + } + const patch: Record = { [field]: value }; + if (field === "mode") { + // the legacy dual-write follows the restored mode + patch.state = legacyStateOf(value as AttachMode, this.resolve().posture); + } + this.recordValue = mergeAttachState(this.recordValue, patch); + restored.push(field); + this.writes.push({ + writer: "restore", + field: field === "mode" ? "mode" : "overlay-fields", + value: field === "mode" ? String(value) : null, + at, + reason: "restoreModeScoped: invariant 1's rollback exemption (restores, never advances)", + }); + } + return { restored }; + } + /** The resolved read (new fields preferred, old mapped, defaults). */ resolve(): ResolvedAttachState { return resolveAttachState(this.recordValue); @@ -277,13 +379,21 @@ export class ModeMachine { return this.writes.filter((w) => w.writer === "transport" && w.field === "mode").length; } - /** The MODE journal surface (P4a-2 persists stage/verify/commit and - * commit-pending here). Posture writes NEVER land in it — the render-only - * exemption, asserted in tests. */ + /** The MODE journal surface (P4a-2's engine persists stage/verify/commit + * and commit-pending here). Posture writes NEVER land in it — the + * render-only exemption, asserted in tests. */ journal(): ModeJournalEntry[] { return [...this.journalEntries]; } + /** The transition engine's journal-append path (#1072): the machine OWNS + * the journal; the engine — its only writer besides confirmMode's + * human-confirm entry — appends its transition entries through this + * seam. Posture writes never arrive here (render-only exemption). */ + appendJournal(entry: ModeJournalEntry): void { + this.journalEntries.push(entry); + } + /** Emit a structural signal: stamp it, log it, notify listeners — and do * NOTHING else. No attach-state write, no posture change, no queue (the * queue is P4a-3). */ @@ -314,3 +424,536 @@ export class ModeMachine { return [...this.structuralLog]; } } + +// ── the two-phase mode-transition engine (#1072 — P4a-2) ────────────────────── +// +// GROWN ON the ModeMachine (evolve, never fork): stage → verify → commit, +// journalled + rollback-able (rearchitect spec §2.2 row 2, invariant 1 as +// amended, §8 F2). It executes what a human ALREADY confirmed (the +// `enrollFleet` propose step precedes) and never mints a confirm of its own +// except the commit's own execution of the confirmed proposal: +// +// · STAGE takes the pre-stage snapshot of MODE-SCOPED local state BEFORE +// any state writes (staging writes no attach-state field — only the +// journal entry); TRANSIENT verify-fail returns to staged with the +// snapshot retained (re-verify needs NO fresh confirm — the human +// confirmed the proposal, not the timing); STRUCTURAL verify-fail rolls +// back AUTOMATICALLY to the pre-stage snapshot (local-only, idempotent, +// hub-side untouched — invariant 1's sole automatic action). +// · COMMIT is the only mode writer, and it writes through the machine's +// human-confirm path; the commit-pending journal record is written +// BEFORE any state write, so a kill mid-commit leaves the transition +// journaled (restored-or-resumed, never torn). commit-pending renders as +// a labeled badge and is bounded by a wall-clock cap that surfaces an +// abort/resume choice confirmable locally, plus a local force-resolve +// verb — no hub round-trip ever resolves a pending transition. +// · The P4a-3 serialization SEAM (D2): the onVerifyEntry hook fires +// BEFORE the attach test, once per verify attempt — the future +// proposal queue drains there. No queue exists in this slice. +// · Budgets are injectable (verify budget clamped UP to the #1034 60s +// patience floor; pending cap) and clock-injectable: the F-harness +// FakeClock drives every deadline — zero wall-clock waits in tests. + +/** The verify outcome taxonomy (row 2): TRANSIENT is timing (return to + * staged, snapshot retained); STRUCTURAL is a world change (automatic + * rollback). */ +export type VerifyOutcome = + | { kind: "pass" } + | { kind: "transient-fail"; detail: string } + | { kind: "structural-fail"; detail?: string }; + +/** What the engine hands the attach test (and the P4a-3 drain hook): the + * confirmed transition + the clamped verify budget (the P4b fleet program + * composes these). */ +export interface VerifyContext { + transition: { id: string; from: AttachMode; to: AttachMode; reason?: string }; + budgetMs: number; +} + +/** The engine's injectable budgets/thresholds (P4b composes them; base + * defaults ship). */ +export interface ModeTransitionConfig { + /** The attach-test budget. CLAMPED to VERIFY_BUDGET_FLOOR_MS from below — + * the #1034 60s patience is the floor, never less. */ + verifyBudgetMs: number; + /** How long commit-pending may persist before the abort/resume choice + * surfaces. */ + pendingCapMs: number; +} + +/** The #1034 patience — the verify budget floor. */ +export const VERIFY_BUDGET_FLOOR_MS = 60_000; + +/** The base defaults: the 60s verify floor + a 5-minute pending cap. */ +export const DEFAULT_MODE_TRANSITION_CONFIG: ModeTransitionConfig = { + verifyBudgetMs: VERIFY_BUDGET_FLOOR_MS, + pendingCapMs: 300_000, +}; + +/** The engine's clock — the F-harness FakeClock shape, so every deadline + * (verify budget, pending cap) is FakeClock-driven in tests with zero + * wall-clock waits. */ +export interface TransitionClock { + now(): number; + setTimeout(callback: () => void, delayMs: number): { id: number }; + clearTimeout(handle: { id: number }): void; +} + +/** The base wall-clock default. */ +class WallClock implements TransitionClock { + private seq = 0; + private readonly timers = new Map>(); + + now(): number { + return Date.now(); + } + + setTimeout(callback: () => void, delayMs: number): { id: number } { + const id = ++this.seq; + this.timers.set(id, setTimeout(callback, delayMs)); + return { id }; + } + + clearTimeout(handle: { id: number }): void { + const t = this.timers.get(handle.id); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(handle.id); + } + } +} + +/** The kill-hook surface the engine invokes at its NAMED boundaries + * ("pre-commit" after verify before anything is written; "mid-commit" after + * the commit-pending journal record, before the apply) — the F-harness + * KillHookRegistry's structural type. */ +export interface KillHookSurface { + invoke(point: string): { kind: string } | undefined; +} + +/** The engine's states: idle → staged → verified → committed, with + * commit-pending (journaled interruption) and rolled-back (the snapshot + * restored) as the honest resting states. commit-pending is a MODE-JOURNAL + * state — never a posture value (invariant 7). */ +export type ModeTransitionEngineState = + | "idle" + | "staged" + | "verified" + | "commit-pending" + | "committed" + | "rolled-back"; + +/** The labeled-badge view of a journaled commit-pending transition: the + * pre-stage snapshot rendered live, the cap arithmetic, and the staged + * abort/resume choice once the cap elapses. */ +export interface PendingTransitionView { + badge: string; + snapshot: TransitionSnapshot; + capElapsed: boolean; + choices: ["abort", "resume"] | null; +} + +export interface ModeTransitionEngineOptions { + machine: ModeMachine; + /** The attach test: probes the tunnel/device and classifies the outcome. */ + verify: (ctx: VerifyContext) => Promise | VerifyOutcome; + config?: Partial; + clock?: TransitionClock; + hooks?: KillHookSurface; + /** P4a-3's serialization SEAM (D2): fires BEFORE the attach test, once + * per verify attempt — the future proposal queue drains here. A throwing + * drain leaves the transition STAGED (nothing written, nothing + * journaled for the attempt). */ + onVerifyEntry?: (ctx: VerifyContext) => Promise | void; +} + +const IN_FLIGHT_STATES: ReadonlySet = new Set([ + "staged", + "verified", + "commit-pending", +]); + +export class ModeTransitionEngine { + private readonly machineValue: ModeMachine; + private readonly verifyImpl: (ctx: VerifyContext) => Promise | VerifyOutcome; + private readonly clockValue: TransitionClock; + private readonly hooks: KillHookSurface | null; + private readonly onVerifyEntry: ((ctx: VerifyContext) => Promise | void) | null; + private readonly configValue: ModeTransitionConfig; + private stateValue: ModeTransitionEngineState = "idle"; + private haltedAtValue: "pre-commit" | "mid-commit" | null = null; + private proposalValue: TransitionProposalRecord | null = null; + private snapshotValue: TransitionSnapshot | null = null; + private pendingSinceMs: number | null = null; + + constructor(opts: ModeTransitionEngineOptions) { + this.machineValue = opts.machine; + this.verifyImpl = opts.verify; + this.clockValue = opts.clock ?? new WallClock(); + this.hooks = opts.hooks ?? null; + this.onVerifyEntry = opts.onVerifyEntry ?? null; + const merged: ModeTransitionConfig = { + ...DEFAULT_MODE_TRANSITION_CONFIG, + ...(opts.config ?? {}), + }; + this.configValue = { + // CLAMPED to the floor from below — the #1034 patience is never less + verifyBudgetMs: Math.max(VERIFY_BUDGET_FLOOR_MS, merged.verifyBudgetMs), + pendingCapMs: merged.pendingCapMs, + }; + this.recoverFromJournal(); + } + + /** The effective budgets (clamped, base defaults shipped). */ + config(): ModeTransitionConfig { + return { ...this.configValue }; + } + + state(): ModeTransitionEngineState { + return this.stateValue; + } + + /** Where a kill halted the engine ("pre-commit" | "mid-commit"), or null. */ + haltedAt(): "pre-commit" | "mid-commit" | null { + return this.haltedAtValue; + } + + /** The staged/retained pre-stage snapshot of the current or last + * transition (null before the first stage). */ + snapshot(): TransitionSnapshot | null { + return this.snapshotValue; + } + + /** The confirmed proposal the engine is executing (null before the first + * stage). */ + proposal(): TransitionProposalRecord | null { + return this.proposalValue; + } + + /** STAGE — the pre-stage snapshot FIRST, before any state writes (AC 1). + * Staging writes NO attach-state field: only the mode-journal entry. The + * snapshot pins the last human-confirmed mode config. */ + stage(input: { + to: AttachMode; + reason?: string; + apply?: Record; + modeScopedFields?: string[]; + }): TransitionSnapshot { + if (IN_FLIGHT_STATES.has(this.stateValue)) { + throw new Error(`stage: a transition is already in flight (state "${this.stateValue}")`); + } + const current = this.machineValue.resolve().mode; + if (input.to === current) { + throw new Error(`stage: already confirmed in mode "${current}" — no transition to stage`); + } + const modeScopedFields = input.modeScopedFields ?? []; + if (modeScopedFields.includes("posture")) { + throw new Error( + "stage: posture is transport-owned render state and is never mode-scoped (ADR-0005)", + ); + } + const raw = this.machineValue.record(); + const resolved = this.machineValue.resolve(); + const fields: Record = { + mode: current, + state: raw.state !== undefined ? raw.state : legacyStateOf(current, resolved.posture), + }; + for (const f of modeScopedFields) fields[f] = raw[f]; + const snapshot: TransitionSnapshot = { mode: current, fields }; + const proposal: TransitionProposalRecord = { + id: randomUUID(), + from: current, + to: input.to, + ...(input.reason !== undefined ? { reason: input.reason } : {}), + ...(input.apply !== undefined ? { apply: { ...input.apply } } : {}), + modeScopedFields: [...modeScopedFields], + }; + this.snapshotValue = snapshot; + this.proposalValue = proposal; + this.pendingSinceMs = null; + this.haltedAtValue = null; + this.machineValue.appendJournal({ + kind: "transition-stage", + from: proposal.from, + to: proposal.to, + snapshot, + proposal, + at: this.nowIso(), + ...(input.reason !== undefined ? { reason: input.reason } : {}), + }); + this.stateValue = "staged"; + return snapshot; + } + + /** VERIFY — the attach test, bounded by the (clamped) verify budget. + * Re-runnable from staged AND verified (re-verify needs no fresh + * confirm). Throws synchronously when no transition is staged. */ + verify(): Promise { + if (this.stateValue !== "staged" && this.stateValue !== "verified") { + throw new Error(`verify: no staged transition to verify (state "${this.stateValue}")`); + } + return this.runVerify(); + } + + private async runVerify(): Promise { + const proposal = this.proposalValue!; + const budgetMs = this.configValue.verifyBudgetMs; + const ctx: VerifyContext = { + transition: { + id: proposal.id, + from: proposal.from, + to: proposal.to, + ...(proposal.reason !== undefined ? { reason: proposal.reason } : {}), + }, + budgetMs, + }; + // arm the deadline FIRST — the FakeClock drives it via advance() in the + // same tick the caller gets the promise back; zero wall-clock waits + let expire!: () => void; + const expiry = new Promise((resolve) => { + expire = resolve; + }); + const timer = this.clockValue.setTimeout(() => expire(), budgetMs); + try { + // the P4a-3 serialization SEAM (D2): the drain fires BEFORE the + // attach test, once per verify attempt + if (this.onVerifyEntry !== null) await this.onVerifyEntry(ctx); + const winner = await Promise.race([ + Promise.resolve(this.verifyImpl(ctx)).then( + (outcome): { tag: "outcome"; outcome: VerifyOutcome } => ({ tag: "outcome", outcome }), + ), + expiry.then((): { tag: "expired" } => ({ tag: "expired" })), + ]); + if (winner.tag === "expired") { + // the budget expiring is TIMING, not a world change: transient + const outcome: VerifyOutcome = { + kind: "transient-fail", + detail: `verify budget of ${budgetMs}ms expired before the attach test settled`, + }; + this.journalVerify(outcome); + this.stateValue = "staged"; // return to staged — the snapshot retained + return outcome; + } + const outcome = winner.outcome; + this.journalVerify(outcome); + if (outcome.kind === "pass") { + this.stateValue = "verified"; + return outcome; + } + if (outcome.kind === "transient-fail") { + // return to staged with the snapshot retained; re-verify needs NO + // fresh confirm — the human confirmed the proposal, not the timing + this.stateValue = "staged"; + return outcome; + } + // STRUCTURAL: a world change — automatic rollback to the pre-stage + // snapshot (local-only, idempotent, hub-side untouched) + this.applyRollback( + `structural verify-fail${outcome.detail !== undefined ? `: ${outcome.detail}` : ""}`, + ); + return outcome; + } finally { + this.clockValue.clearTimeout(timer); + } + } + + private journalVerify(outcome: VerifyOutcome): void { + this.machineValue.appendJournal({ + kind: "transition-verify", + outcome: outcome.kind, + ...(outcome.kind !== "pass" && outcome.detail !== undefined ? { detail: outcome.detail } : {}), + at: this.nowIso(), + }); + } + + /** COMMIT — the only mode writer: atomic + journalled, through the + * machine's human-confirm path (the proposal was human-confirmed; the + * commit executes it). The commit-pending journal record is written + * BEFORE any state write — a kill mid-commit leaves the transition + * journaled (restored-or-resumed), never torn. */ + commit(): void { + if (this.stateValue !== "verified") { + throw new Error(`commit: the transition is not verified (state "${this.stateValue}")`); + } + const proposal = this.proposalValue!; + const snapshot = this.snapshotValue!; + // named boundary "pre-commit": after verify, BEFORE anything is written + // — a kill here leaves nothing applied and nothing journaled + const pre = this.hooks?.invoke("pre-commit"); + if (pre !== undefined) { + this.haltedAtValue = "pre-commit"; + return; + } + // the point of no return: journal commit-pending BEFORE any state write + const sinceMs = this.clockValue.now(); + this.pendingSinceMs = sinceMs; + this.machineValue.appendJournal({ + kind: "transition-commit-pending", + to: proposal.to, + snapshot, + proposal, + sinceMs, + at: this.nowIso(), + }); + this.stateValue = "commit-pending"; + // named boundary "mid-commit": the journal record written, the apply + // not yet run — a kill here leaves commit-pending, never torn + const mid = this.hooks?.invoke("mid-commit"); + if (mid !== undefined) { + this.haltedAtValue = "mid-commit"; + return; + } + this.completeCommit(); + } + + /** Resume after a halt (the proposal was human-confirmed — no fresh + * confirm): a pre-commit halt retries the commit; a commit-pending halt + * (or a crash recovered from the journal) replays to committed. */ + resume(): void { + if (this.stateValue === "verified") { + this.haltedAtValue = null; + this.commit(); + return; + } + if (this.stateValue === "commit-pending") { + this.completeCommit(); + return; + } + throw new Error(`resume: nothing to resume (state "${this.stateValue}")`); + } + + /** The labeled-badge view of the journaled commit-pending transition: the + * pre-stage snapshot rendered live, the cap arithmetic, and the staged + * abort/resume choice once the cap elapses. Null when nothing is + * pending. The POSTURE field never carries any of this (invariant 7). */ + pending(): PendingTransitionView | null { + if (this.stateValue !== "commit-pending") return null; + if (this.snapshotValue === null || this.proposalValue === null) return null; + const capElapsed = this.capElapsed(); + return { + badge: `commit-pending: ${this.snapshotValue.mode} → ${this.proposalValue.to} (mid-commit halt; the badge is the mode journal's state, never a posture value)`, + snapshot: this.snapshotValue, + capElapsed, + choices: capElapsed ? ["abort", "resume"] : null, + }; + } + + /** The cap-surfaced abort/resume choice, confirmable LOCALLY (no hub + * round-trip resolves a pending transition). Refuses before the cap — + * forceResolvePending is the cap-free local verb. */ + resolvePending(action: "abort" | "resume"): void { + if (this.stateValue !== "commit-pending") { + throw new Error(`resolvePending: no commit-pending transition (state "${this.stateValue}")`); + } + if (!this.capElapsed()) { + throw new Error( + `resolvePending: the wall-clock cap (${this.configValue.pendingCapMs}ms) has not elapsed — the choice is not surfaced yet; forceResolvePending is the local verb`, + ); + } + this.resolvePendingVia(action, "cap-choice"); + } + + /** The local force-resolve verb: abort or resume a journaled + * commit-pending WITHOUT the cap and WITHOUT the hub. */ + forceResolvePending(action: "abort" | "resume"): void { + if (this.stateValue !== "commit-pending") { + throw new Error(`forceResolvePending: no commit-pending transition (state "${this.stateValue}")`); + } + this.resolvePendingVia(action, "force"); + } + + /** Explicit rollback to the pre-stage snapshot (also the automatic path + * for structural verify-fails and abort resolutions). Idempotent: a + * rollback from rolled-back is a no-op that writes and journals + * nothing. */ + rollback(reason?: string): { rolledBack: boolean; restoredFields: string[] } { + if (this.stateValue === "rolled-back") { + return { rolledBack: false, restoredFields: [] }; // idempotent no-op + } + if (this.stateValue !== "staged" && this.stateValue !== "verified") { + throw new Error( + `rollback: no in-flight transition to roll back (state "${this.stateValue}")` + + (this.stateValue === "commit-pending" + ? " — resolve the pending transition via resolvePending/forceResolvePending" + : ""), + ); + } + return this.applyRollback(reason ?? "explicit rollback"); + } + + private resolvePendingVia(action: "abort" | "resume", via: "force" | "cap-choice"): void { + this.machineValue.appendJournal({ + kind: "transition-resolve", + action, + via, + at: this.nowIso(), + }); + this.pendingSinceMs = null; + if (action === "resume") { + this.completeCommit(); + } else { + this.applyRollback(`commit-pending resolved: abort (via ${via})`); + } + } + + private completeCommit(): void { + const proposal = this.proposalValue!; + // the ONLY mode write: the human-confirmed commit path — commit executes + // the confirmed proposal through the machine's confirmMode + this.machineValue.confirmMode( + proposal.to, + proposal.reason ?? `transition-commit ${proposal.id}`, + ); + if (proposal.apply !== undefined) { + this.machineValue.overlayRewrite(proposal.apply); + } + this.machineValue.appendJournal({ kind: "transition-commit", to: proposal.to, at: this.nowIso() }); + this.stateValue = "committed"; + this.haltedAtValue = null; + this.pendingSinceMs = null; + } + + private applyRollback(reason: string): { rolledBack: boolean; restoredFields: string[] } { + const snapshot = this.snapshotValue!; + // restoration, not mutation: local-only, idempotent, hub-side untouched + const { restored } = this.machineValue.restoreModeScoped(snapshot.fields); + this.machineValue.appendJournal({ + kind: "transition-rollback", + restored: snapshot.mode, + at: this.nowIso(), + reason, + }); + this.stateValue = "rolled-back"; + this.haltedAtValue = null; + this.pendingSinceMs = null; + return { rolledBack: true, restoredFields: restored }; + } + + private capElapsed(): boolean { + if (this.pendingSinceMs === null) return false; + return this.clockValue.now() - this.pendingSinceMs >= this.configValue.pendingCapMs; + } + + /** Process-death recovery: a FRESH engine on the same machine recovers an + * unresolved commit-pending transition from the MODE journal — the + * journal record restores or resumes, never half-applies. */ + private recoverFromJournal(): void { + let open: Extract | null = null; + for (const entry of this.machineValue.journal()) { + if (entry.kind === "transition-commit-pending") open = entry; + else if (entry.kind === "transition-commit" || entry.kind === "transition-resolve") { + open = null; // the pending transition was resolved + } + } + if (open === null) return; + this.stateValue = "commit-pending"; + this.haltedAtValue = "mid-commit"; + this.snapshotValue = open.snapshot; + this.proposalValue = open.proposal; + this.pendingSinceMs = open.sinceMs; + } + + private nowIso(): string { + return new Date(this.clockValue.now()).toISOString(); + } +}