diff --git a/.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md b/.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
new file mode 100644
index 0000000..d8199b9
--- /dev/null
+++ b/.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
@@ -0,0 +1,1189 @@
+# Phase 1: Foundations & Doctrine — Research
+
+**Researched:** 2026-05-08
+**Domain:** Project scaffolding, browser save framework, content build pipeline, AI-asset provenance gate, architectural-boundary linting, principle-level doctrine documents
+**Confidence:** HIGH (all stack versions verified against npm registry; all integration patterns verified against official docs)
+
+## Summary
+
+Phase 1's research target was narrow by design — the user has already locked the stack (Phaser 4, React 19, Vite, TypeScript, idb, lz-string, Zod, Vitest, Playwright) and rejected every overengineered shape (no curator workflow, no two-stage AI promotion, no lint rules on UX strings, no pre-allocated save slots, no BigQty/Zustand/tick scheduler in this phase). Research therefore concentrated on **the cheapest correct shape** for each of the five Phase-1 success criteria: (1) the official `npm create @phaserjs/game@latest` React+Vite+TS template structure and how to restructure `src/` into the firewall directories, (2) the ESLint boundary rule (`eslint-plugin-boundaries` v6.0.2 wins on cleanness), (3) a minimum-viable `idb` save layer with versioned `{schemaVersion, payload, checksum}` envelope, lz-string compression, last-3 snapshots, and Base64 export/import, (4) a Vite-native content pipeline using `import.meta.glob('/content/**/*', { eager, query: '?raw' })` + gray-matter + Zod (no separate build script needed), and (5) a 30-line standalone Node script that walks `assets/` and validates sidecar `.provenance.json` files.
+
+All recommended package versions were verified against npm on 2026-05-08: Phaser 4.1.0, React 19.2.6, Vite 8.0.11, TypeScript 6.0.3, idb 8.0.3, lz-string 1.5.0, Zod 4.4.3, Vitest 4.1.5, Playwright 1.59.1, eslint-plugin-boundaries 6.0.2, crc-32 1.2.2, gray-matter 4.0.3, yaml 2.8.4, inkjs 2.4.0, inklecate 1.8.1.
+
+**Primary recommendation:** Lean on the Phaser 4 official template heavily — do not restructure aggressively. Add the firewall directories (`src/sim/`, `src/render/`, `src/ui/`, `src/save/`, `src/content/`, `src/audio/`, `src/store/`) as siblings to the template's existing `src/game/` directory; the template's existing entry points (`src/main.tsx`, `src/App.tsx`, `src/PhaserGame.tsx`) keep working. This is the minimum-viable scaffold that exposes ESLint boundary targets without fighting the template.
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+**AI Asset Pipeline Depth (D-01, D-02, D-03):**
+- End-of-Phase-1 north-star reference set is **10–20 hand-curated AI generations** committed to the repo with full provenance metadata. Generated honestly with whatever tool was used (likely Claude/SD/Midjourney/etc. on a per-asset basis); the `model_id` and `checkpoint_hash` fields record what was actually used.
+- **Tool/vendor choice deferred** — Phase 1 commits to the *schema* and *gate*, not a vendor. Tool consolidation happens in Phase 5 when production volume kicks in.
+- **Curation gate is minimum-viable.** Sidecar `name.provenance.json` per asset under `/assets/` carrying the 6 required fields (`model_id`, `checkpoint_hash`, `prompt`, `seed`, `sampler`, `params`); CI script walks the tree and fails the build on any missing/invalid sidecar. Sample refused asset under `/assets/__samples__/refused/` (no sidecar) proves the gate works. **No** curator workflow, **no** two-stage promotion directory, **no** pre-commit hook, **no** CURATION-LOG.md ceremony.
+
+**Save Schema v1 Scope (D-04, D-05, D-06):**
+- v1 save payload is **minimal** — only what Phase 2 will write (garden tile state, plant growth data, harvested fragment IDs, `lastTickAt`, basic settings). It does NOT pre-allocate slots for Roothold, currentSeason, storyFlags, etc. Phase 4 ships a real `migrate_v1_to_v2` when Season-prestige state actually exists.
+- Round-trip migration test in Phase 1 uses a **synthetic v0 → v1 migration** as the proof that the chain works. v0 is a tiny made-up prior schema (e.g., `{garden: []}`) used only to exercise the migration framework end-to-end.
+- Save format `{schemaVersion, payload, checksum}` is locked. Checksum algorithm and exact migration registry shape are Claude's discretion.
+
+**Doctrine Docs Concreteness (D-07, D-08, D-09):**
+- Anti-FOMO doctrine is a **consolidation document** in `.planning/`. **No** lint rule on UX strings.
+- Season 7 end-state design is **principle-level**, not treatment-level.
+- Both doctrine documents live under `.planning/`.
+
+**Project Scaffold Layout (D-10, D-11, D-12):**
+- Use `npm create @phaserjs/game@latest` (React + Vite + TypeScript template). Restructure `src/` to expose `src/sim/`, `src/render/`, `src/ui/`, plus supporting `src/save/`, `src/content/`, `src/audio/`, `src/store/` as siblings.
+- Authored content (`/content/**/*.{md,yaml,ink}`) lives at **repo root**.
+- `/assets/` (with provenance sidecars) lives at repo root. Single package, **no monorepo / no workspaces**.
+
+### Claude's Discretion
+
+- Exact ESLint boundary rule choice (`eslint-plugin-boundaries` vs `no-restricted-paths`).
+- Checksum algorithm (CRC32, simple hash, etc.).
+- Migration registry shape.
+- `idb` wrapper API surface inside `src/save/`.
+- Vitest / Playwright config files; `vite.config.ts` boundary plugin choices.
+- Where the Zod content schemas live and how the build step is invoked (Vite plugin vs separate `npm run build:content` script).
+- Specific images for the 10–20 north-star generations.
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- AI vendor lock-in / model pinning (Phase 5).
+- `BigQty` wrapper around break_eternity.js (Phase 2).
+- Empty Zustand store skeleton (Phase 2).
+- Tick scheduler / monotonic clock (Phase 2).
+- Season 7 treatment-level details (Phase 7).
+- Anti-FOMO lint rule on UX strings (rejected).
+- Curator workflow / two-stage asset promotion / pre-commit hook on assets (rejected).
+- Visual regression testing (Phase 8).
+- AudioContext / Howler.js setup (Phase 2).
+- Phaser scene wiring or rendering (Phase 2-3).
+- Ink dialogue authoring (Phase 2+).
+
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| CORE-01 | Game loads in <5s in modern browsers (Chrome/Firefox/Safari/Edge last 2) on 25 Mbps | Phaser 4 template ships Vite 8 with sensible default code-splitting; the empty scaffold loads in well under 5s. The **enforcement** mechanism is a Playwright smoke test introduced in Phase 2 (PIPE-07); Phase 1 verifies by `npm run build` succeeding and visual sanity in dev. |
+| CORE-04 | Save persists to IndexedDB with localStorage fallback | `idb` 8.0.3 wraps IndexedDB in promises; localStorage fallback is a try/catch around `openDB` failures. See "Save Layer Shape" below. |
+| CORE-05 | `navigator.storage.persist()` called on first save with respectful surfacing of false | Available in Chrome/Firefox/Edge (Safari 17+ partial); when false, surface "saves may be cleared if storage runs low — export a backup from Settings" at next opportunity. |
+| CORE-06 | Saves versioned `{schemaVersion, payload, checksum}`; refuse load on checksum mismatch with recovery option | crc-32 (`crc-32` package, 426k ops/sec, dependency-free, ~3KB) is the right shape. See "Checksum Choice" below. |
+| CORE-07 | `migrate_vN_to_vN+1` chain with Vitest tests for every shipped migration | Ordered registry pattern; synthetic v0→v1 demo migration exercises the chain end-to-end. See "Migration Registry Shape" below. |
+| CORE-08 | Last 3 pre-migration save snapshots retained; "restore previous save" reachable from settings (UI hooks land in Phase 4) | Separate `snapshots` object store keyed by `{schemaVersion, savedAt}`; pre-migration hook writes to it; pruner runs on each write. Phase 1 ships only the storage + retention; settings UI is Phase 4. |
+| CORE-09 | Save export/import as Base64 text via Settings (function pair shipped Phase 1; UI button Phase 2+) | `lz-string.compressToBase64` of the JSON envelope is the export; `decompressFromBase64` is the import. Round-trip test in Vitest. |
+| CORE-10 | `src/sim/` imports nothing from `src/render/` or `src/ui/` — enforced by ESLint boundary rules in CI | `eslint-plugin-boundaries` 6.0.2 with the `boundaries/element-types` rule. See "ESLint Boundary Rule" below. |
+| PIPE-01 | Build compiles `/content/**/*.{md,yaml,ink}` into per-Season JSON via Zod-validated schemas; build fails on schema violation | Vite-native `import.meta.glob('/content/**/*', { eager: true, query: '?raw' })` + gray-matter + `yaml` + Zod, validated at build time. See "Content Pipeline" below. |
+| PIPE-03 | AI asset pipeline records provenance per asset and refuses unprovenanced assets | Standalone `scripts/validate-assets.mjs` walks `assets/` (excluding `__samples__/refused/`); fails CI on any non-sidecar file lacking a sibling `.provenance.json` validated by a Zod schema. |
+| PIPE-05 | `anti-FOMO doctrine` and `Season 7 end-state` design documents in `.planning/` before economy code | Outlined below in "Doctrine Doc Outlines". Principle-level, not treatment. |
+| PIPE-06 | Vitest unit tests cover all save migrations and run on every CI build | Vitest 4.1.5 with a `pnpm/npm test` step in CI. Test files: `src/save/migrations.test.ts`, `src/save/round-trip.test.ts`. |
+| AEST-08 | All AI-assisted assets carry persisted provenance metadata (`{model_id, checkpoint_hash, prompt, seed, sampler, params}`) | Sidecar `.provenance.json` schema enforced by validator script. The "pinned model" wording in REQUIREMENTS is satisfied by per-asset honest provenance for Phase 1; vendor pinning is Phase 5. |
+| AEST-09 | All shipped assets pass a mandatory human curation gate before integration | The validator script *is* the gate (CI fails if sidecar missing/invalid). Phase 1 also commits the 10–20-image north-star reference set as the seed. **No** curator workflow doc, per CONTEXT D-03. |
+| STRY-09 | Every player-visible string externalized in `/content/`, not hardcoded | Phase 1 establishes the `/content/` convention and the loader; **no lint rule** enforces this (per CONTEXT D-07). Phase 2 begins authoring real strings into `/content/season-01-soil/`. |
+| UX-13 | No daily login bonuses, streaks, limited-time content, nag notifications, loss-aversion copy — anti-FOMO doctrine enforced in every UX review | Doctrine doc shipped in this phase; enforcement is by review, not code. |
+
+
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Project scaffold (vite, tsconfig, eslint baseline) | Build / Dev tooling | — | Ships the dev-server + bundler boundary — not user-facing. |
+| Save persistence (IndexedDB, lz-string, checksum) | Browser / Client (Storage) | — | All save state is local-first per CLAUDE.md "no always-online" constraint. |
+| Save migration chain | Browser / Client (Storage) | Simulation core consumer | Migration runs at load time before sim sees state; pure functions, no I/O. |
+| Save export/import (Base64) | Browser / Client (Storage) | UI (Phase 2+ wires button) | Function pair lives in `src/save/`; UI surface is later. |
+| Content build pipeline (md/yaml/ink → JSON) | Build / Static asset transform | Browser runtime consumer | Vite plugin pattern at build; runtime imports compiled JSON only. |
+| Zod content schemas | Build / Static asset validation | Browser runtime type source | Schemas validate at build, derive TS types via `z.infer` at compile. |
+| AI asset provenance gate | CI / Build-time validator | Repo discipline | Standalone Node script; no runtime import. Lives in `scripts/`. |
+| ESLint boundary rule | Build / Static-analysis | — | Lints `src/sim/` ↔ `src/render/`/`src/ui/` import edges; runs in CI and IDE. |
+| Anti-FOMO doctrine | Process / Documentation | UX review reference | Markdown in `.planning/`; not enforced by code. |
+| Season 7 end-state doctrine | Process / Documentation | Future Phase 7 reference | Markdown in `.planning/`; principle-level. |
+| North-star asset reference set | Repo / Static asset | Future Phase 5 model-pinning input | 10–20 images committed under `assets/north-stars/` with sidecars. |
+
+**Important:** Every Phase 1 capability is **build/CI/storage tier** — there is no Simulation or Rendering responsibility in this phase. Phase 2 is where Simulation Core takes ownership.
+
+## Standard Stack
+
+### Core (versions verified against npm 2026-05-08)
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `phaser` | 4.1.0 | 2D game framework (canvas owner, Phase 2+ uses it) | Locked by CONTEXT D-10. Verified `npm view phaser version` → 4.1.0 ("Salusa", April 2026). |
+| `react` / `react-dom` | 19.2.6 | UI shell (DOM overlay) | Locked by CLAUDE.md / STACK.md. `npm view react version` → 19.2.6. |
+| `vite` | 8.0.11 | Dev server + bundler | Phaser 4 official template ships with Vite. `npm view vite version` → 8.0.11. **NB: STACK.md mentions Vite 6 as baseline; the template version may be 6 or 7 — accept whatever the scaffold installs and don't fight it.** Vite 8 with Rolldown is current latest. |
+| `typescript` | 6.0.3 | Static typing | `npm view typescript version` → 6.0.3. |
+| `idb` | 8.0.3 | Promise-based IndexedDB wrapper | Locked. `npm view idb version` → 8.0.3. ~1.19kB brotli'd; openDB + put/get covers Phase 1 entirely. |
+| `lz-string` | 1.5.0 | Save compression + Base64 export | Locked. `compressToBase64` / `decompressFromBase64` is the export round-trip. |
+| `zod` | 4.4.3 | Schema validation (content + save envelope + provenance) | Locked. **Note:** STACK.md cites 3.23+; current is 4.4.3 — Zod 4 is the modern major. Use 4.x for new code. |
+| `vitest` | 4.1.5 | Test runner (migration tests + round-trip test) | Locked. Vite-native; covers PIPE-06. |
+| `@playwright/test` | 1.59.1 | E2E smoke (no test in Phase 1; install only — first test in Phase 2) | Locked by STACK.md. Phase 1 only `npm install`s it; first real spec is PIPE-07 in Phase 2. |
+| `eslint-plugin-boundaries` | 6.0.2 | Architectural-firewall lint rule | Cleaner than `no-restricted-paths` for the `src/sim/` ↔ `src/render/`/`src/ui/` rule. See decision rationale below. |
+| `crc-32` | 1.2.2 | Save checksum | Dependency-free; `CRC32.str(jsonString)` returns a 32-bit signed int; ~3KB. Fastest pure-JS CRC implementation in benchmarks (~426k ops/sec). |
+| `gray-matter` | 4.0.3 | Parse YAML frontmatter from `.md` files | Standard for the fragment manifest; STACK.md already endorses. |
+| `yaml` | 2.8.4 | Parse pure `.yaml` content files | `eemeli/yaml`, dependency-free, full YAML 1.2 spec; replaces `js-yaml` for new code. |
+
+### Supporting (deferred — install but do not use yet)
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `inkjs` | 2.4.0 | Ink narrative runtime | **Install in Phase 1** so the dependency is locked. **Do NOT compile any `.ink` files in Phase 1.** Phase 2 begins authoring + compilation. |
+| `inklecate` | 1.8.1 | Ink compiler CLI | Install as devDependency. Phase 1 includes a `package.json` script `compile:ink` that *exists* (so the build pipeline shape is proven) but is invoked on an empty `/content/dialogue/` directory and is a no-op. Phase 2 starts adding `.ink` files. |
+
+**Phase 1 explicitly does NOT install:** `zustand`, `break_eternity.js`, `howler` + `@types/howler`, `mitt`, `dayjs` — those land in Phase 2 with the simulation core. Do not pre-install — keeping `package.json` honest about phase scope is itself part of the discipline.
+
+### Alternatives Considered
+
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `eslint-plugin-boundaries` | `eslint-plugin-import` rule `no-restricted-paths` | `no-restricted-paths` requires manually enumerating every forbidden path pair, no glob support, no element-type abstraction. For one rule (sim ↔ render/ui) it would work in ~10 lines but the model is awkward. `eslint-plugin-boundaries` defines element types once and rules read clearly. **Pick boundaries.** |
+| `crc-32` | `xxhashjs` / `murmurhash` / `Web Crypto SHA-256 (sliced)` | xxhash is faster on large strings but heavier dependency; murmurhash is comparable but slightly less battle-tested in JS. SHA-256 is overkill for non-adversarial corruption detection (a save isn't a security boundary — checksum mismatch just signals "don't load this"). **CRC-32 is correct: deterministic, ~3KB, dependency-free, fast enough.** Idle-game saves are bytes-to-low-megabytes; even a slower hash would be fine. |
+| Vite-native content pipeline (`import.meta.glob`) | Separate `npm run build:content` Node script | The Vite-native path is one less moving piece — content imports become typed values at build time, HMR works for free, Vite's tree-shaking handles per-Season chunking via dynamic glob in Phase 2. The standalone-script path is more flexible (can emit JSON to disk that Vite never sees) but Phase 1 has no need. **Vite-native wins on simplicity.** Keep the option to refactor later if Phase 5+ asset volume forces it. |
+| Synthetic v0→v1 migration in Phase 1 | Skip migration test until Phase 4's real v1→v2 | CONTEXT D-05 explicitly mandates the synthetic test. The point is to prove the framework, not the data shape. **Synthetic v0 is correct.** |
+| `crc-32` with `CRC32.str()` over JSON string | Hash the parsed object | Hashing the canonical JSON string is deterministic across runs only if `JSON.stringify` is canonical (key order). Since save state is internally controlled (we write the schema), key order is stable. Document the canonicalization in `src/save/checksum.ts`. |
+
+### Installation
+
+```bash
+# Step 0: scaffold (run interactively, choose "React + Vite + TypeScript")
+npm create @phaserjs/game@latest the-last-garden
+cd the-last-garden
+
+# Step 1: Phase 1 production deps
+npm install idb@^8 lz-string@^1.5 zod@^4 crc-32@^1.2 gray-matter@^4 yaml@^2.8 inkjs@^2.4
+
+# Step 2: Phase 1 dev deps
+npm install -D vitest@^4 @playwright/test@^1.59 eslint-plugin-boundaries@^6 inklecate@^1.8
+```
+
+The Phaser 4 template already installs `phaser`, `react`, `react-dom`, `vite`, `typescript`, and an ESLint baseline. Add to it; don't replace it.
+
+### Version verification log
+
+| Package | Verified version | Date |
+|---------|-----------------|------|
+| phaser | 4.1.0 | 2026-05-08 |
+| react | 19.2.6 | 2026-05-08 |
+| vite | 8.0.11 | 2026-05-08 |
+| typescript | 6.0.3 | 2026-05-08 |
+| idb | 8.0.3 | 2026-05-08 |
+| lz-string | 1.5.0 | 2026-05-08 |
+| zod | 4.4.3 | 2026-05-08 |
+| vitest | 4.1.5 | 2026-05-08 |
+| @playwright/test | 1.59.1 | 2026-05-08 |
+| eslint-plugin-boundaries | 6.0.2 | 2026-05-08 |
+| crc-32 | 1.2.2 | 2026-05-08 |
+| gray-matter | 4.0.3 | 2026-05-08 |
+| yaml | 2.8.4 | 2026-05-08 |
+| inkjs | 2.4.0 | 2026-05-08 |
+| inklecate | 1.8.1 | 2026-05-08 |
+| howler | 2.2.4 | 2026-05-08 (Phase 2) |
+| break_eternity.js | 2.1.3 | 2026-05-08 (Phase 2) |
+| zustand | 5.0.13 | 2026-05-08 (Phase 2) |
+
+[VERIFIED: npm registry, 2026-05-08]
+
+## Architecture Patterns
+
+### System Architecture Diagram (Phase 1 deliverables only)
+
+```
+┌──────────────────────────────────────────────────────────────────────┐
+│ REPO ROOT │
+│ /content/ → authored md+yaml+ink (Phase 2+ fills it) │
+│ /assets/ → AI generations + sidecar provenance JSON │
+│ /.planning/ → doctrine docs (anti-fomo, season-7-end-state) │
+│ /scripts/ → validate-assets.mjs (CI provenance gate) │
+│ │
+│ /src/ │
+│ ├── main.tsx, App.tsx, PhaserGame.tsx (template-provided) │
+│ ├── game/ (template-provided — Phaser scenes) │
+│ ├── sim/ [FIREWALL: imports nothing from render/ or ui/] │
+│ ├── render/ (Phase 2+ Phaser scene wiring) │
+│ ├── ui/ (Phase 2+ React HUD) │
+│ ├── save/ ← LOAD-BEARING IN PHASE 1 │
+│ │ ├── db.ts (idb wrapper: openDB + 2 stores) │
+│ │ ├── envelope.ts ({schemaVersion, payload, checksum}) │
+│ │ ├── checksum.ts (CRC32 over canonical JSON) │
+│ │ ├── migrations.ts (registry: {1: v0→v1, ...}) │
+│ │ ├── snapshots.ts (last-3 pre-migration retention) │
+│ │ ├── persist.ts (navigator.storage.persist + result handler) │
+│ │ ├── codec.ts (lz-string + Base64 export/import) │
+│ │ └── *.test.ts (vitest: round-trip + each migration) │
+│ ├── content/ ← LOAD-BEARING IN PHASE 1 │
+│ │ ├── schemas/ (zod schemas: fragment.ts, season.ts) │
+│ │ └── loader.ts (import.meta.glob + gray-matter + zod parse) │
+│ ├── audio/ (empty placeholder for Phase 2) │
+│ └── store/ (empty placeholder for Phase 2) │
+│ │
+│ /eslint.config.js (extends template + boundaries plugin) │
+│ /vitest.config.ts │
+│ /playwright.config.ts (installed; no specs yet) │
+│ /package.json │
+└──────────────────────────────────────────────────────────────────────┘
+
+ Build pipeline (Phase 1) CI gate (Phase 1)
+ ┌─────────────────────────┐ ┌──────────────────────────┐
+ │ vite build │ │ npm run lint │
+ │ → import.meta.glob │ │ (eslint-plugin-bounds) │
+ │ → zod-parse content │ │ npm test │
+ │ → fail on violation │ │ (vitest: migrations) │
+ └─────────────────────────┘ │ node scripts/ │
+ │ validate-assets.mjs │
+ │ (provenance walker) │
+ └──────────────────────────┘
+```
+
+### Recommended Project Structure
+
+```
+the-last-garden/
+├── .planning/
+│ ├── PROJECT.md (existing)
+│ ├── REQUIREMENTS.md (existing)
+│ ├── ROADMAP.md (existing)
+│ ├── STATE.md (existing)
+│ ├── anti-fomo-doctrine.md (NEW — Phase 1 deliverable)
+│ ├── season-7-end-state.md (NEW — Phase 1 deliverable)
+│ ├── research/ (existing)
+│ └── phases/ (existing)
+├── content/ (NEW empty tree — Phase 2+ fills)
+│ ├── seasons/ (per-Season subdirs added in Phase 2+)
+│ └── README.md (explains the content shape)
+├── assets/
+│ ├── north-stars/ (NEW — 10–20 curated images + sidecars)
+│ │ ├── garden-soil-01.png
+│ │ ├── garden-soil-01.provenance.json
+│ │ └── ...
+│ └── __samples__/
+│ └── refused/
+│ └── unprovenanced.png (proves the gate)
+├── scripts/
+│ └── validate-assets.mjs (NEW — CI provenance walker)
+├── src/
+│ ├── main.tsx (template-provided)
+│ ├── App.tsx (template-provided)
+│ ├── PhaserGame.tsx (template-provided)
+│ ├── game/ (template-provided — Phaser scenes)
+│ ├── sim/ (NEW empty — Phase 2+ fills)
+│ │ └── .gitkeep
+│ ├── render/ (NEW empty — Phase 2+ fills)
+│ │ └── .gitkeep
+│ ├── ui/ (NEW empty — Phase 2+ fills)
+│ │ └── .gitkeep
+│ ├── save/ (NEW — Phase 1 ships)
+│ │ ├── db.ts
+│ │ ├── envelope.ts
+│ │ ├── checksum.ts
+│ │ ├── migrations.ts
+│ │ ├── snapshots.ts
+│ │ ├── persist.ts
+│ │ ├── codec.ts
+│ │ ├── round-trip.test.ts
+│ │ └── migrations.test.ts
+│ ├── content/ (NEW — Phase 1 ships loader, no real content yet)
+│ │ ├── schemas/
+│ │ │ ├── fragment.ts
+│ │ │ └── season.ts
+│ │ └── loader.ts
+│ ├── audio/ (NEW empty — Phase 2+)
+│ │ └── .gitkeep
+│ └── store/ (NEW empty — Phase 2+)
+│ └── .gitkeep
+├── eslint.config.js (extends template, adds boundaries plugin)
+├── vitest.config.ts (NEW)
+├── playwright.config.ts (NEW — empty test dir, install only)
+├── tsconfig.json (template-provided, may add path alias)
+├── vite.config.ts (template-provided)
+├── package.json
+└── CLAUDE.md (existing)
+```
+
+### Pattern 1: Save Envelope `{schemaVersion, payload, checksum}`
+
+**What:** Every save written to disk is a top-level envelope:
+```typescript
+interface SaveEnvelope {
+ schemaVersion: number;
+ payload: T;
+ checksum: string; // hex CRC32 of canonical JSON.stringify(payload)
+}
+```
+The checksum is computed over the canonical JSON string of the *payload only* (not the envelope). On load, recompute and compare; mismatch → refuse to load, surface recovery option.
+
+**When to use:** Always. CLAUDE.md mandates the shape; Phase 2+ writes payloads of this shape exclusively.
+
+**Example:**
+```typescript
+// src/save/envelope.ts
+import { z } from 'zod';
+import { crc32hex } from './checksum';
+
+export const SaveEnvelopeSchema = z.object({
+ schemaVersion: z.number().int().positive(),
+ payload: z.unknown(),
+ checksum: z.string().regex(/^[0-9a-f]{8}$/),
+});
+export type SaveEnvelope = z.infer & { payload: T };
+
+export function wrap(payload: T, schemaVersion: number): SaveEnvelope {
+ return {
+ schemaVersion,
+ payload,
+ checksum: crc32hex(canonicalJSON(payload)),
+ };
+}
+
+export function unwrap(env: SaveEnvelope): T {
+ const expected = crc32hex(canonicalJSON(env.payload));
+ if (expected !== env.checksum) throw new SaveCorruptError(env.checksum, expected);
+ return env.payload as T;
+}
+
+// canonicalJSON: stable key order. For Phase 1, JSON.stringify with sorted keys is enough.
+function canonicalJSON(value: unknown): string {
+ return JSON.stringify(value, (_k, v) =>
+ v && typeof v === 'object' && !Array.isArray(v)
+ ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)))
+ : v
+ );
+}
+```
+
+[CITED: zod docs https://zod.dev — schema-from-type pattern]
+
+### Pattern 2: Migration Registry (ordered chain)
+
+**What:** A `Record` mapping target-version → pure function. Loader walks from current envelope's version to `CURRENT_SCHEMA_VERSION`, applying each migration in turn.
+
+```typescript
+// src/save/migrations.ts
+type Migration = (payload: unknown) => unknown;
+
+export const CURRENT_SCHEMA_VERSION = 1;
+
+// Each entry migrates FROM (key-1) TO key. So migrations[1] = v0→v1.
+export const migrations: Record = {
+ 1: (s: any) => ({
+ // Synthetic demo: v0 was {garden: []}. v1 is the real Phase 2 shape.
+ garden: { tiles: s.garden ?? [] },
+ plants: [],
+ harvestedFragmentIds: [],
+ lastTickAt: Date.now(),
+ settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 },
+ }),
+};
+
+export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number } {
+ let current = payload;
+ let v = fromVersion;
+ while (v < CURRENT_SCHEMA_VERSION) {
+ const next = v + 1;
+ const fn = migrations[next];
+ if (!fn) throw new Error(`No migration registered for v${v} → v${next}`);
+ current = fn(current);
+ v = next;
+ }
+ return { payload: current, toVersion: v };
+}
+```
+
+**Why ordered chain over registry-with-named-functions:** The chain is the most testable shape — each migration is a pure function with a known input schema and known output schema; Vitest tests each migration in isolation (`expect(migrations[1]({garden: []})).toEqual({...v1 shape})`). Phase 4's real `migrate_v1_to_v2` slots in by adding `migrations[2] = ...`.
+
+**Pre-migration snapshot rule:** Before invoking `migrate()` on a load, write the original envelope to the `snapshots` object store keyed by `{schemaVersion, savedAt}`. Pruner trims to last 3 entries. See "Pattern 3" below.
+
+[CITED: shapez.io save/load post-mortem — https://deepwiki.com/tobspr-games/shapez.io/2.2-saveload-system; ARCHITECTURE.md Pattern 5]
+
+### Pattern 3: Last-3 Pre-Migration Snapshots
+
+**What:** A second IndexedDB object store, `save_snapshots`, holds copies of envelopes from before any migration ran. Schema:
+```typescript
+interface SnapshotEntry {
+ id: string; // `${schemaVersion}-${savedAtIso}`
+ schemaVersion: number;
+ savedAt: string; // ISO8601
+ envelope: SaveEnvelope;
+}
+```
+
+**Retention rule:** On every snapshot write, query all entries, sort by `savedAt` desc, keep first 3, delete the rest. Synchronous logic inside one IDB transaction.
+
+**Phase scope:** Phase 1 only ships the *storage and pruning* layer + a Vitest test that `(write 5 snapshots) → (assert 3 remain, oldest 2 deleted)`. The "Restore previous save" UI button hooks into this in Phase 4.
+
+**Example:**
+```typescript
+// src/save/snapshots.ts
+import { openSaveDB } from './db';
+import type { SaveEnvelope } from './envelope';
+
+const RETAIN = 3;
+
+export async function snapshot(envelope: SaveEnvelope): Promise {
+ const db = await openSaveDB();
+ const tx = db.transaction('save_snapshots', 'readwrite');
+ const store = tx.objectStore('save_snapshots');
+ const savedAt = new Date().toISOString();
+ await store.put({
+ id: `${envelope.schemaVersion}-${savedAt}`,
+ schemaVersion: envelope.schemaVersion,
+ savedAt,
+ envelope,
+ });
+ // Prune
+ const all = await store.getAll();
+ const sorted = all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
+ const toDelete = sorted.slice(RETAIN);
+ await Promise.all(toDelete.map((e) => store.delete(e.id)));
+ await tx.done;
+}
+
+export async function listSnapshots(): Promise {
+ const db = await openSaveDB();
+ const all = await db.getAll('save_snapshots');
+ return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
+}
+```
+
+### Pattern 4: Vite-Native Content Pipeline
+
+**What:** Use `import.meta.glob('/content/**/*.{md,yaml}', { eager: true, query: '?raw', import: 'default' })` to import every content file as a raw string at build time. Run gray-matter (for `.md`) or `yaml.parse` (for `.yaml`) on each, then validate via Zod schemas. Build fails on any Zod error because `loader.ts` runs at module-evaluation time and a thrown error propagates through Vite to the build process.
+
+**Why this shape over a separate script:**
+- One source of truth: the loader runs at build *and* dev (HMR works for free).
+- No emitted-JSON-file problem: compiled content is just JS values bundled by Vite.
+- Per-Season chunking arrives in Phase 2 by switching to `{ eager: false }` for Seasons 2–7 and `eager: true` for Season 1.
+- Vite already handles file watching, glob expansion, and TypeScript types.
+
+**Phase 1 scope:** Loader exists, schemas exist, but `/content/` is *empty* of real content. Demo schema (one tiny fragment in `/content/seasons/00-demo/fragments.yaml`) proves the round-trip and is removed in Phase 2.
+
+**Example:**
+```typescript
+// src/content/schemas/fragment.ts
+import { z } from 'zod';
+
+export const FragmentSchema = z.object({
+ id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/),
+ season: z.number().int().min(1).max(7),
+ body: z.string().min(1),
+});
+export type Fragment = z.infer;
+
+export const SeasonContentSchema = z.object({
+ fragments: z.array(FragmentSchema),
+});
+```
+
+```typescript
+// src/content/loader.ts
+import grayMatter from 'gray-matter';
+import { parse as parseYAML } from 'yaml';
+import { z } from 'zod';
+import { FragmentSchema, SeasonContentSchema } from './schemas';
+
+// Vite handles glob; ?raw imports as string; eager makes the imports available synchronously.
+const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', {
+ eager: true,
+ query: '?raw',
+ import: 'default',
+}) as Record;
+
+const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', {
+ eager: true,
+ query: '?raw',
+ import: 'default',
+}) as Record;
+
+const fragmentsFromYaml = Object.entries(yamlFiles).flatMap(([path, raw]) => {
+ const data = parseYAML(raw);
+ const parsed = SeasonContentSchema.safeParse(data);
+ if (!parsed.success) {
+ throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
+ }
+ return parsed.data.fragments;
+});
+
+const fragmentsFromMd = Object.entries(mdFiles).map(([path, raw]) => {
+ const { data, content } = grayMatter(raw);
+ const merged = { ...data, body: content.trim() };
+ const parsed = FragmentSchema.safeParse(merged);
+ if (!parsed.success) {
+ throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
+ }
+ return parsed.data;
+});
+
+export const fragments: Fragment[] = [...fragmentsFromYaml, ...fragmentsFromMd];
+```
+
+**Ink files in Phase 1:** Per CONTEXT discretion + cheaper-path recommendation, **defer Ink compilation to Phase 2**. Phase 1 ships:
+1. `npm install inkjs inklecate` so the deps are locked.
+2. A `package.json` script `"compile:ink": "inklecate -o src/content/compiled-ink/ content/dialogue/*.ink || true"` that exists but operates on an empty directory (no-op).
+3. `/content/dialogue/.gitkeep` so the directory is real.
+4. **No Ink validation** in Phase 1 — Phase 2 adds it when the first `.ink` file is written.
+
+This is the minimum-viable shape: the *path* to compile Ink exists; nothing flows through it yet. Full Ink integration is Phase 2.
+
+### Pattern 5: ESLint Boundary Rule (`eslint-plugin-boundaries`)
+
+**What:** Define each top-level `src/` directory as an "element type"; declare which types each type may import from.
+
+```javascript
+// eslint.config.js (flat config, ESM)
+import boundaries from 'eslint-plugin-boundaries';
+
+export default [
+ // ... template's existing config first ...
+ {
+ plugins: { boundaries },
+ settings: {
+ 'boundaries/elements': [
+ { type: 'sim', pattern: 'src/sim/**' },
+ { type: 'render', pattern: 'src/render/**' },
+ { type: 'ui', pattern: 'src/ui/**' },
+ { type: 'save', pattern: 'src/save/**' },
+ { type: 'content', pattern: 'src/content/**' },
+ { type: 'audio', pattern: 'src/audio/**' },
+ { type: 'store', pattern: 'src/store/**' },
+ { type: 'app', pattern: 'src/{main,App,PhaserGame}.tsx' },
+ { type: 'game', pattern: 'src/game/**' },
+ ],
+ },
+ rules: {
+ 'boundaries/element-types': ['error', {
+ default: 'allow',
+ rules: [
+ // The Phase-1 firewall (CORE-10):
+ { from: ['sim'], disallow: ['render', 'ui'] },
+ ],
+ }],
+ },
+ },
+];
+```
+
+**Why this over `no-restricted-paths`:** The element-type abstraction means that when Phase 4 adds `src/server/` (or any new tier), we add one element entry rather than enumerating new path-pair forbids. Cleaner, more declarative, future-proof. The rule above forbids only the exact CORE-10 firewall; everything else is `default: allow` — keeping Phase 1 honest about which constraints actually exist.
+
+**CI integration:** `npm run lint` in the GitHub Actions workflow (or local pre-commit). Failure exits non-zero. Verify with one deliberately-bad file in tests (a unit test that asserts `eslint --rulesdir ... src/sim/__test_violation__/...` exits with the boundary error — only kept in the test fixture, removed from production builds).
+
+[VERIFIED: eslint-plugin-boundaries 6.0.2 README, 2026-05-08]
+
+### Pattern 6: Provenance Sidecar Validator
+
+**What:** A 30-line standalone Node script that walks `assets/`, verifies that every non-sidecar file has a sibling `.provenance.json` parseable against a Zod schema, with the exception of `assets/__samples__/refused/`.
+
+```javascript
+// scripts/validate-assets.mjs
+import { readdir, readFile, stat } from 'node:fs/promises';
+import { join, extname, basename, dirname } from 'node:path';
+import { z } from 'zod';
+
+const ProvenanceSchema = z.object({
+ model_id: z.string().min(1),
+ checkpoint_hash: z.string().min(1),
+ prompt: z.string().min(1),
+ seed: z.union([z.string(), z.number()]),
+ sampler: z.string().min(1),
+ params: z.record(z.string(), z.unknown()),
+});
+
+const ASSETS = 'assets';
+const REFUSED = ['assets/__samples__/refused'];
+
+async function* walk(dir) {
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) yield* walk(path);
+ else yield path;
+ }
+}
+
+const errors = [];
+for await (const path of walk(ASSETS)) {
+ if (REFUSED.some((r) => path.replaceAll('\\', '/').startsWith(r))) continue;
+ if (path.endsWith('.provenance.json')) continue;
+ if (basename(path) === '.gitkeep') continue;
+ const sidecar = path + '.provenance.json'; // e.g. foo.png.provenance.json
+ // OR .provenance.json — pick one convention; document in CONTEXT for Phase 2.
+ try {
+ const raw = await readFile(sidecar, 'utf8');
+ const parsed = ProvenanceSchema.safeParse(JSON.parse(raw));
+ if (!parsed.success) errors.push(`${path}: ${parsed.error.message}`);
+ } catch (e) {
+ errors.push(`${path}: missing or unreadable provenance sidecar (${sidecar})`);
+ }
+}
+
+if (errors.length) {
+ console.error('[provenance] validation failed:');
+ errors.forEach((e) => console.error(' ' + e));
+ process.exit(1);
+}
+console.log('[provenance] all assets carry valid provenance.');
+```
+
+**Wire it up:**
+```json
+// package.json
+"scripts": {
+ "validate:assets": "node scripts/validate-assets.mjs",
+ "lint": "eslint .",
+ "test": "vitest run",
+ "ci": "npm run lint && npm run test && npm run validate:assets && npm run build"
+}
+```
+
+**Sidecar naming convention decision:** Use `.provenance.json` (e.g. `garden-soil-01.png.provenance.json`) — this keeps the sidecar adjacent in directory listings, makes it grep-friendly, and avoids ambiguity for files with the same stem but different extensions.
+
+**Refused-sample test:** Commit `assets/__samples__/refused/no-provenance.png` (an actual image file, no sidecar). Then write a Vitest test that *temporarily moves* the refused asset out of `__samples__/refused/` and asserts the validator script exits non-zero — proving the gate works. Restore the file in the test teardown.
+
+**Should this also run in Vite dev?** No. Per CONTEXT D-03, the gate is CI-only. Running it in dev would slow down `npm run dev` startup; provenance churn during development is normal and shouldn't block iteration.
+
+### Anti-Patterns to Avoid
+
+- **Adding a curator workflow doc / pre-commit hook / two-stage promotion directory** — explicitly rejected by CONTEXT D-03. Sidecar + CI walker is the entire pipeline.
+- **Pre-allocating save schema slots for Roothold / currentSeason / storyFlags** — explicitly rejected by CONTEXT D-04. v1 schema contains only what Phase 2 will write.
+- **Compiling `.ink` files in Phase 1** — explicitly out of scope. The path exists; the runtime doesn't.
+- **Lint rule on UX strings** — explicitly rejected by CONTEXT D-07. Anti-FOMO is a review document, not a code rule.
+- **A separate `npm run build:content` Node script** — Vite-native is simpler. Refactor only if Phase 5+ asset volume forces it.
+- **BigQty wrapper / Zustand store / tick scheduler / Howler bootstrap / AudioContext gesture gate** — all explicitly Phase 2.
+- **Running the asset validator in Vite dev** — CI only. Don't burden the dev loop.
+- **Hand-rolling CRC32 / a custom hash** — `crc-32` is dependency-free at 426k ops/sec. Don't NIH this.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| IndexedDB transactions | `indexedDB.open` + manual `onupgradeneeded` + Promise wrappers | `idb` (`openDB`, `db.put`, `db.get`) | Idb is 1.19kB brotli'd. Hand-rolling promise wrappers around the request-event API loses ~200 lines, has subtle bugs around transaction lifecycle, and reinvents what's already standard. |
+| Save compression | DIY LZ77 / DIY Base64 wrappers | `lz-string` (`compressToUTF16`, `compressToBase64`) | The library is 30k downloads/week, battle-tested in incremental-game saves for ~10 years. UTF-16 storage avoidance + URL-safe variants are subtle. |
+| Schema validation | Hand-written `if (typeof x.id !== 'string')` checks | `zod` | The whole point of Zod is type-safe runtime validation. Doing it by hand on save envelopes / content / provenance is error-prone and tedious. |
+| Frontmatter parsing | Hand-rolled `--- ... ---` parser | `gray-matter` | Edge cases (escaped delimiters inside YAML strings, multi-document YAML) bite. |
+| YAML parsing | Hand-rolled / `JSON.parse` of a yaml-as-json variant | `yaml` (`eemeli/yaml`) | YAML 1.2 spec is gnarly. `yaml` is the reference implementation. |
+| ESLint architectural boundaries | Pre-commit grep / shell scripts | `eslint-plugin-boundaries` | IDE integration, ESLint flat-config compatibility, element-type abstraction. |
+| CRC32 | Hand-rolled bit-twiddling | `crc-32` (SheetJS) | Polynomial tables and seed handling are easy to get subtly wrong; CRC-32 package is dependency-free and tiny. |
+| Glob walking the filesystem | `glob` / `fast-glob` / DIY | `node:fs/promises` `readdir({withFileTypes: true, recursive: true})` for the asset validator; `import.meta.glob` for content | Node 20+ supports recursive readdir natively. No new dependency needed for the 30-line validator. |
+| Test runner | Hand-rolled assertion library | `vitest` | Vite-native, parallel by default, ESM-first. |
+| E2E driver | Puppeteer / DIY | `@playwright/test` | Phase 1 install only — first spec lands in Phase 2. |
+
+**Key insight:** Phase 1 is *infrastructure plumbing*. The temptation is to over-engineer abstractions because there's no feature work to ground them. Resist. Every recommendation above is the *thinnest* shape that satisfies the success criterion. The user pushed back on overengineering before context was even gathered — the planner will too.
+
+## Common Pitfalls
+
+### Pitfall 1: `import.meta.glob` requires literal patterns
+**What goes wrong:** Calling `import.meta.glob(somePathVariable)` silently fails. The glob string MUST be a string literal at the call site.
+**Why it happens:** Vite's plugin walks the AST at build time and can't resolve runtime expressions.
+**How to avoid:** Always inline the glob as a literal. If multiple patterns are needed, write multiple glob calls and merge the results.
+**Warning signs:** A "Could only use literals" error at build time.
+
+[CITED: vite docs https://vite.dev/guide/features#glob-import]
+
+### Pitfall 2: `navigator.storage.persist()` returns false on iOS Safari most of the time
+**What goes wrong:** First-save attempt to call `persist()` returns false on Safari/iOS unless the app has been added to home screen as a PWA or has been heavily interacted with. Player loses 30 days of save progress on Safari's periodic eviction (WebKit bug 266559).
+**Why it happens:** Safari's persistence-grant heuristic is opaque and conservative. Apple has explicitly documented "iOS provides no persistent storage guarantee" outside PWA context.
+**How to avoid:**
+- Always call `navigator.storage.persist()` on first save and check the result.
+- If `false`, write a sentinel flag (`saveIsBestEffort: true`) into the save envelope.
+- Surface a UX message at next opportunity (Phase 2+ writes the actual UI): *"Your browser is keeping this save on a best-effort basis. Export a backup any time from Settings."*
+- The Base64 export/import (CORE-09) is the resilience guarantee against this — make sure it lands in Phase 1 even though no UI button surfaces it yet.
+**Warning signs:** Telemetry showing save-loaded count < expected on Safari (Phase 8 polish, not Phase 1 concern — but the affordance must exist).
+
+[CITED: MDN https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist; PITFALLS.md #8]
+
+### Pitfall 3: JSON key ordering breaks checksums across runs
+**What goes wrong:** `JSON.stringify({a: 1, b: 2})` and `JSON.stringify({b: 2, a: 1})` produce the same string in V8 today, but **this is not spec-guaranteed across engines** (and Object.fromEntries iteration order is technically insertion order). If a Phase 4 migration somehow re-creates an object with different key order, the checksum changes even though the data is identical.
+**Why it happens:** Default `JSON.stringify` doesn't canonicalize.
+**How to avoid:** Implement `canonicalJSON()` that sorts object keys recursively before stringifying, as shown in Pattern 1. Use it everywhere checksums are computed.
+**Warning signs:** A migration test fails with "expected X, got Y" where X and Y are visually identical JSON.
+
+### Pitfall 4: Vite ESM build of Phaser 4 silently breaks if peer versions misalign
+**What goes wrong:** Phaser 4.0.0 had ESM build issues that 4.1.0 fixed. Mixing the wrong React version with the official template can produce runtime "cannot read property X of undefined" with no clear stack trace.
+**Why it happens:** The Phaser 4.1.0 release notes explicitly call out "important fixes for the ESM module build."
+**How to avoid:** Use the template-installed versions exactly. Don't manually upgrade Phaser without checking the release notes; pin Phaser to `^4.1.0`.
+**Warning signs:** Game scaffold loads white screen with console errors after a dependency update.
+
+[CITED: Phaser v4.1.0 "Salusa" release notes — https://phaser.io/news/2026/04/phaser-4-1-0-salusa-release]
+
+### Pitfall 5: Synchronous lz-string compression of huge saves blocks the main thread
+**What goes wrong:** `LZString.compressToBase64` is synchronous; on a 5MB save (Season 5+ scope) it can block the main thread for ~50ms+, dropping a frame mid-cello-ambient and ruining the watercolor mood.
+**Why it happens:** lz-string predates async APIs.
+**How to avoid:** Phase 1's saves are tiny (<10KB) so this isn't a real concern *yet*. Document the eventual mitigation: move save serialization into a Web Worker if Season 5+ saves grow past ~1MB. Don't build the worker now (premature) but **do** wrap save writes in `src/save/codec.ts` so the swap is one file later.
+**Warning signs:** Frame drops on save in a Season-5 simulation profile (Phase 8 concern).
+
+[CITED: PITFALLS.md #8 perf table]
+
+### Pitfall 6: ESLint flat-config plugin imports break with mixed CJS/ESM template baseline
+**What goes wrong:** The Phaser template ships an `.eslintrc.cjs` baseline (legacy config). Adding `eslint-plugin-boundaries` via the new flat-config `eslint.config.js` alongside the legacy file confuses ESLint 9+.
+**Why it happens:** ESLint 9 deprecated legacy config; templates may still ship the older shape during transition.
+**How to avoid:** Migrate the template's `.eslintrc.cjs` to a single `eslint.config.js` (flat) at the start of Phase 1. Don't run both. The migration is mechanical (`@eslint/migrate-config` automates it).
+**Warning signs:** ESLint runs but boundaries rule never fires; or runs twice with conflicting messages.
+
+### Pitfall 7: Synthetic v0→v1 migration test that doesn't actually exercise the registry
+**What goes wrong:** It's tempting to write a "round-trip" test that wraps and unwraps the same object without ever calling `migrate()`. That tests envelope integrity but doesn't validate the migration framework.
+**Why it happens:** Convenience.
+**How to avoid:** The required Vitest assertions for CORE-07 are:
+1. `migrate({garden: []}, 0)` returns `{payload: {...v1 shape...}, toVersion: 1}`.
+2. Calling `migrate(payload, 1)` is a no-op.
+3. Calling `migrate(payload, -1)` or `migrate(payload, 99)` throws.
+4. The full round-trip — write v0 envelope to IDB, load it, migrate, validate via Zod — passes.
+5. Snapshot retention test: write 5 successive saves, list snapshots, assert exactly 3 remain in newest-first order.
+**Warning signs:** Test coverage is high but no test actually invokes `migrations[1]`.
+
+## Code Examples
+
+### Save round-trip (Phase 1's load-bearing test)
+
+```typescript
+// src/save/round-trip.test.ts
+import { describe, it, expect } from 'vitest';
+import { wrap, unwrap } from './envelope';
+import { migrate, CURRENT_SCHEMA_VERSION } from './migrations';
+import LZString from 'lz-string';
+
+describe('save round-trip', () => {
+ it('synthetic v0 envelope migrates, round-trips through Base64, and validates', () => {
+ // Pretend a player had an old v0 save lying around.
+ const v0Payload = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] };
+ const v0Envelope = { schemaVersion: 0, payload: v0Payload, checksum: 'deadbeef' /* dummy */ };
+
+ // Export
+ const exported = LZString.compressToBase64(JSON.stringify(v0Envelope));
+ expect(exported.length).toBeGreaterThan(0);
+
+ // Import (in a fresh "browser")
+ const imported = JSON.parse(LZString.decompressFromBase64(exported)!);
+ expect(imported.schemaVersion).toBe(0);
+
+ // Migrate
+ const { payload, toVersion } = migrate(imported.payload, imported.schemaVersion);
+ expect(toVersion).toBe(CURRENT_SCHEMA_VERSION);
+ expect(payload).toMatchObject({
+ garden: { tiles: expect.any(Array) },
+ lastTickAt: expect.any(Number),
+ });
+
+ // Re-wrap with current version + valid checksum
+ const v1Envelope = wrap(payload, toVersion);
+ expect(unwrap(v1Envelope)).toEqual(payload);
+ });
+});
+```
+
+### Persist API call with respectful surfacing
+
+```typescript
+// src/save/persist.ts
+export interface PersistResult {
+ granted: boolean;
+ apiAvailable: boolean;
+}
+
+export async function requestPersistence(): Promise {
+ if (!('storage' in navigator) || !('persist' in navigator.storage)) {
+ return { granted: false, apiAvailable: false };
+ }
+ try {
+ const granted = await navigator.storage.persist();
+ return { granted, apiAvailable: true };
+ } catch {
+ return { granted: false, apiAvailable: true };
+ }
+}
+
+// Caller (Phase 2 wires UI; Phase 1 ships function only):
+// const result = await requestPersistence();
+// if (!result.granted) {
+// // store flag in save envelope; surface in Phase 2 settings UI:
+// // "Your browser is keeping this save on a best-effort basis.
+// // Export a backup any time from Settings."
+// }
+```
+
+### Provenance sidecar example
+
+```json
+// assets/north-stars/garden-soil-01.png.provenance.json
+{
+ "model_id": "claude-3.7-sonnet@2025-12 / sd-xl-watercolor-lora-v3",
+ "checkpoint_hash": "sha256:abcd1234...",
+ "prompt": "A walled cottage garden at dusk in late autumn, watercolor wash, real but slightly wrong wildflowers, golden palette, no fantasy elements",
+ "seed": 0,
+ "sampler": "DPM++ 2M Karras",
+ "params": {
+ "steps": 30,
+ "cfg_scale": 7.5,
+ "width": 1024,
+ "height": 1024,
+ "lora_weight": 0.8,
+ "notes": "Phase 1 north-star reference image #01 of 20. Hand-curated; reroll-allowed via stored seed."
+ }
+}
+```
+
+### Doctrine doc outline — `anti-fomo-doctrine.md`
+
+Recommended structure (principle-level, ~1 page):
+
+```markdown
+# Anti-FOMO Doctrine
+
+This document is referenced at every UX, monetization, and copy review. It enumerates
+mechanics this game does not use, with the reason for each, so the answer to a
+"should we add X?" question is in writing rather than relitigated.
+
+## Banned Mechanics
+
+| Mechanic | Why Banned |
+|----------|------------|
+| Daily login bonuses | Presence is not a debt the game collects. |
+| Login streaks | Skipping a day is allowed, even encouraged. |
+| Limited-time content (events that disappear) | The game's premise is *what persists*. |
+| Loss-aversion copy ("you'll lose your X") | Tonally incompatible with cozy/contemplative. |
+| Re-engagement push notifications | Memory Storms (opt-in) are the *only* allowed notification class. |
+| Rewarded ads | Anti-cozy; tonally incoherent. |
+| Visible countdown timers in core UI | The cello is the timer. |
+| "Don't miss out" / "limited time" / "only X hours left" copy | Bannable phrases. |
+
+## Allowed Engagement
+
+- Memory Storm opt-in notifications.
+- "While you were away" letter on return (in Lura's voice, not a stat dump).
+- Tab-title bloom indicator (UX-09, Phase 8).
+
+## Review Checklist
+
+When reviewing any UX/copy/monetization change, ask:
+1. Does this create urgency around presence rather than around content?
+2. Does this frame absence as loss?
+3. Would removing this from the game make it less *cozy*?
+
+If yes to any → reject or rewrite.
+
+## Source Documents
+
+- PROJECT.md "Out of Scope" — anti-features
+- REQUIREMENTS.md UX-13, "Out of Scope" table (rows: gacha, daily login, streaks,
+ limited-time, energy/stamina, rewarded ads, push spam)
+- CLAUDE.md "Hard Thematic Constraints"
+- .planning/research/PITFALLS.md #9
+```
+
+### Doctrine doc outline — `season-7-end-state.md`
+
+Recommended structure (principle-level, ~1 page):
+
+```markdown
+# Season 7 End-State Design (Principle-Level)
+
+This document answers the question that ends ROADMAP.md Phase 7's success criterion #4:
+*"the finite Roothold ceiling from Phase 4 has held the line, and the game has ended
+the way A Dark Room and Universal Paperclips ended."*
+
+## What does *rest state* mean?
+
+The rest state is the post-credits configuration the player can return to indefinitely
+without grinding. Concretely:
+
+- No new fragments are added to the pool. All authored content has been delivered.
+- No new currency tiers unlock.
+- The garden continues to render and respond to clicks. Plants can still be planted
+ and harvested, but harvests yield re-readable previously-collected fragments —
+ nothing new.
+- The Pale has receded. The Heartsoil expands beyond the garden walls. Lura's
+ arc has resolved. The Archivist's question has been answered (in the player's
+ binary choice from Season 7's final scene).
+- The cello and ambience continue. The world is *quiet*, *finite*, *understood*.
+
+This is not "endgame content." It is **rest**.
+
+## What is the finite Roothold ceiling tied to?
+
+Roothold's ceiling is anchored in the **count of authored fragments and the count
+of Seasons**, not in an arbitrary number. The ceiling is: *one cannot accumulate
+more Roothold than the player has actually understood, and what the player can
+understand is bounded by what the writer has actually written.*
+
+Concrete tie:
+- Roothold gain per Season is gated to a hard cap proportional to the fragment count
+ of that Season + a small contribution from Roothold-relevant story beats.
+- Total Roothold cap = Σ(per-Season caps).
+- Phase 4 enforces this cap when it implements `migrate_v1_to_v2` and the prestige
+ state machine. Phase 7 verifies the ceiling holds through full play.
+
+## What tonal register does the coda live in?
+
+- **Warm**, not pyrrhic. The garden persists *because* you tended it; this is
+ earned redemption, not survival.
+- **Quiet**, not climactic. The cello does not crescendo. It rests.
+- **Specific**, not abstract. The final visible state is a *real* garden — the
+ one this player built — viewed in soft dawn-silver light.
+- **Final**, not infinite. There is no "Season 8." There is no New Game+. The
+ Pale receded *here*, in *this* garden.
+
+## What this document is NOT
+
+- The text of the Season 7 binary-choice scene (authored Phase 7).
+- The text of either ending paragraph (authored Phase 7).
+- Lura's final line (authored Phase 7).
+- The credits/coda screen visual treatment (designed Phase 7).
+- The tonal register or shape of the final fragments themselves.
+
+This document is *the principle the economy obeys, the writer obeys, and the
+Phase 7 designer obeys* — not the implementation of any of those.
+
+## Source Documents
+
+- PROJECT.md core value — "what survives is what you understood"
+- REQUIREMENTS.md SEAS-04, SEAS-09, SEAS-10, STRY-08
+- ROADMAP.md Phase 7
+- .planning/research/PITFALLS.md #1
+```
+
+## Runtime State Inventory
+
+(Greenfield project — no prior runtime state to migrate.)
+
+| Category | Items Found | Action Required |
+|----------|-------------|------------------|
+| Stored data | None — first commit; no databases, no IndexedDB writes, no on-disk state | None |
+| Live service config | None | None |
+| OS-registered state | None | None |
+| Secrets/env vars | None — no `.env` files, no API keys yet | None |
+| Build artifacts | None — no `node_modules/`, `dist/`, or compiled output yet | First `npm install` will create `node_modules/`; `.gitignore` from template covers it |
+
+**Nothing to migrate** — Phase 1 is the first phase that produces any artifacts.
+
+## Common Pitfalls (CI / Tooling Specific)
+
+(See "Common Pitfalls" section above for the full list of seven pitfalls. This
+section additionally documents three CI-tooling traps unique to first-phase
+infrastructure work.)
+
+### CI Pitfall A: GitHub Actions caching `node_modules` instead of `~/.npm`
+**What goes wrong:** Cache restores `node_modules/` directly; subsequent `npm install` doesn't refresh transitive deps and silently runs against stale versions.
+**How to avoid:** Use `actions/setup-node@v4` with `cache: 'npm'` (caches `~/.npm` based on `package-lock.json`) — never cache `node_modules/` directly.
+
+### CI Pitfall B: Vitest in CI doesn't fail the build on uncaught test errors
+**What goes wrong:** `vitest run` exits 0 if no tests exist (Phase 1 ships only 2-3 test files); CI passes a "no tests" run as success.
+**How to avoid:** Add `--passWithNoTests=false` to the vitest CLI call in CI so empty runs are flagged.
+
+### CI Pitfall C: ESLint flat-config + `--max-warnings 0` in template
+**What goes wrong:** Template may default to allowing warnings; the boundaries rule fires as a warning instead of an error.
+**How to avoid:** Set `boundaries/element-types` severity to `error` (numeric `2` or string `'error'`) explicitly. Add `npm run lint -- --max-warnings 0` to the CI script.
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Phaser 3 + React via webpack | Phaser 4 official template (Vite + React + TS) | April 2026 (Phaser 4 GA) | The official template is the path. Don't pick Phaser 3 in 2026. |
+| ESLint legacy config (`.eslintrc.*`) | ESLint flat config (`eslint.config.js`) | ESLint 9 (2024) | Migrate the template's legacy file at start of Phase 1. |
+| Zod 3.x | Zod 4.x | Zod 4 GA late 2025 | Use 4.x for new code; STACK.md's "3.23+" is conservative; 4.4.3 is current. |
+| Vite 6 (current LTS) | Vite 8 (Rolldown) | 2026 | Phaser 4 template may install Vite 6 or 7. Don't aggressively upgrade — accept template version. |
+| `js-yaml` | `yaml` (eemeli/yaml) | 2023+ | YAML 1.2 spec compliance; better TypeScript types. |
+| Vitest workspace config | Vitest projects config | Vitest 3.2+ (2025) | Workspace is deprecated. Phase 1 doesn't need projects; single config is fine. |
+
+**Deprecated/outdated:**
+- ESLint legacy config — migrate at scaffold time.
+- `js-yaml` — use `yaml` for new code.
+- Phaser 3 starter templates — use Phaser 4 official template.
+
+## Assumptions Log
+
+> Every claim in this document is either [VERIFIED] against a tool/registry, [CITED] from a
+> linked source, or [ASSUMED]. Below are the [ASSUMED] claims that the planner / discuss-phase
+> may want to validate with the user before locking.
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | The Phaser 4 official template (`npm create @phaserjs/game@latest`) installs Vite 6 or 7 (not yet 8). | Standard Stack | Low — the planner can verify by running the scaffold once. If Vite 8, no behavior change needed. |
+| A2 | Sidecar naming convention `..provenance.json` (vs. `.provenance.json`) is right for Phase 1. | Pattern 6 | Low — convention can be flipped in 5 minutes if user has preference. Document choice in CONTEXT for Phase 5. |
+| A3 | `JSON.stringify` with sorted keys is canonical-enough for CRC32 (vs. a stricter canonical-JSON library like `json-stable-stringify`). | Pattern 1 | Low — adding `json-stable-stringify` is a one-line change if a Phase 4 migration ever produces non-stable output. |
+| A4 | The synthetic v0 schema `{garden: []}` is a sufficiently simple prior for the demo migration. | Pattern 2 | Very low — CONTEXT D-05 explicitly endorses any synthetic v0 shape. |
+| A5 | The 10–20 north-star images can be produced as part of Phase 1 execution without a vendor lock-in (CONTEXT D-02). | Standard Stack | Medium — depends on whether the user has access to *some* AI image generation tool during Phase 1 execution. If not, a placeholder commit (10 hand-painted PNGs from a free reference + sidecars filled in honestly) satisfies AEST-08/09 minimally. |
+| A6 | The `inkjs` + `inklecate` install in Phase 1 (with no compilation actually happening) is a "cheap path forward" the user prefers over deferring the install entirely. | Standard Stack | Very low — installing two deps that aren't used is harmless; deferring them leaves a Phase 2 task. Either is fine. |
+| A7 | Phase 1's Vitest config can be a 5-line minimal `vitest.config.ts` with Node environment (not jsdom) — since save tests don't touch DOM. | Validation Architecture | Very low — jsdom is needed for Phase 2 React-component tests, not Phase 1. |
+
+If this list is non-empty after planning, the planner should surface these for user confirmation before locking the plan. None of them block research.
+
+## Open Questions
+
+1. **Should the canonical-JSON implementation be `json-stable-stringify` or hand-rolled?**
+ - What we know: `JSON.stringify` is non-canonical by spec; sorted-key recursion is the standard fix.
+ - What's unclear: Does Phase 4+ need stronger canonicalization (Unicode normalization, number formatting)?
+ - Recommendation: Hand-roll the sorted-key recursion in Phase 1 (5 lines). Add `json-stable-stringify` as a dependency only if Phase 4+ surfaces a concrete reason.
+
+2. **Does the asset validator need a `.provenance.json` schema versioning field for Phase 5 vendor migration?**
+ - What we know: CONTEXT D-02 defers vendor pinning to Phase 5.
+ - What's unclear: When Phase 5 picks a vendor, will the schema need to change (e.g., add `pipeline_version`, `human_curated_at`)?
+ - Recommendation: Add an optional `provenance_schema_version: number` field now (default `1`); Phase 5 bumps it if the schema evolves. This is one optional field — cheap insurance.
+
+3. **Should `src/sim/`, `src/render/`, `src/ui/`, `src/audio/`, `src/store/` be created with `.gitkeep` or with stub `index.ts` files?**
+ - What we know: The directories must exist for ESLint boundaries patterns to match.
+ - What's unclear: `.gitkeep` is convention; `index.ts` would let ESLint also lint imports through them.
+ - Recommendation: `.gitkeep` for now (simpler). Phase 2 replaces with real entry points.
+
+4. **Does Phase 1 need a CI workflow file at all (e.g., `.github/workflows/ci.yml`), or just `package.json` scripts?**
+ - What we know: Solo dev, the user has rejected ceremonial workflows.
+ - What's unclear: Is GitHub Actions in scope for Phase 1, or is "CI" satisfied by `npm run ci` running locally / in the dev's preferred CI?
+ - Recommendation: Ship a 20-line `.github/workflows/ci.yml` that runs `npm run ci` on PR + push to main. This is minimum-viable CI, not ceremony. If user prefers no GitHub Actions, swap to a simpler CI of their choice; the `ci` script is platform-agnostic.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node.js (≥20 for `recursive: true` readdir, ≥22 ideal for crypto.hash native) | All build steps | ✓ | 24.14.1 | — |
+| npm | All install steps | ✓ | 11.11.0 | yarn / pnpm acceptable but template defaults to npm |
+| git | Source control + commit_docs | ✓ | 2.51.0 | — |
+| Modern browser (Chrome/Firefox/Safari/Edge last 2) for IndexedDB testing | CORE-04 verification | Assumed ✓ (dev machine) | — | Use Vitest's happy-dom for headless IndexedDB if no real browser available, but Playwright will need real browsers for Phase 2 PIPE-07 |
+| Image generation tool (any) for north-star reference set | AEST-08 / AEST-09 / D-01 | Unknown — depends on user | — | If no tool available: commit 10 hand-painted reference images (or licensed-CC-BY photographs of real cottage gardens) with provenance fields filled in honestly (`model_id: "human"`, `prompt: "n/a"`, etc.). Schema accepts arbitrary `model_id` strings. |
+
+**Missing dependencies with no fallback:** None known. All Phase 1 work runs on Node + npm + a browser.
+
+**Missing dependencies with fallback:** Image generation tool — see above. Provenance schema is permissive enough that "honest human-painted" entries are valid.
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | Vitest 4.1.5 (verified 2026-05-08) |
+| Config file | `vitest.config.ts` (Wave 0 deliverable; minimal — no jsdom in Phase 1) |
+| Quick run command | `npm test` (alias for `vitest run`) |
+| Full suite command | `npm run ci` (lint + test + validate-assets + build) |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| CORE-04 | IndexedDB save round-trips with localStorage fallback path | unit | `npx vitest run src/save/db.test.ts` | ❌ Wave 0 |
+| CORE-05 | `requestPersistence()` returns `{granted, apiAvailable}` shape and handles missing API | unit | `npx vitest run src/save/persist.test.ts` | ❌ Wave 0 |
+| CORE-06 | `wrap()` produces valid envelope; `unwrap()` rejects checksum mismatch | unit | `npx vitest run src/save/envelope.test.ts` | ❌ Wave 0 |
+| CORE-07 | `migrate({garden:[]}, 0)` produces v1 shape; `migrations[1]` is invoked exactly once | unit | `npx vitest run src/save/migrations.test.ts` | ❌ Wave 0 |
+| CORE-08 | After 5 successive `snapshot()` calls, exactly 3 newest entries remain in `save_snapshots` store | unit | `npx vitest run src/save/snapshots.test.ts` | ❌ Wave 0 |
+| CORE-09 | Base64 export → import → migrate → unwrap yields original payload | unit (round-trip) | `npx vitest run src/save/round-trip.test.ts` | ❌ Wave 0 |
+| CORE-10 | `src/sim/` importing from `src/render/` produces ESLint error | static-analysis (CI) | `npm run lint` (with a fixture file in `src/sim/__test_violation__/` that *should* error, then asserted via a Vitest snapshot of `eslint --rulesdir` output) | ❌ Wave 0 |
+| CORE-01 | Game scaffold `npm run build` produces a valid bundle | smoke (manual + Phase 2 Playwright) | `npm run build` (manual verification in Phase 1; Phase 2 PIPE-07 adds Playwright load-time spec) | manual-only in Phase 1 |
+| PIPE-01 | Demo content file with deliberate schema violation fails the build | unit (build-time) | `npx vitest run src/content/loader.test.ts` (mocks the glob) + manual `npm run build` with bad fixture | ❌ Wave 0 |
+| PIPE-03 | Asset validator script exits non-zero on a fixture missing provenance | integration | `node scripts/validate-assets.mjs` (fixture-driven Vitest test that temporarily creates `assets/__test__/no-provenance.png`, asserts script exits 1, cleans up) | ❌ Wave 0 |
+| PIPE-05 | `.planning/anti-fomo-doctrine.md` and `.planning/season-7-end-state.md` exist with required H2 sections | doc lint (Vitest filesystem assertion) | `npx vitest run scripts/doctrine.test.ts` | ❌ Wave 0 |
+| PIPE-06 | All save migration tests run on every CI build | meta — verified by CI script | `npm run ci` includes `npm test` | ❌ Wave 0 |
+| AEST-08 / AEST-09 | All assets in `assets/` (excluding `__samples__/refused/`) have valid provenance sidecars | integration | `node scripts/validate-assets.mjs` (covered by PIPE-03 test) | ❌ Wave 0 |
+| STRY-09 | (No automated test in Phase 1 — establishes `/content/` convention only; Phase 2+ enforces by code review since CONTEXT D-07 rejects lint rules on UX strings) | manual-only | — | n/a |
+| UX-13 | (No automated test — doctrine doc is enforced by review per CONTEXT D-07) | manual-only | — | n/a |
+
+### Sampling Rate
+
+- **Per task commit:** `npm test` (vitest run, ~5 seconds for the entire Phase 1 test set).
+- **Per wave merge:** `npm run ci` (lint + test + validate-assets + build, ~30 seconds).
+- **Phase gate:** `npm run ci` green, plus manual smoke that `npm run dev` opens the Phaser scaffold in a browser. `gsd-verify-work` consumes the green CI run as evidence.
+
+### Wave 0 Gaps
+
+All test infrastructure must be created in Wave 0 (no test files exist in greenfield repo):
+
+- [ ] `vitest.config.ts` — minimal config (test environment: node)
+- [ ] `playwright.config.ts` — installed only, no specs in Phase 1
+- [ ] `src/save/db.test.ts` — covers CORE-04 (idb open + put + get; happy-dom for IndexedDB shim)
+- [ ] `src/save/persist.test.ts` — covers CORE-05
+- [ ] `src/save/envelope.test.ts` — covers CORE-06
+- [ ] `src/save/migrations.test.ts` — covers CORE-07
+- [ ] `src/save/snapshots.test.ts` — covers CORE-08
+- [ ] `src/save/round-trip.test.ts` — covers CORE-09 (and integrates CORE-04, 06, 07)
+- [ ] `src/save/checksum.test.ts` — covers checksum determinism (canonical JSON keys)
+- [ ] `src/content/loader.test.ts` — covers PIPE-01 (mocked `import.meta.glob`)
+- [ ] `scripts/doctrine.test.ts` — covers PIPE-05 (asserts both doctrine docs exist with required H2 headings)
+- [ ] `scripts/validate-assets.test.ts` — covers PIPE-03 / AEST-08 / AEST-09
+- [ ] `eslint.config.js` — ESLint flat config with `eslint-plugin-boundaries` + boundary rule for `src/sim/` ↔ `src/render/`/`src/ui/`
+- [ ] `src/sim/__test_violation__/violator.ts` (test fixture) — deliberate boundary violation that the lint rule MUST flag; lint test asserts ESLint output contains the expected error
+- [ ] `.github/workflows/ci.yml` (or equivalent) — runs `npm run ci` on PR + push
+- [ ] Framework install: `npm install -D vitest@^4 @vitest/ui happy-dom` — `happy-dom` is needed for IndexedDB in node-side Vitest; lighter than jsdom
+
+## Security Domain
+
+`security_enforcement` is not explicitly set in `.planning/config.json`; treat as enabled.
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | no | No auth in Phase 1 (or v1 — local-first save model). Cloud sync is v2. |
+| V3 Session Management | no | No sessions in Phase 1. |
+| V4 Access Control | no | Single-player; no multi-tenant data. |
+| V5 Input Validation | yes | Save envelope validated by Zod on every load (rejects malformed Base64 import, malformed envelope shape). Provenance sidecar validated by Zod. Content files validated by Zod at build. |
+| V6 Cryptography | partial | CRC32 is not a cryptographic hash; it's a corruption-detection checksum. **Saves are not authenticated** — a player editing their save is fine (single-player, no leaderboards, no monetization gates in Phase 1). Document explicitly: this is by design, not by oversight. PITFALLS.md "Security/Integrity Mistakes" already endorses this. |
+| V7 Error Handling | yes | Save load failures must surface the recovery option (CORE-06) — not silent reset. Phase 1 ships the error class + handler shape; Phase 2 wires the UI. |
+| V8 Data Protection | partial | Saves contain no PII in Phase 1 (just garden state); even player-chosen names are not collected (no Keeper name per STRY-07). |
+| V14 Configuration | yes | `npm audit` should be clean; pinned versions; no environment-secret leak in repo. |
+
+### Known Threat Patterns for `Phaser 4 + React + IndexedDB + lz-string` stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Save tampering (player edits Base64 export) | Tampering | Acceptable — single-player; if Phase 2+ economy values become interesting to tamper, document explicitly that we don't defend against client-side editing. CRC32 detects *corruption* (lossy storage, transmission errors), not adversarial tampering. |
+| Malformed Base64 import (DoS via huge inflated string) | DoS | `lz-string.decompressFromBase64` has bounded output for bounded input; additionally, validate payload size before decompressing (cap at e.g. 50MB). |
+| Cross-origin import from URL params (open future risk if save-via-link added) | Tampering / Spoofing | Phase 1 has no URL-import; flag for Phase 4+ if added: import flow must require explicit user confirmation, never auto-load. |
+| `npm install` supply-chain (e.g., a transitive dep gets compromised) | Tampering | `package-lock.json` committed; `npm audit` in CI; pin Phase 1 deps to caret ranges (caught by lockfile). |
+| Provenance sidecar spoofing (a malicious contributor adds an asset with a fabricated provenance) | Spoofing | Single-developer project in Phase 1; not a real threat. Phase 8+ if external contributors are added: provenance includes a `human_reviewed_by` field signed by the curator. Out of scope for Phase 1. |
+| Path traversal via `import.meta.glob` (a malicious content file with `../../` in frontmatter) | Tampering | Vite glob expansion is at build time; no runtime user input. Not exploitable. Validation step never resolves paths from frontmatter values. |
+
+### Phase 1 explicit security non-goals (documented for clarity)
+
+- **Save authentication** — by design. Player tampering with their own save is acceptable in a contemplative single-player game.
+- **Cloud sync security** — out of v1 scope.
+- **Multi-user data isolation** — single-player; no isolation needed.
+- **Auth / session tokens** — none in v1.
+
+## Project Constraints (from CLAUDE.md)
+
+The following directives from `CLAUDE.md` constrain Phase 1 implementation. The planner must verify the plan honors each:
+
+| Directive | Phase 1 Application |
+|-----------|---------------------|
+| TypeScript strict; no `any` in production code | `tsconfig.json` ships with `"strict": true`; save-layer code uses `unknown` + Zod parsing for untrusted inputs (env on load, Base64 import). |
+| Player-visible strings externalized in `/content/`, never hardcoded | Phase 1 establishes the convention by *not violating it*. No player-facing strings ship in Phase 1 source code (no UI yet). |
+| Memory fragment IDs are stable strings (`season3.canopy.lura_07.vignette`), never numeric | Zod schema in `src/content/schemas/fragment.ts` enforces with regex (`^season\d+\.[a-z0-9._-]+$`). |
+| Simulation modules are pure — no `Date.now()`, no `setInterval`, no DOM, no fetch | `src/sim/` is empty in Phase 1 (Phase 2+ fills); ESLint boundary rule is the structural enforcement. |
+| BigNumbers go through the typed `BigQty` wrapper around `break_eternity.js` | **Phase 2 deliverable, not Phase 1.** Do not pre-create `BigQty` per CONTEXT. |
+| Save format always carries `{schemaVersion, payload, checksum}`. Never serialize raw state | Pattern 1 above. |
+| New AI-generated assets must carry full provenance metadata + pass curation gate | Pattern 6 above. |
+| Tone — player-facing copy is warm, specific, intermittent, sometimes funny, sometimes devastating | No player copy in Phase 1; doctrine docs themselves can be functional — they are project-internal. |
+| `/content/` is at repo root, alongside `src/`, `assets/`, `.planning/` | CONTEXT D-11 confirms; structure above honors it. |
+| `/assets/` at repo root | CONTEXT D-12 confirms. |
+| No monorepo / no workspaces | CONTEXT D-12 confirms; single `package.json`. |
+
+## Sources
+
+### Primary (HIGH confidence)
+- [eslint-plugin-boundaries README on GitHub](https://github.com/javierbrea/eslint-plugin-boundaries/blob/master/README.md) — config patterns, element-types rule, flat-config example
+- [npm registry — verified 2026-05-08 for all package versions](https://www.npmjs.com/) — `npm view version` for phaser, react, vite, typescript, idb, lz-string, zod, vitest, @playwright/test, eslint-plugin-boundaries, crc-32, gray-matter, yaml, inkjs, inklecate, howler, break_eternity.js, zustand
+- [idb GitHub — jakearchibald/idb](https://github.com/jakearchibald/idb) — openDB API, upgrade callback, put/get shortcuts
+- [Phaser v4.1.0 "Salusa" release notes](https://phaser.io/news/2026/04/phaser-4-1-0-salusa-release) — version, ESM fixes, Layer rework
+- [Phaser official template — phaserjs/template-react-ts](https://github.com/phaserjs/template-react-ts) — root structure, src/ layout, package.json scripts, dependencies
+- [MDN — `navigator.storage.persist()`](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist) — return value semantics, `false` conditions
+- [Vite docs — features (`import.meta.glob`)](https://vite.dev/guide/features#glob-import) — eager mode, query/import options, literal-pattern requirement
+- [Zod docs — z.infer + safeParse](https://zod.dev/) — type derivation, runtime validation
+- [Vitest docs — projects (replaces workspace)](https://vitest.dev/guide/projects) — multi-config; not needed for Phase 1 single config
+
+### Secondary (MEDIUM confidence)
+- [SheetJS/js-crc32 — crc-32 npm package](https://github.com/SheetJS/js-crc32) — performance benchmarks (~426k ops/sec), API
+- [Emanuele Feronato — Getting Started with Phaser 4 (April 2026)](https://emanueleferonato.com/2026/04/17/getting-started-with-phaser-4-vite-typescript-setup-using-the-official-create-game-app/) — `create-phaser-game` flow walkthrough
+- [Trivikr — benchmark-crc32](https://github.com/trivikr/benchmark-crc32) — comparative speed of CRC32 npm packages
+- [pieroxy.net — lz-string](https://pieroxy.net/blog/pages/lz-string/index.html) — `compressToBase64` / `decompressFromBase64` semantics, encoding rates
+
+### Tertiary (LOW confidence — flagged for validation)
+- [Magicbell — PWA iOS Limitations 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — Safari 17 partial persistence support; verified by MDN secondarily
+- [DEV.to — Why Your IndexedDB Data Keeps Disappearing](https://dev.to/denyherianto/why-your-indexeddb-data-keeps-disappearing-1m0a) — Chrome storage-pressure eviction patterns
+- [WebKit Bug 266559 — Safari periodic eviction of LocalStorage and IndexedDB](https://bugs.webkit.org/show_bug.cgi?id=266559) — referenced in PITFALLS.md; not re-verified in this research session
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — every package version verified against npm registry on 2026-05-08; locked versions match user constraints from CONTEXT.
+- Architecture (save layer, content pipeline, ESLint, validator): HIGH — patterns verified against official docs (idb, Vite, eslint-plugin-boundaries, MDN); shape is conservative and minimum-viable per user's anti-overengineering directive.
+- Pitfalls: HIGH for stack-specific, MEDIUM for browser quirks (Safari persist behavior is fast-moving; verified against MDN today but worth re-confirming if Phase 1 execution drags past 60 days).
+- Doctrine doc outlines: MEDIUM — they are *suggestions* for principle-level structure; the user is the final arbiter of what "principle-level" means for The Last Garden. The shape is informed by the source documents the docs consolidate.
+- Validation architecture: HIGH — every Phase 1 success criterion maps to a concrete automated test command, with one exception (CORE-01 load-time, which is manual in Phase 1 and gets Playwright coverage in Phase 2 PIPE-07).
+
+**Research date:** 2026-05-08
+**Valid until:** 2026-06-08 (30 days — stack is stable but Vite 8 / Phaser 4.x landings could rotate)