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.
28 KiB
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: bumpcurrent_version, setminimum_supported_version = current_version, ship no step. The chain then cannot reach the new version from below the minimum andopenrefuses the old file — a break is always versioned, always refused at runtime, never silent.pub const legacy_fingerprint: i32 = 1975011655;— the literaluser_versionstamp 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_fingerprintoutside[0, version_floor_guard];step_sql.len == current_version - minimum_supported_version. pub const step_sql: []const [:0]const u8 = &.{};— step i migrates versionminimum_supported_version + ito+ 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>.sqland 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 finalPRAGMA user_version = targetstamp inside the caller's already-open transaction. SLICING CONTRACT:sqlis exactly the[from, target)suffix —sql[0]migratesfrom -> from + 1; asserted:sql.len == @intCast(target - from). Production callers slicestep_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) denyingSQLITE_TRANSACTION— a step cannot BEGIN/COMMIT/ROLLBACK at all, which is the only reliable guard (a step containingCOMMIT; 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-stepsqlite3_get_autocommit(db) == 0check stays.migrateSteps's error set is(db.Error || error{TransactionViolation});runMigrationmapsTransactionViolationtoerror.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).opencalls 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 = OFFandPRAGMA legacy_alter_table = ONBEFOREBEGIN IMMEDIATE(withforeign_keyson — whichdb.applyPragmasenables — a rename of a referenced parent rewrites child tables' FK text to<t>_old, corrupting them the moment the old table drops;legacy_alter_tablealone 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 runsPRAGMA foreign_key_checkand fails the migration on any row. Step sequence:ALTER TABLE <t> RENAME TO <t>_old,CREATE TABLE <t> ...pasted VERBATIM from the targetquerylog_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 (withlegacy_alter_tableon 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 COLUMNon 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)
- Backup.
VACUUM INTOon the live connection (no open transaction) toquerylog.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 witherror.MigrationBackupFailed. - One transaction.
BEGIN IMMEDIATE; re-readuser_versionunder the lock. If it no longer equalsfrom: ROLLBACK, delete this run's backup, REFUSE witherror.MigrationFailed(exclusive ownership makes this outside interference). OtherwisemigrateSteps(db, sql, from, target)— every step and the stamp in this one transaction — then COMMIT once. - 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 attargetand that is said plainly in the log; the backup is KEPT (never deleted on this path).runMigrationdoes NOT close the borrowed connection — it returns the distinct internal errorerror.MigrationCommittedButUnclean, andquerylog_schema.open, which owns the handle and already has the sole error-path close, performs that one close and surfaceserror.MigrationFailedto its caller. The next start takes the current-version lane cleanly. No post-commit path may claim the file unchanged or delete the backup. - 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 namingfrom -> targetand 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:
- Textual, exact: for every non-
sqlite_object insqlite_schema(tables, indexes, views, triggers), compare(type, name, tbl_name, sql)withsqlcompared 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. - Structural belt: per table,
PRAGMA table_xinforows andpragma_table_listwr/strictflags; per table,PRAGMA foreign_key_list; per index,PRAGMA index_xinfoplusindex_listunique/origin/partialflags.
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
1975011655opens, restamps to 1, keeps every row; second open takes the current lane. SchemaTooNewandSchemaUnsupportedrefuse: 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_versionlands on 3; rows survived; the rebuilt table's CREATE text equals the injected target text. - Referenced-parent rebuild: a synthetic step rebuilds
domains(referenced byquery_log); after the migration,query_log's stored FK text still saysREFERENCES domains(id)(notdomains_old),PRAGMA foreign_key_checkis empty, and both pragmas read their defaults (foreign_keysperapplyPragmas,legacy_alter_tableoff) 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_tablepragma is OFF after both success and failure paths.- Post-commit failure branch, driven through
openVersionedwith an injected synthetic plan (not by callingrunMigrationdirectly): 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 witherror.MigrationFailed, and the NEXTopenVersionedunder 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-2suffix tied at the newest epoch has a plain successful open delete only the older epoch — both max-epoch ties survive; an unparseablepre-migrate-*name survives untouched. - Backup consistency: a row committed but not checkpointed (WAL-only) is present in the backup.
PRAGMA user_versiontransactionality: 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/andtools/: thefingerprint_mismatchidentifier and theschema-changedaside-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'squerylog_schema.ddltext; the stamp is NOT part of it — the loader appliesPRAGMA user_version = 1).src/storage/testdata/querylog-v1-data.sql— representative COHERENT content: query_log rows covering everyroute_kindand the NULL variants (qtype, cache_hit, response_time_us, upstream, forward_zone), matchingdomainsrows, a non-defaultavailable_since, andbucket_*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):
- For EVERY starting version in
[minimum_supported_version, current_version): load that version's fixture pair, stamp it, run the real production chain, assertschemaEquivalentagainst a fresh-ddldb, every row survived,available_sincepreserved, projection coherence holds. Empty today; load-bearing without edits the day the chain grows. - The CURRENT version's fixture pair, stamped
current_version, opens on the current lane, isschemaEquivalentto a fresh-ddldb, and passes projection coherence — the pair whose existence Gate 2 requires is thereby proven coherent, since the[minimum, current)loop never exercises it. - The legacy-stamp variant: a v1-fixture file stamped
1975011655— whileminimum_supported_version == 1it 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 witherror.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_versionANDprev_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 isstep_sql.len == current_version - minimum_supported_versionplus the fixture/file checks of Gate 2). - Explicit-break lane:
current_version > prev_versionANDminimum_supported_version == current_versionAND the changelog section contains BOTH "resets your query history" AND a### Restoring your query historyheading 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>.sqlpresent at the previous tag: byte-identical in the tree; missing: FAIL. - Every
src/storage/testdata/querylog-v*-{schema,data}.sqlpresent 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_versionwith neither a new step file nor a break (minimum == current): FAIL.current_version > prev_versionvia 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_minimumbe the previous tag'sminimum_supported_version(module absent at tag: 1).minimum_supported_version < prev_minimum: FAIL.minimum_supported_version > prev_minimumis ONLY acceptable as the full explicit break —minimum == currentANDcurrent_version > prev_versionAND 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_fingerprintis not the literal1975011655: 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); editedlegacy_fingerprintFAILS; previous tag withoutquerylog_versions.zigmaps toprev_version == 1andprev_minimum == 1. - Fixture-validity test and fixture proof loop pass in the plain suite.
zig build cutcompiles; 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 matchingpre-migratebackup),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 +
1975011655stamp) opens under the new binary with every row intact. - Refusals and pre-commit migration failures leave the file logically untouched; a post-commit
MigrationFailedleaves it successfully migrated totarget(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 COLUMNon 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.