storage: version querylog.db and migrate it in place, never reset a healthy file
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m46s
Gates / test-aarch64 (push) Successful in 8m38s
Gates / package (push) Successful in 4m39s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 31m58s

querylog.db carries a schema version; migrations run at startup as one transaction after a vacuumed 0600 backup, and every failure refuses startup (exit 2, no systemd restart loop) instead of starting empty. corruption is the only automatic recreate left. the cut gate now requires a fixture-proven migration or an explicit versioned break with restore instructions, and locks shipped migration files and fixtures byte-for-byte.
This commit is contained in:
2026-08-28 17:56:19 +02:00
parent c9701fae85
commit 0f01c2fbd7
25 changed files with 4312 additions and 176 deletions
+192
View File
@@ -0,0 +1,192 @@
# Milestone 38: querylog schema migrations
Stop the recurring query-history loss: schema changes migrate querylog.db in place; the automatic reset survives only for real corruption; explicit breaks stay possible but must be versioned, refused by `open`, and ship recovery instructions.
Owner rulings (2026-08-28): baseline is the 0.0.12/0.0.13 schema — nothing older is migratable; breaking changes remain allowed but must be explicit with clear changelog instructions; keep only the most recent pre-migration backup.
## Sessions
A (storage framework) first. B (cut gate) needs A's modules. C (docs) after A (documents A's behavior; shares no files with B). The orchestrator writes the changelog.
---
## Session A: migration framework in storage
### A.1 Version metadata module (pure, no SQLite)
New file `src/storage/querylog_versions.zig` — importable by `tools/cut.zig` without linking SQLite. ONLY comptime data:
- `pub const current_version: i32 = 1;`
- `pub const minimum_supported_version: i32 = 1;` — files stamped below this refuse. An EXPLICIT BREAK in a future release is expressed here: bump `current_version`, set `minimum_supported_version = current_version`, ship no step. The chain then cannot reach the new version from below the minimum and `open` refuses the old file — a break is always versioned, always refused at runtime, never silent.
- `pub const legacy_fingerprint: i32 = 1975011655;` — the literal `user_version` stamp the 0.0.12/0.0.13 binaries wrote (CRC32 of their DDL text). FROZEN literal, derived from nothing; comment cites v0.0.12.
- `pub const version_floor_guard: i32 = 1_000_000;`
- Comptime asserts: `minimum_supported_version >= 1`; `minimum_supported_version <= current_version`; `current_version <= version_floor_guard`; `legacy_fingerprint` outside `[0, version_floor_guard]`; `step_sql.len == current_version - minimum_supported_version`.
- `pub const step_sql: []const [:0]const u8 = &.{};` — step i migrates version `minimum_supported_version + i` to `+ i + 1`; each entry is `@embedFile("migrations/v<from>.sql")`. EMPTY this milestone.
- **Steps are SQL-only. There are no migration hooks.** A rebuild that m36-style projections would need is expressible as plain SQL (the recompute statements are SQL); a future change that truly cannot be SQL must amend this design explicitly in its own spec. This keeps every shipped migration byte-comparable (B.2 Gate 2) with no mutable code path.
- Shipped step files `src/storage/migrations/v<from>.sql` and fixtures (B.1) are immutable once released; the cut gate byte-compares them against the previous tag.
### A.2 Runner module and the rebuild rule
New file `src/storage/querylog_migrations.zig` (SQLite side): the runner and the equivalence oracle.
- `pub fn migrateSteps(database: *db.Db, sql: []const [:0]const u8, from: i32, target: i32) (db.Error || error{TransactionViolation})!void` — runs the steps and the final `PRAGMA user_version = target` stamp inside the caller's already-open transaction. SLICING CONTRACT: `sql` is exactly the `[from, target)` suffix — `sql[0]` migrates `from -> from + 1`; asserted: `sql.len == @intCast(target - from)`. Production callers slice `step_sql[from - minimum_supported_version ..]`. While steps execute, the runner installs SQLite's authorizer (`sqlite3_set_authorizer`; expose a scoped install/clear pair on the db wrapper) denying `SQLITE_TRANSACTION` — a step cannot BEGIN/COMMIT/ROLLBACK at all, which is the only reliable guard (a step containing `COMMIT; BEGIN IMMEDIATE;` would pass a post-step autocommit check while breaking atomicity; that exact bypass is a required negative test, and the test must also assert the authorizer is cleared after the rejection: the rollback succeeds and the SAME connection can then execute transaction statements normally — a leaked authorizer would block cleanup and strand the connection inside the migration transaction). The authorizer is cleared on every exit path. Belt: the post-step `sqlite3_get_autocommit(db) == 0` check stays. `migrateSteps`'s error set is `(db.Error || error{TransactionViolation})`; `runMigration` maps `TransactionViolation` to `error.MigrationFailed`. The no-transaction-statements rule is also in the step-authoring doc comment.
- `pub fn runMigration(io: std.Io, dir: std.Io.Dir, path: [:0]const u8, database: *db.Db, sql: []const [:0]const u8, from: i32, target: i32) Error!void` — the full orchestration seam: backup (A.4 step 1), transaction + `migrateSteps` + commit (step 2), failure handling (step 3), retention (step 4). `open` calls it with production metadata; synthetic tests call it directly with test chains, so the REAL backup/collision/retention/error paths are what the tests prove.
- **Rebuild rule** (doc comment on `step_sql`): a step that changes a table's shape must produce a table whose stored CREATE text is byte-identical to the fresh DDL's. The RUNNER brackets every migration with: `PRAGMA foreign_keys = OFF` and `PRAGMA legacy_alter_table = ON` BEFORE `BEGIN IMMEDIATE` (with `foreign_keys` on — which `db.applyPragmas` enables — a rename of a referenced parent rewrites child tables' FK text to `<t>_old`, corrupting them the moment the old table drops; `legacy_alter_table` alone does not prevent that), and restores both pragmas on EVERY exit path, success or failure (they are connection-global and non-transactional). Before COMMIT the runner runs `PRAGMA foreign_key_check` and fails the migration on any row. Step sequence: `ALTER TABLE <t> RENAME TO <t>_old`, `CREATE TABLE <t> ...` pasted VERBATIM from the target `querylog_schema.ddl`, `INSERT INTO <t> SELECT ...` mapping, `DROP TABLE <t>_old`, recreate EVERY dependent object of `<t>` verbatim from the target DDL — indexes AND triggers (both dropped with `<t>_old`). Views are NOT dropped by the rename or the drop (with `legacy_alter_table` on they keep naming `<t>`), so a step DROPs each view over `<t>` FIRST and recreates it verbatim LAST — recreating without the drop fails with "view already exists". `ALTER TABLE ... ADD/RENAME COLUMN` on a kept table is forbidden — SQLite rewrites stored CREATE text under it and the oracle's text layer would rightly fail.
### A.3 The open path (rework `querylog_schema.open`)
`open` owns the file exclusively: nxdns opens querylog.db once at startup before serving, and no other process shares a data dir (existing deployment contract; restate in `open`'s doc comment — the backup-then-lock sequence relies on it).
The version-handling half of `open` is factored as `openVersioned(io, dir, path, handle, plan) Error!void` where `handle: *?db.Db` is an optional SLOT: `openVersioned` closes and nulls it on every error path, so the caller's `errdefer` no-ops and single-close is structural rather than a convention (as built 2026-08-28; the post-commit test asserts `handle == null`). `plan: Plan = .{ .minimum: i32, .current: i32, .legacy_fingerprint: i32, .step_sql: []const [:0]const u8 }`. Production `open` passes the constant plan from `querylog_versions`; tests inject synthetic plans, which is what makes classification, migration, the post-commit mapping, and the sole-close ownership all testable through the REAL open path even while the production chain is empty. Classification itself stays a pure function of `(stamped, plan)`.
Classify a healthy existing file: read `PRAGMA user_version` as `stamped`, map to a logical version FIRST, mutate NOTHING during classification:
| condition | logical version | action |
| --- | --- | --- |
| `stamped == legacy_fingerprint` | 1 | classify version 1 by the rows below; if it lands on "current" or "supported older", first restamp to 1 (one transaction, A.5 error mapping), then proceed |
| `stamped == current_version` | stamped | open as today |
| `minimum_supported_version <= v < current_version` | v | migrate via `runMigration` |
| `current_version < v <= version_floor_guard` | v | REFUSE: `error.SchemaTooNew` |
| anything else (0, negatives, other fingerprints, below minimum) | — | REFUSE: `error.SchemaUnsupported` |
The order matters: after a future explicit break raises the minimum above 1, a legacy-fingerprint file maps to version 1, classifies as below-minimum, and refuses WITHOUT the restamp — an unsupported file is never modified.
REFUSE: the canonical file stays in place, logically untouched (schema, rows, watermark, stamp unchanged — WAL/SHM sidecar bytes may change from the probe; not a violation), nothing set aside, no new file, `open` errors, the server does not start. The log line names the path, the stamped value, the supported range, and `docs/how-to/troubleshoot.md` ("The server refuses to start over querylog.db").
Recreate lanes `missing`, `not_a_database`, `corrupt`, `quick_check_failed` unchanged. `RecreateReason.fingerprint_mismatch` and the `schema-changed` aside tag are DELETED.
Fresh files: after executing `ddl`, stamp `PRAGMA user_version = current_version` (the stamp is already a separate statement; the DDL text does not change this milestone, so `querylog_schema.fingerprint` does not move).
Backup retention has two passes with different authority. A migration's step 4 KNOWS the newest backup — this run's exact filename — and deletes every other `querylog.db.pre-migrate-*`; it is the primary mechanism. A plain successful open at current version runs a CONSERVATIVE retry for cleanups that once failed: parse `<epoch>` and the optional `-N` collision suffix from each name, delete only files whose epoch is STRICTLY below the maximum, keep every file tied at the maximum epoch, and never delete a name that does not parse. This pass EXPLICITLY assumes forward-moving wall clock between migrations (record the assumption in its doc comment): under a clock rollback an older high-epoch name could outrank a genuinely newer backup, which is why the authoritative exact-name pass in step 4 is the primary mechanism and this pass is only the retry for its failures.
### A.4 Running a migration (`runMigration`)
1. **Backup.** `VACUUM INTO` on the live connection (no open transaction) to `querylog.db.pre-migrate-<epoch>` in the database's directory. Destination must not pre-exist: on collision retry `-<epoch>-2`, `-3`, … The path enters the statement through an SQL string-literal quoting helper (double every `'`), never raw interpolation. On failure: delete the partial destination just created (only that file; an older valid backup survives), REFUSE with `error.MigrationBackupFailed`.
2. **One transaction.** `BEGIN IMMEDIATE`; re-read `user_version` under the lock. If it no longer equals `from`: ROLLBACK, delete this run's backup, REFUSE with `error.MigrationFailed` (exclusive ownership makes this outside interference). Otherwise `migrateSteps(db, sql, from, target)` — every step and the stamp in this one transaction — then COMMIT once.
3. **On PRE-COMMIT failure:** ROLLBACK, delete this run's backup, REFUSE with `error.MigrationFailed`, log the failing step index. Canonical file keeps its logical state. Never fall through to recreate.
3b. **On POST-COMMIT failure** (pragma restore or anything after a successful COMMIT): the file IS at `target` and that is said plainly in the log; the backup is KEPT (never deleted on this path). `runMigration` does NOT close the borrowed connection — it returns the distinct internal error `error.MigrationCommittedButUnclean`, and `querylog_schema.open`, which owns the handle and already has the sole error-path close, performs that one close and surfaces `error.MigrationFailed` to its caller. The next start takes the current-version lane cleanly. No post-commit path may claim the file unchanged or delete the backup.
4. **On success:** best-effort delete of every OTHER `querylog.db.pre-migrate-*` (keep this run's). Deletion errors warn and do not fail startup; A.3's every-open retention retries later. Log one line naming `from -> target` and the kept backup.
### A.5 Legacy restamp error mapping
The fingerprint→1 restamp is this milestone's only real mutation of operator data. It runs in one transaction; any failure (statement or commit) maps to `error.MigrationFailed`, rolls back, and leaves the legacy stamp and every row intact — REFUSE semantics, never recreate. Session A adds a test-only fault-injection seam to the db wrapper (`src/storage/db.zig`, following its existing `ReadTx.commit` injection style): one SQL-substring-matched one-shot seam on `Db.exec` covers statement and commit alike (both restamp statements pass through `Db.exec`), and the same seam drives the post-commit pragma-restore failure. Refusal paths log at `err`, which the test runner treats as failure, so `querylog_migrations.expected_failures` (begin/end/capturing, modelled on `db.read_tx_faults`) captures EXPECTED refusal logs per test; an unexpected refusal elsewhere still fails its test (as built 2026-08-28). Acceptance tests: the restamp forced to fail at (a) the statement and (b) the commit each leave `user_version == legacy_fingerprint` and the rows readable by a subsequent successful open.
### A.6 Schema equivalence oracle
`pub fn schemaEquivalent(gpa: std.mem.Allocator, a: *db.Db, b: *db.Db) (db.Error || std.mem.Allocator.Error)!bool` in `querylog_migrations.zig`. Two layers, both must agree:
1. **Textual, exact:** for every non-`sqlite_` object in `sqlite_schema` (tables, indexes, views, triggers), compare `(type, name, tbl_name, sql)` with `sql` compared byte-for-byte. No normalization: the A.2 rebuild rule guarantees a migrated table carries the verbatim fresh CREATE text, and a fresh file trivially does. This layer sees CHECK constraints, foreign keys, WITHOUT ROWID, partial-index predicates, trigger/view bodies.
2. **Structural belt:** per table, `PRAGMA table_xinfo` rows and `pragma_table_list` `wr`/`strict` flags; per table, `PRAGMA foreign_key_list`; per index, `PRAGMA index_xinfo` plus `index_list` `unique`/`origin`/`partial` flags.
Sort object and row lists before comparison. Negative tests: dropped `CHECK (rcode BETWEEN 0 AND 4095)`; dropped `REFERENCES domains(id)`; dropped `WITHOUT ROWID`; added column; and a table rebuilt via `ALTER TABLE ... RENAME` WITHOUT the verbatim-text rule compares UNEQUAL (proves the text layer catches SQLite's rename rewrite).
### A.7 Acceptance criteria
- [ ] Fresh file stamps `user_version = 1`, opens as current.
- [ ] A file stamped `1975011655` opens, restamps to 1, keeps every row; second open takes the current lane.
- [ ] `SchemaTooNew` and `SchemaUnsupported` refuse: schema dump, row count, watermark, stamp unchanged after refusal; no aside, no new file. One byte-hash variant on a checkpointed, sidecar-free fixture.
- [ ] Legacy-below-minimum ordering: with a test-local metadata view where minimum > 1 (drive the classification helper directly with injected constants — classification must be a pure function of `(stamped, minimum, current)` for exactly this reason), a legacy-fingerprint stamp classifies as REFUSE and no restamp happens.
- [ ] A.5 restamp-failure test.
- [ ] Synthetic chain through `runMigration` (1→3, two SQL steps, the second using the full A.2 rebuild sequence on a real table): backup exists, is a valid db, contains pre-migration rows; `user_version` lands on 3; rows survived; the rebuilt table's CREATE text equals the injected target text.
- [ ] Referenced-parent rebuild: a synthetic step rebuilds `domains` (referenced by `query_log`); after the migration, `query_log`'s stored FK text still says `REFERENCES domains(id)` (not `domains_old`), `PRAGMA foreign_key_check` is empty, and both pragmas read their defaults (`foreign_keys` per `applyPragmas`, `legacy_alter_table` off) after success AND after a forced failure.
- [ ] Mid-chain failure (step 2's SQL errors): canonical file logically unchanged (still version 1, rows intact), this run's backup deleted, an older backup preserved, `error.MigrationFailed`.
- [ ] `legacy_alter_table` pragma is OFF after both success and failure paths.
- [ ] Post-commit failure branch, driven through `openVersioned` with an injected synthetic plan (not by calling `runMigration` directly): force the pragma restore to fail after a successful COMMIT (fault seam) and assert: the file is at the target version with the migrated schema, the backup remains, the connection is closed exactly once (by the open path), that startup refuses with `error.MigrationFailed`, and the NEXT `openVersioned` under the same plan succeeds through the current-version lane.
- [ ] Backup retention: two successful synthetic migrations leave exactly one `pre-migrate-*`, the newer (step-4 authority, exact name). A directory seeded with an older epoch, a newest epoch, and a `-2` suffix tied at the newest epoch has a plain successful open delete only the older epoch — both max-epoch ties survive; an unparseable `pre-migrate-*` name survives untouched.
- [ ] Backup consistency: a row committed but not checkpointed (WAL-only) is present in the backup.
- [ ] `PRAGMA user_version` transactionality: set inside a transaction, ROLLBACK, original value observed.
- [ ] Oracle: fresh==fresh true; every A.6 negative test false; a `runMigration`-migrated file vs a fresh file at the target schema true.
- [ ] Grep scoped to `src/` and `tools/`: the `fingerprint_mismatch` identifier and the `schema-changed` aside-tag string are gone from active code (docs, specs, and changelog legitimately keep the words — the downgrade recovery text names the aside). Both suites green.
---
## Session B: cut gate inversion + fixture proof
### B.1 Fixtures
- `src/storage/testdata/querylog-v1-schema.sql` — the version-1 DDL frozen verbatim (today's `querylog_schema.ddl` text; the stamp is NOT part of it — the loader applies `PRAGMA user_version = 1`).
- `src/storage/testdata/querylog-v1-data.sql` — representative COHERENT content: query_log rows covering every `route_kind` and the NULL variants (qtype, cache_hit, response_time_us, upstream, forward_zone), matching `domains` rows, a non-default `available_since`, and `bucket_*` projection rows consistent with the raw rows. A fixture-validity test loads it and runs the projection-coherence oracle BEFORE any migration, so an incoherent fixture fails on its own.
- Immutable once shipped (header comment). From here on, every supported logical version in `[minimum_supported_version, current_version]` has a fixture pair — the current version's pair is the next migration's starting fixture, and an explicit break ships the new baseline pair.
The **fixture proof tests** (appended to `querylog_migrations.zig` by Session B, sequenced after A):
1. For EVERY starting version in `[minimum_supported_version, current_version)`: load that version's fixture pair, stamp it, run the real production chain, assert `schemaEquivalent` against a fresh-`ddl` db, every row survived, `available_since` preserved, projection coherence holds. Empty today; load-bearing without edits the day the chain grows.
2. The CURRENT version's fixture pair, stamped `current_version`, opens on the current lane, is `schemaEquivalent` to a fresh-`ddl` db, and passes projection coherence — the pair whose existence Gate 2 requires is thereby proven coherent, since the `[minimum, current)` loop never exercises it.
3. The legacy-stamp variant: a v1-fixture file stamped `1975011655` — while `minimum_supported_version == 1` it opens, restamps, and passes the same assertions as (2); the test is written against the classification helper's injected constants so that when a future break raises the minimum above 1, its companion assertion (legacy stamp + minimum > 1 REFUSES with `error.SchemaUnsupported`, file untouched) is already in the suite.
### B.2 The gate in tools/cut.zig
`cut` imports `querylog_versions` (pure, no SQLite — the link contract is why A.1 is separate). Two INDEPENDENT gates replace the disclose-a-reset gate. Let `prev_version` be the previous tag's `current_version` (parse `git show <tag>:src/storage/querylog_versions.zig` with the existing simple-extraction style; a tag predating the module means 1).
**Gate 1 — schema text.** Fingerprint the previous tag's DDL text vs the tree's. If changed, require ONE of:
- **Migration lane:** `current_version > prev_version` AND `prev_version >= minimum_supported_version` (the previous release's files are actually reachable — an explicit break can never wear this lane) AND the chain covers `[prev_version, current_version)` (with contiguous per-step files, that is `step_sql.len == current_version - minimum_supported_version` plus the fixture/file checks of Gate 2).
- **Explicit-break lane:** `current_version > prev_version` AND `minimum_supported_version == current_version` AND the changelog section contains BOTH "resets your query history" AND a `### Restoring your query history` heading with a non-empty body.
- Neither: FAIL.
**Gate 2 — migration metadata.** Runs INDEPENDENTLY of Gate 1 (catches data-only migrations and prefix edits when the DDL is unchanged):
- Every `src/storage/migrations/v<from>.sql` present at the previous tag: byte-identical in the tree; missing: FAIL.
- Every `src/storage/testdata/querylog-v*-{schema,data}.sql` present at the previous tag: byte-identical; missing: FAIL.
- A fixture pair exists for every version in `[minimum_supported_version, current_version]`: else FAIL.
- `current_version < prev_version`: FAIL (never regresses).
- `current_version > prev_version` with neither a new step file nor a break (`minimum == current`): FAIL.
- `current_version > prev_version` via new step(s) — REGARDLESS of whether the DDL fingerprint moved (data-only migrations included): the changelog section must contain "migrates your query log in place"; else FAIL.
- Let `prev_minimum` be the previous tag's `minimum_supported_version` (module absent at tag: 1). `minimum_supported_version < prev_minimum`: FAIL. `minimum_supported_version > prev_minimum` is ONLY acceptable as the full explicit break — `minimum == current` AND `current_version > prev_version` AND the break-lane changelog requirements — REGARDLESS of the DDL fingerprint; any other raise: FAIL (a release must never silently drop supported schemas).
- The tree's `legacy_fingerprint` is not the literal `1975011655`: FAIL (the legacy anchor is frozen forever; editing it strands unupgraded 0.0.12/0.0.13 files).
### B.3 Acceptance criteria
- [ ] Gate unit tests (pure functions over injected inputs, house style): unchanged schema + unchanged metadata passes; migration lane passes; explicit-break lane passes; changed schema with neither FAILS; break metadata (`minimum == current`) presented with the migration phrase FAILS Gate 1's migration lane; version bump with short chain FAILS; edited shipped step FAILS despite a version append; edited fixture FAILS; deleted step file FAILS; missing target-version fixture pair FAILS; version regression FAILS; version bump with no step and no break FAILS; data-only step (unchanged DDL) without the migration phrase FAILS; minimum regression FAILS; minimum raised without the full break FAILS (unchanged DDL variant included); edited `legacy_fingerprint` FAILS; previous tag without `querylog_versions.zig` maps to `prev_version == 1` and `prev_minimum == 1`.
- [ ] Fixture-validity test and fixture proof loop pass in the plain suite.
- [ ] `zig build cut` compiles; both suites green.
---
## Session C: docs (after A)
- `docs/how-to/troubleshoot.md`: new section "The server refuses to start over querylog.db" — `SchemaTooNew` (downgraded binary: return to the newer release, or restore the matching `pre-migrate` backup), `SchemaUnsupported` (file predates 0.0.12 or is foreign: not migratable; how to set it aside by hand if starting empty is acceptable), `MigrationFailed`/`MigrationBackupFailed` (the server never starts empty on its own; before the migration committed the file is untouched, and in the rare committed-but-unclean case the log says the migration DID complete, the backup is kept, and the next start simply proceeds).
- `docs/reference/` page on the query-log lifecycle: version stamp, in-place migration, one kept backup, the honest downgrade contract (downgrading to 0.0.13 or older RESETS the log — those binaries predate this contract; migration-aware binaries refuse cleanly), corruption as the only automatic recreate, the explicit-break contract (versioned, refused at startup, changelog carries restore instructions).
- Update the documents that still state the old contract: `PLAN.md`, `docs/explanation/architecture.md`, `specs/release-cut.md` — surgical edits to the stale sentences only.
Acceptance: prose accurate against A/B behavior, unwrapped lines, both suites still green.
---
## Module Layout
- `src/storage/querylog_versions.zig` — NEW: pure version/step metadata (cut-importable, no hooks by design).
- `src/storage/querylog_migrations.zig` — NEW: `migrateSteps`, `runMigration`, `schemaEquivalent`, fixture proof tests.
- `src/storage/migrations/` — one immutable SQL file per shipped step. NOT created this milestone (empty chain; git carries no empty directory) — the first real step creates it.
- `src/storage/querylog_schema.zig` — open-path rework, stamp change, lane deletions, every-open retention.
- `src/storage/testdata/querylog-v1-schema.sql`, `querylog-v1-data.sql` — NEW frozen fixtures.
- `src/storage/querylog_fixtures.zig` — NEW (Session B, as built): fixture loading and the survival oracle — full-content comparison against a pristine copy, each value encoded type-tag + byte-length + bytes so the comparison is injective (review round 2026-08-28).
- `tools/cut.zig` — two-gate rework.
- Session C's doc files.
## File Ownership
A: both new storage modules, `migrations/` dir, `querylog_schema.zig`, callers touched by lane deletion. B (after A): `tools/cut.zig`, `testdata/`, appends tests to `querylog_migrations.zig`, and makes the projection-coherence checker in `queries_repo.zig` `pub` (export-only edit — the checker is currently private to that file, which no session otherwise owns; B's fixture tests need it). C (after A): docs, `PLAN.md`, `specs/release-cut.md`. Orchestrator: CHANGELOG.md, spec sync.
A also owns the fault-injection seam addition in `src/storage/db.zig` (A.5).
## Changelog requirement (orchestrator)
This milestone's own changelog entry must disclose the one hazard neither gate can see: opening querylog.db under this release restamps it from the legacy fingerprint to version 1, so a LATER downgrade to 0.0.13 or older treats the numeric stamp as a fingerprint mismatch, renames the file to a `.schema-changed-<epoch>` aside, and starts an empty log. The restamp itself creates NO backup, so the accurate recovery is: return to a migration-aware release; stop the server; move the empty downgrade-created `querylog.db` out of the way AND delete its `querylog.db-wal`/`querylog.db-shm` sidecars (replaying the empty file's sidecars into the restored history would corrupt it — the recreate code documents this); move the downgrade-created `.schema-changed-<epoch>` aside back to `querylog.db`; start. The entry states the hazard and exactly that procedure.
## Acceptance Criteria (Milestone Complete)
- [ ] No code path recreates or sets aside a healthy querylog.db (grep proves the lane gone).
- [ ] A 0.0.13-created file (v1 schema + `1975011655` stamp) opens under the new binary with every row intact.
- [ ] Refusals and pre-commit migration failures leave the file logically untouched; a post-commit `MigrationFailed` leaves it successfully migrated to `target` (backup kept) and only refuses that one startup; the restamp is this milestone's only real mutation and its failure refuses without loss.
- [ ] The cut gate refuses: a schema change with neither lane, any edit to shipped steps or fixtures, a data-only migration without disclosure, and an explicit break without versioning + restore instructions.
- [ ] Both suites green, fmt clean.
## Anti-Requirements
- NO migration steps for pre-0.0.12 schemas (refusal with instructions is the contract).
- NO real chain step this milestone; synthetic chains live in tests only.
- NO migration hooks — steps are SQL files, period; a future need amends the design in its own spec.
- NO generic column-intersection salvage.
- NO `ALTER TABLE ADD/RENAME COLUMN` on kept tables in future steps (rebuild rule; recorded in doc comments, machine-enforced only via the oracle's exact-text layer).
- NO admin UI/API surface for migrations; startup log lines are the interface.
- NO config knob for backup retention.
- NO change to config.db handling.