Files
nxdns/specs/milestone-38.md
T
mokhtar 0f01c2fbd7
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
storage: version querylog.db and migrate it in place, never reset a healthy file
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.
2026-08-28 17:56:19 +02:00

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: 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.