feat(HSP-084): cutover — dual writers, sena ändringar, rollback #98
@@ -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": {
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user