---
phase: 03-server-documentation-schemas
plan: 07
subsystem: persistence-adr
tags: [adr, persistence, sqlite, litestream, drizzle, lint]
requires:
  - docs/extracted-server/save-formats.json (plan 03-05)
  - tools/db-schema/src/tables.ts (plan 03-06)
  - docs/extracted-server/0001_baseline.sql (plan 03-06)
  - docs/extracted-server/persistence.md (plan 03-04)
  - .planning/research/STACK.md
  - .planning/research/PITFALLS.md §B7
  - tools/asset-catalog/scripts/lint-adr.mjs (plan 02-06)
provides:
  - docs/adr/0002-persistence-layer.md (SDOC-04 ADR — persistence-layer choice locked)
  - tools/asset-catalog/scripts/lint-adr.mjs --no-matrix (Phase 3+ non-engine ADR linter)
  - package.json#scripts.lint:adr (Phase 2 ADR 0001 root invocation)
  - package.json#scripts.lint:adr:0002 (Phase 3 ADR 0002 root invocation)
  - tools/asset-catalog/tests/integration/lint-adr-no-matrix.test.ts
affects:
  - Phase 4 SRV-01..03 (better-sqlite3 + Drizzle runtime; consumes tables.ts + 0001_baseline.sql verbatim per the lock here)
  - Phase 4 SRV-08 (SIGTERM-grace; WAL + atomic-writes survival under kill -9 follows from this ADR's persistence stack)
  - Phase 4 SRV-09 (Better-Auth Drizzle adapter integration; Assumption A5 deferred to wire-time per Consequences §Neutral)
  - Phase 5 DEP-02/03 (Fly.io single-machine + persistent-volume + Litestream pattern)
  - Phase 5 DEP-07 (RESTORE.md; RPO <1s SLA satisfied by Litestream 0.3.13 → Tigris)
  - Phase 7 PAR-07 (admin-UI audit_log writes; payloadJson escape-hatch defended in Decision rationale)
tech-stack:
  added: []
  patterns:
    - Michael Nygard ADR format (D-14 Phase 2 lock, mirrored verbatim from 0001-client-engine.md)
    - --no-matrix lint flag (Phase 3+ non-engine ADRs skip MX-* + Phaser-4 + MATRIX.md checks)
    - Forcing Functions section enumerates re-open triggers (auditable re-evaluation contract)
key-files:
  created:
    - docs/adr/0002-persistence-layer.md (263 lines, Michael Nygard format)
    - tools/asset-catalog/tests/integration/lint-adr-no-matrix.test.ts (4 tests)
    - .planning/phases/03-server-documentation-schemas/03-07-SUMMARY.md
  modified:
    - tools/asset-catalog/scripts/lint-adr.mjs (+--no-matrix flag, --help short-circuit fix)
    - package.json (+lint:adr, +lint:adr:0002 root scripts)
decisions:
  - "ADR 0002 locks better-sqlite3 12.9.0 + Drizzle ORM 0.45.2 + drizzle-kit 0.31.10 + Litestream 0.3.13 for Phase 4+ persistence; Postgres rejected for v1; OPS-03 (v2) preserves migration path."
metrics:
  duration_minutes: ~25
  completed_date: 2026-05-03
  task_count: 2
  files_changed: 4
  files_created: 3
---

# Phase 03 Plan 07: ADR 0002 — Persistence Layer Summary

**One-liner:** SDOC-04 ADR closes Phase 3 by locking better-sqlite3 12.9.0 +
Drizzle 0.45.2 + Litestream 0.3.13 (Tigris/Fly.io) for Phase 4+ persistence,
with `lint-adr.mjs` extended `--no-matrix` for non-engine ADRs and Phase 2
ADR 0001 backward compat preserved.

## What Was Built

Two atomic commits across this plan:

| Task | Name                                                      | Commit    | Files                                                                                                                                       |
| ---- | --------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 1    | Extend lint-adr.mjs with `--no-matrix` flag               | `d43d93f` | `tools/asset-catalog/scripts/lint-adr.mjs`, `tools/asset-catalog/tests/integration/lint-adr-no-matrix.test.ts`, `package.json`              |
| 2    | Author ADR 0002 — Persistence Layer                       | `9e816eb` | `docs/adr/0002-persistence-layer.md`                                                                                                        |

### Task 1: lint-adr.mjs --no-matrix extension

`tools/asset-catalog/scripts/lint-adr.mjs` was extended with a `--no-matrix`
flag that skips three Phase-2-specific invariants when validating non-engine
ADRs:

1. The MX-* row citation count + resolution check (Pitfall 6 mitigation —
   only meaningful when MATRIX-rows.json is the single source of truth, which
   is true for Phase 2 only).
2. The Phaser 4 caveat check (RESEARCH §908-922 — engine-decision specific).
3. The MATRIX.md References-section mention (Phase 2 only).

The four required Michael Nygard sections (Status / Context / Decision /
Consequences) are still enforced regardless of the flag.

The `--help` / `-h` short-circuit was hoisted ahead of positional argument
filtering: under the new flag-aware parser, `--help` would have been filtered
out as a flag, leaving `adrArg` undefined and the script exiting 2. The fix
checks `args.includes('--help')` directly so the legacy "exit 0" contract is
preserved (caught by both `tests/lint-adr.test.ts` and the new integration
test as a regression).

Two root scripts wired in `package.json`:

- `pnpm lint:adr` — Phase 2 ADR 0001 (full MATRIX validation; 6 unique MX-*
  citations resolved).
- `pnpm lint:adr:0002` — Phase 3 ADR 0002 (`--no-matrix` mode; sections-only).

Integration test (`tools/asset-catalog/tests/integration/lint-adr-no-matrix.test.ts`,
4 tests) asserts:

- Minimal Phase-3-shape ADR exits 0 with `--no-matrix`.
- The same minimal ADR exits 1 without `--no-matrix` (Phaser 4 missing) — this
  is the non-trivial assertion proving the flag actually toggles behavior, not
  just suppresses output.
- Committed Phase 2 ADR 0001 still exits 0 with no flag (regression guard).
- `--help` exits 0 (regression for the parser hoist).

### Task 2: ADR 0002 — Persistence Layer

`docs/adr/0002-persistence-layer.md` (263 lines), authored verbatim against
the Phase 2 D-14 Michael Nygard template (mirrored from
`docs/adr/0001-client-engine.md`).

**Decision section locks:** `better-sqlite3 12.9.0 + Drizzle ORM 0.45.2 +
drizzle-kit 0.31.10 + Litestream 0.3.13`, on a Fly.io persistent volume with
the Tigris S3-compatible bucket as the Litestream replica target.

**Context section** cites 4 specific signals (must_haves required ≥3) from
`docs/extracted-server/save-formats.json` + `persistence.md`:

1. Single-writer access pattern (`0349-operations.gml` step event +
   `0367-users_restore.gml` load mirror).
2. Append-mostly with full-file rewrite cadence (`0365-mb_backup.gml`
   "load whole, mutate, write whole").
3. Per-user denormalized layout (`UserData/HXB|Inv|Areas` filename_pattern;
   no relational join requirement surfaces in any documented script).
4. Tiny working set (~50 user rows in deployed legacy snapshot, ~150 KB
   peak MB log, total persistent footprint ≪ 1 MB; projects to <5 MB at
   973-user historical peak).

Plus PITFALLS §B7 (Fly.io machine-restart loss; Litestream + SIGTERM-grace
mitigation) and STACK.md (the Phase-0 research lock).

**Postgres rejection** carries 4 reasons (must_haves required ≥3); OPS-03
(v2 deferral per CONTEXT.md D-11) preserves the migration path with the
Drizzle `pg`-driver one-line swap.

**Forcing Functions** section enumerates 3 re-open triggers (must_haves
required ≥3): relational join surfacing in Phase 7 PAR-04..06; CCU >50
sustained AND OPS-02 room-shard mitigation insufficient; Litestream 0.3.x
deprecated AND 0.5.x stable AND Fly.io pattern shift.

**References** section includes all required links: save-formats.json,
0001_baseline.sql, tables.ts, STACK.md, PITFALLS.md §B7, CONCERNS.md,
OPS-03, CLAUDE.md (must_haves required ≥7; delivered 30 unique reference
matches).

**Determinism guards**: zero `Date.now()` / `new Date()` violations; the
`**Date:**` header is a literal string per `currentDate` env (2026-05-03).

## Verification Results

| Check                                                                                            | Result                                                                                |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `cd tools/asset-catalog && pnpm test`                                                            | 144 passed (10 files) — includes `tests/lint-adr.test.ts` regression                  |
| `cd tools/asset-catalog && pnpm test:integration`                                                | 34 passed (6 files) — includes new `lint-adr-no-matrix.test.ts`                       |
| `pnpm lint:adr:0002`                                                                             | exit 0 — `OK: ADR docs/adr/0002-persistence-layer.md validated (no-matrix mode)`      |
| `pnpm lint:adr` (Phase 2 regression)                                                             | exit 0 — `OK: ADR docs/adr/0001-client-engine.md validated (6 unique MX-* citations resolved)` |
| `node tools/asset-catalog/scripts/lint-adr.mjs --help`                                           | exit 0 (regression after parser hoist)                                                |
| `wc -l docs/adr/0002-persistence-layer.md`                                                       | 263 (≥ 80 required)                                                                    |
| `grep -E "12\.9\.0\|0\.45\.2\|0\.31\.10\|0\.3\.13" docs/adr/0002-persistence-layer.md \| wc -l` | 10 (≥ 4 required — all 4 versions cited multiple times)                                |
| References-section unique-link count                                                             | 30 matches (≥ 7 required)                                                              |
| Postgres-rejection bullet count                                                                  | 4 (≥ 3 required)                                                                       |
| Forcing-function trigger count                                                                   | 3 in `## Forcing Functions for Re-Open` (≥ 3 required)                                 |
| `grep -E "Date\.now\|new Date\(" docs/adr/0002-persistence-layer.md \| wc -l`                    | 0 (must be 0 for determinism)                                                          |

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 — Bug] `--help` flag short-circuit broken by new positional parser**

- **Found during:** Task 1 verification (`pnpm test` + `pnpm test:integration`
  both failed on the `--help → exit 0` test).
- **Issue:** The original parser used `process.argv[2]` directly, so
  `lint-adr.mjs --help` made `adrArg === '--help'` and exited 0. After the
  Plan 03-07 refactor introduced `args.filter((a) => !a.startsWith('--'))` to
  separate flags from positionals, `--help` was filtered out, `adrArg` became
  `undefined`, and the script exited 2 (usage error). This broke both the
  pre-existing `tests/lint-adr.test.ts` `--help → exit 0` case and the new
  integration test's `--help` regression check.
- **Fix:** Hoist the `args.includes('--help') || args.includes('-h')` check
  ahead of positional filtering. Document the regression rationale in the
  scripts comment so a future refactor can't silently undo it.
- **Files modified:** `tools/asset-catalog/scripts/lint-adr.mjs`.
- **Commit:** included in `d43d93f` (Task 1 commit).

**2. [Rule 2 — Auto-add missing critical functionality] Add root `lint:adr` script for Phase 2**

- **Found during:** Task 1 wire-up.
- **Issue:** Plan 03-07 specified `lint:adr:0002` but inspection of root
  `package.json` showed there was no `lint:adr` either — Phase 2 plan 02-06
  shipped the linter without a root script invocation. Adding only `:0002`
  would leave the Phase 2 ADR 0001 lint reachable only by direct `node
  tools/asset-catalog/scripts/lint-adr.mjs ...` invocation, breaking the
  symmetry the plan implied (and making CI integration awkward in Phase 4+).
- **Fix:** Add both `lint:adr` (Phase 2) and `lint:adr:0002` (Phase 3) root
  scripts. The Phase 2 invocation re-validates 0001 in CI alongside the
  Phase 3 ADR.
- **Files modified:** `package.json`.
- **Commit:** included in `d43d93f` (Task 1 commit).

### Pre-existing Out-of-Scope Items (NOT Modified)

- `tools/save-format-doc/output/save-formats.ts` shows as modified in
  `git status` due to LF/CRLF normalization left over from a prior run; this
  file is unchanged in content and was not staged in either Task 1 or
  Task 2 commits. Out of scope for this plan.

No architectural changes (Rule 4) were required.

## Authentication Gates

None.

## Threat Model Status

| Threat ID    | Disposition  | Mitigation Status                                                                                               |
| ------------ | ------------ | --------------------------------------------------------------------------------------------------------------- |
| T-3-07-01    | mitigate     | Done — `tests/integration/lint-adr-no-matrix.test.ts` includes the "Phase 2 ADR 0001 still exits 0" regression. |
| T-3-07-02    | accept       | Done — manual review during Phase 3 verify gate (acceptance grep counts in this SUMMARY).                       |
| T-3-07-03    | mitigate     | Done — ADR References cites tables.ts + 0001_baseline.sql by path; Phase 4 plan-checker can grep.               |

No new threat surface introduced beyond the registered items.

## Known Stubs

None. The ADR is final-form; the lint-adr.mjs `--no-matrix` flag is fully
implemented and exercised by integration test.

## Self-Check

**1. Created files exist:**
- `docs/adr/0002-persistence-layer.md` — FOUND
- `tools/asset-catalog/tests/integration/lint-adr-no-matrix.test.ts` — FOUND
- `.planning/phases/03-server-documentation-schemas/03-07-SUMMARY.md` — FOUND (this file)

**2. Modified files committed:**
- `tools/asset-catalog/scripts/lint-adr.mjs` — committed in `d43d93f`
- `package.json` — committed in `d43d93f`
- `docs/adr/0002-persistence-layer.md` — committed in `9e816eb`

**3. Commits exist on branch:**
- `d43d93f` (Task 1) — FOUND in `git log`
- `9e816eb` (Task 2) — FOUND in `git log`

**4. Lint outputs verified:**
- `pnpm lint:adr:0002` exit 0 — VERIFIED
- `pnpm lint:adr` exit 0 — VERIFIED
- `cd tools/asset-catalog && pnpm test` 144 passed — VERIFIED
- `cd tools/asset-catalog && pnpm test:integration` 34 passed — VERIFIED

## Self-Check: PASSED

## TDD Gate Compliance

This plan was authored as `type=execute` (not `type=tdd`). The Task 1
integration test was written alongside the lint-adr extension in a single
commit (`d43d93f`); a strict RED-then-GREEN gate was not enforced because
the plan did not require it. The integration test, however, is non-trivial
(it asserts both flag-on exit 0 AND flag-off exit 1 on the same fixture
ADR), so it would have caught a no-op extension if the implementation had
been hollow.

## Forward Links

- **Phase 4 SRV-01..03** consumes this ADR's runtime stack lock and the
  schema artifacts from plan 03-06.
- **Phase 4 SRV-08** SIGTERM-grace test exercises the WAL + fullfsync
  guarantee asserted in Decision §rationale.
- **Phase 5 DEP-02/03 + DEP-07** consumes the Litestream + Tigris pattern
  pinned in Decision §rationale + Consequences §Positive.
- **Phase 7 PAR-04..06** is the first re-evaluation gate — the relational-
  join forcing-function fires (or doesn't) here.
- **ADR 0003 (Canonical Snapshot, plan 03-08)** is the next ADR; this
  plan's lint extension makes it cheap to add (`pnpm lint:adr:0003 = node
  ... --no-matrix`).
