RELEASE
planning/2.8.0/plans/STEP2_PHASE1_PLAN.md
sha256 8e91ea6ba035055b · 32650 bytes ·
original held in the private archive
# STEP 2 (v2.8.0) — PHASE 1 — DATA + RECORD FOUNDATION — Implementation Plan
> **Stage 1 of the Protocol 8 workflow (Opus — Diagnose & Plan). PLAN ONLY. No edits, no commits, no pushes.**
> **Baseline:** `dev` @ `53e9eab` — v2.7.0, cache `robco-terminal-v2.7.0-r16`, **1662 tests / 137 suites** (verified against live code this session, not the audit's stale numbers).
> **Plan of record:** `planning/FEATURE_PHILOSOPHY_AUDIT.md` (adopted; recs FP-DATA-5/6, FP-PER-1/2/5, FP-SYS-3/5, FP-IMM-5) + the revised roadmap J-6 / J-7 / J-10. **The task brief re-scopes "Phase 1" as the DATA+RECORD+DIAL foundation** — this consolidates the audit §5 "Phase 1 organizing layer" + "Phase 2 data infrastructure" and pulls IndexedDB ahead of the organizing layer. §3 justifies the divergence.
> This file lives in gitignored `planning/`.
---
## 0. Verification pass — live code vs audit / Phase-0 plan (drift + storage surface)
Phase 0 has landed since the audit was written; every load-bearing seam re-checked against **current dev**:
| Claim (audit / Phase-0) | Live code @ `53e9eab` | Verdict |
| --- | --- | --- |
| MetaStore accessor + `META_MANIFEST` device-key store | `state.js:17` (manifest, 31 keys) + `state.js:61` (`MetaStore.get/set/remove/has/keys`), `window.MetaStore` | ✅ — the single device-key choke point the IDB swap needs (its own header comment at `state.js:16` names "a future IndexedDB swap") |
| `RobcoEvents` bus + `_logCampaignEvent` append+cap | `state.js:104` (bus), `state.js:128` (`_logCampaignEvent`, cap 200), subscribers `state.js:138–158` | ✅ — the Record re-points these |
| `campaign_notes` `[T#]`-string auto-log, 200-cap | `_logCampaignEvent` pushes `[T${state.ticks}] …`, `.slice(-200)` at `state.js:131`; emit points in `api.js`/`ui-render.js` route through the bus | ✅ — this string carrier is exactly what the Terminal Record replaces |
| Save envelope: checksum + schemaVersion + verify | `computeSaveChecksum` (`state.js:185`, FNV-1a via `_fnv1a32` `state.js:162`), `verifySaveEnvelope` (`state.js:216`, returns `ok/legacy/checksum_mismatch/future_version`) | ✅ — reuse for IDB record stamping (Protocol 22, no new hash) |
| Rolling backups: 3-ring + ptr + quota drop-oldest | `snapRollingBackup` (`state.js:241`), `getRollingBackups` (`state.js:290`), keys `robco_backup_1/2/3` + `robco_backup_ptr` | ✅ — a cold-store IDB-primary candidate (P3) |
| `snapshotActiveCampaign()` single robco_v8 builder | `state.js:701`; `saveState()`/`exportSaveFile()`/cloud push all route through it | ✅ — the one campaign-write choke point |
| `saveState()` debounced 500ms write, dirty-check, quota catch | `state.js:857`; `localStorage.setItem('robco_v8', …)` at `882`; quota warn at `872`/`886` | ✅ — the hot-store write site P2 shadows |
| Boot hydration is **synchronous** | `_hydrateStateFromStorage()` `ui-core.js:421`; `localStorage.getItem('robco_v8')` at `424`, v7 fallback `440`, fresh at `461`; called from `window.onload:935` | ✅ — **the async-before-sync problem for P2** (IDB open is async; this read is sync) |
| `beforeunload` flush writes robco_v8 sync | `_wireUnloadFlush()` `ui-core.js:912`; guarded by `_contextSwitching`/`_loadingSave` | ✅ — a write site to mirror |
| State-replacing loads: snap→sanitize→migrate→setItem→`_loadingSave`→reload | cloud `loadCloudSave` (`cloud.js:552–572`), `loadFromSlot` (`ui-saves.js:226`, **already `async`**), `restoreRollingBackup` (`ui-saves.js:335`, **already `async`**), `handleFileUpload` (`ui-saves.js:402`) | ✅ — user-initiated, safe to make async |
| `window.onload` boot decomposed into named phase fns | Suite 132; `_hydrateStateFromStorage`/`_restoreDevicePrefs`/… called in order `ui-core.js:928–961` | ✅ — an IDB-hydration phase slots in cleanly as a new named seam |
| Suite count | last suite **137**; next is **138**; total **1662**; `[Unreleased]` header `Tests: 1662/1662 \| Cache: …-r16` | ✅ |
| IndexedDB anywhere in code | **none** (`state.js:16` comment only) | ✅ greenfield |
| `fake-indexeddb` / IDB in `package.json` | **absent** | ⚠ shapes the test strategy (§2.7 / §4) |
### The complete localStorage surface (every key, every site — the migration's scope)
**Device store (through `MetaStore`, 31 manifest keys):** all `robco_*` prefs — audio mutes, optics (`robco_optic_<ctx>` family), power/haptic/high-lumen, `robco_overseer_log`, `robco_error_log`, `robco_panel_state`, `robco_active_tab`, `robco_typer_speed`, `robco_feature_flags`, gemini key/model/sync, `robco_booted_before`, `robco_sw_installed`. **One choke point already** (`state.js:61`).
**Campaign / save store (direct `localStorage`, NOT in MetaStore — deliberately, per the two-store boundary):**
| Key | Read/write sites |
| --- | --- |
| `robco_v8` (hot campaign container) | write: `state.js:882` (saveState), `ui-core.js:451/924/1141` (boot/wipe), `cloud.js:282/564`, `ui-saves.js:379/435`; read: `ui-core.js:424`, `ui-saves.js:170`, `cloud.js:382`, `snapRollingBackup:247` |
| `robco_v7` (legacy) | `ui-core.js:425/458/487/818`, migration only |
| `robco_chat` | `ui-core.js:511/2835/2861/2903`, `cloud.js:283/386/566`, `ui-saves.js:381/400/437` |
| `robco_playstyle` / `robco_playstyle_type` | `ui-core.js:670/1126/1130`, `cloud.js:285/388/567`, `ui-saves.js:200/272/438/453`, `state.js:913/989` |
| `robco_slot_1/2/3` (`_slotKey(n)` `ui-saves.js:156`) | `ui-saves.js:179/215/227`, `cloud.js:403` |
| `robco_backup_1/2/3` + `robco_backup_ptr` | `state.js:244/247/250/270/271/279/293` |
| `robco_last_cloud_push` | `cloud.js:320/514` |
**Reading:** the device store is one funnel; the campaign store is scattered but has exactly **three durable-write shapes** — the debounced hot write (`saveState`), the unload flush, and the state-replacing loads (all snap→setItem→reload). That shape count is what makes the write-through tractable.
---
## 1. Phase 1 build UNITS — dependency order + chapters
Grouped into 1–3-unit chapters for session rotation. Every unit: full gate on every commit; on `dev`; game-agnostic (Protocol 38); **no `APP_VERSION` bump** (stays 2.7.0 under `[Unreleased]`, cache-rev bumps only); Protocol 15 both-runners parity; Protocol 2a count sync; Protocol 39 UTF-8. Every IDB unit carries the **migration-safety guarantee: localStorage stays authoritative and fail-safe until a read path is explicitly, test-proven flipped — if IndexedDB is absent/blocked/corrupt at any step, the app is byte-identical to today.**
### CHAPTER I — IndexedDB foundation (the data-safety spine — riskiest, most foundational)
| Unit | What | Why ordered here | Risk/Cx | Model |
| --- | --- | --- | --- | --- |
| **P1 — IDB engine + device-store write-through shadow** (FP-DATA-5 slice 1) | New served module `js/idb.js`: async KV over IndexedDB (`IdbStore.get/set/remove/keys/getAll`, Promise-returning), feature-detect + graceful fallback, versioned `onupgradeneeded`, per-record `{value, schemaVersion, checksum, mt}` stamping (reuse `computeSaveChecksum`/`_fnv1a32`). Wire a fire-and-forget **write-through at the one existing device choke point** (`MetaStore.set/remove`). **Reads stay 100% on localStorage. Zero behavior change.** | **FIRST — see §2.** Lowest-risk foundational slice: pure addition through an already-centralized funnel; if IDB fails the app is unchanged. Every later data unit builds on this engine. | **Med** / L | **Opus** plan (this doc) + audit; **Sonnet** impl |
| **P2 — Campaign write-through + boot hydration/reconciliation** (FP-DATA-5 slice 2) | Extend the shadow to the three campaign-write shapes (hot `robco_v8`, unload flush, state-replacing loads). Add an **awaited pre-hydration boot phase** (`_hydrateFromIdb()` before `_hydrateStateFromStorage`) that reconciles IDB↔localStorage by checksum+version — IDB is the durable source; if localStorage is missing/corrupt but IDB is valid, restore from IDB (the recovery/rollback path). Fail-safe: localStorage present+valid → use it unchanged. | Flips READ to be IDB-aware behind a fail-safe default; makes `robco_v8` durable in IDB **before** the Terminal Record adds volume to it (J-7). | **High** / L (async-before-sync boot) | **Opus** plan-heavy + audit; Sonnet careful impl |
| **P3 — Cold-store to IDB-primary (ceiling relief)** (FP-DATA-5 slice 3) | Move save slots 1–3 + rolling backups (+ the P5/P6 lineage/bundle stores) to **IDB-primary**, accessed via async accessors at their already-user-initiated call sites (`loadFromSlot`/`saveToSlot`/`restoreRollingBackup` are already async or trivially so). localStorage keeps only hot state as a synchronous cache. Full zero-loss migration of existing `robco_slot_*` / `robco_backup_*` + rollback. | This is what **actually raises the 5 MB ceiling** (P1/P2 only add a durability shadow). Slots/backups are the bulk. | **High** / L | **Opus** plan; Sonnet impl; Opus audit |
### CHAPTER II — Terminal Record (structured event store — one history, many views)
| **P4 — Terminal Record schema + `[T#]` migration + auto-log re-point + Crossroads/Incident views** (FP-SYS-3 / J-10) | Add campaign field `state.eventLog` (`{t:ticks, rt:wallclock, type, payload}[]`, type-aware retention cap). Re-point `_logCampaignEvent` + the RobcoEvents subscribers (`state.js:138–158`) to append structured events; **`campaign_notes` returns to a pure manual notebook.** Migrate existing `[T#]` strings → parsed events (best-effort, lossless-preserving unparseable as `type:'legacy'`). Crossroads Record re-points to a filtered view; add an Incident view. Protocol 4 (new field: default + migrate + import-map + sanitize) + Protocol 14 (autoImportState round-trip). | J-10: six later features are views over this; every phase that ships before it writes strings needing migration. Placed right after the storage spine so the record is born durable (rides `robco_v8` → cloud/export via Protocol 34 serialized-whole) and can't threaten the hot ceiling (type-aware cap + P2/P3 done). | **Med–High** / L (schema S; `[T#]` migration M; views incremental) | **Opus** schema/event-contract; Sonnet impl |
### CHAPTER III — Save durability features (ride the IDB space from Ch. I)
| **P5 — Save version history (per-save revision ring)** (FP-DATA-6 / FP-PER-3) | Extend the rolling-backup pattern from 3 device-wide slots to a **per-save revision lineage** (N retained revisions per named save), IDB-primary. Restore-any-revision UI on the saves list. | Depends on P3 (IDB cold-store space + async accessors). Completion of the save-integrity chain. | **Med** / M | **Sonnet** |
| **P6 — Full backup bundle (one-file export/import)** (FP-PER-2) | One-file export/import of the **whole campaign container + chat + playstyle + (optional, opt-in) device prefs** — distinct from per-save export. Reuses `exportSaveFile`/`handleFileUpload` envelope + checksum; device-prefs section clearly separated (boundary preserved — Protocol 23). | Independent of P5; both ride P3. Data-safety completeness (whole-terminal portability). | **Med** / M | **Sonnet** |
| **P7 — Offline cloud queue (optional)** (FP-PER-5) | Queue a **manual** push made while offline (`navigator.onLine` + `online` event); retry on reconnect. **Never auto-push** (Prohibited Patterns honored — it only defers a push the user already pressed). Kill-switch flag + graceful fallback (Protocol 32/33). | Greenfield (`cloud.js` has no online detection today). Optional — drop if scope tightens. | **Med** / M | **Sonnet** impl; Opus reviews the never-auto-push invariant |
### CHAPTER IV — Immersion dial (born-compliant seam, independent)
| **P8 — Global Immersion-Level dial (Full / Balanced / Minimal)** (FP-IMM-5 / J-6) | MetaStore device pref `robco_immersion` + `window.getImmersionTier()` accessor + `immersionAllows(tier)` gate helper + a settings control. Wire ≥1 existing ambient consumer (e.g. synth radio / geiger density) as a proof-of-contract. Documented contract: an ambient feature declares its required tier; the Phase-2 Ambient Runtime later enforces it centrally. | **J-6: must exist before the ~10 ambient consumers (all Phase 2+) so each is born dial-aware** — cheaper than retrofitting a dial across 10 shipped features. Fully independent of Ch. I–III (device-pref only) → schedule-flexible; placed last as the clean bridge into Phase 2. | **Low** / S–M | **Sonnet** / **fable** |
**Dependency spine:** `P1 → P2 → P3 → P4 → (P5 ∥ P6 ∥ P7)`; **P8 is independent** (device-pref only — can run any time; recommended last as the Phase-2 bridge).
- **IDB serial cluster** (storage-format hazard — serialize, full gate + test.html between): P1 → P2 → P3. No two IDB units land concurrently.
- **P4 may begin as early as after P2** (it needs only a durable hot `robco_v8`, not the cold-store relief); presented after P3 for a clean linear rotation, but a session may pull it forward once P2 is green.
- **state.js additive sequence:** P1 (`IdbStore` ref + write-through in MetaStore) → P4 (`eventLog` field + migration) → P8 (`robco_immersion` manifest key). Serialize state.js touches.
- **Two-store boundary extends into IDB:** device keys and campaign keys go to **separate IDB object stores** (`meta` vs `campaign`), so FP-PER-4's boundary (Suite 134) is structural in IndexedDB too, not just localStorage.
---
## 2. FIRST UNIT — fully specified: **P1 · IDB engine + device-store write-through shadow**
### 2.1 Why this is the pick (lowest-risk × most-unblocking foundational slice)
- **The task's own hint:** "a low-risk foundational slice of the IndexedDB work." P1 is exactly that — it ships the engine and the write-through pattern **without flipping a single read path.**
- **Lowest risk of the whole phase:** it is a *pure addition* funneled through **one already-centralized choke point** (`MetaStore.set/remove`, built in Phase-0 U5). localStorage stays the sole read source and the sole authority. If IndexedDB is unavailable, blocked, quota-full, or throws at any point, the write-through no-ops and **the app is byte-identical to today** — this is the migration-safety guarantee, and P1 is where it's established and proven.
- **Most-unblocking:** every later data unit (P2 campaign shadow, P3 cold-store, P5 version history, P6 bundle) is built on `IdbStore`; the Terminal Record's durability (P4) rides the same engine. Building the engine + fallback + test harness once front-loads all of it.
- **It de-risks P2:** by the time P2 adds boot hydration, IDB already holds a live, checksum-stamped copy of the device store, so the reconciliation logic has real data to test against.
- **Boundary-preserving:** device keys go to a `meta` object store; the campaign store (`campaign` object store) is untouched this unit — the two-store split (Suite 134 / FP-PER-4) is honored in IDB from the first line.
Chapter-I units P2/P3 are *more* unblocking but materially riskier (async-before-sync boot; cold-store format change) → they are the immediate next units, not first.
### 2.2 Files, functions, exact seams
**New file `js/idb.js`** (served — precached), loaded **immediately before `js/state.js`** in `index.html` so `window.IdbStore` exists before any runtime `MetaStore.set` call (all such calls happen at/after `window.onload`, well after script parse — but ordering it first removes any doubt and is what Suite 56 will assert). API:
- `IdbStore.available` — boolean, set after feature-detect (`'indexedDB' in window` + a successful `open`).
- internal `_ready` — a Promise resolving to the open DB or `null` (unavailable). Every op chains off `_ready`; a never-resolving/`null` engine makes all ops resolve to a no-op — **never throws, never rejects into a caller.**
- `open()` — one DB `robco-uos`, `onupgradeneeded` creates two object stores: **`meta`** and **`campaign`** (P1 uses only `meta`; `campaign` created now so P2 needs no version bump). Version 1.
- `set(store, key, value)` — wraps value as `{value, schemaVersion: APP_VERSION, checksum: computeSaveChecksum(value,[],''), mt: Date.now()}`; `put`. Returns a Promise that **always resolves** (errors swallowed + `console.warn`).
- `get(store, key)` / `remove(store, key)` / `keys(store)` / `getAll(store)` — Promise-returning; `get` returns the unwrapped `value` (or `null`).
- All wrapped in `try/catch` + transaction `onerror`/`onabort` handlers that resolve-soft.
**`js/state.js` — `MetaStore` (`state.js:61`):** add the fire-and-forget write-through (the ONLY behavioral wiring this unit):
- `set(key, val)` — after the existing `localStorage.setItem` (unchanged, still authoritative), append: `if (window.IdbStore) window.IdbStore.set('meta', key, val);` (not awaited; its own internal catch).
- `remove(key)` — likewise `window.IdbStore && window.IdbStore.remove('meta', key)`.
- `get`/`has`/`keys` — **unchanged** (localStorage remains the read source).
- Reference `window.IdbStore` defensively (`typeof`/truthy guard) so state.js parses/runs even if `idb.js` failed to load.
**`index.html`** — add `<script src="js/idb.js"></script>` before the `js/state.js` tag. **`sw.js`** — add `'js/idb.js'` to `ASSETS`; bump `CACHE_NAME` → `robco-terminal-v2.7.0-r17`.
### 2.3 The precise change (net effect)
1. A new async IndexedDB KV engine exists and is feature-detected; it holds a durable, checksum-stamped **shadow copy of the device store only**.
2. Every device-pref write now *also* lands in IDB, asynchronously, best-effort. **No read consults IDB. No campaign key is touched. No existing behavior changes.**
3. If IndexedDB is unavailable at any point → the shadow silently does nothing; localStorage is 100% authoritative exactly as today.
### 2.4 Acceptance criteria (Protocol 26 — verified on the real artifact)
1. `js/idb.js` exists, is in `sw.js` `ASSETS`, has an `index.html` `<script>` tag ordered **before** `js/state.js`, and exposes `window.IdbStore` with `get/set/remove/keys/getAll/available`.
2. `IdbStore` uses **two object stores** (`meta`, `campaign`); P1 writes only `meta`.
3. **Write-through:** a `MetaStore.set(k,v)` results in the same `v` being retrievable via `IdbStore.get('meta',k)` (verified live in `tests/test.html` — real browser IndexedDB).
4. **Read authority unchanged:** `MetaStore.get` reads localStorage only; with IDB and localStorage deliberately desynced, `MetaStore.get` returns the **localStorage** value (proves no read flip).
5. **Fail-safe:** with IndexedDB forced unavailable (stub `window.indexedDB = undefined`), `MetaStore.set/get/remove` behave exactly as today and never throw; `IdbStore.available === false`; every `IdbStore` op resolves to a no-op.
6. **Never throws into a caller:** `IdbStore.set/get/remove` reject-never (resolve-soft) under: open failure, `VersionError`, blocked upgrade, `QuotaExceededError`, transaction abort. Proven behaviorally.
7. **Checksum stamping:** records carry `{value, schemaVersion, checksum, mt}`; checksum matches `computeSaveChecksum(value,[],'')` (reuse — no new hash; Protocol 22).
8. **Boundary:** no campaign key (`robco_v8`/`robco_slot_*`/`robco_backup_*`/`robco_chat`/`robco_playstyle*`/`robco_v7`/`robco_last_cloud_push`) is ever written to IDB by this unit; the `campaign` store stays empty. (Extends Suite 134 / FP-PER-4.)
9. `CACHE_NAME` → `robco-terminal-v2.7.0-r17`; **`APP_VERSION` stays 2.7.0**; `[Unreleased]` accrues.
10. Full gate green: ESLint 0, Prettier clean, both runners at parity, counts synced everywhere (Protocol 2a), Playwright boot-smoke + render-check + `test.html` runtime audit all pass; docs updated (Protocol 2).
11. **Escape-ratchet (Protocol 36b):** new **Suite 138** (both runners, at parity) — structural guards: `js/idb.js` exists + in `ASSETS` + script-tag order before state.js (Suite 56 sibling); `IdbStore` API surface present; MetaStore `set`/`remove` reference `IdbStore`; MetaStore `get` does **not** (no read flip); the `meta`/`campaign` two-store split present; no campaign key literal passed to `IdbStore`. Plus `test.html` behavioral: real round-trip + fail-safe.
### 2.5 Verification steps
1. `npm run lint && npm run format`.
2. `npm run gate` (full) — "Both runners agree: N tests", identical totals; Suite 56 (load order incl. new idb.js tag), Suite 49 (ASSETS completeness), Suite 134 (boundary), new Suite 138 all green.
3. **Real-IndexedDB behavioral** in `tests/test.html` (Protocol 40 — the browser is the only runner with native IndexedDB): open the page, drive `MetaStore.set` → assert `IdbStore.get('meta',k)` matches; force `window.indexedDB` undefined → assert `MetaStore` unchanged + no throw; assert `campaign` store empty.
4. Manual smoke in the preview: set an audio toggle, reload → pref persists (localStorage path, unchanged); open DevTools → Application → IndexedDB → confirm `robco-uos/meta` holds the shadow copy; confirm `robco-uos/campaign` is empty.
5. Confirm no campaign write site changed (grep the P1 diff touches only `js/idb.js` (new), `js/state.js` MetaStore, `index.html`, `sw.js`, tests, docs).
### 2.6 Entry paths / edge cases enumerated (Protocol 8 plan-audit)
- **Fresh device (no IDB db)** → `onupgradeneeded` creates `meta`+`campaign`; first `MetaStore.set` populates `meta`. ✔
- **IndexedDB unavailable** (Firefox private mode, disabled, sandboxed iframe) → `open` rejects/throws → `available=false` → all ops no-op → app byte-identical. ✔
- **IDB open blocked** (another tab mid-upgrade) → `onblocked`/timeout → resolve-soft to `null` → no-op. ✔
- **Quota exceeded on IDB `put`** → transaction `onerror` swallowed + warn; localStorage authoritative, so the pref is still saved. ✔
- **`MetaStore.set` fired before `_ready` resolves** → op chains off `_ready`; buffered by the promise, applied when open completes, or no-op if it never opens. Never throws. ✔
- **Corrupt IDB record** → irrelevant this unit (no reads); P2 handles read-side reconciliation. ✔
- **Concurrent async writes to same key** → last-write-wins in IDB; localStorage authoritative so user-visible state is correct regardless. ✔
- **`family` key** (`robco_optic_<ctx>`) → written like any other device key via MetaStore; no special-casing. ✔
- **Campaign key** → never routed through MetaStore (boundary), so never shadowed; `campaign` store stays empty (criterion 8). ✔
- **SW precache** → `idb.js` served offline like every other `js/*.js`. ✔
### 2.7 Protocol implications (checklist for the implement session)
- **P1 cache bump:** YES → `sw.js` `CACHE_NAME` → `robco-terminal-v2.7.0-r17` (new served `js/idb.js` + `index.html` + `state.js`).
- **P2 docs:** `ARCHITECTURE.md` (new `js/idb.js` in script-load-order §; note the durability-shadow architecture + two-store IDB boundary); `CHANGELOG.md` `[Unreleased]` (plain-English + `Under the Hood`); `README.md` script-load-order/file-structure if it enumerates `js/`.
- **P2a counts:** new Suite 138 → update every count location (RULES.md ×5, CLAUDE.md ×5, README ×4, ARCHITECTURE, CHANGELOG `[Unreleased]` header `Tests: N/N`, both runners' per-suite comments, `tests/test.html` `Suites: N` if a runtime suite is added). Run the `Select-String` drift scan.
- **P13 regression test:** Suite 138 (structural, both runners) + the `test.html` behavioral round-trip/fail-safe **are** the guards.
- **P15 parity:** both runners updated together, identical total. **P40:** `test.html` gains the real-IDB behavioral suite (browser-only) + its `Suites:` marker synced.
- **P36b escape-ratchet:** Suite 138 negative guard (no campaign key to IDB; no read flip) + positive guard (write-through present).
- **P38:** no game literals — key/value agnostic. **P39/Suite 90:** `js/idb.js` is ASCII; Edit/Node writes only.
- **P19 batch:** land P1 as one complete gated commit (engine + write-through + tests + docs + cache).
- **Test-runner reality (§4 risk):** Node and PowerShell runners have **no IndexedDB** — real behavioral IDB coverage lives in `test.html` (browser); the canonical runners carry structural + parity guards. **No new devDependency** (`fake-indexeddb`) is proposed — this matches the existing split (Suite 133 VM-sandbox for logic, `test.html` for live browser contracts). If a future IDB unit needs headless behavioral coverage, adding `fake-indexeddb` is an **owner ruling** (§4).
---
## 3. Phase 1 / Phase 2 boundary — recommendation
**Recommendation: Phase 1 = exactly the four foundations above (P1–P8). The boundary falls after the Immersion dial (P8) and before the Ambient Runtime (FP-SYS-1).**
**Why the line falls there:**
1. **One risk domain per phase.** Phase 1 concentrates *data-safety* risk — storage-engine migration, event-store schema, save durability. The organizing layer (Ambient Runtime state machine + scheduler, Command Language, Module Bay, Peripheral Bus) is *lifecycle/UI* risk. Mixing a localStorage→IndexedDB migration with a boot-lifecycle refactor in one phase compounds two independent high-risk surfaces. Keep them apart.
2. **IDB-before-Record (J-7) and Record-before-consumers (J-10) both resolve *inside* Phase 1.** The Record's six view-consumers (incident/news/recaps/karma-timeline/combat-log + Crossroads) are all Phase 2+ — so shipping the Record here means they're born as views over a structured store. Nothing in Phase 1 needs to wait on Phase 2.
3. **The dial (J-6) needs only to *exist* before its consumers.** Every ambient consumer (~10) lives in the Ambient Runtime / UPLINK (Phase 2+). Shipping `robco_immersion` + the gate helper as the last Phase-1 unit satisfies "born compliant" with margin; the Ambient Runtime then *enforces* a dial that already exists — a clean seam, not a retrofit.
4. **The Ambient Runtime is the natural Phase-2 opener,** and it depends on the dial existing (it enforces it) but not on any Phase-1 storage work. So "dial last in Phase 1 → Ambient Runtime first in Phase 2" is the dependency-correct cut.
**What stays out of Phase 1 (→ Phase 2+):** Ambient Runtime state machine + scheduler (FP-SYS-1), Command Language v1 + autocomplete + macros (FP-NAV-1/2/3), Modal driver + alert/confirm purge (FP-SYS-8/10 — *note:* Phase-0 U12 already consolidated the modal driver + confirm helper, so only residual sweep remains), Module Bay = unified settings (FP-DATA-7 + D-1), Peripheral Bus framing, all of Wasteland UPLINK, Distribution Network, Hardware Life, and Game Identity. These are the "organizing layer / living machine / identity" work — none is a data-foundation, and each is cheaper *after* the storage engine, event store, and dial exist.
**Divergence from audit §5 (flagged):** the audit split these items across its Phase 1 (organizing) + Phase 2 (data infra), and placed IndexedDB *after* the organizing layer. The task brief re-consolidates them into a **data-first Phase 1**, pulling IndexedDB ahead. This is internally consistent with the audit's own J-1 ("foundations before organizing layer") and J-7 ("IndexedDB before volume") — applied one level up: do the *data* foundation before the *organizing* layer, so Record/version-history/bundle are born on the durable engine and the later boot/runtime features build on already-migrated storage. **Recommend adopting the brief's data-first ordering.** The one thing it front-loads — the riskiest infra (IndexedDB) — is best done while sessions are fresh and the storage-risk domain is isolated, which is a feature, not a bug.
---
## 4. Discrepancies (audit ↔ code) + risks
**Line/number drift (all corrected against `53e9eab` in §0):** the audit's baseline was v2.7.0 @ `2bd28aa` / 1557 tests; live is **1662 tests / 137 suites**, cache **r16**. Boot is now decomposed into named phase functions (Suite 132) — the audit's "~900-line window.onload" is fully stale; an IDB-hydration phase slots in as a clean new seam. The modal driver + `confirmAction` (FP-SYS-8/10) already landed in Phase-0 U12 — Phase 1 does **not** re-do them.
**Risks (highest first):**
- **P2 async-before-sync boot** (the phase's central risk): IndexedDB `open` is async; `_hydrateStateFromStorage` reads `robco_v8` synchronously (`ui-core.js:424`). Mitigation: an **awaited** `_hydrateFromIdb()` phase *before* the sync hydrate, with a **fail-safe default = current behavior** (localStorage present+valid → use it, ignore IDB). IDB is consulted only as recovery when localStorage is missing/corrupt. Never black-screen on a slow/failed IDB open (Protocol 33 spirit — the storage upgrade must not become its own outage). Verify every boot path: fresh, migrated v7→v8, corrupt v8 quarantine (`ui-core.js:436`), IDB-newer-than-LS, LS-newer-than-IDB, IDB-empty, IDB-unavailable.
- **Test-runner IndexedDB gap:** Node + PowerShell runners have no IndexedDB. Real behavioral coverage must live in `test.html` (Protocol 40, browser-native IDB); canonical runners carry structural + parity guards. **Owner ruling wanted:** accept the test.html-only behavioral model (recommended — zero new deps, matches the Suite 133 VM / test.html split), **or** add `fake-indexeddb` as a devDependency for headless behavioral coverage of P2/P3's reconciliation logic. Recommend the former for P1; revisit for P2.
- **Ceiling not relieved until P3.** P1/P2 add a durability *shadow*; the 5 MB localStorage ceiling only lifts when cold-store (slots/backups) moves IDB-primary (P3). State this in ARCHITECTURE so no one assumes P1 solves quota. The Terminal Record (P4) mitigates its own contribution with a **type-aware retention cap** (decide retention policy per event class at P4 — the audit's Observation 3).
- **Two-store boundary must extend into IDB** (FP-PER-4 / Suite 134): device vs campaign stays structural — separate IDB object stores (`meta`/`campaign`), guarded. A blurred boundary in IDB would be a silent regression the localStorage-only Suite 134 wouldn't catch; extend the suite.
- **`snapRollingBackup` reads `robco_v8` synchronously at boot** (`state.js:247`, called from `_hydrateStateFromStorage:423`) — P2/P3 must keep this working when backups move to IDB-primary (it becomes async, called from the awaited pre-hydrate phase).
- **Offline cloud queue (P7) never-auto-push invariant** (Prohibited Patterns): the queue must only defer a push the user *already pressed* — Opus reviews this seam; kill-switch + fallback (Protocol 32/33).
- **Changelog/version tension:** stays `APP_VERSION 2.7.0` under `[Unreleased]`, cache-rev bumps only, per constraints. The `2.7.0 → 2.8.0` flip is the release event and needs **explicit owner approval** (Protocol 2 MAJOR/MINOR + the standing memory note that work stays under `[Unreleased]`). **Owner ruling wanted on when to flip** (likely at the Step-2 `dev → main` release, not mid-Phase-1).
---
## 5. Standing constraints for the build (every unit)
- All work on **`dev`**; full gate green on **every** commit (Protocol 43 — same bar as `main`).
- **Protocol 1** cache-rev bump on any served-file (`index.html`/`sw.js`/`manifest.json`/icons/`css/`/`js/`) commit; test-/doc-/script-only commits skip it. Next rev after r16 = **r17**.
- **Migration-safety guarantee (this phase's prime directive):** localStorage stays authoritative and fail-safe until a read path is explicitly, test-proven flipped; if IndexedDB is absent/blocked/corrupt/slow at any step, the app is byte-identical to today and never black-screens (Protocol 33 spirit).
- **No `APP_VERSION` bump** without owner approval (stays 2.7.0; `[Unreleased]` accrues).
- **Protocol 4** for every new state field (P4 `eventLog`, P8 nothing campaign-side): default + `migrateState` + `autoImportState` map + `sanitizeImportedContainer` entry + cloud-sync-path note (serialized-whole for `eventLog`).
- **Protocol 34** cloud writes additive; destructive ops confirm-gated (P5 restore, P6 import, P3 migration rollback).
- **Two-store boundary** (Protocol 23 / FP-PER-4 / Suite 134) held in IDB too (`meta` vs `campaign` object stores); MetaStore stays the only device-key writer.
- **Protocol 38** game-agnostic (key/value + event payloads carry no game literals; a new game needs zero storage-code change); **Protocol 39** UTF-8 (Edit/Node writes only).
- **Protocol 13/14/15/2a** — regression test + AI-contract (P4) + both-runners parity + counts synced, same commit; **Protocol 40** — `test.html` carries the real-browser IDB behavioral contract; **Protocol 19** batch related work into one gated push.
---
*END — Stage 1 plan. Nothing implemented, committed, or pushed. `dev` @ `53e9eab` untouched; `planning/` is gitignored.*
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).