GENERATED VIEW · PRIVATE ARCHIVE · DO NOT SERVE
RobCo Industries · Archive Museum

SAVE_INTEGRITY_PASS_PLAN.md



RELEASE

planning/2.8.5/plans/SAVE_INTEGRITY_PASS_PLAN.md

sha256 0fd25e7da604b93a · 27517 bytes · original held in the private archive

# SAVE-INTEGRITY PASS — Stage 1 (Opus) Diagnose + Plan > **ARCHIVE-class snapshot as of 2026-07-14, branch `dev` @ `27896d5`. Not current truth.** > Protocol 8 Stage 1 output. No code written, no commits. Line numbers are as-of this snapshot and WILL drift — Stage 2 (Sonnet) re-verifies every anchor against the live files before editing. > Scope: QUEUE.md item 5 "Save integrity pass" — Layer 1 (semantic survival) + Layer 2 (storage survival). --- ## 0. THE HONEST VERDICT (read this first) **This is a MEDIUM unit, not a small one — but it is "build the proof + close two real asymmetries + add the missing storage request," NOT "there is a screaming data-loss bug today."** The audit-completeness review's read was **essentially correct**: - The migration contract is exercised **at the migrateState()-function level**, in a `vm` sandbox, against a hand-picked field subset (Suites 12 and 46.20–46.22). **Nothing boots a real, fully-populated OLD save through the ACTUAL boot path (`_hydrateStateFromStorage`) to a ready state and asserts every durable field survived.** - The two existing "behavioural-ish" tests (46.20–46.22) call `sanitizeImportedContainer` + `migrateState` **directly**, with a **stubbed** `GAME_DEFS: {FNV:{}, FO3:{}}` and `_buildFactions: () => ({})` — so they do not exercise the real boot merge, the real defaults, or the paths that a plain reload / file-import actually take. Two **genuine structural asymmetries** exist, both worth fixing/proving: 1. **The v8 boot fast-path skips `migrateState()`.** [`_hydrateStateFromStorage`](js/ui/ui-core.js:351) does `state = { ...state, ...activeCampaign }` + `_migrateEventLog` + `reconcileEquipped` + a faction-key backfill — but **not** the full `migrateState()`. It is *protected from top-level field loss* by the default-base spread (the pristine default `state` is the base, so every top-level key is present either from the save or the default), but malformed **nested/legacy** values from an old v8 save escape re-coercion (e.g. `campaignMode`/`mapView`/`playthroughType` normalization, legacy-faction pruning, `lincolnItems: 'other'`). 2. **The v8-container FILE-IMPORT path skips `migrateState()` too.** [`handleFileUpload`](js/ui/ui-saves.js:705) → [`_writeImportedContainer`](js/core/state.js:1976) runs **only** `sanitizeImportedContainer`, then reloads onto the migrate-skipping fast-path. Meanwhile **cloud-load, slot-load, and backup-restore all run `migrateState()`** ([cloud.js:866](js/services/cloud.js:866), [ui-saves.js:425](js/ui/ui-saves.js:425), [ui-saves.js:677](js/ui/ui-saves.js:677)). So the *same container* migrates differently depending on how it arrives. This is a real parity inconsistency — **candidate code fix**, and exactly the kind of thing the behavioural test may surface as a concrete drop against the oldest fixture. **Layer 2 (storage survival) is entirely absent and is the single biggest exposure.** `navigator.storage.persist()` is called **nowhere** (confirmed: 0 hits in code; only named in QUEUE.md). Worse, the **live campaign container `robco_v8` is `localStorage`-ONLY** — the IndexedDB durability shadow covers *cold-store* (slots / rolling backups / version rings / cloud queue) and *device prefs*, but **not** the hot live save. So on iOS Safari eviction (low-storage pressure or ~2 weeks unopened) the sacred live campaign can vanish with every gate check green. This is small-effort, high-value. **Bottom line for the reviewer:** the semantic contract is *substantially* sound because the default-base spread quietly does most of the field-preservation work — but it is **unproven**, has **two real asymmetries**, and the storage layer that keeps the bytes alive is **not requested at all**. The deliverable is a genuinely behavioural test that boots real old saves to ready and compares the whole durable-field inventory, a `migrateState()`-parity fix for the file-import path, the `persist()` request + denied-state handling, and an explicit fail-loud audit of every write path. --- ## 1. PHASE 1 — INVESTIGATION FINDINGS ### 1A. The durable-field inventory (THE CONTRACT) The authoritative list is `let state = {…}` at [state.js:1595](js/core/state.js:1595). A "silent drop" = any of these vanishing, resetting to default, duplicating, or type-corrupting across a transition. **This exact list is what the semantic test compares — sourced programmatically (see §2), never hand-copied, so a newly-added field can't silently escape coverage.** | # | Field | Type | Notes / migration touch-point | |---|-------|------|-------------------------------| | 1 | `lvl` | int | sanitize coerces; autoImport clamps | | 2 | `xp` | int | | | 3 | `hpCur` | int | | | 4 | `hpMax` | int | | | 5–11 | `s,p,e,c,i,a,l` | int 1–10 | clamped on DOM-sync + import | | 12 | `caps` | int | | | 13 | `loc` | string | | | 14 | `rads` | int, clamped to `GAME_DEFS[ctx].maxRads` | Protocol 38 clamp | | 15 | `karma` | int | | | 16 | `ticks` | int | calendar-derived | | 17–21 | `la,ra,ll,rl,hd` | 'OK'\|'CRIPPLED' | whitelisted | | 22 | `factions` | object{key:{fame,infamy}} | migrateState prunes legacy keys + backfills; boot also backfills | | 23 | `skills` | object | game-specific keys (`getSkillKeys()`) | | 24 | `status` | array | AI tick-down | | 25 | `inventory` | array | ammo-typed items rerouted to `ammo` | | 26 | `squad` | array | | | 27 | `campaign_notes` | string[] | **P4:** purely-manual after `_migrateEventLog` split | | 28 | `eventLog` | array{t,rt,type,text} | **P4:** born from `[T#]` split; capped 1000 | | 29 | `perks` | array | | | 30 | `quests` | array | | | 31 | `equipped` | {weapon,armor,headgear} | `reconcileEquipped` clears stale weapon/armor | | 32 | `ammo` | object | | | 33 | `stats` | {kills,capsEarned,damageDealt,sessionStart} | | | 34 | `locationHistory` | string[] | fog-of-war; permanent | | 35 | `gameContext` | 'FNV'\|'FO3' | **NOT AI-settable**; unknown→FNV | | 36 | `collectibles` | string[] | registry-validated on import | | 37 | `lincolnItems` | object (FO3) | disposition vocab; `'other'`→`'found'` | | 38 | `traits` | string[] (FNV) | registry-validated | | 39 | `skillBooks` | string[] | registry-validated | | 40 | `magazines` | string[] (FNV) | registry-validated | | 41 | `campaignMode` | 'standard'\|'rng'\|'rng-locked' | normalized in migrateState | | 42 | `playthroughType` | enum(5) | migrated from legacy LS key | | 43 | `mapView` | 'auto'\|'full'\|'core' | validated | | 44 | `padBindings` | {up,down,left,right} | **player-authority** — never AI-mapped | **Envelope-level durable data that rides saves but lives OUTSIDE `state`** (must be in the contract too): `chat` (`chatHistory` ↔ `robco_chat`) and `playstyle` (`robco_playstyle`). Both are serialized into export / cloud / bundle envelopes and checksummed by [`computeSaveChecksum`](js/core/state.js:423). **Explicitly NOT durable campaign data (out of contract — the two-store boundary, Protocol 23):** every `MetaStore` / `META_MANIFEST` device pref ([state.js:17](js/core/state.js:17)). The test must assert the boundary too: prefs must **not** leak into the campaign save, and campaign fields must **not** leak into `meta`. ### 1B. The migration reality - **Oldest supported envelope:** `robco_v7` — a flat, single-campaign `state` blob read at [ui-core.js:355](js/ui/ui-core.js:355). `migrateState()` ([state.js:2049](js/core/state.js:2049)) migrates it all the way down through the **v1.x** era: flat faction keys `nf/ni/lf/li/sf/si` → structured `factions`, `macros` removal, faction pruning/archival, the `[T#]`→`eventLog` split, and defaulting every v2.0 field. Below v7 there's an additional in-boot flat-faction migration ([ui-core.js:416](js/ui/ui-core.js:416)). - **`migrateState()` RUNS on:** v7→v8 boot upgrade ([ui-core.js:384](js/ui/ui-core.js:384)); fresh-boot default ([ui-core.js:407](js/ui/ui-core.js:407)); cloud pull + cloud-picker load ([cloud.js:868](js/services/cloud.js:868), [cloud.js:1006](js/services/cloud.js:1006)); slot load ([ui-saves.js:425](js/ui/ui-saves.js:425)); backup restore ([ui-saves.js:677](js/ui/ui-saves.js:677)). - **`migrateState()` does NOT run on:** ⚠ **the v8 fast-path (plain reload)** ([ui-core.js:358](js/ui/ui-core.js:358)) and ⚠ **the v8-container file import** ([state.js:1976](js/core/state.js:1976) via [ui-saves.js:741](js/ui/ui-saves.js:741)). The legacy `.state`-form file import instead runs [`autoImportState`](js/services/api-import.js:208) ([ui-saves.js:753](js/ui/ui-saves.js:753)) — a *third* field-mapping path with its own coercions, not migrateState. - **Why the fast-path is mostly safe anyway:** `state = { ...state, ...activeCampaign }` uses the **pristine default `state` as the base** (nothing mutates `state` before hydrate runs), so any top-level durable field absent from an old save is supplied by the default — **no top-level key can be dropped.** The residual risk is (a) **nested** default keys replaced wholesale (e.g. an `equipped` missing `headgear`), and (b) **malformed legacy nested values** that never get re-coerced because migrateState is skipped. `_migrateEventLog` + `reconcileEquipped` + faction backfill patch the highest-value cases; the rest (`campaignMode`/`mapView`/`playthroughType`/legacy-faction-prune/`lincolnItems:'other'`) are the ones that escape — **once a save has been migrated (via v7→v8 or a cloud/slot round-trip) they stay migrated, so this only bites a v8 save authored on an old version and then plain-reloaded or file-imported.** ### 1C. The persistence paths (map + interrupted-write windows) | Path | Where | Store | Migrate? | Interrupted-write / failure window | |------|-------|-------|----------|-----------------------------------| | Debounced live write | [saveState](js/core/state.js:1778) → `snapshotActiveCampaign` → `localStorage.robco_v8` | **localStorage ONLY** | n/a | 500ms debounce (covered by unload flush); **QuotaExceeded** → warns in chat, edit not persisted, **old value stays intact** | | Unload flush | [_wireUnloadFlush](js/ui/ui-core.js:1089) | localStorage | n/a | guarded by `_contextSwitching`/`_loadingSave` (clobber fix) | | Boot hydrate | [_hydrateStateFromStorage](js/ui/ui-core.js:351) | localStorage (v8→v7→fresh) | **fast-path NO** / v7 YES | corrupt JSON → quarantine + boot fresh | | Cold store (slots, backups, version rings, cloud-queue) | [_coldReadObj/_coldWriteObj](js/core/state.js:498) | **IDB-primary + LS mirror** | on read paths | LS quota non-fatal (IDB is durable home) | | Cloud save/load | [cloud.js:418](js/services/cloud.js:418) / [cloud.js:833](js/services/cloud.js:833) | Firestore (additive `addDoc`) | load YES | network fail → warns, non-fatal | | Export / import file | [exportSaveFile](js/core/state.js:1839) / [handleFileUpload](js/ui/ui-saves.js:705) | localStorage | **import NO** | failed parse rejected pre-write (no partial apply) | | Full backup bundle | [buildFullBundle](js/core/state.js:1886) / [applyBundleData](js/core/state.js:1996) | LS + IDB | on reload | shape+checksum gate before any write | **Authoritative store:** `localStorage.robco_v8` for the **live** campaign; IDB is authoritative only for **cold** store. ⚠ **This is the Layer-2 crux: the sacred live save has no IDB durability shadow AND no `persist()` request — it is the most volatile thing in the app.** ### 1D. Existing coverage — precise classification - **Suite 12** ([tests/robco-diagnostics.js:936](tests/robco-diagnostics.js:936)): runs `migrateState('1.0.0', v1Payload)` + the stale-equipped self-heal in a sandbox. **Function-level, hand-picked assertions.** - **Suite 46.20–46.22** ([tests/robco-diagnostics.js:4011](tests/robco-diagnostics.js:4011)): the strongest today — a realistic prod-2.7.0 FNV campaign through `sanitizeImportedContainer` + `migrateState`, asserting the two 2.8.0-added fields default and ~14 hand-picked fields survive. **BUT:** (a) VM sandbox with **stubbed** `GAME_DEFS`/`_buildFactions`; (b) tests the migrate **function**, not `_hydrateStateFromStorage`; (c) never exercises the **fast-path** or **file-import** paths; (d) hand-picked field subset, so a *new* field dropped in a *new* code path slips through; (e) 2.7.0 fixture, not the **oldest** (v7/v1.x) shape. - Scattered per-field migration guards (Suites 66/67/85, etc.) are **static source greps** — they prove the *guard line exists*, not that a real save survives. **Verdict on coverage:** mapping-level, not behavioural. The gap the review named is real. ### 1E. `navigator.storage.persist()` + the warning-banner precedent - **Confirmed absent** (0 code hits). Should fire as a **fire-and-forget boot phase** inside [window.onload](js/ui/ui-core.js:1110) — after `_hydrateStateFromStorage()` (so we know a campaign exists) and it **must never block or throw into boot** (Protocol 33 fail-safe spirit). - API: `await navigator.storage.persist()` → `true` (granted) / `false` (denied); `navigator.storage.persisted()` reads current state without prompting. Both behind feature-detect (`navigator.storage?.persist`). - **Denied-state surfacing — reuse, don't invent (Protocol 22):** the **REUSABLE DISABLED BANNER TEMPLATES** block ([index.html:133](index.html:133)) is the exact precedent — clone a `<template>`'s content into the DOM and wire it. A new inert `<template id="storageWarningBannerTemplate">` carrying a diegetic *"MEMORY CORE UNSTABLE — HOST WILL NOT GUARANTEE PERSISTENCE. EXPORT A SAVE FILE REGULARLY."* notice fits the established pattern (the `updateBannerTemplate` / `fo3WarningBanner` family). Game-agnostic copy (Protocol 38). - **Persistence state in MetaStore:** register a device-pref key (e.g. `robco_storage_persisted: {type:'string', default:'', owner:'ui-core.js'}` holding `'granted'|'denied'|''`) so the denied warning can be shown once-per-state without re-prompting every boot, and so the state is inspectable. Device pref, not campaign state (Protocol 23). --- ## 2. PHASE 2 — THE PLAN ### 2.1 The semantic-survival test (CORE DELIVERABLE) — `tests/save-survival.mjs` **A genuinely behavioural, real-boot Playwright check. Extends the existing harness (Protocol 22) — same `browser-shared.mjs` acquire, same `file://` + `addInitScript(localStorage.setItem(...))` + `page.goto(INDEX)` pattern already proven in [render-integrity.mjs:1209](tests/render-integrity.mjs:1209). NO dev server (Protocol 10/task constraint).** **Read mechanism (no production change needed):** after boot, read the live merged state via `page.evaluate(() => window.snapshotActiveCampaign().campaigns[window.robco_v8.activeContext])` — [`snapshotActiveCampaign`](js/core/state.js:1569) deep-clones the live `state` (no DOM round-trip, no debounce), so it reflects exactly what would be re-persisted. (`state` itself is not on `window`; this is the clean handle.) **Field-list sourcing (anti-brittleness):** the durable-field key set is read from the page itself — `page.evaluate(() => Object.keys(window._defaultState))` ([state.js:1664](js/core/state.js:1664) exposes the pristine default) — so the comparison enumerates **every** durable field automatically and a newly-added field is covered the day it lands, with **zero hand-maintained list**. Compare **field-by-field on the parsed objects**, never serialized text (immune to key-ordering / transient-field churn). **The core assertion for each fixture:** boot → snapshot → for every key in `_defaultState`, assert the field is **present and not silently reset to its default when the fixture supplied a non-default value**, not dropped, not duplicated, not type-corrupted. A field legitimately *equal* to its default is fine; a field the fixture populated that comes back as the default (or missing) is a FAIL. **Fixtures (each a full envelope, injected into the right key):** | Fixture | Shape | Injected as | Proves | |---------|-------|-------------|--------| | `CURRENT` | current-schema v8 container, every subsystem populated | `robco_v8` | happy round-trip, no regression | | `MATURE` | high-density: 44 fields all non-default, big inventory/quests/eventLog near cap, both `campaigns.FNV` **and** `campaigns.FO3` populated | `robco_v8` | density + dual-campaign survival + game-specific skill keys | | `OLDEST_V7` | flat single-campaign v7 with **v1.x-era** shapes (flat `nf/ni/lf/li` factions, `campaign_notes` with `[T#]` lines, `lincolnItems:'other'`, missing 2.0 fields, missing `eventLog`/`padBindings`) | `robco_v7` (no `robco_v8`) | the full v7→v8→ready migration chain end-to-end | | `MALFORMED` | v8 container with garbage: `factions` a non-object, `equipped` missing keys, `padBindings` an array, out-of-range SPECIAL/rads, `campaignMode` bogus | `robco_v8` | fast-path resilience + no crash + no user-data loss (bad values coerced, good values kept) | | `DIVERGENT` (cloud in scope) | local `robco_v8` older, a cloud/bundle copy newer with different field values | LS + injected cloud/bundle stub | conflict resolution surfaces without silent loss | **Paths exercised (each = one boot/reload cycle in the test):** 1. **save → reload** — inject `CURRENT`/`MATURE`, boot, snapshot, assert full inventory intact. 2. **export → import → reload** — boot, `page.evaluate` an `exportSaveFile`-shaped payload, feed it through `handleFileUpload`'s container-apply (or directly seed the imported `robco_v8` and reload), assert intact. ⚠ **This path currently skips `migrateState` — run the `OLDEST`/`MALFORMED` fixture through it specifically to surface the asymmetry.** 3. **legacy → migrate → reload → ready** — inject `OLDEST_V7`, boot, assert the whole v7→v8 chain lands every field (flat factions structured, `[T#]` folded into `eventLog`, manual notes preserved, 2.0 fields defaulted, `lincolnItems:'other'`→`'found'`). 4. **installed → update → offline-reload** — inject a save stamped an *older* `schemaVersion`, boot on current `APP_VERSION` **offline** (Playwright `context.setOffline(true)`), assert migration ran and app reached ready with no field lost and no network dependency. 5. **failed-import leaves the original intact** — seed a known-good `robco_v8`, attempt import of a **malformed/checksum-mismatched** envelope, assert the import is *rejected before write* and the original `robco_v8` is byte-for-byte unchanged (no partial apply). 6. **cloud conflict without silent loss** (if cloud in scope) — `DIVERGENT` fixture through the local-vs-cloud recency gate ([cloud.js:820](js/services/cloud.js:820)); assert the older-save confirm gate fires and neither copy's data is silently dropped. **Wiring:** register in [scripts/gate.js](scripts/gate.js) as a **push-gate** browser check (alongside boot-smoke/render-check/a11y, [gate.js:232](scripts/gate.js:232)) — too slow for `gate:fast`. Standalone-runnable (`node tests/save-survival.mjs`) like the siblings. **Protocol 42 trap-door:** if any path surfaces a **real field drop** (most likely candidate: the file-import/fast-path migrate-skip against `OLDEST`/`MALFORMED`), that is **NOT a test to write green — it is a BUG to fix in the same commit** (see §2.2), then the test locks the fix. ### 2.2 Candidate code fix — migrate-parity for the import/fast paths (Protocol 22, 42) **Finding (Phase 1B):** `_writeImportedContainer` ([state.js:1976](js/core/state.js:1976)) sanitizes but does not migrate; cloud/slot/backup paths migrate. **Fix:** route `_writeImportedContainer` (and, if the behavioural test proves a drop, the v8 boot fast-path) through `migrateState()` per-campaign, mirroring [cloud.js:866–870](js/services/cloud.js:866) **exactly** — extend the shared path, do **not** fork a second migrator. This makes every container-arrival path apply the identical `sanitize → migrate` sequence. **Gate the exact scope on the behavioural evidence:** if `OLDEST`/`MALFORMED` survive the import/fast paths intact today (the default-base spread may already cover them), this fix is *parity-hardening* (still worth it, still add a guard); if they drop a field, it is a *data-loss fix* (mandatory, Protocol 13/42). Stage-2 states the verdict explicitly in the commit. ### 2.3 Layer 2 — storage-survival work - **`persist()` request** — a new fire-and-forget boot phase `_requestPersistentStorage()` called from [window.onload](js/ui/ui-core.js:1110) after `_hydrateStateFromStorage()`. Feature-detected, wrapped so it can never throw into boot (Protocol 33). Reads `navigator.storage.persisted()` first; only calls `persist()` when not already granted. Records `'granted'|'denied'` to the new `robco_storage_persisted` MetaStore key. - **Denied-state diegetic warning** — clone a new inert `<template id="storageWarningBannerTemplate">` (§1E) into the DOM **only** when the result is `'denied'`, using the established banner-clone pattern. Never on granted/unknown. The message tells the Courier to export saves regularly. Register the template's markup guard (a "NEVER DELETE" static test, mirroring the existing banner-template Suite). - **`robco_v8` durability shadow (evaluate, likely fold in):** the live container is localStorage-only. Consider mirroring `robco_v8` into IDB `campaign` on write (reusing the existing `_idbShadow`/cold-store machinery, Protocol 22) so eviction of localStorage is survivable. **Flag for owner decision** — it's the highest-value storage-survival change but larger than the persist() request; the plan recommends at minimum *proving the exposure* in the test and shipping persist()+warning now, with the IDB shadow as a fast-follow if scoped out. ### 2.4 Fail-loudly criteria (audit + lock every write path) Per the QUEUE refinement: **a failed / interrupted / quota-exhausted write must fail LOUDLY with the original intact — never silently swallowed.** Audit each writer and assert the behaviour: - **Live `robco_v8` quota fail** ([saveState catch](js/core/state.js:1814)): already warns in chat + old value stays intact (setItem is atomic on throw). ✅ — add a regression test that the warning fires and the prior value survives. - **Unload flush** ([ui-core.js:1096](js/ui/ui-core.js:1096)): unguarded `setItem` — audit that a quota throw here can't half-write; add guard/try-catch if missing, and a test. - **Cold-store write** ([_coldWriteObj](js/core/state.js:518)): returns `idbOk||lsOk`; a *total* failure (both stores reject) currently returns `false` — assert the caller surfaces it rather than reporting success on a silent double-failure. - **Import/cloud/bundle apply:** confirm each rejects malformed input **before** any write (no partial apply) — path 5 above locks this. **Where fail-loud lives:** the writers themselves stay the owners; the tests assert the loud behaviour. No new global error channel invented (that's the 2.9.0 event-bus hardening item, out of scope here). --- ## 3. ACCEPTANCE CRITERIA (Definition of Done — Protocol 26) Done **only** when every one is verified on the real artifact (actually booted, not assumed): 1. `tests/save-survival.mjs` exists, runs on the shared Chromium via `file://` (no dev server), and is wired into the **push gate** + standalone-runnable. 2. It boots **all five fixtures** (`CURRENT`, `MATURE`, `OLDEST_V7`, `MALFORMED`, `DIVERGENT`) through the **real `_hydrateStateFromStorage` boot path** to a ready state and compares the **full durable-field inventory sourced from `window._defaultState`** (not a hand-picked subset) — asserting no silent drop, default-reset, duplication, or type corruption. Comparison is on parsed fields, not serialized text. 3. All six paths pass: save→reload · export→import→reload · legacy(v7)→migrate→reload→ready · installed→update→**offline**-reload · **failed-import-leaves-original-intact** · cloud-conflict-without-silent-loss. 4. The two-store boundary is asserted: no device pref leaks into the campaign save; no campaign field lands in `meta`. 5. The migrate-parity verdict (§2.2) is stated explicitly, the fix (parity-hardening or data-loss) is applied by extending the shared `sanitize→migrate` path, and a regression test locks it (Protocol 13/42). 6. `navigator.storage.persist()` fires once per boot (feature-detected, non-blocking, never throws into boot), records state to `robco_storage_persisted`, and only calls `persist()` when not already granted. 7. The **DENIED path** is exercised by a real test (Playwright override of `navigator.storage.persist` → `false`), not just the happy path — and the diegetic warning banner (cloned from the new inert template) renders on denied and **not** on granted. 8. Every write path's fail-loud behaviour is audited and locked: quota/interrupted/failed writes surface (warn) and leave the prior save intact; nothing is silently swallowed. 9. Full gate green (229 suites / 3221 tests + new browser check), counts synced per Protocol 2a, `CACHE_NAME` bumped **only if** a served file changes (persist() lives in `js/ui/ui-core.js` → served → bump; a test-only + `index.html`-template addition also serves → bump). Docs (ARCHITECTURE/CHANGELOG/README) updated per Protocol 2. Game-agnostic throughout (Protocol 38). No parallel migrator/importer (Protocol 22). --- ## 4. SELF-AUDIT OF THIS PLAN (Protocol 8 plan-audit) - **Every durable field enumerated?** Yes — §1A, 44 state fields + 2 envelope fields, plus the explicit not-durable boundary. The test sources the list from `_defaultState` so it can't drift. - **Every persistence path enumerated with intended behaviour?** Yes — §1C table, 7 paths, each with its store, migrate-status, and failure window. - **Every save-version transition?** v1.x → v7 → v8 (via migrateState); v8 fast-path (no migrate); import (no migrate); cloud/slot/backup (migrate). All named with the intended behaviour and which are asymmetric. - **A field that CAN be lost today = BUG not test?** Called out explicitly (§0, §2.2): the file-import/fast-path migrate-skip is the prime suspect; the behavioural test decides parity-hardening vs data-loss; if data-loss, it's a same-commit fix. - **Did I assume or verify?** Verified in code: `persist()` absent (grep), migrateState skip on fast-path + file-import (read both paths), migrateState present on cloud/slot/backup (read), the default-base spread as the field-preservation mechanism (read hydrate), the banner-template precedent (read index.html), the test-injection pattern + read handle (read render-integrity + snapshotActiveCampaign). The one thing I did **not** run is the behavioural test itself — that is Stage 2's job and is exactly where a latent drop would surface. - **Weakened any assertion?** No — the plan only *adds* a genuinely behavioural check and locks existing ones; it explicitly forbids serialized-text comparison and hand-picked field lists. --- ## 5. HANDOFF TO STAGE 2 (Sonnet) Re-verify every line-number anchor against live files first (they drift). Build `tests/save-survival.mjs` extending `browser-shared.mjs`. Author the five fixtures. Run all six paths. **State the migrate-parity verdict from real evidence** before touching code. If a field drops, fix by extending the shared `sanitize→migrate` path (never fork). Add persist() + denied warning + MetaStore key + banner template. Audit fail-loud on every writer. Sync counts (Protocol 2a), bump cache (served files change), update docs (Protocol 2), full push gate green. Then Stage 3 (Opus) audits the committed diff on `dev`.
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).