Files
TheLastGarden/.planning/phases/01-foundations-and-doctrine/01-03-save-layer-PLAN.md
T
josh 39563f6934 docs(01): plan phase 1 — 7 plans across 3 waves, verified after 1 revision
Wave 1: Plan 01 (scaffold + test infra)
Wave 2: Plans 02 (eslint firewall), 03 (save layer), 04 (content pipeline),
        05 (asset provenance — autonomous:false human-curate checkpoint),
        06 (doctrine docs)
Wave 3: Plan 07 (CI workflow)

All 16 Phase-1 REQ-IDs covered. Plan-checker found 4 blockers + 6 warnings
on first pass; revision iteration 1 landed all 10 fixes; iteration 2
returned VERIFICATION PASSED. Two orchestrator judgment calls during
revision: (1) implement CORE-04 localStorage fallback in Phase 1 (the
literal requirement and ROADMAP success criterion #2 both call for it),
(2) reclassify STRY-09 as vacuously satisfied in Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:09:08 -04:00

58 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01 03 execute 2
01-01
src/save/checksum.ts
src/save/checksum.test.ts
src/save/envelope.ts
src/save/envelope.test.ts
src/save/migrations.ts
src/save/migrations.test.ts
src/save/db.ts
src/save/db-localstorage-adapter.ts
src/save/db.test.ts
src/save/snapshots.ts
src/save/snapshots.test.ts
src/save/persist.ts
src/save/persist.test.ts
src/save/codec.ts
src/save/round-trip.test.ts
src/save/index.ts
true
CORE-04
CORE-05
CORE-06
CORE-07
CORE-08
CORE-09
truths artifacts key_links
A save envelope `{schemaVersion, payload, checksum}` can be wrapped from a payload + version, unwrapped back, and `unwrap` throws on checksum mismatch (CORE-06)
Canonical-JSON serialization sorts object keys recursively so the same payload always produces the same checksum across runs
A synthetic v0 payload `{garden: []}` migrates to the v1 shape (garden tiles, plants, harvestedFragmentIds, lastTickAt, settings) via the migration registry (CORE-07, per CONTEXT D-04 + D-05)
After 5 successive `snapshot()` calls, exactly 3 newest entries remain in the `save_snapshots` IndexedDB store (CORE-08)
A v0 envelope can be Base64-exported via lz-string, Base64-imported into a fresh DB, migrated through the chain, and unwrapped to the original payload (CORE-09)
`requestPersistence()` returns `{granted: boolean, apiAvailable: boolean}` and handles missing `navigator.storage.persist` gracefully (CORE-05)
An IndexedDB save round-trips: `openSaveDB() → put(envelope) → get() → equals original` (CORE-04)
CORE-04 IndexedDB-primary + localStorage-fallback both round-trip via Vitest: when `openDB` rejects, the same `get`/`put`/`delete` interface is served by `LocalStorageDBAdapter`, and a stub-injected IDB failure exercises the fallback path
path provides exports
src/save/checksum.ts crc32hex(string) → 8-char lowercase hex CRC-32; canonicalJSON(value) → recursively-key-sorted JSON string
crc32hex
canonicalJSON
path provides exports
src/save/envelope.ts wrap<T>(payload, schemaVersion), unwrap<T>(env), SaveEnvelope type, SaveCorruptError class, SaveEnvelopeSchema (Zod)
wrap
unwrap
SaveEnvelope
SaveCorruptError
SaveEnvelopeSchema
path provides exports
src/save/migrations.ts migrate(payload, fromVersion) → {payload, toVersion}; CURRENT_SCHEMA_VERSION constant; migrations registry with v0→v1 synthetic demo
migrate
CURRENT_SCHEMA_VERSION
migrations
path provides exports
src/save/db.ts openSaveDB() → SaveDB (IDBPDatabase or LocalStorageDBAdapter); SAVE_DB_NAME constant; two object stores: 'saves' (singleton) + 'save_snapshots' (keyed by id); falls back to LocalStorageDBAdapter on IDB failure (CORE-04)
openSaveDB
SAVE_DB_NAME
path provides exports
src/save/db-localstorage-adapter.ts LocalStorageDBAdapter — thin localStorage-backed implementation of the same minimal interface as the IDB DB (get/put/delete on saves + save_snapshots), keyed under tlg.saves.* / tlg.save_snapshots.* (CORE-04 fallback path)
LocalStorageDBAdapter
path provides exports
src/save/snapshots.ts snapshot(envelope), listSnapshots() — last-3 retention; SnapshotEntry type
snapshot
listSnapshots
SnapshotEntry
path provides exports
src/save/persist.ts requestPersistence() → Promise<{granted, apiAvailable}>
requestPersistence
PersistResult
path provides exports
src/save/codec.ts exportToBase64<T>(envelope), importFromBase64(base64) — lz-string round-trip with 50MB DoS cap
exportToBase64
importFromBase64
MAX_IMPORT_BYTES
path provides
src/save/index.ts Public re-exports for Phase 2 consumption
from to via pattern
src/save/envelope.ts src/save/checksum.ts import { crc32hex, canonicalJSON } from './checksum' import { crc32hex, canonicalJSON } from './checksum'
from to via pattern
src/save/migrations.ts synthetic v0 payload {garden: []} migrations[1] receives {garden: any[]} and produces v1 shape per CONTEXT D-04 garden:\s*{\s*tiles:
from to via pattern
src/save/snapshots.ts src/save/db.ts openSaveDB() — uses 'save_snapshots' object store save_snapshots
from to via pattern
src/save/db.ts src/save/db-localstorage-adapter.ts openSaveDB wraps openDB() in try/catch and returns LocalStorageDBAdapter when IDB rejects (CORE-04 fallback) LocalStorageDBAdapter
from to via pattern
src/save/round-trip.test.ts src/save/codec.ts + envelope.ts + migrations.ts Full pipeline: wrap → exportToBase64 → importFromBase64 → migrate → unwrap exportToBase64.*importFromBase64.*migrate.*unwrap
**Plan 03 modifies 16 files across 3 tasks — at the upper edge of the per-plan budget.** Recommend `/clear` between tasks if executor context fills (after the Task 1 commit and after the Task 2 commit). Tasks are independently committable; the Wave 2 frontmatter has no other plan depending on intermediate state from this plan, so a context reset between tasks is safe. Build the load-bearing save layer for the entire game: envelope `{schemaVersion, payload, checksum}` with CRC-32 over canonical JSON, a forward-only migration chain seeded with a synthetic v0→v1 demo migration, an `idb`-wrapped IndexedDB DB with two object stores (`saves` + `save_snapshots`) **plus a thin localStorage fallback adapter for CORE-04 when IndexedDB is unavailable** (private mode, blocked by browser, quota exceeded), last-3 pre-migration snapshot retention, `navigator.storage.persist()` with respectful surfacing of `false`, and Base64 export/import via lz-string with a 50MB DoS cap on import. Every behavior is covered by a Vitest unit test, plus a single round-trip test that exercises the full pipeline end-to-end.

Purpose: Phase 2's first feature commit will write the first real save — and Phase 4 will ship the first real migrate_v1_to_v2. If the framework here is wrong, every subsequent Season's save migration is broken. CONTEXT D-04 + D-05 + D-06 lock the shape: minimal v1 payload (only what Phase 2 will write), synthetic v0→v1 demo migration to prove the chain, envelope shape locked from CLAUDE.md. RESEARCH § Patterns 1, 2, 3 + Pitfalls 3 (canonical JSON), 5 (lz-string sync caveat), 7 (real migration registry test) provide concrete code. REQUIREMENTS.md CORE-04 ("with localStorage fallback") + ROADMAP success criterion #2 ("with localStorage fallback and navigator.storage.persist()") require the fallback to ship in Phase 1; this plan satisfies that with a ~30-LoC adapter + one stub-injected Vitest test.

Output: A complete save subsystem under src/save/ with one entry point (src/save/index.ts), 7 implementation files + 6 test files + 1 codec round-trip test, all Vitest tests passing in the happy-dom environment.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md @.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md @.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md @CLAUDE.md @.planning/research/ARCHITECTURE.md @.planning/research/PITFALLS.md

From src/save/envelope.ts (this plan creates):

export interface SaveEnvelope<T = unknown> {
  schemaVersion: number;
  payload: T;
  checksum: string; // 8-char lowercase hex CRC-32 over canonicalJSON(payload)
}
export class SaveCorruptError extends Error { /* expected, actual */ }
export function wrap<T>(payload: T, schemaVersion: number): SaveEnvelope<T>;
export function unwrap<T>(env: SaveEnvelope<unknown>): T; // throws SaveCorruptError on mismatch

From src/save/migrations.ts:

export const CURRENT_SCHEMA_VERSION = 1;
export const migrations: Record<number, (payload: unknown) => unknown>;
export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number };

From src/save/codec.ts:

export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; // 50MB DoS cap per Phase 1 threat model
export function exportToBase64<T>(env: SaveEnvelope<T>): string;
export function importFromBase64(base64: string): SaveEnvelope<unknown>; // throws on >MAX or invalid
Task 1: Checksum + envelope + migrations (the pure-function core) src/save/checksum.ts, src/save/checksum.test.ts, src/save/envelope.ts, src/save/envelope.test.ts, src/save/migrations.ts, src/save/migrations.test.ts - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 1: Save Envelope" (verbatim code), § "Pattern 2: Migration Registry" (verbatim code), § "Common Pitfalls — Pitfall 3: JSON key ordering breaks checksums across runs" (canonical-JSON requirement), § "Common Pitfalls — Pitfall 7: Synthetic v0→v1 migration test that doesn't actually exercise the registry" (the 5 required Vitest assertions for CORE-07) - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-04 — minimal v1 payload shape: garden tiles, plant growth data, harvested fragment IDs, lastTickAt, basic settings; D-05 — synthetic v0→v1 demo migration; D-06 — envelope shape locked, checksum/registry Claude's discretion) - CLAUDE.md "Code Style" — TypeScript strict, no `any` in production; `BigQty` is Phase 2 (do NOT pre-create) - **checksum.ts:** - Test 1: `crc32hex('hello')` returns the same 8-char lowercase hex string on every call (deterministic). - Test 2: `crc32hex('hello')` and `crc32hex('world')` differ. - Test 3: `canonicalJSON({b:1, a:2})` and `canonicalJSON({a:2, b:1})` are byte-identical. - Test 4: `canonicalJSON` recursively sorts nested object keys. - Test 5: `canonicalJSON` preserves array order (arrays are NOT sorted). - **envelope.ts:** - Test 1: `wrap({foo: 'bar'}, 1)` returns `{schemaVersion: 1, payload: {foo: 'bar'}, checksum: <8-char hex>}`. - Test 2: `unwrap(wrap(p, 1))` deep-equals `p` for several payload shapes. - Test 3: `unwrap` with a tampered checksum throws `SaveCorruptError` with `expected` and `actual` fields. - Test 4: `unwrap` with a tampered payload (checksum mismatched) throws `SaveCorruptError`. - Test 5: `SaveEnvelopeSchema.safeParse` rejects malformed envelopes (missing keys, non-hex checksum). - **migrations.ts:** - Test 1 (the load-bearing one per Pitfall 7): `migrate({garden: [{id: 'tile-1'}]}, 0)` returns `{payload: {garden: {tiles: [{id: 'tile-1'}]}, plants: [], harvestedFragmentIds: [], lastTickAt: , settings: {...}}, toVersion: 1}`. - Test 2: `migrate(, 1)` is a no-op (returns `{payload, toVersion: 1}` unchanged). - Test 3: `migrate(, 99)` throws (no migration to a future version). - Test 4: `migrate(, -1)` throws (no migration registered). - Test 5: `migrations[1]` is invoked exactly once when migrating from v0 to v1 (use a spy/mock or count by replacing `migrations[1]` and asserting call count). - Test 6: `CURRENT_SCHEMA_VERSION === 1` (sanity). Write each file using the Write tool, copying the patterns from RESEARCH.md verbatim where they exist. Pure-function core — no I/O, no async.
**Step 1 — `src/save/checksum.ts`** (per RESEARCH Pattern 1 + Pitfall 3):
```typescript
import CRC32 from 'crc-32';

/**
 * 8-char lowercase hex CRC-32 of the input string.
 * crc-32 returns a signed 32-bit integer; we mask to unsigned and pad.
 * Used by envelope.wrap/unwrap to detect save corruption.
 */
export function crc32hex(input: string): string {
  const signed = CRC32.str(input);
  const unsigned = signed >>> 0; // coerce to uint32
  return unsigned.toString(16).padStart(8, '0');
}

/**
 * Deterministic JSON serialization with recursively-sorted object keys.
 * Required because checksum stability depends on stable key order across
 * V8 / SpiderMonkey / JavaScriptCore runs and across migration round-trips
 * (per .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 3).
 * Arrays are NOT sorted (their order is meaningful).
 */
export function canonicalJSON(value: unknown): string {
  return JSON.stringify(value, (_key, val) => {
    if (val && typeof val === 'object' && !Array.isArray(val)) {
      return Object.fromEntries(
        Object.entries(val as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)),
      );
    }
    return val;
  });
}
```

Per RESEARCH Open Question #1, hand-rolled sorted-key recursion is recommended (no `json-stable-stringify` dep). The `>>> 0` coercion converts crc-32's signed return to unsigned per the SheetJS docs.

**Step 2 — `src/save/checksum.test.ts`** with all 5 behaviors above. Concrete shape:
```typescript
import { describe, it, expect } from 'vitest';
import { crc32hex, canonicalJSON } from './checksum';

describe('crc32hex', () => {
  it('is deterministic', () => {
    expect(crc32hex('hello')).toBe(crc32hex('hello'));
  });
  it('returns 8-char lowercase hex', () => {
    expect(crc32hex('hello')).toMatch(/^[0-9a-f]{8}$/);
  });
  it('differs for different inputs', () => {
    expect(crc32hex('hello')).not.toBe(crc32hex('world'));
  });
});

describe('canonicalJSON', () => {
  it('produces byte-identical output for objects with same keys in any order', () => {
    expect(canonicalJSON({ b: 1, a: 2 })).toBe(canonicalJSON({ a: 2, b: 1 }));
  });
  it('sorts nested object keys recursively', () => {
    expect(canonicalJSON({ b: { z: 1, a: 2 }, a: 1 }))
      .toBe(canonicalJSON({ a: 1, b: { a: 2, z: 1 } }));
  });
  it('does NOT sort arrays', () => {
    expect(canonicalJSON([3, 1, 2])).toBe('[3,1,2]');
  });
});
```

**Step 3 — `src/save/envelope.ts`** (per RESEARCH Pattern 1 verbatim, with Zod schema added):
```typescript
import { z } from 'zod';
import { crc32hex, canonicalJSON } from './checksum';

export const SaveEnvelopeSchema = z.object({
  schemaVersion: z.number().int().nonnegative(),
  payload: z.unknown(),
  checksum: z.string().regex(/^[0-9a-f]{8}$/),
});
export type SaveEnvelope<T = unknown> = z.infer<typeof SaveEnvelopeSchema> & { payload: T };

export class SaveCorruptError extends Error {
  readonly name = 'SaveCorruptError';
  constructor(public readonly expected: string, public readonly actual: string) {
    super(`Save checksum mismatch: expected ${expected}, got ${actual}`);
  }
}

export function wrap<T>(payload: T, schemaVersion: number): SaveEnvelope<T> {
  return {
    schemaVersion,
    payload,
    checksum: crc32hex(canonicalJSON(payload)),
  };
}

export function unwrap<T>(env: SaveEnvelope<unknown>): T {
  const expected = crc32hex(canonicalJSON(env.payload));
  if (expected !== env.checksum) {
    throw new SaveCorruptError(env.checksum, expected);
  }
  return env.payload as T;
}
```
Per CONTEXT D-04: zero (the synthetic v0) is a valid `schemaVersion`, so the Zod refinement uses `nonnegative` not `positive`. RESEARCH Pattern 1's example uses `positive` but that conflicts with D-05's synthetic-v0 requirement — use `nonnegative`.

**Step 4 — `src/save/envelope.test.ts`** with all 5 behaviors above.

**Step 5 — `src/save/migrations.ts`** (per RESEARCH Pattern 2 verbatim):
```typescript
type Migration = (payload: unknown) => unknown;

export const CURRENT_SCHEMA_VERSION = 1;

/**
 * Forward-only migration chain. Each entry migrates FROM (key-1) TO key.
 * - migrations[1] = v0 → v1 (synthetic demo per CONTEXT D-05).
 * - migrations[2] = v1 → v2 will be added in Phase 4 when Roothold/prestige state lands.
 *
 * v0 was a hypothetical prior shape `{garden: []}`; v1 is the minimal Phase-2 shape per CONTEXT D-04:
 * garden tiles, plants, harvested fragment IDs, lastTickAt, settings.
 */
export const migrations: Record<number, Migration> = {
  1: (s: unknown) => {
    const v0 = (s ?? {}) as { garden?: unknown[] };
    return {
      garden: { tiles: v0.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 } {
  if (fromVersion < 0) {
    throw new Error(`Cannot migrate from negative version ${fromVersion}`);
  }
  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;
  }
  if (v > CURRENT_SCHEMA_VERSION) {
    throw new Error(`Cannot migrate from future version ${fromVersion} (current: ${CURRENT_SCHEMA_VERSION})`);
  }
  return { payload: current, toVersion: v };
}
```
Note: the future-version throw protects against `migrate(p, 99)` per RESEARCH Pitfall 7 assertion #3.

**Step 6 — `src/save/migrations.test.ts`** with all 6 behaviors above. Particularly:
```typescript
it('synthetic v0 payload migrates to v1 shape (CONTEXT D-04 + D-05)', () => {
  const v0 = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] };
  const result = migrate(v0, 0);
  expect(result.toVersion).toBe(1);
  expect(result.payload).toMatchObject({
    garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] },
    plants: [],
    harvestedFragmentIds: [],
    lastTickAt: expect.any(Number),
    settings: { musicVolume: expect.any(Number), ambientVolume: expect.any(Number), sfxVolume: expect.any(Number) },
  });
});
```

**Step 7 — Run `npm test` and confirm all 16 tests in this task pass (5+5+6).**

**Step 8 — Commit `feat(01-03): save envelope + canonical-JSON CRC32 + synthetic v0→v1 migration`.**
npx vitest run src/save/checksum.test.ts src/save/envelope.test.ts src/save/migrations.test.ts - All 6 files exist: `for f in checksum envelope migrations; do test -f src/save/$f.ts && test -f src/save/$f.test.ts; done`. - `src/save/checksum.ts` exports both `crc32hex` and `canonicalJSON` — verify with `grep -E "^export function (crc32hex|canonicalJSON)" src/save/checksum.ts | wc -l` returns 2. - `src/save/envelope.ts` exports `wrap`, `unwrap`, `SaveCorruptError`, `SaveEnvelopeSchema` — verify with `grep -cE "^export (function|class|const) (wrap|unwrap|SaveCorruptError|SaveEnvelopeSchema)" src/save/envelope.ts` returns 4. - `src/save/migrations.ts` exports `migrate`, `CURRENT_SCHEMA_VERSION`, `migrations` — verify with `grep -cE "^export (function|const) (migrate|CURRENT_SCHEMA_VERSION|migrations)" src/save/migrations.ts` returns 3. - `migrations.ts` v0→v1 migration produces the v1 shape from CONTEXT D-04 — verify with `grep -E "tiles:|plants:|harvestedFragmentIds:|lastTickAt:|settings:" src/save/migrations.ts | grep -v '^#' | wc -l` returns at least 5. - `npm test` for these 3 files passes 16 tests total — verify with `npx vitest run src/save/checksum.test.ts src/save/envelope.test.ts src/save/migrations.test.ts 2>&1 | grep -E "16 passed|Tests *16"`. - No `any` types in production code (excluding test files) — verify with `grep -nE ': any\\b' src/save/checksum.ts src/save/envelope.ts src/save/migrations.ts`; expect zero matches (CLAUDE.md TypeScript-strict rule). Pure-function save core (checksum, envelope, migrations) implemented per RESEARCH Patterns 1 + 2; 16 Vitest tests covering all RESEARCH Pitfall 7 assertions plus canonical-JSON determinism plus checksum-mismatch throw; no `any` in production; commit landed. Task 2: idb DB + localStorage fallback adapter (CORE-04) + snapshots (last-3 retention) + persist API src/save/db.ts, src/save/db-localstorage-adapter.ts, src/save/db.test.ts, src/save/snapshots.ts, src/save/snapshots.test.ts, src/save/persist.ts, src/save/persist.test.ts - c:/Users/josh1/Documents/Code/TheLastGarden/package.json (Plan 01 Task 1 already installed `fake-indexeddb@^6` as a devDependency — confirm `grep -q '"fake-indexeddb"' package.json` exits 0 before writing the IDB tests) - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 3: Last-3 Pre-Migration Snapshots" (verbatim code), § "Code Examples — Persist API call with respectful surfacing" (verbatim code) - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Common Pitfalls — Pitfall 2: navigator.storage.persist() returns false on iOS Safari most of the time" - .planning/research/PITFALLS.md #8 (storage eviction; multi-layer write requirement) - REQUIREMENTS.md CORE-04 ("with localStorage fallback") + ROADMAP success criterion #2 (the orchestrator authorized implementing the fallback in Phase 1; this task ships ~30 LoC + 1 test) - src/save/envelope.ts (read the SaveEnvelope type from Task 1 — snapshots.ts and db.ts need it) - idb 8.0.3 README: openDB upgrade callback shape, transaction API - **db.ts:** - Test 1: `openSaveDB()` returns an IDBPDatabase with two object stores: `saves` and `save_snapshots`. - Test 2: `saves` store uses keyPath `'id'` (singleton; only one save per slot). - Test 3: `save_snapshots` store uses keyPath `'id'`. - Test 4: `put` + `get` round-trips a SaveEnvelope without modification. - **Test 5 (CORE-04 fallback): when `openDB` rejects (stub-injected), `openSaveDB()` returns a `LocalStorageDBAdapter` and the same `put`/`get` round-trip succeeds against `localStorage`.** - **db-localstorage-adapter.ts:** - Implements the minimal interface used by the rest of the save layer (`get(store, key)`, `put(store, value)`, `delete(store, key)`, `getAll(store)`, plus a `transaction()` helper that proxies to direct localStorage operations — snapshots.ts uses `db.transaction('save_snapshots', 'readwrite').objectStore(...)`). - Namespaces keys under `tlg.saves.` and `tlg.save_snapshots.`. - JSON-encodes values; throws on missing. - **snapshots.ts:** - Test 1: After 1 `snapshot()` call, `listSnapshots()` returns 1 entry. - Test 2 (the load-bearing one for CORE-08 per Pitfall 7 #5): After 5 successive `snapshot()` calls, `listSnapshots()` returns exactly 3 entries, in newest-first order. - Test 3: Each pruned (deleted) entry has the oldest `savedAt` timestamps. - Test 4: `listSnapshots()` on an empty store returns `[]`. - **persist.ts:** - Test 1: When `navigator.storage.persist` exists and resolves true, returns `{granted: true, apiAvailable: true}`. - Test 2: When `navigator.storage.persist` exists and resolves false, returns `{granted: false, apiAvailable: true}`. - Test 3: When `navigator.storage.persist` throws, returns `{granted: false, apiAvailable: true}`. - Test 4: When `navigator.storage` is missing entirely, returns `{granted: false, apiAvailable: false}`. **Step 1 — `src/save/db-localstorage-adapter.ts`** (~30-40 LoC). The adapter implements the minimal interface that `snapshots.ts` and Phase 2's save consumer will call. Use Write tool: ```typescript import type { SaveEnvelope } from './envelope';
/**
 * CORE-04 fallback path. When IndexedDB is unavailable (private mode, blocked
 * by browser, quota exceeded), `openSaveDB()` returns this adapter instead of
 * an IDBPDatabase. The interface intersects with what snapshots.ts and Phase 2's
 * save consumer actually call — get/put/delete on the two stores (`saves`,
 * `save_snapshots`) plus a transaction helper that, for localStorage, is a
 * straight-through proxy (no real transaction semantics — single-threaded
 * synchronous storage).
 *
 * Per .planning/research/PITFALLS.md #8, multi-layer storage is the v1 contract;
 * IndexedDB is primary, localStorage is the fallback when IDB throws.
 */

type StoreName = 'saves' | 'save_snapshots';

interface SavedRecord {
  id: string;
  envelope: SaveEnvelope;
  savedAt: string;
}

interface SnapshotRecord {
  id: string;
  schemaVersion: number;
  savedAt: string;
  envelope: SaveEnvelope;
}

type RecordOf<S extends StoreName> = S extends 'saves' ? SavedRecord : SnapshotRecord;

function nsKey(store: StoreName, id: string): string {
  return `tlg.${store}.${id}`;
}

function nsPrefix(store: StoreName): string {
  return `tlg.${store}.`;
}

export class LocalStorageDBAdapter {
  readonly objectStoreNames = {
    contains: (s: string) => s === 'saves' || s === 'save_snapshots',
  };

  async get<S extends StoreName>(store: S, key: string): Promise<RecordOf<S> | undefined> {
    const raw = localStorage.getItem(nsKey(store, key));
    return raw ? (JSON.parse(raw) as RecordOf<S>) : undefined;
  }

  async put<S extends StoreName>(store: S, value: RecordOf<S>): Promise<void> {
    localStorage.setItem(nsKey(store, value.id), JSON.stringify(value));
  }

  async delete(store: StoreName, key: string): Promise<void> {
    localStorage.removeItem(nsKey(store, key));
  }

  async getAll<S extends StoreName>(store: S): Promise<RecordOf<S>[]> {
    const prefix = nsPrefix(store);
    const out: RecordOf<S>[] = [];
    for (let i = 0; i < localStorage.length; i++) {
      const k = localStorage.key(i);
      if (k && k.startsWith(prefix)) {
        const raw = localStorage.getItem(k);
        if (raw) out.push(JSON.parse(raw) as RecordOf<S>);
      }
    }
    return out;
  }

  /**
   * Transaction shim — localStorage has no real transactions. We expose the
   * same shape as idb's transaction() so snapshots.ts's `db.transaction(...).objectStore(...)`
   * pattern works against both backends. `done` resolves immediately because
   * each set/remove is its own atomic operation.
   */
  transaction(store: StoreName, _mode: 'readwrite' | 'readonly') {
    const adapter = this;
    return {
      objectStore: (s: StoreName) => ({
        put: (value: RecordOf<typeof s>) => adapter.put(s, value),
        get: (key: string) => adapter.get(s, key),
        delete: (key: string) => adapter.delete(s, key),
        getAll: () => adapter.getAll(s),
      }),
      done: Promise.resolve(),
    };
  }
}
```

**Step 2 — `src/save/db.ts`:**
```typescript
import { openDB, type IDBPDatabase } from 'idb';
import type { SaveEnvelope } from './envelope';
import { LocalStorageDBAdapter } from './db-localstorage-adapter';

export const SAVE_DB_NAME = 'tlg-save';
const DB_VERSION = 1;

export interface SavedRecord {
  id: 'main'; // singleton key — Phase 1 ships one save slot only
  envelope: SaveEnvelope;
  savedAt: string; // ISO8601
}

export interface SnapshotRecord {
  id: string; // `${schemaVersion}-${savedAt}`
  schemaVersion: number;
  savedAt: string;
  envelope: SaveEnvelope;
}

export interface SaveDBSchema {
  saves: { key: string; value: SavedRecord };
  save_snapshots: { key: string; value: SnapshotRecord };
}

/**
 * Type union of the two backends — IndexedDB primary, localStorage fallback.
 * Phase 2's save consumer only calls the methods both backends implement
 * (`get`, `put`, `delete`, `getAll`, `transaction`).
 */
export type SaveDB = IDBPDatabase<SaveDBSchema> | LocalStorageDBAdapter;

/**
 * Opens the save DB. Tries IndexedDB first; on rejection (private mode, blocked,
 * quota exceeded — anything that makes openDB throw), falls back to a
 * LocalStorageDBAdapter that exposes the same minimal interface.
 *
 * CORE-04: "IndexedDB-primary with localStorage fallback".
 * Tested in db.test.ts via stub-injected openDB rejection.
 */
export async function openSaveDB(): Promise<SaveDB> {
  try {
    return await openDB<SaveDBSchema>(SAVE_DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains('saves')) {
          db.createObjectStore('saves', { keyPath: 'id' });
        }
        if (!db.objectStoreNames.contains('save_snapshots')) {
          db.createObjectStore('save_snapshots', { keyPath: 'id' });
        }
      },
    });
  } catch (err) {
    // IDB unavailable — fall back to localStorage. Phase 2's settings UI
    // will surface a "running on localStorage" notice when this path triggers
    // (per .planning/research/PITFALLS.md #8 multi-layer write requirement).
    console.warn('[save] IndexedDB unavailable, falling back to localStorage:', err);
    return new LocalStorageDBAdapter();
  }
}
```

Per RESEARCH § "Don't Hand-Roll", `idb` is the right wrapper. The two-store split (`saves` + `save_snapshots`) is per RESEARCH Pattern 3 — snapshots are kept separate so migrating the main save never affects the snapshot history. The localStorage fallback adapter mirrors the same two stores, namespaced under `tlg.saves.*` / `tlg.save_snapshots.*`.

**Step 3 — `src/save/db.test.ts`:** Plan 01 Task 1 already installed `fake-indexeddb@^6` (verify with `grep -q '"fake-indexeddb"' package.json` before authoring); import it directly:
```typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import 'fake-indexeddb/auto'; // happy-dom doesn't ship IDB; fake-indexeddb is the polyfill (installed by Plan 01)
import { openSaveDB, SAVE_DB_NAME } from './db';
import { wrap } from './envelope';
import { LocalStorageDBAdapter } from './db-localstorage-adapter';

beforeEach(async () => {
  // Reset IDB and localStorage between tests
  indexedDB.deleteDatabase(SAVE_DB_NAME);
  localStorage.clear();
  vi.unstubAllGlobals();
  vi.resetModules();
});

describe('openSaveDB (CORE-04 IndexedDB-primary path)', () => {
  it('opens a DB with saves and save_snapshots object stores', async () => {
    const db = await openSaveDB();
    expect(db.objectStoreNames.contains('saves')).toBe(true);
    expect(db.objectStoreNames.contains('save_snapshots')).toBe(true);
  });

  it('round-trips a SaveEnvelope through saves store', async () => {
    const db = await openSaveDB();
    const envelope = wrap({ hello: 'world' }, 1);
    await db.put('saves', { id: 'main', envelope, savedAt: new Date().toISOString() });
    const retrieved = await db.get('saves', 'main');
    expect(retrieved?.envelope).toEqual(envelope);
  });
});

describe('openSaveDB (CORE-04 localStorage fallback path)', () => {
  it('falls back to LocalStorageDBAdapter when IndexedDB is unavailable', async () => {
    // Stub the idb module's openDB so it rejects, simulating private mode / blocked IDB.
    vi.doMock('idb', async () => ({
      openDB: vi.fn().mockRejectedValue(new Error('IDB unavailable (test stub)')),
    }));
    // Re-import db.ts after the mock is registered so it picks up the rejecting openDB.
    const { openSaveDB: openSaveDBFresh } = await import('./db');

    const db = await openSaveDBFresh();
    expect(db).toBeInstanceOf(LocalStorageDBAdapter);

    // Round-trip works against localStorage
    const envelope = wrap({ fallback: true }, 1);
    await db.put('saves', { id: 'main', envelope, savedAt: new Date().toISOString() });
    const retrieved = await db.get('saves', 'main');
    expect(retrieved?.envelope).toEqual(envelope);

    // Verify it actually wrote to localStorage (not just memory)
    expect(localStorage.getItem('tlg.saves.main')).toBeTruthy();

    vi.doUnmock('idb');
  });
});
```

**Step 4 — `src/save/snapshots.ts`** (per RESEARCH Pattern 3 verbatim, but consuming the union `SaveDB` type so it works against both backends):
```typescript
import { openSaveDB } from './db';
import type { SaveEnvelope } from './envelope';

export interface SnapshotEntry {
  id: string;
  schemaVersion: number;
  savedAt: string;
  envelope: SaveEnvelope;
}

const RETAIN = 3;

/**
 * Write a pre-migration snapshot. After every write, prune to the 3 newest
 * entries by savedAt (descending). Works against both IDB and localStorage backends.
 */
export async function snapshot(envelope: SaveEnvelope): Promise<void> {
  const db = await openSaveDB();
  const tx = db.transaction('save_snapshots', 'readwrite');
  const store = tx.objectStore('save_snapshots');
  const savedAt = new Date().toISOString();
  // Make ID unique even if two snapshots fire in the same ms (rare in tests)
  const id = `${envelope.schemaVersion}-${savedAt}-${Math.random().toString(36).slice(2, 8)}`;
  await store.put({ id, 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<SnapshotEntry[]> {
  const db = await openSaveDB();
  const all = await db.getAll('save_snapshots');
  return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
}
```

**Step 5 — `src/save/snapshots.test.ts`** with the 4 behaviors above. The CORE-08 test:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import 'fake-indexeddb/auto';
import { snapshot, listSnapshots } from './snapshots';
import { wrap } from './envelope';
import { SAVE_DB_NAME } from './db';

beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME));

describe('CORE-08: last-3 snapshot retention', () => {
  it('retains exactly 3 newest entries after 5 successive snapshot calls', async () => {
    for (let i = 0; i < 5; i++) {
      await snapshot(wrap({ generation: i }, 1));
      await new Promise((r) => setTimeout(r, 2)); // ensure savedAt timestamps differ
    }
    const list = await listSnapshots();
    expect(list).toHaveLength(3);
    // Newest first: payloads should be {generation:4}, {generation:3}, {generation:2}
    expect(list.map((e) => (e.envelope.payload as any).generation)).toEqual([4, 3, 2]);
  });
});
```

**Step 6 — `src/save/persist.ts`** (per RESEARCH § "Code Examples — Persist API call with respectful surfacing" verbatim):
```typescript
export interface PersistResult {
  granted: boolean;
  apiAvailable: boolean;
}

/**
 * Request persistent storage from the browser. Returns granted=true only if
 * the browser actually granted persistence (Chrome/Firefox/Edge mostly will;
 * iOS Safari mostly will NOT — see .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
 * Pitfall 2 + .planning/research/PITFALLS.md #8). Caller (Phase 2 settings UI)
 * surfaces apiAvailable=false / granted=false respectfully.
 */
export async function requestPersistence(): Promise<PersistResult> {
  if (typeof navigator === 'undefined' || !('storage' in navigator) || !navigator.storage || !('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 };
  }
}
```

**Step 7 — `src/save/persist.test.ts`** with the 4 behaviors. Use Vitest's `vi.stubGlobal` to mock `navigator.storage.persist` per case:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { requestPersistence } from './persist';

describe('requestPersistence (CORE-05)', () => {
  beforeEach(() => vi.unstubAllGlobals());

  it('returns granted=true when navigator.storage.persist resolves true', async () => {
    vi.stubGlobal('navigator', { storage: { persist: async () => true } });
    expect(await requestPersistence()).toEqual({ granted: true, apiAvailable: true });
  });

  it('returns granted=false when navigator.storage.persist resolves false', async () => {
    vi.stubGlobal('navigator', { storage: { persist: async () => false } });
    expect(await requestPersistence()).toEqual({ granted: false, apiAvailable: true });
  });

  it('returns granted=false when persist throws', async () => {
    vi.stubGlobal('navigator', { storage: { persist: async () => { throw new Error('boom'); } } });
    expect(await requestPersistence()).toEqual({ granted: false, apiAvailable: true });
  });

  it('returns apiAvailable=false when navigator.storage is missing', async () => {
    vi.stubGlobal('navigator', {});
    expect(await requestPersistence()).toEqual({ granted: false, apiAvailable: false });
  });
});
```

**Step 8 — Verify all tests pass: `npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts`.**

**Step 9 — Commit `feat(01-03): idb DB + localStorage fallback adapter (CORE-04) + last-3 snapshot retention + navigator.storage.persist API`.**
npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts - Precondition: `fake-indexeddb` is listed in devDependencies (installed by Plan 01 Task 1) — verify with `grep -q '"fake-indexeddb"' package.json` (this is a precondition assertion, not a new install). - `src/save/db-localstorage-adapter.ts` exists and exports `LocalStorageDBAdapter` — verify with `grep -q "^export class LocalStorageDBAdapter" src/save/db-localstorage-adapter.ts`. - `LocalStorageDBAdapter` namespaces under `tlg.saves.*` and `tlg.save_snapshots.*` — verify with `grep -E "tlg\\.(saves|save_snapshots)\\." src/save/db-localstorage-adapter.ts | wc -l` returns at least 2. - `src/save/db.ts` opens a DB with stores `saves` AND `save_snapshots` — verify with `grep -E "createObjectStore\\('(saves|save_snapshots)'" src/save/db.ts | wc -l` returns 2. - `src/save/db.ts` wraps `openDB` in try/catch and returns `LocalStorageDBAdapter` on failure (CORE-04 fallback) — verify with `grep -q "LocalStorageDBAdapter" src/save/db.ts && grep -E "catch\\b" src/save/db.ts`. - `src/save/db.test.ts` includes a test that stub-injects an IDB failure and verifies the fallback path round-trips — verify with `grep -q "vi.doMock" src/save/db.test.ts && grep -q "LocalStorageDBAdapter" src/save/db.test.ts && grep -q "tlg.saves.main" src/save/db.test.ts`. - `src/save/snapshots.ts` exports `snapshot` and `listSnapshots` — verify with `grep -cE "^export (async )?function (snapshot|listSnapshots)" src/save/snapshots.ts` returns 2. - `RETAIN` constant in snapshots.ts is exactly 3 — verify with `grep -E "RETAIN\\s*=\\s*3" src/save/snapshots.ts`. - `src/save/persist.ts` exports `requestPersistence` and `PersistResult` — verify with `grep -cE "^export (function|interface|type) (requestPersistence|PersistResult)" src/save/persist.ts` returns 2. - The CORE-08 test asserts `toHaveLength(3)` after 5 writes — verify with `grep -q "toHaveLength(3)" src/save/snapshots.test.ts`. - All 4 persist.test.ts cases stub `navigator.storage` — verify with `grep -cE "vi.stubGlobal" src/save/persist.test.ts` returns at least 4. - All tests pass: `npx vitest run src/save/db.test.ts src/save/snapshots.test.ts src/save/persist.test.ts 2>&1 | grep -E "passed"`. - No `any` types in production files (test files may use `as any` for stub typing only) — verify with `grep -nE ': any\\b' src/save/db.ts src/save/db-localstorage-adapter.ts src/save/snapshots.ts src/save/persist.ts`; zero matches. `idb`-wrapped IndexedDB with both stores; `LocalStorageDBAdapter` (~30-40 LoC) implementing the same minimal interface for the CORE-04 fallback path; `openSaveDB()` returns the IDB DB on success and the adapter on rejection; one Vitest test stub-injects an IDB failure and exercises the localStorage round-trip end-to-end (verifies `tlg.saves.main` key is written); last-3 snapshot retention with the CORE-08 5-then-3 invariant test; persist API with all 4 navigator.storage scenarios covered; commit landed. Task 3: Base64 codec + round-trip integration test + index re-exports src/save/codec.ts, src/save/round-trip.test.ts, src/save/index.ts - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Code Examples — Save round-trip (Phase 1's load-bearing test)" (verbatim test) and § "Common Pitfalls — Pitfall 5: Synchronous lz-string compression of huge saves blocks the main thread" - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Security Domain" (Phase 1 threat: malformed Base64 import — DoS via huge inflated string; cap payload size at 50MB before decompression) - src/save/envelope.ts (this plan's Task 1 — wrap/unwrap signatures) - src/save/migrations.ts (this plan's Task 1 — migrate signature) - lz-string 1.5.0 README: compressToBase64 / decompressFromBase64 semantics - **codec.ts:** - Test 1: `exportToBase64(env)` returns a non-empty string. - Test 2: `importFromBase64(exportToBase64(env))` deep-equals the original envelope. - Test 3: `importFromBase64('not-valid-base64-junk')` throws (malformed import detection). - Test 4: `importFromBase64()` throws BEFORE decompression (DoS cap). - **round-trip.test.ts (the load-bearing CORE-09 test):** - The full pipeline per RESEARCH § "Save round-trip" verbatim: synthesize a v0 envelope, export to Base64, simulate a "fresh browser" by importing back from Base64, migrate v0 → v1, wrap in a v1 envelope with valid checksum, unwrap, assert original payload returned. - Bonus: write the migrated v1 envelope to IDB, read it back, unwrap, assert equality (proves IDB + envelope + migration + codec all integrate). - **index.ts:** - Re-exports the public surface for Phase 2 consumption. **Step 1 — `src/save/codec.ts`:** ```typescript import LZString from 'lz-string'; import { SaveEnvelopeSchema, type SaveEnvelope } from './envelope';
/**
 * 50MB cap on Base64 import string length, per Phase 1 threat model
 * (.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § Security Domain
 * — malformed Base64 import / DoS via huge inflated string).
 * lz-string.decompressFromBase64 has bounded output for bounded input;
 * still, refuse pathologically large inputs at the boundary.
 */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;

/**
 * Export a SaveEnvelope to a Base64 text blob suitable for "Settings → Export".
 * Phase 1 ships the function pair; Phase 2 wires the UI button (CORE-09).
 */
export function exportToBase64<T>(envelope: SaveEnvelope<T>): string {
  return LZString.compressToBase64(JSON.stringify(envelope));
}

/**
 * Import from a Base64 text blob. Throws on:
 *   - input larger than MAX_IMPORT_BYTES (DoS cap)
 *   - lz-string decompression failure
 *   - JSON parse failure
 *   - SaveEnvelopeSchema validation failure (malformed envelope shape)
 *
 * Note: this does NOT verify checksum or run migrations — the caller chains
 * importFromBase64 → migrate → unwrap. See round-trip.test.ts for the full pipeline.
 */
export function importFromBase64(base64: string): SaveEnvelope<unknown> {
  if (base64.length > MAX_IMPORT_BYTES) {
    throw new Error(`Import payload exceeds ${MAX_IMPORT_BYTES} bytes (got ${base64.length})`);
  }
  const decompressed = LZString.decompressFromBase64(base64);
  if (!decompressed) {
    throw new Error('Failed to decompress Base64 import (malformed input)');
  }
  const parsed = JSON.parse(decompressed);
  const validated = SaveEnvelopeSchema.safeParse(parsed);
  if (!validated.success) {
    throw new Error(`Imported envelope failed schema validation: ${validated.error.message}`);
  }
  return validated.data as SaveEnvelope<unknown>;
}
```
Per RESEARCH Pitfall 5: lz-string is synchronous; for Phase 1 saves (<10KB) this is fine. Document the eventual mitigation as a code comment so Phase 8 perf work knows where to look. Do NOT build the Web Worker now (premature per CONTEXT D-09 minimum-viable directive).

**Step 2 — `src/save/round-trip.test.ts`** (per RESEARCH § "Code Examples — Save round-trip" verbatim, extended with IDB integration):
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import 'fake-indexeddb/auto';
import { wrap, unwrap } from './envelope';
import { migrate, CURRENT_SCHEMA_VERSION } from './migrations';
import { exportToBase64, importFromBase64 } from './codec';
import { openSaveDB, SAVE_DB_NAME } from './db';

beforeEach(() => indexedDB.deleteDatabase(SAVE_DB_NAME));

describe('CORE-09 + CORE-04 + CORE-06 + CORE-07: full save round-trip', () => {
  it('synthetic v0 envelope migrates, round-trips through Base64, validates, persists', async () => {
    // Pretend a player had an old v0 save lying around (CONTEXT D-05 synthetic v0).
    const v0Payload = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] };
    // v0 envelope: schemaVersion 0, with a placeholder checksum that we won't verify
    // (the v0 era didn't have our checksum scheme).
    const v0Envelope = {
      schemaVersion: 0,
      payload: v0Payload,
      checksum: '00000000', // 8-char hex placeholder
    };

    // Export through Base64 codec
    const exported = exportToBase64(v0Envelope as any);
    expect(exported.length).toBeGreaterThan(0);

    // Import (simulating a fresh browser) — note: import returns a parsed envelope
    // that PASSES our SaveEnvelopeSchema (schemaVersion 0 is allowed since z.number().nonnegative()).
    const imported = importFromBase64(exported);
    expect(imported.schemaVersion).toBe(0);

    // Migrate the imported payload
    const { payload, toVersion } = migrate(imported.payload, imported.schemaVersion);
    expect(toVersion).toBe(CURRENT_SCHEMA_VERSION);
    expect(payload).toMatchObject({
      garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] },
      plants: [],
      harvestedFragmentIds: [],
      lastTickAt: expect.any(Number),
      settings: { musicVolume: expect.any(Number), ambientVolume: expect.any(Number), sfxVolume: expect.any(Number) },
    });

    // Re-wrap with current version and a valid checksum
    const v1Envelope = wrap(payload, toVersion);
    expect(unwrap(v1Envelope)).toEqual(payload);

    // Persist to IDB and read back (CORE-04)
    const db = await openSaveDB();
    await db.put('saves', { id: 'main', envelope: v1Envelope, savedAt: new Date().toISOString() });
    const retrieved = await db.get('saves', 'main');
    expect(retrieved?.envelope).toEqual(v1Envelope);
    expect(unwrap(retrieved!.envelope)).toEqual(payload);
  });

  it('rejects oversized Base64 import (DoS cap)', () => {
    const huge = 'A'.repeat(50 * 1024 * 1024 + 1);
    expect(() => importFromBase64(huge)).toThrow(/exceeds/);
  });

  it('rejects malformed Base64', () => {
    expect(() => importFromBase64('not-valid-base64-)(*&^%$')).toThrow();
  });
});
```

**Step 3 — `src/save/index.ts`** — public re-exports for Phase 2:
```typescript
/**
 * Public surface of the save layer. Phase 2's tick scheduler + Zustand store
 * are the first consumers.
 */
export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope';
export type { SaveEnvelope } from './envelope';
export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations';
export { snapshot, listSnapshots } from './snapshots';
export type { SnapshotEntry } from './snapshots';
export { requestPersistence } from './persist';
export type { PersistResult } from './persist';
export { exportToBase64, importFromBase64, MAX_IMPORT_BYTES } from './codec';
export { openSaveDB, SAVE_DB_NAME } from './db';
export type { SaveDB, SaveDBSchema, SavedRecord, SnapshotRecord } from './db';
export { LocalStorageDBAdapter } from './db-localstorage-adapter';
export { crc32hex, canonicalJSON } from './checksum';
```

**Step 4 — Run the full save test suite: `npx vitest run src/save/`.** Expect all tests across 8 files (checksum, envelope, migrations, db, snapshots, persist, round-trip; LocalStorageDBAdapter is exercised by db.test.ts) to pass.

**Step 5 — Run `npm test`** to confirm the entire test suite (sentinel + lint-firewall from Plan 02 + all save tests) passes.

**Step 6 — Run `npm run build`** to confirm the save layer compiles cleanly with TypeScript strict.

**Step 7 — Commit `feat(01-03): Base64 codec + DoS-capped import + full save round-trip integration test + index re-exports`.**
npx vitest run src/save/ && npm run build - `src/save/codec.ts` exports `exportToBase64`, `importFromBase64`, `MAX_IMPORT_BYTES` — verify with `grep -cE "^export (function|const) (exportToBase64|importFromBase64|MAX_IMPORT_BYTES)" src/save/codec.ts` returns 3. - `MAX_IMPORT_BYTES` is exactly 50MB — verify with `grep -E "MAX_IMPORT_BYTES\\s*=\\s*50\\s*\\*\\s*1024\\s*\\*\\s*1024" src/save/codec.ts`. - `importFromBase64` validates against `SaveEnvelopeSchema` — verify with `grep -q "SaveEnvelopeSchema.safeParse" src/save/codec.ts`. - `src/save/index.ts` exports the full public surface including `LocalStorageDBAdapter` — verify with `grep -cE "^export " src/save/index.ts` returns at least 11 (wrap, unwrap, SaveCorruptError, migrate, snapshot, requestPersistence, exportToBase64, importFromBase64, openSaveDB, LocalStorageDBAdapter, crc32hex, etc). - The round-trip test asserts on the v0→v1 migration shape from CONTEXT D-04 — verify with `grep -E "tiles:|plants:|harvestedFragmentIds:|lastTickAt:|settings:" src/save/round-trip.test.ts | grep -v '^#' | wc -l` returns at least 5. - The round-trip test exercises EXPORT → IMPORT → MIGRATE → WRAP → UNWRAP → IDB PUT → IDB GET — verify with `grep -E "exportToBase64|importFromBase64|migrate|wrap|unwrap|openSaveDB|db\\.put|db\\.get" src/save/round-trip.test.ts | wc -l` returns at least 7 (one per stage). - The DoS cap is tested — verify with `grep -q "50 \\* 1024 \\* 1024 + 1" src/save/round-trip.test.ts`. - `npx vitest run src/save/` passes ALL tests — verify exit 0; expect roughly 16+5+4+2+3 = ~30 tests across 7 test files (db.test.ts now has 1 extra fallback test). - `npm run build` exits 0 — TypeScript strict compilation passes including the full save layer. - No `any` types in production code (codec.ts, index.ts) — verify with `grep -nE ': any\\b' src/save/codec.ts src/save/index.ts`; zero matches. Base64 export/import codec with 50MB DoS cap; full round-trip test exercising every save layer file (CORE-04 + 06 + 07 + 09 in one go); public re-exports (including `LocalStorageDBAdapter` and `SaveDB` union type) indexed for Phase 2; entire save test suite green under `npm test`; `npm run build` succeeds under TypeScript strict; commit landed.

<threat_model>

Trust Boundaries

Boundary Description
Disk → IndexedDB Saved envelopes loaded back from storage; data may have been corrupted in lossy storage (Chrome eviction, partial write, browser crash). Mitigated by CRC-32 envelope checksum.
User → Base64 import A pasted Base64 string from "Settings → Import" (Phase 2 wires the UI; Phase 1 ships the function). User is single-player but the input is still untrusted bytes.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-01-01 Tampering unwrap() of save envelope mitigate CRC-32 checksum over canonical JSON detects corruption (lossy storage, partial writes); throws SaveCorruptError on mismatch. Not a cryptographic guarantee — a player editing their own save is by-design acceptable in a single-player contemplative game per RESEARCH § Security Domain. Phase 2's UI surfaces the recovery option (last-3 snapshots from CORE-08) when this throws.
T-01-02 Denial of Service importFromBase64() mitigate Cap input length at MAX_IMPORT_BYTES = 50 * 1024 * 1024 BEFORE invoking lz-string.decompressFromBase64 (which is synchronous and would block the main thread on huge inputs per RESEARCH Pitfall 5 + § Security Domain). Throws an Error with /exceeds/ message when input exceeds cap. Tested in round-trip.test.ts.
T-01-03 Tampering Save authentication (player edits Base64 export and reimports) accept Single-player game; no leaderboards, no monetization gates in Phase 1; player tampering with their own save is the player's prerogative. Documented explicitly in codec.ts and RESEARCH § "Phase 1 explicit security non-goals". CRC-32 detects corruption, NOT adversarial editing — by design.
T-01-04 Information Disclosure Save contents accept Phase 1 saves contain no PII (no Keeper name per STRY-07; no auth, no sessions). Garden state, plant data, harvested fragment IDs are non-sensitive.
T-01-05 Spoofing Cross-origin import via URL params (future risk if save-via-link is added) accept (out of scope) Phase 1 has no URL import mechanism. Flagged here for Phase 4+ when Settings UI is wired: import flow MUST require explicit user confirmation, NEVER auto-load from URL.
</threat_model>
- `npx vitest run src/save/` passes every test (target: ≥30 tests across 7 test files; db.test.ts now includes the CORE-04 localStorage fallback test). - `npm run build` exits 0 under TypeScript strict (no `any` in production code). - `npm run lint` exits 0 (the save layer respects the firewall — no `import` from `src/render/` or `src/ui/`; this would also fire the Plan 02 boundary rule). - All 6 CORE requirements (CORE-04 through CORE-09) have at least one Vitest assertion explicitly named or commented as covering them. CORE-04 specifically covers BOTH the IDB-primary path AND the localStorage-fallback path. - The CRC-32 envelope and the 50MB DoS cap satisfy Phase 1's two STRIDE-mitigate threats.

<success_criteria>

  • Save envelope {schemaVersion, payload, checksum} with CRC-32 over canonical JSON, exported via wrap/unwrap and tested for round-trip + tamper-detection.
  • Migration chain with CURRENT_SCHEMA_VERSION = 1 and one synthetic v0 → v1 demo migration that produces the v1 shape from CONTEXT D-04.
  • IDB DB with two object stores: saves (singleton) + save_snapshots (last-3 retention).
  • LocalStorageDBAdapter implementing the same minimal interface as the IDB DB; openSaveDB() falls back to the adapter when IDB is unavailable (CORE-04 IndexedDB-primary + localStorage-fallback contract).
  • requestPersistence() covers all four navigator.storage scenarios.
  • Base64 export/import via lz-string with a 50MB DoS cap.
  • Full round-trip test covers every component end-to-end.
  • All 6 Phase-1 CORE save requirements automated and green. </success_criteria>
After completion, create `.planning/phases/01-foundations-and-doctrine/01-03-SUMMARY.md` documenting: - Final test count (`npx vitest run src/save/ 2>&1 | tail -5`). - The exact `CURRENT_SCHEMA_VERSION` (must be 1) and what the v1 shape contains (so Phase 4's `migrate_v1_to_v2` author has the contract). - **CORE-04 fallback note:** the localStorage fallback is shipped in Phase 1 per the orchestrator's revision-iteration-1 decision (REQUIREMENTS.md CORE-04 + ROADMAP success criterion #2 both require it). The fallback is a thin (~30-40 LoC) `LocalStorageDBAdapter` exposing the same minimal interface as the IDB DB; `openSaveDB()` wraps `openDB()` in try/catch and returns the adapter on rejection. A single Vitest test (`db.test.ts` "falls back to LocalStorageDBAdapter when IndexedDB is unavailable") stub-injects an IDB failure via `vi.doMock('idb')` and asserts the round-trip succeeds with `tlg.saves.main` written to localStorage. - Confirmation that the public surface in `src/save/index.ts` is the only entry point Phase 2 should import from.