Lumen/SPIKE-02-ATOMIC-DATASET-UPDATES.md
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

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:

  1. Fetch latest.json → monotonic version comparison (reject ≤ active).
  2. Fetch candidate manifest.json.
  3. Signature over manifest digest (authenticity) — before parsing or downloading anything else.
  4. Parse manifest → compatibility (appCompatibility, schemaVersion in shell range) and size budgets — before downloading files.
  5. Stage files into the inactive slot; per-file SHA-256 + size check on each (integrity), one short transaction per file.
  6. Schema validation of staged sections.
  7. Activate: single transaction on lumen-system flipping pointer + version + verification record.
  8. 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 readbackPending flag 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.