RELEASE
planning/2.8.0/plans/STEP2_PHASE2_PLAN.md
sha256 925fb3130ca3cac5 · 34435 bytes ·
original held in the private archive
# STEP 2 (v2.8.0) — PHASE 2 — THE LIVING MACHINE & IDENTITY LAYER — Implementation Plan
> **Stage 1 of the Protocol 8 workflow (Opus — Diagnose & Plan). PLAN ONLY. No edits, no commits, no pushes.**
> **Baseline:** `dev` @ `01cb93d` — v2.7.0, cache `robco-terminal-v2.7.0-r24`, **1759 tests / 145 suites**, `tests/test.html` 18 suites / 120 assertions. Phase 1 (P1–P8) complete.
> **Plan of record:** `planning/STEP2_PHASE1_PLAN.md` §3 (the boundary this phase opens on) + `planning/FEATURE_PHILOSOPHY_AUDIT.md` §5 (revised roadmap Phases 1/3/4/5 + recs FP-SYS-1/2/4/9, FP-NAV-*, FP-WS-*, FP-IMM-*, FP-GI-*, FP-DATA-7) + the owner's OS-architecture design philosophy.
> This file lives in gitignored `planning/`.
---
## 0. What Phase 1 delivered (the seams Phase 2 builds on)
Verified against live dev this session:
| Seam | Where | Phase-2 consumer |
| --- | --- | --- |
| **Immersion dial** — `getImmersionTier()` → `full\|balanced\|minimal` (fail-safe `full`); `immersionAllows(tier)` (unknown-req **fails open**); `setImmersionTier()`; `IMMERSION_TIERS=['minimal','balanced','full']` | `js/state.js` (on `window`) | **The Ambient Runtime enforces it centrally** — every ambient observer declares a tier; the runtime mutes it below threshold |
| **RobcoEvents** bus (`on`/`emit`) + `_wire*EventBusSubscribers()` boot wiring | `js/state.js` + `ui-core.js` window.onload | Runtime state-change events; UPLINK/Hardware-Life reactions subscribe |
| **Terminal Record** — `state.eventLog` `{t,rt,type,text}` + `_logEvent(type,text)` + views (Crossroads/Incident) | `js/state.js`, `ui-render.js` | UPLINK personal-legend news **reads** it (never writes); Hardware Life gets a **read-only** view (device data, not campaign — see §4 boundary) |
| **MetaStore** device-key store + `META_MANIFEST` (33 keys) + IDB `meta` object store | `js/state.js` | All Phase-2 device prefs (immersion already there; UPLINK seed, hardware-log, macros) register here — never campaign state |
| **IdbStore** two-store engine (`meta` / `campaign`) | `js/idb.js` | Hardware-log + macros ride `meta`; nothing atmospheric touches `campaign` |
| **Decomposed boot** — 13 named `window.onload` phase fns (Suite 132; slim-window.onload guard threshold now 45) | `ui-core.js` | The runtime init slots in as one more named phase |
| **Standby machine (ad-hoc)** — `_standbyActive`, `_startUptimeClock`/`_startMemCycle` (memory-cycle already gated on `immersionAllows('balanced')`, P8 proof), `enterStandby`/`exitStandby`, wired to `blur`/`focus`/`visibilitychange` | `ui-core.js:369–436, 668–676` | **Absorbed by the runtime** (Chapter A2) |
| **≥8 ambient timers** — uptime clock, memory cycle, geiger Poisson (`geigerTimeout`), radio motif, tinnitus swell, heartbeat, overseer-flush (`setInterval` ui-core.js:229), standby dim | `ui-core.js` / `ui-audio.js` | Each becomes a runtime **observer** (Chapter A2) |
| **`_bootActive` window** (WU-B10) + `runBootSequence(onComplete)` | `ui-audio.js:833`, `runBootSequence` | Drives the COLD_BOOT → READY transition |
| **Modal driver + `confirmAction`** (Phase-0 U12) | `ui-core.js` | Reused by Module Bay / MANUALS / Distribution Network |
| **`GAME_DEFS[ctx].theme` + `.ai` + WU-T themes/identity strings** | `js/state.js` | Game Identity (framing/radio/CRT/terminology) reads these — data-driven, N-game |
**Nothing in Phase 1 blocks Phase 2.** The dial exists (J-6 satisfied); the runtime enforces it. The Record exists (J-10); UPLINK/Hardware are views/readers, never string-writers.
---
## 1. Phase 2 build UNITS — dependency order + chapters
Standing constraints on **every** unit (same as Phase 1): all on `dev`; full gate green each commit (Protocol 43); **no `APP_VERSION` bump** (stays 2.7.0 under `[Unreleased]`, cache-rev only); Protocol 15 both-runners parity; Protocol 2a counts synced; Protocol 39 UTF-8 (Edit/Node writes); Protocol 38 game-agnostic; Protocol 10/17 mobile verification for any UI; Protocol UI-1..5 for any panel. **Plus the three Phase-2 prime invariants (§4):** the **hard atmosphere/save boundary** (the ambient layer writes NOTHING durable to the campaign), **no-auto-anything** (no auto-save, no auto-push, AI opt-in only), and **dial-gated** (every ambient consumer declares an immersion tier the runtime enforces).
Ambient Runtime is the foundational opener; everything atmospheric observes it.
### CHAPTER A — AMBIENT RUNTIME (the one heartbeat, many observers)
| Unit | What | Seam / invariants | Risk/Cx | Model |
| --- | --- | --- | --- | --- |
| **A1 — Ambient Runtime core (state machine + scheduler + observer registry), ADDITIVE** (FP-SYS-1) | New `js/runtime.js`: `window.AmbientRuntime` with the canonical state (`OFF→COLD_BOOT→READY→ACTIVE→IDLE→STANDBY→SHUTDOWN`), ONE heartbeat, `register({id,cadenceMs,states,tier,onTick,onEnter,onExit})`, and central dial enforcement. Tracks state from the existing signals (boot-complete, blur/focus, visibility, idle-timeout, shutdown) **in parallel** — the old standby/timers keep running unchanged. Registers ONE demo observer to prove tick+state+tier gating. **Zero behavior change.** | Uses `immersionAllows`/`RobcoEvents`/`_bootActive`. HARD boundary: runtime writes nothing durable to campaign — device/in-memory/DOM only (gate-guarded). Fail-safe: if it can't start, app is byte-identical. | **Med** / L | **Opus** plan+audit; Sonnet impl. **FIRST — see §2.** |
| **A2 — Migrate the ≥8 timers + the standby machine onto the runtime** (FP-SYS-1 / D-3) | Convert uptime clock, memory cycle, geiger scheduler, radio motif, tinnitus swell, heartbeat, standby dim, overseer-flush into runtime **observers** (declaring states + tier). Retire the standalone `setInterval`s and fold `enterStandby`/`exitStandby` actions into the runtime's STANDBY on/off. Every existing behavior + mute/visibility/dial gate preserved (now centralized). | The risky consolidation — one behavior-preserving refactor per timer, verified live. Serialize; full gate + `test.html` between. | **High** / L | **Opus** plan; Sonnet careful impl; Opus audit |
| **A3 — IDLE + STANDBY + SHUTDOWN experiences** (FP-IMM-2/7) | The showcase consumers of the new states: attract mode (IDLE), "STANDBY — MONITORING FREQUENCIES" watch (STANDBY), and the `SHUTDOWN` command → CRT collapse-to-dot (reduced-motion instant) → OFF splash. Device-only, dial-gated, reduced-motion-honored. | Runtime states from A1. No save/stat writes. Protocol 10/17 (visual). | **Med** / M | **Sonnet**; Opus reviews the SHUTDOWN ritual |
### CHAPTER B — ORGANIZING OS LAYER
| **B1 — Command Language v1 + chat autocomplete** (FP-NAV-1/2) | Promote routing to a bare-word grammar (`INV`, `QUEST`, `MAP`, `SCAN <t>`→THREAT, `CONSULT <x>`, `TRADE`, `CRAFT`, `SLEEP`, `WAIT 4`) — **keep every existing `[TOKEN]` alias** (Protocol 25 muscle memory). One parse function; router table becomes data. Extend the proven `#acPanel` autocomplete singleton to the chat input for command tokens + registry args. | `NATIVE_COMMAND_ROUTER`/`PANEL_NAV_ALIASES`/`COMMAND_REGISTRY` (Suite 113 lock). Player-authority: no AI. | **Med** / M | **Sonnet** |
| **B2 — MODULE BAY = unified settings + PERIPHERAL BUS framing + Data-Cartridge manifest** (FP-DATA-7 / D-1 / Peripheral-Bus) | Consolidate the 4 settings homes (Security&Config / Audio / Power / device-side of CAMPG) into ONE Module Bay of installed "boards"; surface the per-game data-cartridge manifest (`{id,label,dataVersion,files[]}`) here; frame the WU-F devices (wake/haptic/share/badge/radio) under one Peripheral-Bus registration/status/fallback. **Campaign config stays in CAMPG** (boundary). | MetaStore for every device pref (Protocol 23). Protocol 25 (don't relocate working controls unprompted — reframe, preserve muscle memory). | **Med–L** / L | **Opus** IA design; Sonnet impl |
### CHAPTER C — WASTELAND UPLINK (the one ambient engine, on the runtime)
> **HARD invariant for the whole chapter:** UPLINK writes **nothing durable to the campaign** (no state/save/stats/eventLog) — seeded rolls + world-clock are derived from a **device** seed (MetaStore) + wall-clock; everything it produces is ephemeral/device-only until the player *acts* through a normal confirm-gated path. **NO day/night, NO dusk/dawn** (owner-removed — §3). Every subsystem is a runtime observer, dial-gated.
| **C1 — UPLINK U0: world-clock/seed core + no-durable-write guard + terminology layer** (FP-WS-1 / GI-6) | The seeded-roll engine + world-clock built as **observers of the A1 scheduler** (not its own tick), with the gate-guarded no-durable-write invariant. Plus the `term(key)` terminology preset layer (per-game strings: COURIER vs LONE WANDERER, MOJAVE vs CAPITAL WASTELAND) so UPLINK banks are **born per-game** (J-8, before banks multiply strings). | Runtime observer; device seed (MetaStore); `GAME_DEFS[ctx].ambient` seam. Gate-guarded no-write suite. | **Med–L** / L | **Opus** engine design; Sonnet impl |
| **C2 — Broadcasts + rad-storm weather (Peripheral-Bus sensor) + per-game ambient banks** (FP-WS-2 / GI-5) | Broadcast push (static bank, offline); rad-storm **weather** framed as a Peripheral-Bus "environmental sensor" device (geiger/CRT consume it); per-game ambient banks via `term()`. (No day/night.) | Observers on A1; weather consumed by geiger/CRT observers; banks in `GAME_DEFS[ctx].ambient`. | **Med** / M–L | **Sonnet** |
| **C3 — Radio pull + per-game radio character; INTERCEPT inbox + Signal Scanner + badge; chained transmissions; personal-legend news (reads the Record); location ambience** (FP-WS-3/4/5/6, GI-3, AUTO-4) | Synth-radio station params → `GAME_DEFS[ctx].theme/ambient`; INTERCEPT inbox surface + `SCAN` command + unread → app badge (unified through `_updateAppBadge`); chained multi-part transmissions (per-chain state machine, no-write until player acts); "your deeds echo as news" templates that **READ** `state.eventLog` (never write); zone-type ambience from registry location `type`. | Observers; badge via existing single source (Protocol 22); news is a **read** of the Record. | **Med–L** / L | **Sonnet**; Opus reviews chained-state |
| **C4 — DISTRIBUTION NETWORK: one channel, many payloads** (FP-SYS-9) | Client-side payload abstraction (`{channel:'firmware'\|'cartridge'\|'broadcast'\|'roadmap'\|'intercept-pack'}`): firmware = existing SW update reframed + receipt to SYSTEM STATUS; changelog + roadmap panel = local payloads through one viewer; Remote Transmissions = the one **live** channel (read-only public Firestore doc + `remoteTransmissions` kill-switch + App Check) — **closes OPEN Q3**. | Reuses SW update flow (unchanged mechanics), changelog viewer (Suite 62), kill-switch (Protocol 32/33). Live channel = read-only, fail-safe. | **Med–L** / L | **Opus** channel design; Sonnet impl |
### CHAPTER D — HARDWARE LIFE (terminal-as-salvaged-hardware)
> **Boundary (§4):** Hardware Life is **DEVICE** state (the physical terminal), so its history is a **device** log (MetaStore / IDB `meta`), NOT the campaign `eventLog`. The Terminal Record shows a **read-only Hardware VIEW** over it. Nothing here writes campaign state.
| **D1 — SYSTEM STATUS consolidation + boot codes / per-game POST bank + corporate handshake** (FP-SYS-4 / GI-4 / IMM-1) | One machine-health home (fold Overseer telemetry + `[LOGS]` error ring + a `RUN DIAGNOSTIC` self-test + Distribution-Network receipts); per-game POST line bank (degraded/cold variants); once-per-device cold-boot corporate handshake (extends the first-boot POST gate). | Reuses Overseer log + error ring (device data). `GAME_DEFS[ctx].theme.bootFlavor` bank. | **Med** / M | **Sonnet** |
| **D2 — Hardware quirks/condition + emergent CRT + hardware-log VIEW; SHUTDOWN hardware flush** (FP-IMM-9 / IMM-2 tie-in) | Cosmetic wear/warmth CRT classes fed by a **device** hardware-condition log (boot count, power-on hours — data already in the Overseer log); the log surfaced as the Record's read-only Hardware view; always loses to real hazards; reduced-motion honored. | Device hardware-log (meta-store); Record Hardware view is **read-only**. Protocol 17 (never color-only, reduced-motion). | **Med** / M | **Sonnet** |
### CHAPTER E — GAME IDENTITY (NV terminal-that-syncs-with-Pip-Boy vs FO3 Pip-Boy device)
| **E1 — HARDWARE SHELL / bezel + per-game framing + game-styled saves** (FP-IMM-4 / GI-1 / GI-2) | The physical chrome around the screen (home for Module-Bay access + hardware-life lamps + cartridge slot); per-game shell variants driven by `GAME_DEFS[ctx].theme.framing` (data already shipped in WU-T1); game-styled save manager on the shipped `saveLabel` hooks. Desktop-first where space exists; mobile gets a minimal band. | Data-driven (Protocol 38). **Protocol 10/17 HEAVY** — 360/412/desktop, no overflow, no desktop-only interaction. | **L** / L | **Opus** design; Sonnet impl; Opus audit |
| **E2 — Per-game CRT character + signature-panel emphasis + per-game empty states + diegetic naming sweep** (FP-GI-7/8/10 / IMM-6) | Scanline weight/flicker/hum-freq as theme data (reduced-motion honored); per-game default panel prominence via a `GAME_DEFS`-ordered priority **respecting the user's saved panel state** (Protocol 25); game-flavored empty states via `term()`; `>` prefix uniformity sweep. | Theme data + `term()`. Protocol 25 (don't override saved panel state). | **Med** / M–L | **Sonnet** |
### CHAPTER F — NAVIGATION COMPLETION (view-over-targets — built last, J-2)
| **F1 — RobCo MANUALS (help as documents) + "?"-affordance rollout** (FP-NAV-5 / FEAT-8) | Move `[FEATURES]` + SAVE-MENU help + per-terminal usage + onboarding chapters into precached MANUALS/ documents, one renderer; `HELP` opens the index, `HELP VATS` a page; generated command-reference page keeps Suite 113 honest; "?" deep-links on the complex panels. | Reuses the modal driver (Phase-0 U8/U12). | **Med** / M | **Sonnet** |
| **F2 — Filesystem skin + Local MACROS + deep-link routes** (FP-NAV-4/3/6) | `DIR`/`OPEN` + a browsable tree modal mapping onto existing surfaces (ARCHIVES/→saves, INTERCEPTS/→inbox, MANUALS/→docs, LOGS/→record+errors, SYSTEM/→Module Bay+status, USER/→notes+exports); user MACROS (`MACRO REST = SLEEP; BIO-SCAN; STATUS`, device-side, never synced/AI-visible, replays through the same router = same confirm gates); deep-link routes for all panels. | **Built last** — needs MANUALS (F1) + Module Bay (B2) + INTERCEPT inbox (C3) to exist (J-2). Macros in MetaStore. | **Med** / M | **Sonnet** |
**Dependency spine:** `A1 → A2 → A3` (runtime serial); then `B1 ∥ B2`; then `C1 → C2 → C3 → C4` (UPLINK serial on the runtime); `D1 → D2`; `E1 → E2`; `F1 → F2` **last** (needs B2 + C3 + F1). UPLINK/Hardware/Identity chapters can interleave once the runtime (A) + organizing core (B) land.
- **Runtime serial cluster** (A1→A2→A3): the timer migration (A2) is behavior-preserving and high-risk — never land two timer migrations concurrently; full gate + live verification between.
- **`GAME_DEFS` additive sequence:** C1 (`ambient` banks + `term`) → E1/E2 (framing/CRT/panel-priority) → GI/EXP. Serialize state.js/GAME_DEFS touches.
- **Every ambient consumer registers with the runtime and declares a tier** — the dial (P8) is enforced in exactly one place (A1), so no feature re-implements the gate (the anti-pattern J-6 killed).
---
## 2. FIRST UNIT — fully specified: **A1 · Ambient Runtime core (additive)**
### 2.1 Why this is the pick (dependency-correct opener × lowest-risk foundational slice)
- **Everything atmospheric observes it.** The runtime is the substrate for A2 (timers), A3 (IDLE/STANDBY/SHUTDOWN), all of UPLINK (C), Hardware Life choreography (D), and the attract/standby experiences — it is *the* Phase-2 opener (Phase-1 plan §3 named it exactly this).
- **It's where the Immersion dial (P8) finally gets enforced.** P8 shipped the pref + `immersionAllows()` as a born-compliant seam with ONE proof consumer; A1 makes the runtime the single enforcement point so every later ambient feature is a subscription, not a re-implementation (J-6).
- **Lowest-risk slice of a large refactor — the P1/P2 pattern applied again.** A1 builds the state machine + scheduler + observer API as *pure additive infrastructure* and tracks state **in parallel** with the existing standby/timers. It changes **zero** existing behavior; the timer/standby *migration* (the risky part) is deferred to A2, unit-by-unit and verified. If the runtime fails to start, the app is byte-identical to today.
- **Headless-provable.** The state machine + scheduler + dial gating are pure logic (like the IDB engine and the immersion helpers) → VM-testable in both runners + `test.html`, so A1 lands on a hard behavioral proof, not just structural guards.
### 2.2 Files, functions, exact seams
**New file `js/runtime.js`** (served — add to `sw.js` ASSETS; script tag ordered **after `js/state.js`** so `immersionAllows`/`RobcoEvents`/`MetaStore` exist; Suite 56 sibling guard). `window.AmbientRuntime` API:
- **Canonical state** (module const `RUNTIME_STATES = ['OFF','COLD_BOOT','READY','ACTIVE','IDLE','STANDBY','SHUTDOWN']`) + `getState()`. Starts `OFF`; boot moves it to `COLD_BOOT`.
- `register({ id, cadenceMs, states, tier, onTick, onEnter, onExit })` → returns an `unregister()` handle. `states` = array of states the observer runs in; `tier` = required immersion tier; `cadenceMs` = min interval between its ticks.
- **One heartbeat** — a single `setInterval` at a base granularity (≈250–500 ms) OR a `requestAnimationFrame` loop throttled to that granularity; each beat, for every registered observer: run `onTick` **iff** `states.includes(getState())` **and** `immersionAllows(tier)` **and** `now - lastTick >= cadenceMs`. Wrapped per-observer in try/catch (one bad observer never breaks the beat — the RobcoEvents pattern).
- `transition(to)` (internal) — validates the edge, fires each observer's `onExit(from)`/`onEnter(to)`, emits `RobcoEvents.emit('runtime.state', { from, to })`. Idempotent (no-op if `to === current`).
- **State-transition wiring (ADDITIVE — parallel to existing standby):**
- boot: `COLD_BOOT` while `_bootActive`; `runBootSequence` completion / `_bootActive` close → `READY` → `ACTIVE`.
- activity: any user interaction (`pointerdown`/`keydown`) → `ACTIVE` + resets the idle timer; no interaction for `IDLE_MS` → `IDLE`.
- visibility/focus: `blur` or `document.hidden` → `STANDBY`; `focus`/visible → `ACTIVE` (from `IDLE`/`STANDBY`).
- shutdown: only via an explicit call (`AmbientRuntime.shutdown()` — wired to the `SHUTDOWN` command in A3) → `SHUTDOWN` → `OFF`.
- **Dial enforcement** lives here and nowhere else: `immersionAllows(observer.tier)` re-evaluated **each tick** (live — a dial change takes effect immediately).
- **Init**: `initAmbientRuntime()` — a new named `window.onload` boot phase (after `_wireStandby`, before/after `_startAmbientTimers` — it must NOT yet own those timers). Starts the heartbeat, registers the state listeners, registers ONE demo observer.
- **Demo observer (the proof, inert):** an observer `{id:'runtime-selftest', cadenceMs:1000, states:['ACTIVE'], tier:'minimal', onTick: () => AmbientRuntime._ticks++}` that only increments an in-memory counter — assertable, user-invisible, writes nothing.
**`index.html`** — `<script src="js/runtime.js">` after the state.js load (or in the static tag block, before `ui-core.js`, since ui-core's `initAmbientRuntime()` call needs `AmbientRuntime`). **`sw.js`** — add `'./js/runtime.js'` + bump `CACHE_NAME` → r25. **`ui-core.js`** — add `initAmbientRuntime()` to `window.onload` (one named call). **`eslint.config.mjs`** — register `AmbientRuntime` / `initAmbientRuntime` globals.
### 2.3 The precise change (net effect)
1. A runtime exists that OWNS the canonical terminal state and offers an observer scheduler with central dial enforcement.
2. It tracks state transitions **in parallel** with the existing standby/timers — which are **untouched**. No existing timer is migrated, no standby action is moved. **Zero user-visible behavior change.**
3. If `runtime.js` fails to load or the heartbeat can't start, the app is byte-identical to today (existing standby/timers still run).
### 2.4 Acceptance criteria (Protocol 26 — verify on the real artifact)
1. `js/runtime.js` exists, is precached (`sw.js` ASSETS), script-tag ordered after `state.js` and before `ui-core.js`, exposes `window.AmbientRuntime` with `getState`/`register`/`transition`(internal ok)/`shutdown`.
2. Canonical states present; `getState()` returns the current one; starts `OFF`, boot → `COLD_BOOT` → `READY` → `ACTIVE`.
3. `register()` works; an observer's `onTick` fires **only** when `getState()` ∈ its `states` **and** `immersionAllows(tier)` **and** `cadenceMs` elapsed; `onEnter`/`onExit` fire on transitions; `unregister()` stops it.
4. **Dial enforcement:** at Minimal only `tier:'minimal'` observers tick; at Balanced `minimal`+`balanced`; at Full all — re-evaluated live when the dial changes (proven behaviorally).
5. **ADDITIVE / zero behavior change:** the existing `_startUptimeClock`/`_startMemCycle`/geiger/etc. and `enterStandby`/`exitStandby` are **unchanged and still run**; the runtime only tracks state + schedules its own observers. (Guard: A1 does not delete or re-point any existing timer/standby code.)
6. **HARD atmosphere/save boundary:** `runtime.js` writes nothing durable to the campaign — no `saveState`, no `robco_v8`, no `state.<field>=`, no `eventLog`/`_logEvent`. Device-pref writes (if any) go through MetaStore only; state is ephemeral/in-memory. **Gate-guarded** (new suite: negative grep + a boundary assertion).
7. **Fail-safe:** if `requestAnimationFrame`/`setInterval` is unavailable or the heartbeat throws, init is caught and the app is unaffected; the runtime never throws into boot (boot-smoke green).
8. **Desktop + mobile:** transitions use only signal-based events (blur/visibility/interaction) — **no width-gated behavior**; A1 adds no visible UI, so render-check is a smoke (no overflow at 360/412/desktop, no console errors).
9. New Suite (both runners at parity) — structural (file/ASSETS/order/API/no-durable-write/game-agnostic) + `test.html` **behavioral** (drive the real state machine + observer gating + dial gating headlessly). Cache r25. Protocol 2/2a docs+counts. 2.7.0 under `[Unreleased]`.
### 2.5 Verification steps
1. `npm run lint && npm run format`.
2. `npm run gate` (full) — both runners at parity, boot-smoke (0 console errors, no black screen — the additive runtime must not perturb boot), render-check 360/412, a11y, `test.html`.
3. `test.html` behavioral: instantiate the runtime, drive `transition()` through every state, assert the demo observer ticks only in `ACTIVE`+tier; flip `setImmersionTier('minimal')` and assert a `balanced` observer stops ticking while a `minimal` one continues.
4. Live (preview): confirm boot still runs, standby still dims on blur (existing behavior intact), 0 console errors; DevTools → `AmbientRuntime.getState()` reflects ACTIVE/STANDBY on focus/blur.
### 2.6 Entry paths / edge cases enumerated (Protocol 8 plan-audit)
- **Each state** — OFF (pre-boot), COLD_BOOT (during `_bootActive`), READY (boot complete, pre-first-frame), ACTIVE (interacting), IDLE (no interaction `IDLE_MS`), STANDBY (blurred/hidden), SHUTDOWN (explicit) → OFF. Every edge tested; illegal edges are no-ops.
- **Boot** — runtime init runs as a named onload phase; COLD_BOOT set before/at boot, READY→ACTIVE on `_bootActive` close. Must not race the existing boot sequence (additive; if `_bootActive` is already false by init, go straight to ACTIVE).
- **Reload** — runtime state is **ephemeral** (re-derived each boot: OFF→COLD_BOOT→…); nothing persisted (device seed for UPLINK is a *later* unit).
- **Standby (parallel)** — blur → runtime STANDBY **and** the old `enterStandby()` both fire; they must not conflict (runtime only schedules its own observers + emits an event; the old standby keeps its own dim/timer-clear). A2 later unifies them.
- **Immersion Minimal** — the demo observer (tier 'minimal') keeps ticking; a would-be 'balanced'/'full' observer does not. IDLE attract (A3, tier 'full') would be silent at Minimal — correct.
- **Desktop vs mobile** — identical signal-based transitions; no `window.innerWidth` gating anywhere in the runtime.
- **Rapid focus/blur** — debounced/idempotent transitions (no thrash); `transition()` no-ops when `to===current`.
- **Runtime unavailable** — no rAF/interval → init catches, app unaffected; `getState()` still returns a sane default (`OFF`/`ACTIVE`).
- **Dial changed mid-session** — next heartbeat re-evaluates `immersionAllows` for every observer (live).
- **A bad observer throws** — caught per-observer; the beat and other observers are unaffected (RobcoEvents pattern).
### 2.7 Protocol implications (checklist for the implement session)
- **P1 cache bump:** YES → `sw.js` r24 → **r25** (new served `js/runtime.js` + `index.html` + `ui-core.js`).
- **P2 docs:** ARCHITECTURE (new "Ambient Runtime" section — states, observer contract, dial enforcement, the hard no-write boundary, the additive-then-migrate plan); CHANGELOG `[Unreleased]` `Under the Hood`; README script-load-order + file list.
- **P2a counts:** new suite → every count location (RULES/CLAUDE ×5, README ×4, ARCHITECTURE, CHANGELOG header, both runners' per-suite comments, `test.html` `Suites:` marker). Run the `Select-String` drift scan.
- **P13 regression:** the new suite + `test.html` behavioral ARE the guards.
- **P15 parity + P40 `test.html`:** the runtime core is browser-executable → behavioral proof in `test.html`; structural + parity in both runners.
- **P38:** no game literals (pure lifecycle logic). **P39/Suite 90:** ASCII source; Edit/Node writes.
- **P19 batch:** land A1 as one complete gated commit (engine + init + demo observer + tests + docs + cache).
- **Ripple watch:** Suite 132 (slim-window.onload) — adding `initAmbientRuntime()` is one more named call; the threshold (bumped to 45 in P8) should still hold, but recount. Suite 56/49 (load-order/ASSETS) gain a `runtime.js` guard.
---
## 3. Owner rulings wanted (flagged, not guessed)
1. **Day/night — REMOVED, honored.** Per the owner instruction + memory, the UPLINK engine has **no day/night and no dusk/dawn** — the audit's WS-2 "day/night + dusk/dawn" is dropped; only rad-storm **weather** remains as the Peripheral-Bus sensor. **Confirm** this is the intended scope (weather-yes, time-of-day-no).
2. **Phase-2 size / internal split.** As scoped (organizing + UPLINK + Hardware Life + Game Identity + Navigation completion) this is **~15 units across 6 chapters** — the bulk of the v2.8.0 program. Recommend keeping the owner's grouping but treating **Chapter A (Ambient Runtime) as its own audited milestone** before committing to UPLINK, and being willing to split the phase at the A/B boundary if session budget warrants. **Owner ruling:** one large Phase 2, or split (e.g. 2a = living machine A–C, 2b = identity + navigation D–F)?
3. **Hardware Life ↔ Terminal Record boundary (§4).** I've specced Hardware Life as **device** state — its history is a device log (meta-store), surfaced as a **read-only** Hardware VIEW in the Record; it does **not** write the campaign `eventLog` (that would breach the atmosphere/save boundary and the two-store line the whole program guards). The owner phrase "Hardware Life … feeding the Terminal Record" is read as *a read-only view over device data*. **Confirm** (vs. genuinely writing hardware events into the campaign eventLog — which I'd advise against).
4. **Remote Transmissions live channel (C4).** Needs a public read-only Firestore doc + `remoteTransmissions` kill-switch + App Check. **Confirm** the owner wants a **live** channel now, or static/local payloads only for v2.8.0 (live channel deferred).
5. **Module Bay & Command Language vs Protocol 25 (UX stability).** B1/B2 reframe working controls (settings, command routing). Plan is **reframe + preserve muscle memory** (keep every `[TOKEN]` alias; don't relocate a working control's behavior). **Confirm** the owner is OK with the visual reorganization into a Module Bay (it *moves* where settings live, which Protocol 25 says do only when the feature requires it — the unified-settings feature does).
6. **Filesystem/MANUALS placement.** The audit puts MANUALS + filesystem in its Phase 6 (experience); the owner's Phase-2 organizing layer names "filesystem". I've placed them **last in Phase 2** (Chapter F, after their targets exist — J-2), pulling MANUALS in as the filesystem's prerequisite. **Confirm**, or defer F to Phase 3.
---
## 4. The three Phase-2 prime invariants (every unit holds these)
1. **Hard atmosphere/save boundary.** The ambient layer (runtime, UPLINK, weather, radio, hardware choreography) writes **NOTHING durable to the campaign** — no `state.<field>`, no `saveState`, no `robco_v8`, no `eventLog`/`_logEvent`, no `stats`. Ephemeral in-memory + device prefs (MetaStore / IDB `meta`) only. UPLINK's news and Hardware Life's view **read** the Record/device-log; they never write the campaign. **Gate-guarded** — a generalized no-durable-write suite over `runtime.js` + every UPLINK/ambient file (FP-PER-4 extended). This is the single most important invariant of the phase.
2. **No-auto-anything / player authority.** No auto-save, no auto-cloud-push (cloud stays manual-button-only — Suite 23), AI opt-in only (nothing here calls the AI or is AI-driven). UPLINK/weather/news change nothing the player didn't do; the player only ever mutates state through the existing confirm-gated native paths.
3. **Dial-gated ambience.** Every ambient consumer declares an immersion tier; the Ambient Runtime (A1) is the **one** enforcement point (`immersionAllows`). At **Minimal**, the terminal is quiet (only `minimal`-tier observers run); no feature re-implements the gate.
Plus the standing house rules on `dev`: full gate every commit, both-runners parity, counts synced, cache bump on served changes, 2.7.0 under `[Unreleased]`, Protocol 38 game-agnostic, Protocol 39 UTF-8, Protocol 10/17 mobile verification, Protocol UI-1..5 for panels.
---
## 5. Phase-2 / Phase-3 boundary — recommendation
**Recommendation: Phase 2 = Chapters A–F (Ambient Runtime + Organizing + UPLINK + Hardware Life + Game Identity + Navigation completion) — the "living machine + identity" layer. Phase 3 = the EXPERIENCE close-out + the v2.8.0 RELEASE.**
**Phase 3 (what falls out of Phase 2):**
- **Experience polish:** PLAY-ALONG / QUICK-ENTRY mode (FP-IMM-10), diegetic onboarding (skippable-persistent via MANUALS), OPERATOR CLEARANCE auth reframe (FP-IMM-3, mechanics untouched — Protocol 30/31), effective-skill resolver (FP-FEAT-6, the v2.9.0 bridge), a11y deep pass, new-feature-highlight protocol (FP-AUTO-5).
- **Close-out gate:** new-game-readiness audit (FP-EXP-6 — every Phase-2 feature shows its `GAME_DEFS` seam), skill-less (FO4) degradation re-check.
- **The RELEASE (WU-VER):** `APP_VERSION` 2.7.0 → **2.8.0** (owner-gated), final cache rev, consolidate `[Unreleased]` → dated `[v2.8.0]` block, holistic README rewrite, `dev → main` `--no-ff` release merge (per the release-deploy gotcha in memory).
**Why the line falls there:**
- Phase 2 is *systems + identity* (buildable machinery); Phase 3 is *view-over-those-systems + ship*. PLAY-ALONG, onboarding, and the effective-skill resolver all sit **on top of** the organizing layer + record + identity that Phase 2 builds — they're cheaper and clearer once those exist.
- The **release** is its own owner-gated milestone (the 2.7.0→2.8.0 flip needs explicit approval and a `--no-ff` merge). Keeping it as the Phase-3 exit gate matches how Phase 1 stayed under `[Unreleased]` — the whole of v2.8.0 accrues, then ships once.
- If the owner prefers a smaller Phase 2 (ruling §3.2), the natural cut is **Phase 2a = A–C (living machine)**, **Phase 2b = D–F (identity + navigation)**, with Phase 3 unchanged.
*(Note: the audit's v2.9.0 GAMEPLAY systems — Manifest, geographic map, ENCOUNTER, faction-consequence engine, quest database, build planner, companion mgmt, dialogue helper, hacking/debug console — remain **Step 3**, beyond this v2.8.0 program. Phase 3 here is the v2.8.0 *release*, not Step 3.)*
---
## 6. Risks
- **A2 timer migration is the real Phase-2 risk** (mirrors Phase-1's P2/P3): eight behavior-preserving conversions + folding two standby entry points into the runtime, across `ui-core.js`/`ui-audio.js`, each with its own mute/visibility/dial logic. Mitigation: A1 lands the runtime additively and de-risks the API first; A2 migrates **one timer per commit** with the full gate + live verification between; the memory-cycle (already dial-gated in P8) is the proven template.
- **Boot-order hazard (the U7/P-lesson).** `runtime.js` is a static-ish served file that references `state.js` globals (`immersionAllows`, `RobcoEvents`). Per the standing lesson, any top-level code touching those must be inside a function called from `window.onload` (after state.js's dynamic GAME_FILES load), never at module top level. `initAmbientRuntime()` is that function; `runtime.js`'s top level only defines `window.AmbientRuntime` (no cross-file reads at parse time).
- **The atmosphere/save boundary is easy to breach accidentally** (e.g., a "news" feature tempted to log to `eventLog`, a weather effect tempted to persist). The gate-guarded no-durable-write suite (§4.1) must land **with A1** and extend to every subsequent ambient file, so a breach fails the gate rather than shipping.
- **Line drift.** Phase-1 added ~1300 lines across state.js/ui-core.js/ui-saves.js/cloud.js; the §0 seam line numbers will drift — the implement session re-verifies against live dev before touching each seam.
- **Game Identity (E1 shell/bezel) is Protocol-10/17-heavy** — desktop-first chrome that must degrade to a minimal mobile band with zero overflow at 360/412; render-check + real-device the risk surface.
---
*END — Stage 1 plan. Nothing implemented, committed, or pushed. `dev` @ `01cb93d` 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).