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
This commit is contained in:
commit
c0bfd413ff
100 changed files with 9863 additions and 0 deletions
242
experiments/exp2-ab-update-sim.mjs
Normal file
242
experiments/exp2-ab-update-sim.mjs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
#!/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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue