- 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
6.9 KiB
SPIKE-02 — Atomic Dataset Updates (A/B Slots)
- Phase: Architecture Validation
- Date: 2026-08-30
- Decision under test: ADR-006 (A/B dual-slot, staged download, atomic activation, rollback).
- Method: adversarial state-machine simulation with crash/fault injection
at every stage (
experiments/exp2-ab-update-sim.mjs), plus analysis of the ordering that makes single-database atomicity sufficient.
1. The invariant under test
"Either the previous valid dataset remains active, or the new valid dataset becomes active. The application never knowingly exposes a partial dataset."
Four distinct mechanisms must cooperate for this to hold; they are often confused, so they are separated here explicitly:
| Mechanism | What it guarantees | What it does NOT guarantee |
|---|---|---|
| Atomic database operation (single IDB transaction) | The pointer flip and its metadata land together or not at all. | That the dataset being pointed at is complete or valid. |
| Validation (signature → compatibility → hashes → schema) | That a dataset is eligible to become active. | That it will be activated, or survive after activation. |
| Activation (commit pointer to a validated dataset) | That the active slot is, at the moment of commit, a validated dataset. | That it stays uncorrupted afterwards. |
| Crash recovery (boot light-verify + fallback + readback) | That a previously-committed good state is found and served after any interruption. | Prevention of the interruption itself. |
The invariant is a property of the composition of all four, not of any one.
2. Verification ordering (normative)
Cheapest and most authoritative checks first, so untrusted data never forces work:
- Fetch
latest.json→ monotonic version comparison (reject ≤ active). - Fetch candidate
manifest.json. - Signature over manifest digest (authenticity) — before parsing or downloading anything else.
- Parse manifest → compatibility (
appCompatibility,schemaVersionin shell range) and size budgets — before downloading files. - Stage files into the inactive slot; per-file SHA-256 + size check on each (integrity), one short transaction per file.
- Schema validation of staged sections.
- Activate: single transaction on
lumen-systemflipping pointer + version + verification record. - Readback spot-check after activation (and at next boot if pending).
3. Fourteen-step walkthrough (results from EXP-2)
exp2-ab-update-sim.mjs models slots as atomic stores and injects faults at
each stage. Boot runs light verification and falls back to the other slot when
the active slot fails. 16/16 scenarios PASS.
| # | Step / injected fault | Active dataset after | Result |
|---|---|---|---|
| 1 | Download begins, crash before any file | Old (v1) | PASS |
| 2 | Download partially completes (3/5 files) | Old (v1) | PASS |
| 3 | Browser terminated mid-staging | Old (v1) | PASS |
| 4 | Phone reboots before activation | Old (v1) | PASS |
| 5 | Dataset validation fails (generic) | Old (v1), candidate rejected | PASS |
| 6 | Integrity hash fails (corrupt file) | Old (v1), candidate rejected | PASS |
| 7 | Signature validation fails | Old (v1), nothing downloaded | PASS |
| 8 | Schema validation fails | Old (v1), candidate rejected | PASS |
| 9 | Compatibility validation fails | Old (v1), nothing downloaded | PASS |
| 10 | Activation succeeds | New (v2) | PASS |
| 11 | Pointer update occurs | (covered by 10/12) | PASS |
| 12 | Browser terminates immediately after flip | New (v2) — flip committed; boot confirms | PASS |
| 13 | App starts again (crash during flip) | Old (v1) — transaction never committed | PASS |
| 14 | Recovery: corruption found by readback | Rollback to last complete slot | PASS |
Extra scenarios: active slot corrupted at boot → fallback slot served (PASS); both slots lost → RECOVERY state with embedded emergency baseline and favorites intact (PASS); favorites survive a full update (PASS).
4. Why single-database atomicity is sufficient
There is no cross-database transaction in IDB, and the pointer lives in a different database than the dataset files. This is safe because of ordering, not because of a distributed transaction:
- The inactive slot is fully written and validated before the pointer can move. The flip therefore only ever points at an already-complete dataset.
- The flip itself is one transaction in one database (
lumen-system), so the pointer, version, and verification record cannot disagree with each other. - If the process dies at any point: before flip → old pointer stands; during flip → transaction uncommitted, old pointer stands; after flip → new pointer stands and boot light-verify confirms the slot it points at.
- Post-activation corruption (rare) is caught by readback/next-boot light verification, which falls back to the other slot if complete, else RECOVERY.
5. Findings and refinements
- F-1 (add): per-file staging must commit bytes + progress record in the same transaction. A separate progress write could orphan a record after a crash. (Carried to ADR-006 addendum; simulation already models this.)
- F-2 (add): the activation transaction must include the verification record (what was verified, when, which version), so boot can trust the active slot without re-hashing everything.
- F-3 (add): a
readbackPendingflag is set in the activation transaction and cleared after a successful readback (same boot if immediate, next boot otherwise). Scenario 12's crash window is thereby explicitly covered. - F-4 (accepted trade-off, document): rollback depth is exactly one. Beginning a new staging run reuses (wipes) the inactive slot, which is where the previous rollback copy lived. Invariant still holds (a valid dataset is always active); only the depth of undo is limited. Three-slot storage was considered and rejected for footprint.
- F-5 (confirmed): version monotonicity + quarantine of rejected versions prevents replay of a known-bad package.
- F-6 (confirmed): eviction is per-origin all-or-nothing, so there is no "one database evicted, another survives" partial state to handle; eviction routes to RECOVERY + baseline (SPIKE-01 §3.9).
6. What this spike did NOT prove
- It simulated the state machine, not IndexedDB's physical transaction semantics. The claim "an IDB transaction is atomic on iOS under jetsam kill" is INFERRED from spec + platform documentation and remains UNVERIFIED on physical iOS (SPIKE-01 §5).
- It did not measure staging throughput (device-dependent).
7. Verdict
ACCEPT WITH CHANGES.
The A/B architecture genuinely guarantees the invariant given IDB transaction atomicity holds on target devices (the one platform assumption, isolated and queued for physical testing in SPIKE-01 §5). Changes adopted: F-1, F-2, F-3 added to ADR-006; F-4 documented as an accepted limitation.