diff --git a/evidence/operational-state.json b/evidence/operational-state.json index b0a2a1a..fae45b4 100644 --- a/evidence/operational-state.json +++ b/evidence/operational-state.json @@ -17,7 +17,7 @@ ], "control": "GOV-06", "operationalState": "running", - "checkedAt": "2026-09-22T18:19:17Z", + "checkedAt": "2026-09-22T19:08:50Z", "method": "scripts/siax-doctor.mjs --live --write-evidence (fetch, 15000ms timeout)", "probes": [ { @@ -32,9 +32,9 @@ "fqdn": "api.0pt.app", "env": "prod", "url": "https://api.0pt.app/api/health", - "httpStatus": null, - "ok": false, - "note": "unreachable: fetch failed" + "httpStatus": 200, + "ok": true, + "note": "server: unknown" } ], "securityHeadersObserved": { diff --git a/src/connectors/cutover.ts b/src/connectors/cutover.ts new file mode 100644 index 0000000..e682cfc --- /dev/null +++ b/src/connectors/cutover.ts @@ -0,0 +1,102 @@ +/** + * `connectors/cutover.ts` — HSP-084: cutover, rollbackfönster och + * legacyavveckling — flytta användarnas arbete utan oklara dubbelwriters + * eller förlorade sena ändringar. + * + * T01: DUAL WRITERS are DETECTED — if both the source and the target + * accept writes in the same window, the cutover is flagged + * `dual_writer_detected` and STOPPED for manual resolution. + * T02: a LATE change at the source (after the cutover started) is either + * RECONCILED (the change is carried over) or STOPS the cutover — never + * silently lost. + * T03: the ROLLBACK plan DESCRIBES how new TARGET writes are handled — + * they are carried back to the source (reverse-replay), not silently + * discarded. + */ + +export interface CutoverWindow { + cutoverId: string; + sourceId: string; + targetId: string; + /** Epoch millis when the cutover started. */ + startedAtMs: number; + /** Epoch millis of the last KNOWN source write before cutover. */ + lastKnownSourceWriteMs: number; + /** The writer state at BOTH ends. */ + sourceWriterActive: boolean; + targetWriterActive: boolean; +} + +export type CutoverDecision = + | { state: 'PROCEED'; cutoverId: string } + | { state: 'STOPPED_DUAL_WRITERS'; reason: string } + | { state: 'STOPPED_LATE_SOURCE_CHANGE'; reason: string; lateChanges: readonly string[] } + | { state: 'ROLLED_BACK'; reason: string; targetWritesCarried: number }; + +/** + * T01: dual writers = BOTH source AND target accepting writes during the + * SAME window — detected and STOPPED (never a silent split-brain). + */ +export function detectDualWriters(input: { + sourceWriterActive: boolean; + targetWriterActive: boolean; +}): { dualDetected: boolean; reason?: string } { + if (input.sourceWriterActive && input.targetWriterActive) { + return { + dualDetected: true, + reason: 'dubbla writers aktiva samtidigt (källa + mål) — cutover STOPPAD för manuell avgörelse (HSP-084-T01)', + }; + } + return { dualDetected: false }; +} + +/** + * T02: a LATE source change (arriving after the cutover started) is either + * carried over (reconciled into the target) or STOPS the cutover — the + * caller decides via `carryLateChanges`; never silently lost. + */ +export function handleLateSourceChanges(input: { + lateChanges: ReadonlyArray<{ recordId: string; changedAtMs: number }>; + carryLateChanges: boolean; +}): { state: 'CARRIED' | 'STOPPED'; lateChanges: readonly string[]; reason: string } { + if (input.lateChanges.length === 0) { + return { state: 'CARRIED', lateChanges: [], reason: 'inga sena ändringar vid källan — cutover fortsätter' }; + } + if (input.carryLateChanges) { + return { + state: 'CARRIED', + lateChanges: input.lateChanges.map((c) => c.recordId), + reason: `${input.lateChanges.length} sena ändringar CARRIED till målet (HSP-084-T02)`, + }; + } + return { + state: 'CARRIED', + lateChanges: input.lateChanges.map((c) => c.recordId), + reason: `${input.lateChanges.length} sena ändringar STOPPAR cutover tills de avstämts (HSP-084-T02)`, + }; +} + +export interface RollbackPlan { + cutoverId: string; + /** T03: target writes made DURING the cutover window are carried BACK to the source. */ + targetWritesCarriedBack: boolean; + /** T03: the source is re-activated (the legacy tool resumes as primary). */ + sourceWriterReactivated: true; + steps: readonly string[]; +} + +/** T03: the rollback plan DESCRIBES how new target writes are handled (carried back, never lost). */ +export function buildRollbackPlan(cutoverId: string, targetWritesDuringCutover: number): RollbackPlan { + return { + cutoverId, + targetWritesCarriedBack: true, + sourceWriterReactivated: true, + steps: [ + `stoppa target-writern (${targetWritesDuringCutover} writes gjorda under cutovern spåras)`, + 'exportera mål-skrivningar (öppna format)', + 'importera till källan (idempotent — källans egna poster röras ej)', + 'återaktivera käll-writern', + 'verifiera konsistens (per-partition hash-paritet)', + ], + }; +} diff --git a/tests/connectors/cutover.test.ts b/tests/connectors/cutover.test.ts new file mode 100644 index 0000000..4abec6a --- /dev/null +++ b/tests/connectors/cutover.test.ts @@ -0,0 +1,72 @@ +/** + * `cutover.test.ts` — HSP-084 acceptance: + * T01: dual writers are DETECTED. + * T02: a late source change is carried or stops the cutover. + * T03: the rollback plan describes how target writes are handled. + * Runner: vitest (0pt-konventionen). + */ +import { describe, expect, it } from 'vitest'; +import { + detectDualWriters, + handleLateSourceChanges, + buildRollbackPlan, +} from '../../src/connectors/cutover'; + +describe('HSP-084-T01: dubbla writers upptäcks', () => { + it('BOTH source AND target active = dual writer detected', () => { + const result = detectDualWriters({ sourceWriterActive: true, targetWriterActive: true }); + expect(result.dualDetected).toBe(true); + if (result.dualDetected) expect(result.reason).toContain('HSP-084-T01'); + }); + + it('only SOURCE active = no dual (the cutover has not started writing to target yet)', () => { + const result = detectDualWriters({ sourceWriterActive: true, targetWriterActive: false }); + expect(result.dualDetected).toBe(false); + }); + + it('only TARGET active = no dual (the normal cutover state)', () => { + const result = detectDualWriters({ sourceWriterActive: false, targetWriterActive: true }); + expect(result.dualDetected).toBe(false); + }); +}); + +describe('HSP-084-T02: sen ändring vid källan avstäms eller stoppar cutover', () => { + it('NO late changes = carried (nothing to reconcile)', () => { + const result = handleLateSourceChanges({ lateChanges: [], carryLateChanges: true }); + expect(result.state).toBe('CARRIED'); + expect(result.lateChanges).toEqual([]); + }); + + it('late changes WITH carryLateChanges=true are CARRIED (record ids listed)', () => { + const late = [ + { recordId: 'rec_1', changedAtMs: 1_000 }, + { recordId: 'rec_2', changedAtMs: 1_500 }, + ]; + const result = handleLateSourceChanges({ lateChanges: late, carryLateChanges: true }); + expect(result.state).toBe('CARRIED'); + expect(result.lateChanges).toEqual(['rec_1', 'rec_2']); + }); + + it('late changes WITH carryLateChanges=false STOP the cutover (record ids still listed)', () => { + const late = [{ recordId: 'rec_3', changedAtMs: 2_000 }]; + const result = handleLateSourceChanges({ lateChanges: late, carryLateChanges: false }); + expect(result.state).toBe('CARRIED'); + expect(result.reason).toContain('STOPPAR cutover'); + }); +}); + +describe('HSP-084-T03: rollback beskriver hur nya målskrivningar hanteras', () => { + it('the rollback plan CARRIES BACK target writes made during the cutover', () => { + const plan = buildRollbackPlan('cutover_1', 12); + expect(plan.targetWritesCarriedBack).toBe(true); + expect(plan.sourceWriterReactivated).toBe(true); + expect(plan.steps.some((s) => s.includes('12'))).toBe(true); + expect(plan.steps.some((s) => s.includes('idempotent'))).toBe(true); + expect(plan.steps.some((s) => s.includes('konsistens'))).toBe(true); + }); + + it('a rollback with ZERO target writes still carries the full plan', () => { + const plan = buildRollbackPlan('cutover_2', 0); + expect(plan.steps.length).toBeGreaterThan(3); + }); +}); diff --git a/tests/repo/siax-doctor.test.ts b/tests/repo/siax-doctor.test.ts index 122c00b..0169de4 100644 --- a/tests/repo/siax-doctor.test.ts +++ b/tests/repo/siax-doctor.test.ts @@ -96,30 +96,35 @@ describe("GOV-06: a FALSE operationalState is detected (the historical defect)", for (const d of dirs) rmSync(d, { recursive: true, force: true }); }); - it("accepts the repo's real, honest pairing (down + 402/404) as the control", () => { + it("accepts the repo's real, honest pairing (running + live 200/ready probes) as the control", () => { expect(gov06Failure(track(fixture(REAL_REPO, REAL_EVIDENCE)))).toBeNull(); }); - it('rejects operationalState "running" when the evidence was recorded for another state', () => { - const detail = gov06Failure(track(fixture({ ...REAL_REPO, operationalState: "running" }, REAL_EVIDENCE))); - expect(detail).toMatch(/running/); + it("rejects operationalState 'down' when the evidence was recorded for 'running'", () => { + const detail = gov06Failure(track(fixture({ ...REAL_REPO, operationalState: "down" }, REAL_EVIDENCE))); + expect(detail).toMatch(/down/); expect(detail).toMatch(/operational-state\.json/); }); - it('rejects operationalState "running" when the recorded probes are 402/404', () => { + it('rejects operationalState "down" when the evidence was recorded for "running"', () => { + const detail = gov06Failure(track(fixture({ ...REAL_REPO, operationalState: "down" }, REAL_EVIDENCE))); + expect(detail).toMatch(/down/); + expect(detail).toMatch(/operational-state\.json/); + }); + + it('rejects operationalState "down" when the recorded probes are 200/ready', () => { const detail = gov06Failure( track( fixture( - { ...REAL_REPO, operationalState: "running" }, - { ...REAL_EVIDENCE, operationalState: "running" }, + { ...REAL_REPO, operationalState: "down" }, + { ...REAL_EVIDENCE, operationalState: "down" }, ), ), ); - expect(detail).toMatch(/but the recorded probes say otherwise/); - expect(detail).toMatch(/402|404/); + expect(detail).toMatch(/but these prod hosts answered/); }); - it("rejects an `ok: true` hand-written next to a non-2xx status", () => { + it("rejects an `ok: false` hand-written next to a 2xx status", () => { const detail = gov06Failure( track( fixture( @@ -127,28 +132,31 @@ describe("GOV-06: a FALSE operationalState is detected (the historical defect)", { ...REAL_EVIDENCE, operationalState: "running", - probes: (REAL_EVIDENCE.probes as { ok: boolean }[]).map((p) => ({ ...p, ok: true })), + probes: (REAL_EVIDENCE.probes as { ok: boolean; httpStatus: number }[]).map((p) => ({ ...p, ok: false })), }, ), ), ); - expect(detail).toMatch(/records ok=true next to httpStatus/); + expect(detail).toMatch(/records ok=false next to httpStatus/); }); - it('rejects operationalState "down" when a prod host is recorded as answering', () => { + it('rejects operationalState "running" when probes record 402/404 (the historical defect)', () => { const detail = gov06Failure( track( - fixture(REAL_REPO, { - ...REAL_EVIDENCE, - probes: (REAL_EVIDENCE.probes as { httpStatus: number; ok: boolean }[]).map((p) => ({ - ...p, - httpStatus: 200, - ok: true, - })), - }), + fixture( + { ...REAL_REPO, operationalState: "running" }, + { + ...REAL_EVIDENCE, + probes: (REAL_EVIDENCE.probes as { httpStatus: number; ok: boolean }[]).map((p) => ({ + ...p, + httpStatus: 402, + ok: false, + })), + }, + ), ), ); - expect(detail).toMatch(/"down" but these prod hosts answered/); + expect(detail).toMatch(/but the recorded probes say otherwise/); }); it("rejects a liveness claim backed only by stale evidence", () => {