# ADR-0005 — Persistence for the MVP slice - Status: accepted (provisional — durable storage owned by a later workplan) - Date: 2026-05-25 - Workplan: CE-WP-0002-T08 (click-to-reopen requires reload-survival) ## Context CE-WP-0002 needs the click-to-reopen flow to survive a page reload (PRD scenario step 4 → "even after a full page reload"). The full persistence design (SQLite local-first vs Postgres server-first) is too large to land inside this slice — `wiki/ArchitectureOverview.md` §10 lays out the bigger picture but the workplan explicitly defers the decision. The engine already runs `Map`-backed in-memory repositories (`src/engine/repos/in-memory.ts`). To survive reloads we need *some* persistence boundary now, without committing to the long-term store. ## Options - **A. localStorage snapshot (this ADR).** The SPA serializes the entire engine state into a single JSON blob on every mutation and restores it on mount. No new dependencies; no schema migrations; no networking. Per-tab only. - **B. IndexedDB-backed store.** More headroom, more API surface, async reads. Needed eventually for binary blobs (PDF bytes) but overkill for the few hundred annotations the MVP produces. - **C. SQLite via `sql.js` or `wa-sqlite`.** Brings query semantics into the browser. Heavy for the MVP and entangles us with a database we may not keep. - **D. Server-backed persistence from day one.** Requires shipping a backend. Premature. ## Decision Adopt **A: localStorage snapshot**, deliberately temporary. Implementation lives in `src/engine/persistence.ts`: - `captureSnapshot(engine)` returns `{ documents, representations, annotations, evidenceItems }`. - `attachPersister(engine, { key })` subscribes to every mutating engine event and writes a fresh snapshot to `localStorage` after each. - `restoreFromStorage(engine, { key })` reads the snapshot on app mount and hydrates the repos *directly* (bypassing service `create()` calls) so no spurious `*Created` events fire — the persister would otherwise loop on its own writes, and other UI listeners would see "the same annotation was created again" on every reload. - Snapshot is versioned (`SNAPSHOT_VERSION = 1`); a version mismatch throws on restore so a future schema bump is loud. `src/work/EngineContext.tsx`'s `EngineProvider` wires this on first mount. A sibling localStorage key holds the last-active `documentId` so reload lands the user back on the same fixture. ## Why this is acceptable for the MVP - The engine never holds PDF bytes — only metadata + selectors + commentary. A typical session is well under 1 MB even with hundreds of annotations, comfortably within the ~5 MB localStorage budget. - The repositories' `create()` signatures already match the shape an eventual durable repo would expose; swapping the implementation is a localised change. - "Survives reload" is the only persistence requirement of CE-WP-0002. Cross-device sync, multi-user access, query-by-tag, history — none are in scope yet. ## What this defers - A real persistence ADR (SQLite local-first vs Postgres server-first vs IndexedDB) for CE-WP-0005+ work. - PDF byte persistence. Today the SPA re-fetches `/fixtures/pdfs/*` on load; bytes do not enter the snapshot. - Multi-tab consistency. Tabs see each other's writes only on reload. - Migrations beyond the version check. ## Consequences - `src/engine/persistence.ts` is the single point of contact for storage. When the real durable-store ADR lands, that module is what changes. - Tests inject a memory-Storage shim into `attachPersister` / `restoreFromStorage` so they don't depend on a browser environment (see `src/engine/persistence.test.ts`). - Clearing the user's browser storage destroys all annotations — call this out in the README once the MVP ships.