Checkpoint: current working state
Some checks failed
ci / check (push) Has been cancelled

This commit is contained in:
Lumen Stage1 2026-09-29 21:33:33 -05:00
commit e83946c207
57 changed files with 6257 additions and 775 deletions

View file

@ -0,0 +1,627 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/**
* Stage 9 — Offline Ready predicate C1–C8, six-state taxonomy, boot evaluation.
* Pure truth table (every state reachable) + boot integration against
* fake-indexeddb using the Stage 8 staging/activation path.
* Trace: SPIKE-05 §2–§5, IMPLEMENTATION-CONTRACT.md §20.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
import { readFileSync } from "node:fs";
const g = globalThis as unknown as Record<string, unknown>;
g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown;
import { buildPackage } from "../../pipeline/package.js";
import { generateTestKeyPair } from "../../pipeline/sign.js";
import { makeValidInput } from "../../pipeline/fixtures.js";
import { canonicalJson } from "../../pipeline/canonical-json.js";
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
import { verifyPackage } from "../../src/sync/verifier/package.js";
import type { VerifiedPackage } from "../../src/sync/verifier/types.js";
import {
activateStagedPackage,
restorePreviousSlot,
stageVerifiedPackage,
} from "../../src/sync/activation.js";
import { openSystemDB, readSystemMeta, writeSystemMeta } from "../../src/data/system-meta/store.js";
import { INITIAL_SYSTEM_META } from "../../src/data/system-meta/types.js";
import {
initializeStaging,
markStagingComplete,
openSlotDB,
readSlotFileMeta,
writeSlotFile,
writeSlotFileWithProgress,
writeSlotManifest,
} from "../../src/data/slot/store.js";
import type { FestivalManifest } from "../../src/data/festival-package/types.js";
import { withTx } from "../../src/platform/idb/wrapper.js";
import { DB, SLOT_FILES } from "../../src/platform/idb/names.js";
import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../../src/domain/readiness/config.js";
import {
chipForState,
evaluateReadiness,
preparationLevel,
} from "../../src/domain/readiness/logic.js";
import type { ReadinessEvidence } from "../../src/domain/readiness/types.js";
import { checkShellCache } from "../../src/platform/cache/shell-cache.js";
import { defaultBootDeps, evaluateBootReadiness } from "../../src/app/readiness.js";
import type { BootReadinessDeps } from "../../src/app/readiness.js";
const NOW = 1_750_000_000_000;
function okEvidence(patch: Partial<ReadinessEvidence> = {}): ReadinessEvidence {
return {
shellValid: true,
datasetPresent: true,
authenticity: "verified",
integrity: "ok",
schemaCompatible: true,
requiredSectionsOk: true,
requiredAssetsOk: true,
emergencyFloorOk: true,
missing: [],
hadVerifiedDataset: true,
manifestOk: true,
incompatible: false,
failure: null,
presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[],
stagedPending: null,
...patch,
};
}
describe("readiness predicate — C1–C8 truth table (SPIKE-05 §5)", () => {
it("all true → READY, level L2, green chip", () => {
const result = evaluateReadiness(okEvidence());
expect(result.state).toBe("READY");
expect(result.missing).toEqual([]);
expect(result.level).toBe("L2");
const chip = chipForState("READY");
expect(chip.text).toBe("OFFLINE READY ✓");
expect(chip.tone).toBe("ready");
});
it("no dataset + never verified → NOT_READY; + previously verified → RECOVERY(missing)", () => {
expect(
evaluateReadiness(okEvidence({ datasetPresent: false, hadVerifiedDataset: false })).state,
).toBe("NOT_READY");
const recovery = evaluateReadiness(
okEvidence({ datasetPresent: false, hadVerifiedDataset: true }),
);
expect(recovery.state).toBe("RECOVERY");
expect(recovery.reason).toBe("missing");
});
it("record present but files gone → RECOVERY(missing), not corrupt", () => {
const result = evaluateReadiness(
okEvidence({ authenticity: "mismatch", integrity: "files-missing", missing: ["schedule"] }),
);
expect(result.state).toBe("RECOVERY");
expect(result.reason).toBe("missing");
});
it("size tampering → RECOVERY(corrupt); record mismatch alone → RECOVERY(corrupt)", () => {
expect(evaluateReadiness(okEvidence({ integrity: "corrupt" }))).toMatchObject({
state: "RECOVERY",
reason: "corrupt",
});
expect(evaluateReadiness(okEvidence({ authenticity: "mismatch" }))).toMatchObject({
state: "RECOVERY",
reason: "corrupt",
});
});
it("incompatible schema demotes to RECOVERY(incompatible), ahead of PARTIAL", () => {
const result = evaluateReadiness(
okEvidence({
schemaCompatible: false,
incompatible: true,
requiredSectionsOk: false,
missing: ["map"],
}),
);
expect(result.state).toBe("RECOVERY");
expect(result.reason).toBe("incompatible");
});
it("files present but record missing → PARTIAL(verification), full re-verify path", () => {
const result = evaluateReadiness(okEvidence({ authenticity: "record-missing" }));
expect(result.state).toBe("PARTIAL");
expect(result.missing).toContain("verification");
});
it("staged manifest missing → PARTIAL(manifest)", () => {
const result = evaluateReadiness(
okEvidence({ manifestOk: false, requiredSectionsOk: false, missing: ["manifest"] }),
);
expect(result.state).toBe("PARTIAL");
expect(result.missing).toContain("manifest");
});
it("missing required sections/assets → PARTIAL(list); optional never gates", () => {
const partial = evaluateReadiness(
okEvidence({ requiredAssetsOk: false, missing: ["asset:map-base-overview"] }),
);
expect(partial.state).toBe("PARTIAL");
expect(partial.missing).toEqual(["asset:map-base-overview"]);
// Optional sections are not evidence at all — absence changes nothing.
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
});
it("session failure → FAILED ahead of everything, even READY evidence", () => {
const result = evaluateReadiness(okEvidence({ failure: { op: "activation" } }));
expect(result.state).toBe("FAILED");
expect(result.failedOp).toBe("activation");
expect(chipForState("FAILED", "activation").tone).toBe("failed");
});
it("broken floor build → FAILED(emergency-floor)", () => {
const result = evaluateReadiness(okEvidence({ emergencyFloorOk: false }));
expect(result.state).toBe("FAILED");
expect(result.failedOp).toBe("emergency-floor");
});
it("shell cache missing + dataset → PARTIAL(shell-cache); without dataset → NOT_READY", () => {
expect(evaluateReadiness(okEvidence({ shellValid: false })).state).toBe("PARTIAL");
expect(evaluateReadiness(okEvidence({ shellValid: false })).missing).toContain("shell-cache");
expect(
evaluateReadiness(
okEvidence({ shellValid: false, datasetPresent: false, hadVerifiedDataset: false }),
).state,
).toBe("NOT_READY");
});
it("interrupted staging with no active pointer → PARTIAL (resumable)", () => {
const result = evaluateReadiness(
okEvidence({
datasetPresent: false,
hadVerifiedDataset: false,
requiredSectionsOk: false,
requiredAssetsOk: false,
missing: ["info", "map", "assets"],
presentSections: ["emergency", "schedule"],
stagedPending: { missing: ["info", "map", "assets"] },
}),
);
expect(result.state).toBe("PARTIAL");
expect(result.level).toBe("L1");
});
it("preparation levels L0/L1/L2 follow staged sections, not trust", () => {
expect(preparationLevel([])).toBe("L0");
expect(preparationLevel(["emergency"])).toBe("L0");
expect(preparationLevel(["emergency", "schedule"])).toBe("L1");
expect(preparationLevel(["emergency", "schedule", "info", "map", "assets"])).toBe("L2");
});
it("chip mapping covers every state", () => {
expect(chipForState("PARTIAL", "map").tone).toBe("partial");
expect(chipForState("NOT_READY").text).toBe("Get festival data");
expect(chipForState("RECOVERY", "missing").text).toContain("missing");
expect(chipForState("BASELINE_ONLY").tone).toBe("baseline");
});
it("verdicts do not consult clocks", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2031-05-04T12:00:00Z"));
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
vi.setSystemTime(new Date("1999-01-01T00:00:00Z"));
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
} finally {
vi.useRealTimers();
}
});
it("APP_VERSION matches package.json; schema range covers v1", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
expect(APP_VERSION).toBe(pkg.version);
expect(SUPPORTED_SCHEMA_RANGE).toContain(1);
});
});
describe("shell cache check (C1)", () => {
it("reports unavailable where Cache Storage is absent (Node)", async () => {
expect(await checkShellCache()).toMatchObject({ ok: false, cacheName: null });
});
});
// ——— Boot integration ———
function deleteDb(name: string): Promise<void> {
return new Promise((resolve, reject) => {
const request = (g.indexedDB as IDBFactory).deleteDatabase(name);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error ?? new Error("delete database failed"));
};
request.onblocked = () => {
resolve();
};
});
}
async function fixture(version = 1): Promise<VerifiedPackage> {
const keyPair = generateTestKeyPair();
const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
const files = new Map<string, Uint8Array>();
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
const result = await verifyPackage(
{
manifestBytes,
signature: built.pkg.signature,
files: { getFile: (name) => Promise.resolve(files.get(name)) },
emergencyFloor: built.pkg.emergencyFloor,
},
{
trustedKeys: new Map([
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
]),
appVersion: "1.0.0",
supportedSchemaRange: [1],
},
);
if (!result.ok) throw new Error(`fixture verify failed: ${result.reason}`);
return result;
}
function testDeps(patch: Partial<BootReadinessDeps> = {}): BootReadinessDeps {
return {
...defaultBootDeps,
checkShell: () => Promise.resolve({ ok: true, cacheName: "lumen-shell-test" }),
estimate: () => Promise.resolve(null),
// Pipeline fixtures declare minAppVersion 1.0.0 / schema 1 (activation.test
// does the same) — the dev shell version in package.json is younger.
appVersion: "1.0.0",
supportedSchemaRange: [1],
now: () => NOW,
...patch,
};
}
async function activateFixture(version: number): Promise<VerifiedPackage> {
const pkg = await fixture(version);
const staged = await stageVerifiedPackage(pkg);
const activated = await activateStagedPackage(pkg, staged, APP_VERSION, () => NOW);
expect(activated.ok).toBe(true);
return pkg;
}
beforeEach(async () => {
await deleteDb(DB.SYSTEM);
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
await deleteDb(DB.USER);
});
describe("boot evaluation", () => {
it("fresh install → NOT_READY, L0, floor present", async () => {
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("NOT_READY");
expect(report.level).toBe("L0");
expect(report.floorVersion).toBeTruthy();
expect(report.canRestore).toBe(false);
});
it("staged + activated fixture → READY with version detail, L2", async () => {
const pkg = await activateFixture(1);
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("READY");
expect(report.edition).toBe(pkg.manifest.edition);
expect(report.packageVersion).toBe(1);
expect(report.generatedAt).toBe(pkg.manifest.generatedAt);
// Journal progress timestamps come from the real clock at staging time —
// display only, never a verdict input.
expect(report.fetchedAt).toBeGreaterThan(0);
expect(report.sections).toHaveLength(5);
expect(report.sections.every((s) => s.present && s.parseable)).toBe(true);
expect(report.assets.total).toBeGreaterThan(0);
expect(report.assets.present).toBe(report.assets.total);
expect(report.level).toBe("L2");
expect(report.shellCache).toBe("lumen-shell-test");
});
it("second update retains rollback slot → canRestore; restore returns to v1 READY", async () => {
await activateFixture(1);
await activateFixture(2);
const updated = await evaluateBootReadiness(testDeps());
expect(updated.state).toBe("READY");
expect(updated.packageVersion).toBe(2);
expect(updated.canRestore).toBe(true);
expect(await restorePreviousSlot()).toBe(true);
const restored = await evaluateBootReadiness(testDeps());
expect(restored.state).toBe("READY");
expect(restored.packageVersion).toBe(1);
});
it("slot loss with surviving record → RECOVERY(missing)", async () => {
await activateFixture(1);
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("missing");
});
it("size tampering post-activation → RECOVERY(corrupt)", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
system.close();
const slot = await openSlotDB(meta.activeSlot ?? "A");
const current = await readSlotFileMeta(slot, "schedule");
await writeSlotFile(slot, "schedule", {
bytes: (current?.bytes ?? 100) + 7,
sha256: current?.sha256 ?? "0".repeat(64),
json: { section: "schedule" },
});
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("corrupt");
});
it("staged manifest deleted → PARTIAL(manifest), re-verify path", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
system.close();
const slot = await openSlotDB(meta.activeSlot ?? "A");
await withTx(slot, SLOT_FILES, "readwrite", async (tx) => {
await new Promise<void>((resolve, reject) => {
const req = tx.objectStore(SLOT_FILES).delete("manifest");
req.onsuccess = () => {
resolve();
};
req.onerror = () => {
reject(req.error ?? new Error("delete failed"));
};
});
});
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("PARTIAL");
expect(report.missing).toContain("manifest");
});
it("verification record wiped but files present → PARTIAL(verification)", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
await writeSystemMeta(system, { ...meta, verification: null });
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("PARTIAL");
expect(report.missing).toContain("verification");
});
it("interrupted staging (emergency+schedule) with no active → PARTIAL, L1 core-ready", async () => {
const pkg = await fixture(1);
const slot = await openSlotDB("A");
let journal = await initializeStaging(slot, {
edition: pkg.manifest.edition,
packageVersion: 1,
manifestSha256: pkg.manifestSha256,
startedAt: NOW,
lastProgressAt: NOW,
});
for (const id of ["emergency", "schedule"] as const) {
const entry = pkg.manifest.sections[id];
const bytes = pkg.files.get(entry.file);
if (!bytes) throw new Error("fixture file missing");
journal = await writeSlotFileWithProgress(
slot,
id,
{
bytes: entry.bytes,
sha256: entry.sha256,
json: JSON.parse(new TextDecoder().decode(bytes)) as unknown,
},
journal,
);
}
expect(journal.complete).toBe(false);
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("PARTIAL");
expect(report.level).toBe("L1");
expect(report.missing).toEqual(expect.arrayContaining(["info", "map", "assets"]));
expect(report.activationPending).toBe(false);
});
it("hand-built incompatible package (schema 99) → RECOVERY(incompatible)", async () => {
const manifest = {
format: "lumen.package/1",
edition: "test-edition",
packageVersion: 9,
schemaVersion: 99,
generatedAt: "2026-09-01T00:00:00.000Z",
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
sections: {
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10 },
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
},
counts: { events: 0, pois: 0, assets: 0 },
limits: { totalBytes: 52 },
} as unknown as FestivalManifest;
const slot = await openSlotDB("A");
let journal = await initializeStaging(slot, {
edition: "test-edition",
packageVersion: 9,
manifestSha256: "f".repeat(64),
startedAt: NOW,
lastProgressAt: NOW,
});
for (const id of ["emergency", "schedule", "map", "info"] as const) {
journal = await writeSlotFileWithProgress(
slot,
id,
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
journal,
);
}
journal = await writeSlotFileWithProgress(
slot,
"assets",
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
journal,
);
await writeSlotManifest(slot, manifest);
journal = await markStagingComplete(slot);
expect(journal.complete).toBe(true);
slot.close();
const system = await openSystemDB();
await writeSystemMeta(system, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "test-edition",
activePackageVersion: 9,
verification: {
packageVersion: 9,
edition: "test-edition",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "test-key",
verifiedAt: NOW,
appVersionAtActivation: APP_VERSION,
},
appVersionAtActivation: APP_VERSION,
});
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("incompatible");
});
it("optional section (required:false) absent → still READY", async () => {
const manifest = {
format: "lumen.package/1",
edition: "test-edition",
packageVersion: 3,
schemaVersion: 1,
generatedAt: "2026-09-01T00:00:00.000Z",
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
sections: {
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10, required: false },
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
},
counts: { events: 0, pois: 0, assets: 0 },
limits: { totalBytes: 42 },
} as unknown as FestivalManifest;
const slot = await openSlotDB("A");
let journal = await initializeStaging(slot, {
edition: "test-edition",
packageVersion: 3,
manifestSha256: "f".repeat(64),
startedAt: NOW,
lastProgressAt: NOW,
});
for (const id of ["emergency", "schedule", "map"] as const) {
journal = await writeSlotFileWithProgress(
slot,
id,
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
journal,
);
}
journal = await writeSlotFileWithProgress(
slot,
"assets",
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
journal,
);
await writeSlotManifest(slot, manifest);
await markStagingComplete(slot);
slot.close();
const system = await openSystemDB();
await writeSystemMeta(system, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "test-edition",
activePackageVersion: 3,
verification: {
packageVersion: 3,
edition: "test-edition",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "test-key",
verifiedAt: NOW,
appVersionAtActivation: APP_VERSION,
},
appVersionAtActivation: APP_VERSION,
});
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("READY");
expect(report.sections.find((s) => s.id === "info")).toMatchObject({
required: false,
present: false,
});
});
it("readbackPending left by a kill resolves at next boot → READY", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
expect(meta.readbackPending).toBe(false);
await writeSystemMeta(system, { ...meta, readbackPending: true });
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("READY");
const reopened = await openSystemDB();
expect((await readSystemMeta(reopened)).readbackPending).toBe(false);
reopened.close();
});
it("unavailable storage → BASELINE_ONLY; slot IDB error → FAILED", async () => {
const baseline = await evaluateBootReadiness(
testDeps({
openSystem: () => Promise.reject(new Error("denied")),
}),
);
expect(baseline.state).toBe("BASELINE_ONLY");
expect(baseline.floorVersion).toBeTruthy();
await activateFixture(1);
const failed = await evaluateBootReadiness(
testDeps({
openSlot: () => Promise.reject(new Error("IDB exploded")),
}),
);
expect(failed.state).toBe("FAILED");
expect(failed.failedOp).toBe("boot-check");
});
it("stale staging (>7d) is not resumable → NOT_READY", async () => {
const pkg = await fixture(1);
const slot = await openSlotDB("A");
const journal = await initializeStaging(slot, {
edition: pkg.manifest.edition,
packageVersion: 1,
manifestSha256: pkg.manifestSha256,
startedAt: NOW - 8 * 24 * 60 * 60 * 1000,
lastProgressAt: NOW - 8 * 24 * 60 * 60 * 1000,
});
expect(journal.complete).toBe(false);
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("NOT_READY");
});
});