Lumen/experiments/exp2-ab-update-sim.mjs
Lumen Stage1 c0bfd413ff Stage 1: project foundation (strict TS, lint boundaries B-1..B-7, directory structure, CI, boundary tests)
- dedicated git repo at /home/avi/Projects/Lumen (main)
- TypeScript strict (target ES2022, bundler, exactOptionalPropertyTypes, noUncheckedIndexedAccess)
- ESLint 9 + typescript-eslint strictTypeChecked + eslint-plugin-boundaries for B-1..B-7, no-restricted-globals/syntax for B-1/B-7
- Prettier 3.5
- Structure per IMPLEMENTATION-CONTRACT.md §4 (src/platform/idb|cache|sw, storage, data, sync/{transport,verifier}, domain/{emergency,schedule,map,festival,readiness,clock,favorites}, ui/{components,views,router,render}, app, emergency-baseline, assets, public, content, pipeline, tests, scripts)
- CI: .github/workflows/ci.yml (typecheck + lint + format + test)
- Boundary tests: tests/unit/boundaries.test.ts (4 tests) + scripts/check-boundaries.ts
- No feature code, no PWA/IDB/sync/mesh/accounts per contract Stage 1
2026-08-30 23:25:35 -05:00

242 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* EXP-2 — A/B atomic dataset update state-machine simulation (SPIKE-02)
* Disposable experiment. NOT application code. No dependencies.
*
* This simulates the *design logic* of the A/B dual-slot architecture with
* crash/fault injection at every stage. It does NOT test IndexedDB itself
* (platform behavior is out of scope on a dev machine; see SPIKE-01).
*
* Modeled mechanics (mirroring ARCHITECTURE-DESIGN §18):
* - system meta (single atomic store): activeSlot, activeVersion, verifiedVersion
* - two dataset slots; staging writes ONLY to the inactive slot
* - per-file staging is atomic: bytes + progress record commit together
* - activation is ONE atomic transaction flipping pointer + version + verification record
* - boot performs light verification; readback spot-check after activation
*
* INVARIANT under test:
* "Either the previous valid dataset remains active or the new valid dataset
* becomes active. The app never knowingly exposes a partial dataset."
*/
'use strict';
// ---------- tiny transactional store model ----------
class AtomicStore {
constructor() { this.map = new Map(); }
// a transaction: apply fn to a draft; commit is all-or-nothing
txn(fn) {
const draft = new Map(this.map);
fn(draft); // if fn throws, nothing commits
this.map = draft;
}
}
class Device {
constructor() {
this.system = new AtomicStore(); // lumen-system
this.slots = { A: new AtomicStore(), B: new AtomicStore() };
this.user = new AtomicStore(); // lumen-user (favorites)
this.system.txn((m) => m.set('meta', { activeSlot: 'A', activeVersion: 1, verifiedVersion: 1 }));
// seed active dataset v1 (old known-good)
this.writeCompleteDataset('A', 1, 'old-content');
this.user.txn((m) => m.set('fav:e-0001', { addedAt: 0 }));
}
writeCompleteDataset(slot, version, tag) {
const s = this.slots[slot];
s.txn((m) => {
m.set('manifest', { packageVersion: version, sections: ['emergency', 'schedule', 'map', 'info', 'assets'], tag });
for (const sec of ['emergency', 'schedule', 'map', 'info', 'assets']) {
m.set(`file:${sec}`, { bytes: `${tag}:${sec}:v${version}`, hash: `h-${tag}-${sec}-v${version}` });
}
m.set('staged', new Set(['emergency', 'schedule', 'map', 'info', 'assets']));
m.set('verified', version);
});
}
meta() { return this.system.map.get('meta'); }
// full dataset check = every manifest-listed file present with matching hash
isComplete(slot, expectVersion = null) {
const s = this.slots[slot];
const man = s.map.get('manifest');
if (!man) return false;
if (expectVersion !== null && man.packageVersion !== expectVersion) return false;
const staged = s.map.get('staged');
if (!staged || staged.size !== man.sections.length) return false;
for (const sec of man.sections) {
const f = s.map.get(`file:${sec}`);
if (!f || f.hash !== `h-${man.tag}-${sec}-v${man.packageVersion}`) return false;
}
return true;
}
// crash mid-transaction: fn throws → nothing committed (atomic store semantics)
}
// ---------- update pipeline with fault injection ----------
class UpdateSession {
constructor(dev, opts) { this.dev = dev; this.opts = opts; }
run() {
const { newVersion = 2, tag = 'new-content', fault = null, faultAt = 0 } = this.opts;
const manifest = { packageVersion: newVersion, sections: ['emergency', 'schedule', 'map', 'info', 'assets'], tag };
// 1) signature verification (fault: bad signature)
if (fault === 'signature') return { outcome: 'rejected-signature' };
// 2) compatibility check (fault: incompatible)
if (fault === 'compatibility') return { outcome: 'rejected-compatibility' };
// 3) choose inactive slot, wipe it (rollback data sacrificed — documented)
const target = this.dev.meta().activeSlot === 'A' ? 'B' : 'A';
this.dev.slots[target].txn((m) => m.clear());
const staged = new Set();
// 4) stage files; each file = ONE atomic txn (bytes + progress together)
for (const sec of manifest.sections) {
if (fault === 'crash-staging' && staged.size >= faultAt) return { outcome: 'crashed-staging', stagedSoFar: staged.size, target };
const bytes = fault === 'hash' && sec === 'map' ? 'CORRUPTED' : `${tag}:${sec}:v${newVersion}`;
this.dev.slots[target].txn((m) => {
m.set('manifest', manifest);
m.set(`file:${sec}`, { bytes, hash: `h-${tag}-${sec}-v${newVersion}` });
const s = m.get('staged') || new Set(); s.add(sec); m.set('staged', s);
});
staged.add(sec);
}
// 5) full verification: hashes then schema
for (const sec of manifest.sections) {
const f = this.dev.slots[target].map.get(`file:${sec}`);
if (f.bytes !== `${tag}:${sec}:v${newVersion}`) return { outcome: 'rejected-hash', target };
}
if (fault === 'schema') return { outcome: 'rejected-schema', target };
if (fault === 'validation') return { outcome: 'rejected-validation', target };
// 6) activation: single atomic transaction on system meta
if (fault === 'crash-before-flip') return { outcome: 'crashed-before-flip', target };
const flipCommitted = fault !== 'crash-during-flip';
if (flipCommitted) {
this.dev.system.txn((m) => m.set('meta', { activeSlot: target, activeVersion: newVersion, verifiedVersion: newVersion }));
} else {
return { outcome: 'crashed-during-flip', target }; // txn never committed
}
// 7) crash window after flip, before readback
if (fault === 'crash-after-flip') return { outcome: 'crashed-after-flip', target };
// 8) readback spot-check
if (fault === 'readback-fail') {
// simulate post-activation corruption discovered by readback
this.dev.slots[target].txn((m) => m.set('file:map', { bytes: 'BITROT', hash: 'h-bad' }));
}
const ok = this.dev.isComplete(target, newVersion);
if (!ok) {
// automatic rollback: flip back if previous slot still complete
const other = target === 'A' ? 'B' : 'A';
if (this.dev.isComplete(other, this.dev.meta().activeVersion === newVersion ? null : this.dev.meta().verifiedVersion) || this.dev.isComplete(other)) {
const prevVersion = this.dev.slots[other].map.get('manifest')?.packageVersion;
this.dev.system.txn((m) => m.set('meta', { activeSlot: other, activeVersion: prevVersion, verifiedVersion: prevVersion }));
return { outcome: 'rollback-after-readback', target };
}
return { outcome: 'recovery-needed', target };
}
return { outcome: 'activated', target };
}
}
// ---------- boot / recovery ----------
function boot(dev) {
const meta = dev.meta();
if (dev.isComplete(meta.activeSlot, meta.activeVersion)) return { state: 'READY', version: meta.activeVersion };
const other = meta.activeSlot === 'A' ? 'B' : 'A';
const om = dev.slots[other].map.get('manifest');
if (om && dev.isComplete(other)) {
dev.system.txn((m) => m.set('meta', { activeSlot: other, activeVersion: om.packageVersion, verifiedVersion: om.packageVersion }));
return { state: 'READY-via-fallback', version: om.packageVersion };
}
return { state: 'RECOVERY', baseline: true }; // embedded emergency baseline still present
}
// ---------- invariant assertion ----------
let pass = 0, fail = 0;
function invariant(name, dev, expect) {
const b = boot(dev);
const meta = dev.meta();
const activeComplete = dev.isComplete(meta.activeSlot);
const fav = dev.user.map.get('fav:e-0001') !== undefined;
const ok = activeComplete === expect.complete && fav === true &&
(expect.state ? b.state === expect.state || (expect.state === 'READY' && b.state === 'READY-via-fallback') : true) &&
(expect.version ? meta.activeVersion === expect.version : true);
ok ? pass++ : fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${name} boot=${b.state} active=${meta.activeSlot} v${meta.activeVersion} complete=${activeComplete} favorites=${fav}`);
if (!ok) console.log(` expected: ${JSON.stringify(expect)}`);
}
console.log('EXP-2 A/B UPDATE STATE MACHINE — 14 SCENARIOS\n');
// S1 download begins, crash before any file staged
{ const d = new Device(); new UpdateSession(d, { fault: 'crash-staging', faultAt: 0 }).run();
invariant('S01 crash at download start', d, { complete: true, version: 1, state: 'READY' }); }
// S2 download partially completes
{ const d = new Device(); new UpdateSession(d, { fault: 'crash-staging', faultAt: 3 }).run();
invariant('S02 partial download', d, { complete: true, version: 1, state: 'READY' }); }
// S3 browser terminated (mid staging)
{ const d = new Device(); new UpdateSession(d, { fault: 'crash-staging', faultAt: 2 }).run();
invariant('S03 browser terminated', d, { complete: true, version: 1, state: 'READY' }); }
// S4 phone reboots (crash before flip)
{ const d = new Device(); new UpdateSession(d, { fault: 'crash-before-flip' }).run();
invariant('S04 reboot before activation', d, { complete: true, version: 1, state: 'READY' }); }
// S5 dataset validation fails (generic full-verification failure)
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'validation' }).run();
invariant(`S05 validation fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S6 integrity hash fails
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'hash' }).run();
invariant(`S06 hash fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S7 signature validation fails
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'signature' }).run();
invariant(`S07 signature fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S8 schema validation fails
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'schema' }).run();
invariant(`S08 schema fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S9 compatibility validation fails
{ const d = new Device(); const r = new UpdateSession(d, { fault: 'compatibility' }).run();
invariant(`S09 compatibility fails (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S10 activation succeeds
{ const d = new Device(); const r = new UpdateSession(d, { newVersion: 2 }).run();
invariant(`S10 activation succeeds (${r.outcome})`, d, { complete: true, version: 2, state: 'READY' }); }
// S11 pointer update occurs (flip committed) — verified by S10/S12 state
// S12 browser terminates immediately after flip (before readback)
{ const d = new Device(); const r = new UpdateSession(d, { newVersion: 2, fault: 'crash-after-flip' }).run();
invariant(`S12 crash right after flip (${r.outcome})`, d, { complete: true, version: 2, state: 'READY' }); }
// S13 app starts again after crash during flip (transaction not committed)
{ const d = new Device(); const r = new UpdateSession(d, { newVersion: 2, fault: 'crash-during-flip' }).run();
invariant(`S13 restart after failed flip (${r.outcome})`, d, { complete: true, version: 1, state: 'READY' }); }
// S14 recovery: corruption discovered by readback after activation → rollback
{ const d = new Device(); new UpdateSession(d, { newVersion: 2 }).run(); // v2 activates, old slot retained
const r = new UpdateSession(d, { newVersion: 3, fault: 'readback-fail' }).run();
invariant(`S14 readback corruption → rollback (${r.outcome})`, d, { complete: true, state: 'READY' }); }
// X1 extra: corruption of ACTIVE slot discovered at boot, fallback slot intact
{ const d = new Device(); new UpdateSession(d, { newVersion: 2 }).run();
d.slots[d.meta().activeSlot].txn((m) => m.set('file:schedule', { bytes: 'BITROT', hash: 'bad' }));
invariant('X01 active corrupt at boot → fallback slot', d, { complete: true, state: 'READY', version: 1 }); }
// X2 extra: BOTH slots corrupt at boot → RECOVERY with baseline, favorites intact
{ const d = new Device();
d.slots.A.txn((m) => m.clear()); d.slots.B.txn((m) => m.clear());
invariant('X02 both slots lost → RECOVERY + baseline', d, { complete: false, state: 'RECOVERY' }); }
// X3 extra: user state survives a full successful update
{ const d = new Device(); new UpdateSession(d, { newVersion: 2 }).run();
const fav = d.user.map.get('fav:e-0001');
const ok = fav !== undefined; ok ? pass++ : fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} X03 favorites survive update`); }
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);