Skip to main content

fathomdb_schema/
lib.rs

1//! **FathomDB schema** — the versioned migration registry and bootstrap.
2//!
3//! An internal leaf crate of the FathomDB workspace. It owns `SCHEMA_VERSION`,
4//! the ordered `MIGRATIONS` table, and the routine that brings an on-disk
5//! SQLite database up to the current version. `fathomdb-engine` calls it on the
6//! open path; **application code should depend on the `fathomdb` facade crate
7//! instead** and never invoke migration directly.
8//!
9//! The on-disk sentinel is SQLite's `PRAGMA user_version`. A migration step is
10//! applied inside one `BEGIN IMMEDIATE` together with the version bump, so a
11//! crash mid-step rolls back and the step re-runs whole.
12//!
13//! ⚠ Most steps are accretive, but not all are. Step 23 (TC-33) recreates
14//! `canonical_edges` with INTEGER epoch-second temporal columns and **does not
15//! migrate the data**: existing edge rows do not survive and no stored ISO-8601
16//! value is converted. Nodes are unaffected. Anything that describes upgrading
17//! an existing workspace must disclose this.
18
19use std::fmt::{Display, Formatter};
20use std::time::Instant;
21
22use rusqlite::Connection;
23
24pub const SCHEMA_VERSION: u32 = 25;
25
26/// SQLite `PRAGMA` name carrying the on-disk schema-version sentinel.
27///
28/// Public on-disk surface per `dev/interfaces/wire.md` § Schema-version
29/// sentinel; advanced by successful migrations per `dev/design/migrations.md`.
30pub const PRAGMA_USER_VERSION: &str = "user_version";
31
32/// Suffix of the canonical SQLite database file (`<db-name>.sqlite`).
33pub const SQLITE_SUFFIX: &str = ".sqlite";
34
35/// Suffix of the SQLite write-ahead log file (`<db-name>.sqlite-wal`).
36pub const WAL_SUFFIX: &str = "-wal";
37
38/// Suffix of the sidecar lock file (`<db-name>.sqlite.lock`).
39///
40/// Per `dev/design/bindings.md` § 7, this sidecar flock is the load-bearing
41/// cross-process exclusion layer; it surfaces lock contention before SQLite
42/// I/O begins.
43pub const LOCK_SUFFIX: &str = ".lock";
44
45/// Suffix of the optional SQLite rollback journal file
46/// (`<db-name>.sqlite-journal`).
47pub const JOURNAL_SUFFIX: &str = "-journal";
48
49#[must_use]
50pub fn bootstrap_steps() -> &'static [&'static str] {
51    &["create canonical tables", "register projection metadata", "seed rewrite-era configuration"]
52}
53
54/// Canonical tables owned by the rewrite-era schema, in stable display
55/// order. Excludes FTS, vec0, and projection shadow tables (re-derivable
56/// from canonical state) and internal `_fathomdb_*` metadata.
57///
58/// `doctor dump-row-counts` enumerates this set; `doctor dump-schema`
59/// uses it to order canonical tables ahead of derived/internal ones.
60pub const CANONICAL_TABLES: &[&str] = &[
61    "canonical_nodes",
62    "canonical_edges",
63    "operational_collections",
64    "operational_mutations",
65    "operational_state",
66];
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub struct Migration {
70    pub step_id: u32,
71    pub sql: &'static str,
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct MigrationStepReport {
76    pub step_id: u32,
77    pub duration_ms: Option<u64>,
78    pub failed: bool,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct MigrationReport {
83    pub schema_version_before: u32,
84    pub schema_version_after: u32,
85    pub migration_steps: Vec<MigrationStepReport>,
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct MigrationFailureReport {
90    pub schema_version_before: u32,
91    pub schema_version_current: u32,
92    pub migration_steps: Vec<MigrationStepReport>,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub enum MigrationError {
97    IncompatibleSchemaVersion { seen: u32, supported: u32 },
98    MigrationError(MigrationFailureReport),
99    Storage { message: &'static str },
100}
101
102impl Display for MigrationError {
103    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
104        match self {
105            Self::IncompatibleSchemaVersion { seen, supported } => {
106                write!(f, "database schema version {seen} is incompatible with supported version {supported}")
107            }
108            Self::MigrationError(report) => write!(
109                f,
110                "schema migration failed at step {}",
111                report.migration_steps.last().map_or(0, |step| step.step_id)
112            ),
113            Self::Storage { message } => write!(f, "schema storage error: {message}"),
114        }
115    }
116}
117
118impl std::error::Error for MigrationError {}
119
120pub const MIGRATIONS: &[Migration] = &[
121    Migration {
122        step_id: 1,
123        sql: "CREATE TABLE IF NOT EXISTS _fathomdb_schema_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)",
124    },
125    Migration {
126        step_id: 2,
127        sql: "CREATE TABLE IF NOT EXISTS _fathomdb_migrations(step_id INTEGER PRIMARY KEY, applied_at_ms INTEGER NOT NULL);
128              CREATE TABLE IF NOT EXISTS canonical_nodes(write_cursor INTEGER NOT NULL, kind TEXT NOT NULL, body TEXT NOT NULL);
129              CREATE TABLE IF NOT EXISTS canonical_edges(write_cursor INTEGER NOT NULL, kind TEXT NOT NULL, from_id TEXT NOT NULL, to_id TEXT NOT NULL);",
130    },
131    Migration {
132        step_id: 3,
133        sql: "CREATE TABLE IF NOT EXISTS _fathomdb_embedder_profiles(profile TEXT PRIMARY KEY, name TEXT NOT NULL, revision TEXT NOT NULL, dimension INTEGER NOT NULL)",
134    },
135    Migration {
136        step_id: 4,
137        sql: "CREATE TABLE IF NOT EXISTS operational_collections(
138                  name TEXT PRIMARY KEY,
139                  kind TEXT NOT NULL CHECK(kind IN ('append_only_log', 'latest_state')),
140                  schema_json TEXT NOT NULL,
141                  retention_json TEXT NOT NULL,
142                  format_version INTEGER NOT NULL,
143                  created_at INTEGER NOT NULL
144              );
145              CREATE TABLE IF NOT EXISTS operational_mutations(
146                  id INTEGER PRIMARY KEY AUTOINCREMENT,
147                  collection_name TEXT NOT NULL,
148                  record_key TEXT NOT NULL,
149                  op_kind TEXT NOT NULL CHECK(op_kind = 'append'),
150                  payload_json TEXT NOT NULL,
151                  schema_id TEXT,
152                  write_cursor INTEGER NOT NULL
153              );
154              CREATE TABLE IF NOT EXISTS operational_state(
155                  collection_name TEXT NOT NULL,
156                  record_key TEXT NOT NULL,
157                  payload_json TEXT NOT NULL,
158                  schema_id TEXT,
159                  write_cursor INTEGER NOT NULL,
160                  PRIMARY KEY(collection_name, record_key)
161              );
162              CREATE TABLE IF NOT EXISTS _fathomdb_open_state(key TEXT PRIMARY KEY, value TEXT NOT NULL);
163              INSERT OR IGNORE INTO operational_collections(
164                  name, kind, schema_json, retention_json, format_version, created_at
165              ) VALUES (
166                  'projection_failures',
167                  'append_only_log',
168                  '{\"type\":\"object\"}',
169                  '{}',
170                  1,
171                  0
172              );",
173    },
174    Migration {
175        step_id: 5,
176        sql: "CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
177                  body,
178                  kind UNINDEXED,
179                  write_cursor UNINDEXED
180              );",
181    },
182    Migration {
183        step_id: 6,
184        sql: "CREATE TABLE IF NOT EXISTS _fathomdb_projection_state(
185                  kind TEXT PRIMARY KEY,
186                  last_enqueued_cursor INTEGER NOT NULL DEFAULT 0,
187                  updated_at INTEGER NOT NULL DEFAULT 0
188              );
189              CREATE TABLE IF NOT EXISTS _fathomdb_vector_kinds(
190                  kind TEXT PRIMARY KEY,
191                  profile TEXT NOT NULL,
192                  created_at INTEGER NOT NULL DEFAULT 0
193              );
194              CREATE TABLE IF NOT EXISTS _fathomdb_vector_rows(
195                  rowid INTEGER PRIMARY KEY,
196                  kind TEXT NOT NULL,
197                  write_cursor INTEGER NOT NULL UNIQUE
198              );",
199    },
200    Migration {
201        step_id: 7,
202        sql: "CREATE TABLE IF NOT EXISTS _fathomdb_projection_terminal(
203                  write_cursor INTEGER PRIMARY KEY,
204                  state TEXT NOT NULL CHECK(state IN ('failed', 'up_to_date'))
205              );",
206    },
207    // Phase 9 Pack B — REQ-026 / AC-028a/b/c / AC-042 recovery seam.
208    // `source_id` is nullable; existing canonical rows back-fill to NULL,
209    // so reads from older callers stay schema-stable. REQ-045 accretion
210    // offset is documented in `migrations/008_source_id.sql` as inherently
211    // impossible for this slice (every existing canonical column is
212    // load-bearing for replay / projections / recovery locators); the
213    // next schema-touching pack carries the offset budget for two adds.
214    Migration {
215        step_id: 8,
216        sql: "ALTER TABLE canonical_nodes ADD COLUMN source_id TEXT;
217              ALTER TABLE canonical_edges ADD COLUMN source_id TEXT;
218              CREATE INDEX IF NOT EXISTS canonical_nodes_source_id_idx
219                  ON canonical_nodes(source_id);
220              CREATE INDEX IF NOT EXISTS canonical_edges_source_id_idx
221                  ON canonical_edges(source_id);",
222    },
223    // 0.7.0 Pack 1 — Vector binary-quantization data encoding.
224    // Per `dev/design/0.7.0-vector-quant-pack1.md` D4 (fix-3). Stages
225    // the existing f32 corpus + kind mapping into a regular SQL table,
226    // drops + recreates `vector_default` with the new schema (sibling
227    // `embedding_bin bit[768]`, `source_type TEXT partition key`,
228    // `kind TEXT`, `created_at INTEGER`), then repopulates with
229    // SQL-side `vec_quantize_binary` and the D3 CASE mapping. A
230    // prefix CHECK-constraint preflight aborts the migration if any
231    // `_fathomdb_vector_rows.kind` is outside the locked vocabulary.
232    //
233    // `<dim>=768` is hardcoded against the default profile
234    // (`load_default_profile` -> `DEFAULT_EMBEDDER_DIMENSION` in
235    // fathomdb-engine). The design notes this constraint and defers
236    // a runtime-dim migration to 0.7.1.
237    Migration {
238        step_id: 9,
239        // SQL-side: D4 fix-3.1 preflight only. The vec0 reshape itself is
240        // dim-aware and lives in the engine crate's
241        // `ensure_vector_partition_pack1` (called by `ensure_vector_partition`
242        // immediately after `migrate_with_event_sink` returns). Splitting the
243        // preflight (SQL, in-tx with `apply_one`) from the reshape (Rust,
244        // dim-driven by `_fathomdb_embedder_profiles.dimension`) is required
245        // because `fathomdb-schema::Migration` is a `&'static str` with no
246        // runtime parameterization, and the existing dim=8 / dim=384 test
247        // suite must stay GREEN. The reshape is idempotent across crashes:
248        // if open fails between this step's commit (user_version=9) and the
249        // Rust reshape, the next open re-detects the old shape and replays
250        // the reshape. See dev/plans/runs/0.7.0-PVQ-P1-IMPL-output.json
251        // for the design-memo deviation note.
252        sql: "CREATE TEMP TABLE _vec0_migration_assertion(
253                  check_passes INTEGER NOT NULL CHECK(check_passes = 1)
254              );
255              INSERT INTO _vec0_migration_assertion(check_passes)
256                  SELECT CASE WHEN EXISTS (
257                      SELECT 1 FROM _fathomdb_vector_rows
258                      WHERE kind NOT IN ('email','article','paper','meeting','note','todo','doc')
259                  ) THEN 0 ELSE 1 END;
260              DROP TABLE _vec0_migration_assertion;",
261    },
262    // 0.7.1 EU-5a2 — mean-centering schema column.
263    // Per `dev/design/embedder.md` §0.2: nullable BLOB holding the
264    // pinned per-workspace mean vector for the default profile. Byte
265    // length, when non-NULL, MUST equal `4 * dimension` (f32 little-endian).
266    // Pure additive ALTER; SQLite stores NULL for the pre-existing row.
267    // Lifecycle (compute-once-on-first-ingest threshold-pin) is in the
268    // engine crate, not the schema layer.
269    Migration {
270        step_id: 10,
271        sql: "ALTER TABLE _fathomdb_embedder_profiles ADD COLUMN mean_vec BLOB",
272    },
273    // 0.8.0 Slice 5 (G1) — global FTS5 tokenizer-default upgrade.
274    // Per `dev/plans/0.8.0-implementation.md` § "Slice 5" and the design
275    // memo `dev/design/0.8.0-slice-5-G1-design.md`. Migrations are
276    // forward-only and immutable, and FTS5 has no `ALTER … tokenize`, so the
277    // tokenizer default is upgraded by dropping and recreating the
278    // `search_index` virtual table rather than editing the step-5 DDL (which
279    // would change the tokenizer for new DBs only). The drop+recreate leaves
280    // the FTS index empty on a migrated DB; the engine re-tokenizes from the
281    // canonical source rows immediately after this step lands (open path,
282    // `reproject_search_index_after_tokenizer_upgrade`) — projection-only, no
283    // source-record migration. `DROP TABLE` already satisfies the accretion
284    // guard's `names_removal` branch; the exemption marker is carried to
285    // document intent and match the established pattern.
286    Migration {
287        step_id: 11,
288        sql: "-- MIGRATION-ACCRETION-EXEMPTION: tokenizer-default upgrade (drop+recreate FTS5 projection; no source-record migration)
289              DROP TABLE IF EXISTS search_index;
290              CREATE VIRTUAL TABLE search_index USING fts5(
291                  body,
292                  kind UNINDEXED,
293                  write_cursor UNINDEXED,
294                  tokenize = 'porter unicode61 remove_diacritics 2'
295              );",
296    },
297    // 0.8.0 Slice 15 (G0 KEYSTONE) — transaction-time canonical-identity
298    // substrate. Per `dev/adr/ADR-0.8.0-canonical-identity-substrate.md`
299    // (SIGNED 2026-06-03) and `dev/design/slice-15-g0-design.md`. Two additive
300    // nullable columns on BOTH canonical tables: `logical_id TEXT` (stable
301    // cross-re-ingestion identity; NULL = legacy/own-identity row) and
302    // `superseded_at INTEGER` (transaction-time tombstone; NULL = active row).
303    // A partial UNIQUE INDEX `(logical_id) WHERE superseded_at IS NULL` per table
304    // enforces one active version per logical id — scoped to `logical_id` ALONE
305    // (Decision 5, HITL-SIGNED 2026-06-05; `kind` is payload/classification on
306    // nodes and relationship-type on edges, NEVER an identity-scope component).
307    // NULL-safe, so the many legacy NULL-logical_id rows never collide (SQLite
308    // treats each NULL as distinct; load-bearing). The folded G4/G5 read indexes
309    // (`canonical_nodes(kind)`, `canonical_edges(from_id)/(to_id)`) ride this one
310    // accretion offset budget. Pure additive ALTER (no DROP) → the exemption
311    // marker is REQUIRED (the accretion guard rejects ADD COLUMN without it);
312    // legacy rows read NULL with no data migration / re-open (in-place ALTER).
313    // Step-12 amended IN PLACE (Slice 31, no SCHEMA_VERSION bump): already-migrated
314    // local v12 DBs keep the old compound index until rebuilt (HITL: disposable).
315    Migration {
316        step_id: 12,
317        sql: "-- MIGRATION-ACCRETION-EXEMPTION: G0 transaction-time identity substrate
318              ALTER TABLE canonical_nodes ADD COLUMN logical_id TEXT;
319              ALTER TABLE canonical_nodes ADD COLUMN superseded_at INTEGER;
320              ALTER TABLE canonical_edges ADD COLUMN logical_id TEXT;
321              ALTER TABLE canonical_edges ADD COLUMN superseded_at INTEGER;
322              CREATE UNIQUE INDEX IF NOT EXISTS canonical_nodes_logical_active_idx
323                  ON canonical_nodes(logical_id) WHERE superseded_at IS NULL;
324              CREATE UNIQUE INDEX IF NOT EXISTS canonical_edges_logical_active_idx
325                  ON canonical_edges(logical_id) WHERE superseded_at IS NULL;
326              CREATE INDEX IF NOT EXISTS canonical_nodes_kind_idx
327                  ON canonical_nodes(kind);
328              CREATE INDEX IF NOT EXISTS canonical_edges_from_id_idx
329                  ON canonical_edges(from_id);
330              CREATE INDEX IF NOT EXISTS canonical_edges_to_id_idx
331                  ON canonical_edges(to_id);",
332    },
333    // 0.8.0 Slice 33 (G3 / F4-READ) — op-store paginated read-back hardening.
334    // Per `dev/design/slice-33-cursor-hardening-design.md`. The governed
335    // `read.collection` / `read.mutations` SELECT is
336    // `WHERE collection_name = ?1 AND id > ?2 ORDER BY id LIMIT ?3`. Without an
337    // index on `collection_name`, SQLite rides the `id` PRIMARY KEY (EXPLAIN:
338    // `SEARCH … USING INTEGER PRIMARY KEY (rowid>?)`), scanning the id-ordered
339    // log and filtering `collection_name` row-by-row — O(rows-scanned) for a
340    // small collection inside a large multi-collection log. The composite
341    // `(collection_name, id)` index makes the plan index-driven (EXPLAIN:
342    // `SEARCH … USING INDEX operational_mutations_collection_id_idx
343    // (collection_name=? AND id>?)`): the leading equality on `collection_name`
344    // fixes the prefix, the trailing `id` serves BOTH the after-id cursor range
345    // and `ORDER BY id` with no temp B-tree — O(page). Pure additive
346    // `CREATE INDEX` (no table/column add, no DROP, no table reshape), so the
347    // accretion guard does not flag it and no exemption marker is required.
348    Migration {
349        step_id: 13,
350        sql: "CREATE INDEX IF NOT EXISTS operational_mutations_collection_id_idx
351                  ON operational_mutations(collection_name, id);",
352    },
353    // 0.8.1 Slice 15 (G11) — fact-on-edge enrichment + edge projectability.
354    // Per `dev/adr/ADR-0.8.1-graph-substrate-g11-migration.md` (HITL-SIGNED
355    // 2026-06-13). Five additive nullable columns on `canonical_edges`:
356    //   `body`              — the fact/relationship text for FTS + vector projection
357    //   `t_valid`           — event valid-time; NULL = "still valid"
358    //   `t_invalid`         — event invalid-time; NULL = "still valid"
359    //     (SUPERSEDED BY STEP 23 / TC-33: both were ISO-8601 TEXT here and are
360    //     now INTEGER epoch seconds with a `typeof` CHECK. The "NULL = still
361    //     valid" semantic is UNCHANGED and load-bearing — see step 23 for why
362    //     `NOT NULL` would be the wrong structural spelling.)
363    //   `confidence`        — extraction confidence ∈ [0.0, 1.0] from the harness
364    //   `extractor_model_id`— opaque model id from BYO-LLM harness `ready.model`
365    // All five are nullable; pre-G11 rows read NULL (no data migration required).
366    // Also creates `search_index_edges` FTS5 virtual table for edge-body FTS
367    // projection (Option B: separate table, no modification to the existing
368    // `search_index` path). MIGRATION-ACCRETION-EXEMPTION required for ADD COLUMN.
369    Migration {
370        step_id: 14,
371        sql: "-- MIGRATION-ACCRETION-EXEMPTION: G11 edge enrichment (5 additive nullable columns + edge FTS table)
372              ALTER TABLE canonical_edges ADD COLUMN body TEXT;
373              ALTER TABLE canonical_edges ADD COLUMN t_valid TEXT;
374              ALTER TABLE canonical_edges ADD COLUMN t_invalid TEXT;
375              ALTER TABLE canonical_edges ADD COLUMN confidence REAL;
376              ALTER TABLE canonical_edges ADD COLUMN extractor_model_id TEXT;
377              CREATE VIRTUAL TABLE IF NOT EXISTS search_index_edges USING fts5(
378                  body,
379                  kind UNINDEXED,
380                  write_cursor UNINDEXED,
381                  tokenize = 'porter unicode61 remove_diacritics 2'
382              );",
383    },
384    // 0.8.1 Slice 30 (R3) SCHEMA-GATE-1 — temporal_fallback provenance flag.
385    // HITL-SIGNED 2026-06-13: approved additive schema bump.
386    // Edges whose `t_valid` was defaulted to `created_at` by the ELPS extractor
387    // (not text-grounded) carry this flag so the graph-arm BFS can exclude them
388    // from temporal queries. NULL = not a fallback (pre-column rows and edges
389    // written without the flag are treated as NOT temporal_fallback — safe default
390    // since they were written before provenance tracking existed or via a direct
391    // write where the caller owns the t_valid).
392    // MIGRATION-ACCRETION-EXEMPTION required for ADD COLUMN.
393    Migration {
394        step_id: 15,
395        sql: "-- MIGRATION-ACCRETION-EXEMPTION: R3 temporal_fallback provenance flag (additive nullable BOOLEAN column)
396              ALTER TABLE canonical_edges ADD COLUMN temporal_fallback INTEGER;",
397    },
398    // 0.8.14 Slice 5 (EXP-S KEYSTONE) — kind-tagged coexisting-index substrate.
399    // Per `dev/adr/ADR-0.8.14-exp-s-kind-tagged-coexisting-index-substrate.md` D1
400    // (RATIFIED 2026-07-03) and `dev/plans/plan-0.8.14.md` §2 (R-SUB-1/R-SUB-3).
401    // Adds `row_kind` — a SEPARATE structural-role axis on `canonical_nodes`,
402    // orthogonal to the doc-type `kind` (email/article/paper/meeting/note/todo/
403    // doc/edge_fact). Vocabulary: `leaf` (normal record — the DEFAULT, which
404    // preserves current behavior for every existing/normal row), `coverage`
405    // (coverage/summary rows), `graph` (graph structural rows). D1 is explicit:
406    // this must NOT overload the doc-type `kind` vocabulary or touch its three
407    // hard-locked sites (engine `resolve_source_type` / `KIND_TO_SOURCE_TYPE_CASE_SQL`
408    // / this crate's migration-9 preflight CHECK). NOT NULL DEFAULT 'leaf' is a
409    // constant default, so pre-existing rows back-fill to `leaf` in-place (no data
410    // migration / re-open) and the migration is forward-only. Additive ADD COLUMN
411    // (no DROP) → the accretion guard REQUIRES the exemption marker. No vec0
412    // embedding/quant/pooling change (ADR §D6): this step does NOT rewrite vec0
413    // rows, so the eu7 fidelity gate stays a documented no-op at Slice 20.
414    Migration {
415        step_id: 16,
416        sql: "-- MIGRATION-ACCRETION-EXEMPTION: EXP-S row_kind structural-role tag (additive NOT NULL DEFAULT 'leaf' column; separate axis from doc-type kind)
417              ALTER TABLE canonical_nodes ADD COLUMN row_kind TEXT NOT NULL DEFAULT 'leaf';",
418    },
419    // 0.8.14 Slice 10 (F5 — fielded FTS / BM25F) — multi-column FTS5 index.
420    // Per `dev/adr/ADR-0.8.1-deferred-f5-fielded-fts-bm25f.md` §3.1 and
421    // `dev/adr/ADR-0.8.14-exp-s-kind-tagged-coexisting-index-substrate.md` §D4
422    // (RATIFIED 2026-07-03; F5 co-lands by the §D8 HITL override) and
423    // `dev/plans/plan-0.8.14.md` §2 (R-F5-1 / R-SUB-3). Creates a NEW FTS5 virtual
424    // table `search_index_v2` over the doc-type node fields `kind` / `body` /
425    // `status`, so a BM25F query can weight each field independently
426    // (`bm25(search_index_v2, W_kind, W_body, W_status)`), riding the EXP-S
427    // substrate. This is ADDITIVE and coexists with the single-column body-only
428    // `search_index` (which is RETAINED, byte-unchanged — the existing RRF/lexical
429    // query path keeps using it, so its determinism pins are untouched): the new
430    // table is a second coexisting index in the "one store, many indexes"
431    // substrate, exactly like `search_index_edges` (step 14, Option B). FTS5 has
432    // no in-place column-add, so BM25F requires a new virtual table + an O(N)
433    // re-index; the co-land with step 16 means an old DB pays ONE re-index window
434    // (`SCHEMA_VERSION` 15 -> 17 in one open). The `status` field is derived from
435    // the JSON body's `$.status`, guarded by `json_valid` so non-JSON bodies
436    // index an empty status; this is F5's own `$.status`-derived field, NOT
437    // the value the shipped G10 SearchFilter reads (G10 reads vec0 `status`,
438    // still the empty sentinel). The
439    // `write_cursor` UNINDEXED column mirrors `search_index` for the
440    // canonical-row join (rowid==write_cursor identity is preserved by the
441    // engine write path; the vec0 corpus is NOT touched, so the eu7 fidelity gate
442    // stays a documented no-op at Slice 20 — ADR-0.8.14 §D6). `CREATE VIRTUAL
443    // TABLE` does not trip the accretion guard (it fires only on `CREATE TABLE` /
444    // `ADD COLUMN`), but the exemption marker is carried to document the additive
445    // re-index intent and match the step-11/step-14 virtual-table precedent.
446    Migration {
447        step_id: 17,
448        sql: "-- MIGRATION-ACCRETION-EXEMPTION: F5 fielded FTS (new multi-column search_index_v2 FTS5 table + O(N) re-index; search_index retained)
449              CREATE VIRTUAL TABLE IF NOT EXISTS search_index_v2 USING fts5(
450                  kind,
451                  body,
452                  status,
453                  write_cursor UNINDEXED,
454                  tokenize = 'porter unicode61 remove_diacritics 2'
455              );
456              INSERT INTO search_index_v2(kind, body, status, write_cursor)
457                  SELECT
458                      kind,
459                      body,
460                      CASE WHEN json_valid(body)
461                           THEN COALESCE(json_extract(body, '$.status'), '')
462                           ELSE '' END,
463                      write_cursor
464                  FROM canonical_nodes;",
465    },
466    // 0.8.16 Slice 5 (F9 KEYSTONE) — node-level importance ranking scalar.
467    // Per `dev/adr/ADR-0.8.16-f9-importance-confidence-ranking.md` §2.1
468    // (SIGNED 2026-07-08) and `dev/plans/plan-0.8.16.md` §2 (R-F9-1/R-F9-4).
469    // Adds `importance REAL` on `canonical_nodes` — a caller-supplied ranking
470    // scalar, symmetric with the existing genuine-NULL `canonical_edges.confidence`
471    // (step-14). 3-way sentinel (frozen): `NULL` = never assigned (graceful-absent,
472    // ranks NEUTRAL — the OPP-12 Q6a graceful-absent state, load-bearing for
473    // R-F9-4); `0.0` = explicit floor/de-weight; `(0.0, 1.0]` = explicit importance.
474    // Nullable, so pre-existing rows read NULL in-place (no data migration / re-open):
475    // the graceful-absent default preserves current ranking for every existing row.
476    // Additive `ADD COLUMN` (no DROP) → the accretion guard REQUIRES the exemption
477    // marker. This step does NOT rewrite vec0 / vector rows (ADR §4 eu7 no-op basis):
478    // it adds a scalar column only, so the eu7 fidelity gate stays a documented no-op.
479    Migration {
480        step_id: 18,
481        sql: "-- MIGRATION-ACCRETION-EXEMPTION: F9 importance ranking scalar (additive nullable REAL; 3-way sentinel, NULL=graceful-absent)
482              ALTER TABLE canonical_nodes ADD COLUMN importance REAL;",
483    },
484    // 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the
485    // `_fathomdb_embed_probe` self-check substrate. Per
486    // `dev/adr/ADR-0.8.18-vector-equivalence-self-check.md` (SIGNED 2026-07-09)
487    // and `dev/design/0.8.18-slice-0-vector-equivalence-publish-design.md` §U1
488    // (R-VEQ-1). Creates a new internal table holding the 45 committed
489    // equivalence probes, each with its **UN-centered f32 reference vector**
490    // (`4 * dim` little-endian bytes) and the embedder identity that produced it.
491    // The engine populates the 45 rows at first vector-kind registration (open
492    // path, adjacent to `ensure_vector_partition`); this migration only creates
493    // the empty table. **Store f32 ONLY — the Phase-1 mean-centered bits are
494    // NEVER persisted** (they are recomputed at check time from the un-centered
495    // reference + the live pinned `mean_vec`, U1-d). This step does NOT rewrite
496    // vec0 / vector rows (eu7 no-op basis): it creates a fresh sidecar table only,
497    // so the eu7 fidelity gate stays a documented no-op. `CREATE TABLE` adds
498    // schema (no DROP) → the accretion guard REQUIRES the exemption marker.
499    Migration {
500        step_id: 19,
501        sql: "-- MIGRATION-ACCRETION-EXEMPTION: #5 vector-equivalence probe substrate (new internal _fathomdb_embed_probe table; UN-centered f32 references only, NEVER persists P1 bits)
502              CREATE TABLE IF NOT EXISTS _fathomdb_embed_probe(
503                  probe_ordinal INTEGER PRIMARY KEY,
504                  probe_text TEXT NOT NULL,
505                  reference_vec BLOB NOT NULL,
506                  embedder_name TEXT NOT NULL,
507                  embedder_revision TEXT NOT NULL,
508                  dim INTEGER NOT NULL
509              );",
510    },
511    // 0.8.19 Slice 5 (OPP-12 record-lifecycle Phase-1 KEYSTONE) — the existence
512    // axis. Per `dev/design/0.8.19-slice-0-opp12-phase1-design.md` §5 (the ONE
513    // 19→20 migration) and `dev/plans/plan-0.8.19.md` §2 (R-EX-1/R-MIG-1). Adds
514    // the two existence columns on `canonical_nodes`:
515    //   `state`  — the `LifecycleState` enum, stored as TEXT. `NOT NULL DEFAULT
516    //              'active'` so EVERY pre-existing row back-fills to `active`
517    //              in-place (no data migration / re-open); the shipped corpus is
518    //              wholly `active`, so the new default-read exclusion
519    //              (`AND state = 'active'` co-located with `superseded_at IS NULL`
520    //              at each retrieval site) is a documented NO-OP on it (eu7 no-op
521    //              basis, design §9).
522    //   `reason` — nullable advisory cause for the CURRENT state (quarantine cause
523    //              for `pending`; delete cause for the delete-family). Engine never
524    //              interprets it.
525    // Plus `canonical_nodes_state_active_idx` — a PARTIAL index over active rows
526    // keyed by `write_cursor` (the dominant retrieval/join key), serving the
527    // active-only default-read hot path.
528    // Scoped per F-23 ruling 1a: existence columns ONLY — NO surrogate-`logical_id`
529    // backfill (anonymous rows keep `logical_id = NULL`; surrogate minting is
530    // Phase-2/0.8.20). One migration per release (I-6). This step does NOT rewrite
531    // vec0 / vector rows (eu7 no-op basis). Additive `ADD COLUMN` (no DROP) → the
532    // accretion guard REQUIRES the exemption marker.
533    Migration {
534        step_id: 20,
535        sql: "-- MIGRATION-ACCRETION-EXEMPTION: OPP-12 Phase-1 existence axis (state NOT NULL DEFAULT 'active' + nullable reason on canonical_nodes + active-only partial index; no surrogate backfill — F-23 ruling 1a)
536              ALTER TABLE canonical_nodes ADD COLUMN state TEXT NOT NULL DEFAULT 'active';
537              ALTER TABLE canonical_nodes ADD COLUMN reason TEXT;
538              CREATE INDEX IF NOT EXISTS canonical_nodes_state_active_idx
539                  ON canonical_nodes(write_cursor) WHERE state = 'active';",
540    },
541    // Step 21 (0.8.20 Slice 5c) — legacy provenance backfill, per
542    // `dev/design/0.8.20-slice0-erasure-design.md` §4 work item 7 and
543    // `dev/plans/plan-0.8.20.md` R-20-E8.
544    //
545    // Erasure runs through provenance: `excise_source` addresses rows BY
546    // `source_id`, so a stored row with `source_id IS NULL` is reachable by no
547    // erasure call at all — it is un-erasable. Pre-0.8.20 the public write type
548    // carried `source_id: Option<String>` and a `None` landed NULL, so shipped
549    // databases hold such rows. R-20-E3 closes the write path going forward
550    // (`SourceId` makes the absence inexpressible); this step repairs the rows
551    // already on disk by stamping them with the reserved
552    // `_legacy:pre-0.8.20`, after which an operator can erase them.
553    //
554    // THE GATE IS EXACT, LOAD-BEARING AND **NODE-ONLY**: on `canonical_nodes`
555    // the predicate is `WHERE source_id IS NULL AND logical_id IS NULL`; on
556    // `canonical_edges` it is `WHERE source_id IS NULL` alone. The asymmetry is
557    // deliberate, and the reason is that the gate's rationale holds for one
558    // table and not the other.
559    //
560    // The rationale comes from the TC-11 pin (CLOSED): a GOVERNED row — one
561    // carrying a `logical_id` — is addressable in its own right, because `purge`
562    // reaches it BY `logical_id`. Stamping it with a shared `_legacy:`
563    // provenance would make it collateral of an
564    // `excise_source('_legacy:pre-0.8.20')` call aimed at anonymous rows, which
565    // is precisely the over-erasure the pin forbids. That argument is sound FOR
566    // NODES: governed nodes keep NULL `source_id` by design, and that is not a
567    // gap.
568    //
569    // It is FALSE FOR EDGES. `purge` resolves its lifecycle target exclusively
570    // through `canonical_nodes` (`SELECT state FROM canonical_nodes WHERE
571    // logical_id = ?1 AND superseded_at IS NULL`) and then erases edges by
572    // ENDPOINT (`from_id`/`to_id`) — it never resolves an edge by edge
573    // `logical_id`. An edge `logical_id` is only a SUPERSESSION identity; it
574    // confers no purge-addressability whatsoever. Applying the node gate to
575    // edges therefore left legacy edges with `source_id IS NULL AND logical_id
576    // IS NOT NULL` skipped by this backfill (⇒ unreachable by
577    // `excise_source`/`erase_source`) AND not purge-addressable (⇒ unreachable
578    // by `purge`), so they were erasable by NO verb and could only disappear
579    // incidentally when a connected node happened to be purged. That defeats
580    // R-20-E8, whose entire purpose is that legacy NULL-provenance rows become
581    // erasable. (codex §9 P1; `legacy_backfill_covers_governed_edges`.)
582    //
583    // Back-filling an edge's `source_id` does NOT touch the TC-11 pin: the pin
584    // forbids populating `logical_id` on an existing row and forbids re-deriving
585    // a stored row's id-space, and this writes neither.
586    //
587    // The pin's enforcing invariant is also respected: this statement READS
588    // `logical_id` as its predicate and NEVER writes one. No row transitions
589    // `logical_id` NULL -> NOT NULL, and no stored row's id-space is re-derived
590    // (`s21_backfill_populates_no_logical_id` asserts both).
591    //
592    // Rows that already carry provenance are untouched (`source_id IS NULL`
593    // half of the predicate), so caller-supplied ids are never overwritten.
594    //
595    // No accretion exemption marker: this is a pure data `UPDATE` with no
596    // `CREATE TABLE` / `ADD COLUMN`, so the guard does not fire (cf. step 13).
597    // One migration per release (I-6).
598    Migration {
599        step_id: 21,
600        sql: "UPDATE canonical_nodes
601                 SET source_id = '_legacy:pre-0.8.20'
602               WHERE source_id IS NULL AND logical_id IS NULL;
603              UPDATE canonical_edges
604                 SET source_id = '_legacy:pre-0.8.20'
605               WHERE source_id IS NULL;",
606    },
607    // Step 22 (0.8.20 Slice 10b) — R-20-NV node validity window, per
608    // `dev/plans/plan-0.8.20.md` §3 (R-20-NV). Adds the two world-time validity
609    // columns on `canonical_nodes`:
610    //   `valid_from`  — inclusive lower bound of the window.
611    //   `valid_until` — EXCLUSIVE upper bound of the window.
612    // The interval is HALF-OPEN: `[valid_from, valid_until)`. A node is valid at
613    // instant `t` iff `(valid_from IS NULL OR valid_from <= t) AND (valid_until
614    // IS NULL OR valid_until > t)`. NULL means UNBOUNDED on that side, so
615    // NULL/NULL is "valid for all time". This convention is stated once here and
616    // is the same one `ReadView::valid_as_of` compiles to at every read site.
617    //
618    // **UNITS: INTEGER epoch SECONDS (UTC).** At the time this step shipped it
619    // DELIBERATELY DIVERGED from `canonical_edges.t_valid`/`t_invalid` (step 14),
620    // which were then ISO-8601 TEXT compared through `datetime(...)`. The
621    // divergence was intentional and flagged rather than silently resolved:
622    //   (a) the release contract for R-20-NV specifies INTEGER windows;
623    //   (b) INTEGER windows are directly comparable/indexable with no `datetime()`
624    //       conversion per row, so the validity conjunct stays sargable against
625    //       `canonical_nodes_validity_idx`;
626    //   (c) the node-validity instant is a BOUND PARAMETER (`:now` seam), never a
627    //       `datetime('now')` SQL literal, so node validity is deterministically
628    //       testable — whereas the EDGE path then still inlined `datetime('now')`.
629    //
630    // **RESOLVED by step 23 (TC-33, HITL-RATIFIED 2026-07-21).** The divergence
631    // this step escalated is now CLOSED: the edge columns are INTEGER epoch
632    // seconds too, and the edge read sites bind the same `:now` seam described in
633    // (c). Reason (c)'s "the shipped EDGE path still inlines `datetime('now')`"
634    // and the step-22 SQL comment's "which are unchanged" are both HISTORICAL as
635    // of step 23 — the migration SQL string is left verbatim because applied SQL
636    // text is not rewritten, and this Rust comment carries the correction.
637    //
638    // Existing rows get NULL/NULL on both columns (SQLite `ADD COLUMN` with no
639    // DEFAULT back-fills NULL in place, no table rewrite), i.e. unbounded ⇒
640    // always valid ⇒ EVERY pre-existing row's default-view visibility is
641    // UNCHANGED (asserted by `s22_preexisting_rows_stay_visible_in_default_view`
642    // and, at the engine level, by the R-20-NV suite). This step does NOT rewrite
643    // vec0 / vector rows (eu7 no-op basis).
644    //
645    // Crash-safety + idempotence come from the runner, exactly as for step 20:
646    // `apply_one` wraps the batch AND the `PRAGMA user_version` bump in a single
647    // `BEGIN IMMEDIATE`/`COMMIT`, so a crash mid-step rolls back to 21 and the
648    // step re-runs whole; and `migrate_with_event_sink` only applies steps with
649    // `step_id > user_version`, so a completed step never re-runs (which matters
650    // because `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS` form).
651    // One migration per release (I-6). Additive `ADD COLUMN` (no DROP) → the
652    // accretion guard REQUIRES the exemption marker.
653    Migration {
654        step_id: 22,
655        sql: "-- MIGRATION-ACCRETION-EXEMPTION: R-20-NV node validity window (valid_from/valid_until INTEGER epoch-seconds on canonical_nodes; NULL = unbounded; half-open [valid_from, valid_until); deliberately INTEGER, diverging from the ISO-8601 TEXT canonical_edges.t_valid/t_invalid, which are unchanged)
656              ALTER TABLE canonical_nodes ADD COLUMN valid_from INTEGER;
657              ALTER TABLE canonical_nodes ADD COLUMN valid_until INTEGER;
658              CREATE INDEX IF NOT EXISTS canonical_nodes_validity_idx
659                  ON canonical_nodes(valid_from, valid_until)
660                  WHERE superseded_at IS NULL AND state = 'active';",
661    },
662    // 0.8.20 Slice 15c (TC-33) — edge temporal representation → INTEGER epoch
663    // seconds, closing the divergence step 22 deliberately flagged above.
664    // HITL-RATIFIED 2026-07-21 (`dev/plans/plan-0.8.20.md` §9 decision 3):
665    // `t_valid`/`t_invalid` are INTEGER epoch seconds in STORAGE and on the
666    // GOVERNED SDK SURFACE; the BYO-LLM EXTRACTOR boundary keeps ISO-8601 and is
667    // normalised engine-side with HARD REJECTION.
668    //
669    // **Why the CHECKs, and why NOT `NOT NULL`.** The failure mode this step
670    // exists to remove is FAIL-OPEN. A NULL `t_invalid` means "still valid", so
671    // an unparseable timestamp that coerces to NULL silently RESURRECTS an
672    // invalidated edge. Under the old TEXT column the junk failed CLOSED by
673    // accident (`datetime('junk')` → NULL ⇒ the disjunct is falsy ⇒ the row
674    // vanished from every read); moving to INTEGER would INVERT that polarity
675    // unless junk is made unstorable. So the invariant is STRUCTURAL — a
676    // `typeof(...)` CHECK — not merely upheld by call sites (cf. TC-28, an
677    // invariant held only by call sites; not repeated here).
678    // `NOT NULL` would be WRONG: NULL legitimately means "still valid" and that
679    // shipped semantic must survive. `typeof(x) = 'integer'` makes junk
680    // unstorable while preserving NULL-means-still-valid.
681    //
682    // **NO DATA MIGRATION (HITL 2026-07-21).** SQLite cannot change a column's
683    // type in place, and cannot add a CHECK via `ALTER TABLE`, so both INTEGER
684    // affinity and the structural CHECKs require RECREATING the table. Per the
685    // ruling this is a PLAIN RECREATE: existing `canonical_edges` rows DO NOT
686    // SURVIVE. Nothing is staged, converted, backfilled, or re-inserted, and no
687    // stored ISO-8601 value is converted. FathomDB is pre-1.0 beta and 0.8.20 is
688    // a coordinated breaking pair — users do not carry data across it.
689    //
690    // **Two consequences that DO need handling** (neither is a data migration —
691    // one clears derived state, the other preserves a monotonic counter):
692    //
693    //   1. `search_index_edges` is edge-derived and would be left holding FTS
694    //      rows for edges that no longer exist. Every reader JOINs it back to
695    //      `canonical_edges` so orphans are inert, but they are dead weight and
696    //      are cleared here.
697    //   1b. The VECTOR projection of the dropped edges is ALSO row-owned and
698    //      must be removed — it is NOT inert (fix-6, codex §9 P1). It has two
699    //      halves (see the engine's `ROW_OWNED_PROJECTIONS`, class `Vector`):
700    //        - `_fathomdb_vector_rows` — the sidecar/registry table (created by
701    //          migration step 6, so it ALWAYS exists here). Its dropped-edge
702    //          rows are deleted BELOW, scoped to edge cursors read from
703    //          `canonical_edges` while it still exists (it also holds NODE
704    //          sidecar rows, which MUST survive — so this is a scoped DELETE,
705    //          not a truncate like `search_index_edges`).
706    //        - `vector_default` — the vec0 virtual table that actually feeds
707    //          KNN candidate selection. It is created by the ENGINE's dim-aware
708    //          `ensure_vector_partition` AFTER `migrate` returns, so on a fresh
709    //          DB (and in the schema crate's own migration tests) it does not
710    //          exist yet and referencing it in this SQL would fail the step. Its
711    //          orphaned edge rows are therefore pruned by the ENGINE right after
712    //          `ensure_vector_partition`, matched against the sidecar this step
713    //          clears. These orphans are NOT "made harmless by (2)": (2) only
714    //          stops cursor REUSE; an orphaned `vector_default` row (whose
715    //          `canonical_edges` row is gone) still occupies a top-K KNN
716    //          candidate slot and is then discarded at hydration, silently
717    //          returning too few vector results on an upgraded DB.
718    //   2. `load_next_cursor` takes MAX(write_cursor) across canonical_nodes /
719    //      canonical_edges / operational_mutations / operational_state. Dropping
720    //      the edge rows can LOWER that high-water mark, so freshly allocated
721    //      cursors would REUSE values that stale `_fathomdb_projection_terminal`
722    //      / `_fathomdb_vector_rows` / vec0 rows still key on — silently marking
723    //      a brand-new row as already-projected, so it never gets indexed. The
724    //      old maximum is therefore RESERVED into `_fathomdb_open_state` and
725    //      `load_next_cursor` folds it in. This preserves NO user data; it keeps
726    //      an identifier counter monotonic.
727    //      The `HAVING` is load-bearing: a bare aggregate over an EMPTY
728    //      `canonical_edges` still returns one row, whose `MAX` is NULL, which
729    //      violates `_fathomdb_open_state.value NOT NULL` — i.e. without it the
730    //      step fails on EVERY fresh database.
731    //   3. `write_cursor` is a SINGLE global sequence shared across nodes AND
732    //      edges, and `advance_projection_cursor` (engine) walks the readiness
733    //      watermark forward ONE value at a time, ONLY while the next cursor has
734    //      a `_fathomdb_projection_terminal` row. A body-bearing edge whose
735    //      vector projection had NOT completed at upgrade has NO terminal row; if
736    //      step 23 dropped it we would leave a cursor value with no terminal and
737    //      no owning row, so the projection cursor STALLS PERMANENTLY at that gap
738    //      — and because the sequence is shared this also freezes advancement
739    //      past SURVIVING node projections (every upgraded DB's `wait_for_idle` /
740    //      search-freshness wedges). So BEFORE the DROP — while `canonical_edges`
741    //      still exists to read — a terminal is recorded for every edge cursor
742    //      that lacks one. This is projection-cursor STATE bookkeeping, NOT data
743    //      preservation: the edge rows still do not survive; we only reconcile
744    //      the engine's cursor state machine so it does not dangle on cursors
745    //      whose rows we correctly dropped. It is COMPLEMENTARY to (2): (2) stops
746    //      cursor REUSE below the old high-water mark; (3) stops cursor STALL on
747    //      the dropped cursors themselves. Both are needed.
748    //      The state token is `'up_to_date'`, NOT `'superseded'`. The terminal
749    //      table (step 7) carries `CHECK(state IN ('failed','up_to_date'))` and
750    //      the writer is `INSERT OR IGNORE`; under SQLite, `OR IGNORE` SKIPS a
751    //      CHECK-violating row and returns no error, so a `'superseded'` backfill
752    //      would be SILENTLY DROPPED and the cursor would still stall (a vacuous
753    //      green). `'up_to_date'` is the CHECK-valid, non-`'failed'` terminal
754    //      that honestly means "nothing left to project here" for a deleted row,
755    //      and `INSERT OR IGNORE` leaves any already-present terminal untouched
756    //      (the write_cursor PRIMARY KEY conflict is ignored).
757    //
758    // The recreate restores the full step-1→22 column set IN ORDER (positional
759    // `row.get(i)` sites depend on it) and all four indexes, which `DROP TABLE`
760    // removes with the table.
761    //
762    // Crash-safety/idempotence are the runner's, as for steps 20/22: `apply_one`
763    // wraps the batch AND the `PRAGMA user_version` bump in one `BEGIN
764    // IMMEDIATE`, so a crash mid-step rolls back to 22 and the step re-runs
765    // whole. `check_migration_accretion` does not fire (the SQL names both
766    // `CREATE TABLE` and `DROP TABLE`), but the exemption marker is carried for
767    // documentation, matching the convention of the surrounding steps.
768    Migration {
769        step_id: 23,
770        sql: "-- MIGRATION-ACCRETION-EXEMPTION: TC-33 edge temporal representation → INTEGER epoch seconds (recreate canonical_edges with INTEGER t_valid/t_invalid + typeof CHECKs so junk is UNSTORABLE; NULL still means \"still valid\"). NO DATA MIGRATION (HITL 2026-07-21): existing edge rows do NOT survive and no stored ISO-8601 value is converted.
771              INSERT OR REPLACE INTO _fathomdb_open_state(key, value)
772                  SELECT 'tc33_reserved_write_cursor',
773                         CAST(MAX(write_cursor) AS TEXT)
774                  FROM canonical_edges
775                  HAVING MAX(write_cursor) IS NOT NULL;
776              DELETE FROM search_index_edges;
777              -- fix-4 (TC-33): mark every edge cursor terminal BEFORE the DROP so
778              -- the SHARED projection cursor can walk past rows this recreate
779              -- removes; a pending edge (no terminal) would otherwise strand the
780              -- cursor and freeze surviving node projections too. 'up_to_date' is
781              -- the CHECK-valid token ('superseded' would be swallowed by
782              -- OR IGNORE). Complementary to the reserved-high-water fix above.
783              INSERT OR IGNORE INTO _fathomdb_projection_terminal(write_cursor, state)
784                  SELECT write_cursor, 'up_to_date' FROM canonical_edges;
785              -- fix-6 (TC-33): delete the dropped edges' VECTOR sidecar rows
786              -- BEFORE the DROP, while canonical_edges still lists the edge
787              -- cursors. Scoped to edge cursors — _fathomdb_vector_rows also
788              -- holds NODE sidecar rows, which must survive. The vec0 table
789              -- vector_default is engine-created (dim-aware) and may not exist
790              -- here, so the engine prunes it to match right after
791              -- ensure_vector_partition. This is the third row-owned-projection
792              -- facet step 23 clears for every dropped edge (with the reserved
793              -- high-water mark and the terminal backfill above). NO DATA
794              -- MIGRATION: it deletes derived rows for already-dropped edges.
795              DELETE FROM _fathomdb_vector_rows
796                  WHERE write_cursor IN (SELECT write_cursor FROM canonical_edges);
797              DROP TABLE canonical_edges;
798              CREATE TABLE canonical_edges(
799                  write_cursor INTEGER NOT NULL,
800                  kind TEXT NOT NULL,
801                  from_id TEXT NOT NULL,
802                  to_id TEXT NOT NULL,
803                  source_id TEXT,
804                  logical_id TEXT,
805                  superseded_at INTEGER,
806                  body TEXT,
807                  t_valid INTEGER CHECK (t_valid IS NULL OR typeof(t_valid) = 'integer'),
808                  t_invalid INTEGER CHECK (t_invalid IS NULL OR typeof(t_invalid) = 'integer'),
809                  confidence REAL,
810                  extractor_model_id TEXT,
811                  temporal_fallback INTEGER
812              );
813              CREATE INDEX IF NOT EXISTS canonical_edges_source_id_idx
814                  ON canonical_edges(source_id);
815              CREATE UNIQUE INDEX IF NOT EXISTS canonical_edges_logical_active_idx
816                  ON canonical_edges(logical_id) WHERE superseded_at IS NULL;
817              CREATE INDEX IF NOT EXISTS canonical_edges_from_id_idx
818                  ON canonical_edges(from_id);
819              CREATE INDEX IF NOT EXISTS canonical_edges_to_id_idx
820                  ON canonical_edges(to_id);",
821    },
822    // 0.8.20 Slice 15d (R-20-PR / R-20-EAV) — the projection-registry EAV +
823    // property-FTS substrate the declarative `configure_projections` verb
824    // projects into. NET-NEW: before this step there is NO attribute/EAV store
825    // and NO property-FTS (only `body`-FTS `search_index`/`search_index_v2` +
826    // vector). Three tables:
827    //
828    //   1. `_fathomdb_projection_registry` — the DURABLE record of every declared
829    //      `ProjectionSpec` (Q5: the engine `ProjectionSpec` is a DERIVED cache
830    //      re-driven idempotently on boot; this table is what boot re-derive
831    //      reads). `roles` is a JSON array of `ProjectionRole` (set semantics —
832    //      dedup/membership, no order dependence). `fts_tokenizer` is non-NULL
833    //      iff a `searchable→FTS` sub-target was declared; `vector_embedder` +
834    //      `vector_declared` record the `searchable→vector` sub-object which is
835    //      STORED here but NOT built in 15d (Slice 20 R-20-DR attaches
836    //      `dense_readiness` onto exactly this `vector` sub-object — additive).
837    //
838    //   2. `canonical_attributes` — the EAV attribute store. A ROW-OWNED,
839    //      rebuild-durable projection: each row is 1:1 with the owning canonical
840    //      node's `write_cursor`, holds one declared attribute value at rest, and
841    //      MUST die with that node (registered in `ROW_OWNED_PROJECTIONS` so
842    //      `purge`/`excise_source` reach it — an unregistered content-storing
843    //      table re-opens the Slice-5 `search_index_v2` leak class). The values
844    //      are derived from the node `body` JSON (`$.<name>`), so the store is
845    //      re-derivable from canonical state (CQRS drift answer). `filterable`
846    //      queries hit the `(attr_name, attr_value)` composite index (cheap
847    //      equality/range, same-transaction).
848    //
849    //   3. `property_search_index` — the property-FTS5 shadow of the attribute
850    //      values (`searchable→FTS`, same-transaction). Also ROW-OWNED (keyed by
851    //      `write_cursor UNINDEXED`, same shape as `search_index_edges`). Default
852    //      tokenizer matches `body`-FTS (`porter unicode61 remove_diacritics 2`);
853    //      a per-attr custom tokenizer is the ≥0.9.x multi-field FTS work and is
854    //      recorded in the registry but not honoured here (graceful-graft later).
855    //
856    // NO DATA MIGRATION (HITL 2026-07-21): these steps define the new shape only.
857    // Nothing is backfilled at migrate time — `configure_projections` backfills
858    // per-declaration, and boot re-derive re-applies the persisted registry.
859    //
860    // Additive `CREATE TABLE` (no DROP) → the accretion guard REQUIRES the
861    // exemption marker. Crash-safety/idempotence are the runner's: `apply_one`
862    // wraps the batch + the `user_version` bump in one `BEGIN IMMEDIATE`.
863    Migration {
864        step_id: 24,
865        sql: "-- MIGRATION-ACCRETION-EXEMPTION: R-20-PR/R-20-EAV projection-registry EAV + property-FTS substrate (net-new _fathomdb_projection_registry durable derived-cache + canonical_attributes row-owned EAV projection + property_search_index FTS5 property-FTS). NO DATA MIGRATION (HITL 2026-07-21): shape only, no backfill.
866              CREATE TABLE _fathomdb_projection_registry(
867                  name TEXT PRIMARY KEY,
868                  roles TEXT NOT NULL,
869                  fts_tokenizer TEXT,
870                  vector_embedder TEXT,
871                  vector_declared INTEGER NOT NULL DEFAULT 0
872              );
873              CREATE TABLE canonical_attributes(
874                  write_cursor INTEGER NOT NULL,
875                  attr_name TEXT NOT NULL,
876                  attr_value TEXT
877              );
878              CREATE INDEX canonical_attributes_name_value_idx
879                  ON canonical_attributes(attr_name, attr_value);
880              CREATE INDEX canonical_attributes_cursor_idx
881                  ON canonical_attributes(write_cursor);
882              CREATE VIRTUAL TABLE property_search_index USING fts5(
883                  attr_value,
884                  attr_name UNINDEXED,
885                  write_cursor UNINDEXED,
886                  tokenize = 'porter unicode61 remove_diacritics 2'
887              );",
888    },
889    // 0.8.21 Slice 45 — an additive durable encoding of the optional literal
890    // nested-member source path for a projection declaration. `NULL` preserves
891    // the legacy direct top-level lookup by projection name; a JSON array stores
892    // every path segment exactly. Shape only: no canonical body rewrite and no
893    // projection backfill happens during migration.
894    Migration {
895        step_id: 25,
896        sql: "-- MIGRATION-ACCRETION-EXEMPTION: Slice-45 nested projection source declaration; additive registry column only, no data migration or canonical-body rewrite.
897              ALTER TABLE _fathomdb_projection_registry ADD COLUMN source TEXT;",
898    },
899];
900
901/// `_fathomdb_open_state` key under which step 23 reserved the pre-TC-33
902/// `canonical_edges` write-cursor high-water mark.
903///
904/// Step 23 recreates `canonical_edges` (no data migration), which can LOWER the
905/// `MAX(write_cursor)` the engine's cursor allocator derives from the canonical
906/// tables. Reusing a cursor would collide with stale projection shadow rows that
907/// still key on it, silently marking a new row as already-projected. The engine
908/// folds this reserved value into its allocation so cursors stay monotonic.
909pub const RESERVED_WRITE_CURSOR_KEY: &str = "tc33_reserved_write_cursor";
910
911pub fn migrate(conn: &Connection) -> Result<MigrationReport, MigrationError> {
912    migrate_with_steps(conn, MIGRATIONS)
913}
914
915pub fn migrate_with_steps(
916    conn: &Connection,
917    migrations: &[Migration],
918) -> Result<MigrationReport, MigrationError> {
919    migrate_with_event_sink(conn, migrations, |_| {})
920}
921
922pub fn migrate_with_event_sink(
923    conn: &Connection,
924    migrations: &[Migration],
925    mut emit: impl FnMut(&MigrationStepReport),
926) -> Result<MigrationReport, MigrationError> {
927    let before = user_version(conn)?;
928    if before > SCHEMA_VERSION {
929        return Err(MigrationError::IncompatibleSchemaVersion {
930            seen: before,
931            supported: SCHEMA_VERSION,
932        });
933    }
934
935    let mut current = before;
936    let mut reports = Vec::new();
937
938    for migration in migrations.iter().filter(|migration| migration.step_id > before) {
939        if migration.step_id != current.saturating_add(1) {
940            return Err(MigrationError::Storage {
941                message: "migration registry is not contiguous",
942            });
943        }
944
945        let started = Instant::now();
946        if let Err(_err) = apply_one(conn, migration) {
947            reports.push(MigrationStepReport {
948                step_id: migration.step_id,
949                duration_ms: Some(duration_ms(started)),
950                failed: true,
951            });
952            emit(reports.last().expect("failed step report was just pushed"));
953            let schema_version_current = user_version(conn).unwrap_or(current);
954            return Err(MigrationError::MigrationError(MigrationFailureReport {
955                schema_version_before: before,
956                schema_version_current,
957                migration_steps: reports,
958            }));
959        }
960
961        current = migration.step_id;
962        reports.push(MigrationStepReport {
963            step_id: migration.step_id,
964            duration_ms: Some(duration_ms(started)),
965            failed: false,
966        });
967        emit(reports.last().expect("successful step report was just pushed"));
968    }
969
970    Ok(MigrationReport {
971        schema_version_before: before,
972        schema_version_after: user_version(conn)?,
973        migration_steps: reports,
974    })
975}
976
977fn apply_one(conn: &Connection, migration: &Migration) -> rusqlite::Result<()> {
978    conn.execute_batch("BEGIN IMMEDIATE")?;
979    let result = (|| {
980        conn.execute_batch(migration.sql)?;
981        conn.pragma_update(None, PRAGMA_USER_VERSION, migration.step_id)?;
982        Ok(())
983    })();
984
985    match result {
986        Ok(()) => conn.execute_batch("COMMIT"),
987        Err(err) => {
988            let _ = conn.execute_batch("ROLLBACK");
989            Err(err)
990        }
991    }
992}
993
994fn user_version(conn: &Connection) -> Result<u32, MigrationError> {
995    conn.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0))
996        .map_err(|_| MigrationError::Storage { message: "could not read schema version" })
997}
998
999fn duration_ms(started: Instant) -> u64 {
1000    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
1001}
1002
1003#[derive(Clone, Debug, Eq, PartialEq)]
1004pub struct MigrationAccretionError {
1005    pub offender: String,
1006}
1007
1008impl Display for MigrationAccretionError {
1009    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1010        write!(f, "migration accretion guard rejected {}", self.offender)
1011    }
1012}
1013
1014impl std::error::Error for MigrationAccretionError {}
1015
1016/// 0.8.20 Slice 25 (R-20-SUR) — rejection from the TC-11 `logical_id` pin.
1017///
1018/// Carries the offending migration's `name` (as `MigrationAccretionError` does)
1019/// PLUS the normalised offending `statement`, because a migration step is a
1020/// batch: naming the file alone would leave an author hunting for which of a
1021/// dozen statements tripped the guard.
1022#[derive(Clone, Debug, Eq, PartialEq)]
1023pub struct MigrationLogicalIdPinError {
1024    /// The migration step / file name handed to the guard.
1025    pub offender: String,
1026    /// The single offending statement, comment-stripped, string-literal-elided,
1027    /// uppercased and whitespace-collapsed (the guard's normalised view).
1028    pub statement: String,
1029}
1030
1031impl Display for MigrationLogicalIdPinError {
1032    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1033        write!(
1034            f,
1035            "TC-11 logical_id pin rejected {}: a migration may never populate `logical_id` on an \
1036             existing canonical row — offending statement: {}",
1037            self.offender, self.statement
1038        )
1039    }
1040}
1041
1042impl std::error::Error for MigrationLogicalIdPinError {}
1043
1044/// The two canonical tables that carry the identity column the pin protects.
1045/// Uppercase because the guard compares against a normalised (uppercased) view.
1046const PINNED_IDENTITY_TABLES: [&str; 2] = ["CANONICAL_NODES", "CANONICAL_EDGES"];
1047
1048/// The protected column, uppercased for the same reason.
1049const PINNED_IDENTITY_COLUMN: &str = "LOGICAL_ID";
1050
1051/// 0.8.20 Slice 25 (R-20-SUR) — the **TC-11 pin's static migration guard**, and
1052/// the sibling of [`check_migration_accretion`].
1053///
1054/// # What it enforces
1055///
1056/// TC-11 pin A (HITL-ratified 2026-07-12; `dev/plans/plan-0.8.20.md` §2.1) rules
1057/// that anonymous / doc-seeded nodes stay `h:<content-hash>` **permanently** —
1058/// the anonymous-surrogate leg is CANCELLED, not deferred — and that enforcement
1059/// is "**no new column**": the record IS `canonical_nodes.logical_id`'s
1060/// null-ness. The invariant is therefore a PROHIBITION:
1061///
1062/// > No migration, backfill, or verb shall ever populate `logical_id` on an
1063/// > existing canonical row, and a stored row's id-space is NEVER re-derived.
1064///
1065/// This function is the migration half of that prohibition, checked STATICALLY —
1066/// before the SQL can ever run. Supplying a `logical_id` at WRITE time is what
1067/// makes a record governed; a migration is not a write time.
1068///
1069/// # Rejected shapes
1070///
1071/// On `canonical_nodes` / `canonical_edges` only:
1072///
1073/// - `UPDATE … SET … logical_id …` — the direct forward-mint;
1074/// - `INSERT`/`REPLACE … INTO …` that names `logical_id`, i.e. a recreate-and-copy
1075///   backfill (`SELECT COALESCE(logical_id, mint(…))`);
1076/// - `INSERT`/`REPLACE … INTO …` with NO explicit column list — it writes every
1077///   column, `logical_id` among them, without naming it;
1078/// - `ALTER TABLE … ADD COLUMN logical_id … DEFAULT …` — a default populates
1079///   every EXISTING row in one statement;
1080/// - `ALTER TABLE … RENAME … logical_id` — a rename turns an already-populated
1081///   column INTO the identity column with no write at all;
1082/// - `CREATE TRIGGER … logical_id …` — a deferred `UPDATE`;
1083/// - `CREATE TABLE …(… logical_id … DEFAULT …)` — same reasoning as `ADD COLUMN`;
1084/// - ANY of the write shapes above behind a leading `WITH … AS (…)` /
1085///   `WITH RECURSIVE …` CTE clause, which SQLite allows in FRONT of `UPDATE`,
1086///   `INSERT` and `DELETE` — see the keyword-independent catch-all below.
1087///
1088/// # Accepted shapes (the ladder already ships all four)
1089///
1090/// - DECLARING the column (`ALTER TABLE canonical_nodes ADD COLUMN logical_id
1091///   TEXT`, step 12) — declaration is not population;
1092/// - INDEXING it (`CREATE UNIQUE INDEX … ON canonical_nodes(logical_id) WHERE
1093///   superseded_at IS NULL`, steps 12/23);
1094/// - READING it as a PREDICATE (`WHERE source_id IS NULL AND logical_id IS
1095///   NULL`, step 21 — which writes only `source_id`);
1096/// - RE-DECLARING it in a `CREATE TABLE` recreate (step 23's TC-33 edge
1097///   recreate, which deliberately copies no rows).
1098///
1099/// # Deliberately conservative
1100///
1101/// The guard refuses any statement that WRITES INTO `logical_id` on a canonical
1102/// table, including a copy that would merely PRESERVE the value through a
1103/// table-recreate. Distinguishing "preserving copy" from "minting copy" needs
1104/// positional matching of an insert column list against a `SELECT` expression
1105/// list — fragile, and trivially gamed by the exact class of change the pin
1106/// exists to stop. A future recreate must follow step 23's shipped precedent
1107/// (declare the column; do not copy rows through it), or come back through the
1108/// HITL to change the pin itself.
1109///
1110/// Conservatism also decides the SCHEMA-QUALIFIED case: `main.canonical_nodes`,
1111/// `temp.canonical_nodes` and every quoted spelling of either half normalise to
1112/// the bare table name before the comparison, so qualifying the table is not an
1113/// escape. `main.canonical_nodes_old` is NOT the pinned table — the qualifier is
1114/// stripped, never widened into a substring match.
1115///
1116/// SQLite's `UPDATE OR <conflict-action> …` prefix is not an escape either. All
1117/// five actions (`ROLLBACK` / `ABORT` / `FAIL` / `IGNORE` / `REPLACE`, verified
1118/// against SQLite 3.45.1) sit BETWEEN the keyword and the table name, so the
1119/// clause is skipped — token-exactly, so a table genuinely named `or_ledger`
1120/// still reads as the table — before the comparison. The `INSERT`/`REPLACE` arm
1121/// anchors on ` INTO `, which the conflict clause precedes, so it never had the
1122/// hole.
1123///
1124/// The reverse error — over-rejection — is a real cost too, so table names are
1125/// matched by TOKEN, never by substring, on EVERY arm including the trigger one.
1126/// A `CREATE TRIGGER … ON canonical_nodes_backup` targets a different table and
1127/// is accepted; a trigger whose BODY writes `logical_id` on a real canonical
1128/// table is still rejected, because the trigger arm scans the whole fragment
1129/// (which, by the `;` split, carries the head AND the first body statement) for
1130/// a pinned table token, and later body statements are their own fragments.
1131///
1132/// Conservatism is also why the arms above are not the whole guard. Each of them
1133/// anchors on the statement's LEADING keyword, and SQLite's grammar puts an
1134/// optional `WITH …` CTE clause in front of `UPDATE` / `INSERT` / `DELETE`, so
1135/// `WITH x AS (SELECT 1) UPDATE canonical_nodes SET logical_id = …` (valid, and
1136/// verified running against SQLite 3.45.1) would match no arm at all. Rather
1137/// than teach the guard to skip balanced parentheses — a SQL parser by another
1138/// name — a keyword-INDEPENDENT catch-all runs FIRST and refuses any statement
1139/// that writes `logical_id` in an ASSIGNMENT position on a pinned table:
1140/// a ` SET ` clause naming the column while some token names a canonical table,
1141/// or an ` INTO ` target that IS a canonical table (naming the column, or
1142/// omitting the column list). Read positions — a `WHERE` predicate, an index
1143/// column list, a column declaration — are untouched, which is precisely what
1144/// keeps the four shipped accepts green. See
1145/// [`writes_pinned_identity_anywhere`], whose assignment half deliberately
1146/// over-rejects: after an arbitrary prefix the update TARGET cannot be located
1147/// without parsing, so merely MENTIONING a canonical table anywhere in a
1148/// statement that assigns `logical_id` is refused.
1149///
1150/// # No exemption escape hatch — deliberately
1151///
1152/// [`check_migration_accretion`] honours a `-- MIGRATION-ACCRETION-EXEMPTION: `
1153/// marker because accretion is a BUDGET an author may knowingly spend. The TC-11
1154/// pin is not a budget; it is TERMINAL-FOREVER. An escape hatch would defeat it,
1155/// so there is none: a marked offender is rejected identically to an unmarked
1156/// one. Changing the pin is an HITL decision, not a comment.
1157///
1158/// # Known limits (stated, not hidden)
1159///
1160/// This is a lexical guard, not a SQL parser. It normalises away comments and
1161/// string literals, unwraps `"…"` / `[…]` / `` `…` `` quoted identifiers, and
1162/// tightens whitespace around `.` so a schema qualifier is one token (SQLite
1163/// accepts `main . canonical_nodes`; verified against SQLite 3.45) — then it
1164/// splits on `;`. A `CREATE TRIGGER` body therefore splits into fragments: the
1165/// FIRST body statement stays glued to the `CREATE TRIGGER …` head (the split
1166/// point is the `;` that ends it) and is judged with the head by the trigger
1167/// arm's whole-fragment token scan; every LATER body statement is its own
1168/// fragment and is judged independently by the `UPDATE` / `INSERT` arms. It
1169/// cannot see through dynamically-built SQL, which migrations do not use
1170/// (`MIGRATIONS` holds `&'static str` literals).
1171///
1172/// A leading `WITH …` CTE is NOT among the limits — the keyword-independent
1173/// catch-all above closes that family. What remains open, stated plainly rather
1174/// than papered over, is **cross-statement table identity**: the guard judges
1175/// each `;`-separated statement ALONE and holds no model of which name refers to
1176/// which table over the course of a step. A migration that renames the pinned
1177/// table out of the way, writes `logical_id` under the new name, and renames it
1178/// back —
1179///
1180/// ```sql
1181/// ALTER TABLE canonical_nodes RENAME TO tmp_x;
1182/// UPDATE tmp_x SET logical_id = 'minted' WHERE logical_id IS NULL;
1183/// ALTER TABLE tmp_x RENAME TO canonical_nodes;
1184/// ```
1185///
1186/// — is therefore ACCEPTED, as is its sibling (`CREATE TABLE … AS SELECT …
1187/// 'minted' AS logical_id FROM canonical_nodes`, `DROP`, `RENAME TO
1188/// canonical_nodes`). Closing it means tracking table identity across
1189/// statements, which is a different guard, not a wider arm; it is recorded here
1190/// (and in the slice's reviewer record) as a KNOWN residual rather than silently
1191/// implied to be covered. Note that the second half of the TC-11 prohibition —
1192/// the runtime write path — is enforced elsewhere, so this guard being lexical
1193/// is not the pin's only defence.
1194pub fn check_migration_logical_id_pin(
1195    name: &str,
1196    sql: &str,
1197) -> Result<(), MigrationLogicalIdPinError> {
1198    for statement in normalized_statements(sql) {
1199        if statement_violates_logical_id_pin(&statement) {
1200            return Err(MigrationLogicalIdPinError { offender: name.to_string(), statement });
1201        }
1202    }
1203    Ok(())
1204}
1205
1206/// Split `sql` into per-statement NORMALISED views: comments removed, string
1207/// literals elided to `''`, quoted identifiers unwrapped to bare ones,
1208/// uppercased, whitespace collapsed to single spaces.
1209///
1210/// Scanning is strictly left-to-right so precedence is correct: a `--` inside a
1211/// string literal is data, and a `'` inside a comment is prose.
1212fn normalized_statements(sql: &str) -> Vec<String> {
1213    let mut statements = Vec::new();
1214    let mut current = String::new();
1215    let chars: Vec<char> = sql.chars().collect();
1216    let mut i = 0;
1217
1218    while i < chars.len() {
1219        let c = chars[i];
1220        match c {
1221            // Line comment: drop to end of line (the newline becomes whitespace).
1222            '-' if chars.get(i + 1) == Some(&'-') => {
1223                while i < chars.len() && chars[i] != '\n' {
1224                    i += 1;
1225                }
1226            }
1227            // Block comment: drop to the closing delimiter.
1228            '/' if chars.get(i + 1) == Some(&'*') => {
1229                i += 2;
1230                while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) {
1231                    i += 1;
1232                }
1233                i = (i + 2).min(chars.len());
1234                current.push(' ');
1235            }
1236            // String literal: elide the contents (they can never be an
1237            // identifier). `''` inside a literal is an escaped quote.
1238            '\'' => {
1239                i += 1;
1240                while i < chars.len() {
1241                    if chars[i] == '\'' {
1242                        if chars.get(i + 1) == Some(&'\'') {
1243                            i += 2;
1244                            continue;
1245                        }
1246                        i += 1;
1247                        break;
1248                    }
1249                    i += 1;
1250                }
1251                current.push_str("''");
1252            }
1253            // Quoted identifiers: UNWRAP them, so `"logical_id"` / `[logical_id]`
1254            // / `` `logical_id` `` are the same identifier to the guard that they
1255            // are to SQLite.
1256            '"' | '`' | '[' => {
1257                let close = if c == '[' { ']' } else { c };
1258                i += 1;
1259                while i < chars.len() && chars[i] != close {
1260                    current.push(chars[i].to_ascii_uppercase());
1261                    i += 1;
1262                }
1263                i += 1;
1264            }
1265            ';' => {
1266                statements.push(normalize_statement(&current));
1267                current.clear();
1268                i += 1;
1269            }
1270            _ => {
1271                current.push(c.to_ascii_uppercase());
1272                i += 1;
1273            }
1274        }
1275    }
1276    statements.push(normalize_statement(&current));
1277    statements.retain(|s| !s.is_empty());
1278    statements
1279}
1280
1281/// The per-statement tail of the normalisation: collapse whitespace, then
1282/// tighten qualified names so a schema qualifier is ONE token.
1283fn normalize_statement(raw: &str) -> String {
1284    tighten_qualified_names(&collapse_whitespace(raw))
1285}
1286
1287fn collapse_whitespace(raw: &str) -> String {
1288    raw.split_whitespace().collect::<Vec<_>>().join(" ")
1289}
1290
1291/// Remove whitespace around `.` so `main . canonical_nodes` normalises to
1292/// `MAIN.CANONICAL_NODES`.
1293///
1294/// SQLite's tokenizer accepts whitespace around the qualifier dot (verified
1295/// against SQLite 3.45: `UPDATE main . t SET …` parses and targets `main.t`),
1296/// while the token extractors below cut at whitespace — so without this the
1297/// spaced spelling would extract the bare token `MAIN` and slip the pin.
1298/// Comments are already stripped and string literals already elided to `''` by
1299/// this point, so no `.` that survives here is data.
1300fn tighten_qualified_names(statement: &str) -> String {
1301    if !statement.contains('.') {
1302        return statement.to_string();
1303    }
1304    statement.split('.').map(str::trim).collect::<Vec<_>>().join(".")
1305}
1306
1307/// Does one NORMALISED statement write into `logical_id` on a canonical table?
1308fn statement_violates_logical_id_pin(statement: &str) -> bool {
1309    let names_column = statement.contains(PINNED_IDENTITY_COLUMN);
1310
1311    // FIRST, and independent of the leading keyword: the keyword-anchored arms
1312    // below all begin `statement.starts_with(…)`, and SQLite's grammar allows a
1313    // `WITH …` CTE clause in FRONT of UPDATE / INSERT / DELETE — so
1314    // `WITH x AS (SELECT 1) UPDATE canonical_nodes SET logical_id = …` runs the
1315    // forbidden backfill while starting with none of them. This catch-all closes
1316    // that whole family without a paren-matching CTE parser, and runs BEFORE the
1317    // arms because several of them `return false` early (a non-pinned UPDATE
1318    // target, say), which would otherwise shadow it.
1319    if writes_pinned_identity_anywhere(statement, names_column) {
1320        return true;
1321    }
1322
1323    // A trigger is a deferred write; one that so much as mentions the identity
1324    // column AND names a canonical table — as its ON-target, or anywhere in the
1325    // first body statement, which the `;` split glues onto the head — is refused
1326    // whole. Later body statements split into their own fragments, which the
1327    // UPDATE / INSERT arms below catch independently.
1328    if statement.starts_with("CREATE") && statement.contains(" TRIGGER ") && names_column {
1329        return mentions_pinned_table(statement);
1330    }
1331
1332    if statement.starts_with("UPDATE") {
1333        let Some(table) = update_target_table(statement) else { return false };
1334        if !is_pinned_table(&table) {
1335            return false;
1336        }
1337        // Only the SET clause assigns. Reading `logical_id` in a WHERE predicate
1338        // (step 21) is explicitly legitimate.
1339        return set_clause(statement).is_some_and(|clause| clause.contains(PINNED_IDENTITY_COLUMN));
1340    }
1341
1342    if statement.starts_with("INSERT") || statement.starts_with("REPLACE") {
1343        let Some((table, rest)) = table_after_into(statement) else { return false };
1344        if !is_pinned_table(&table) {
1345            return false;
1346        }
1347        // Naming the column anywhere in an insert into a canonical table is a
1348        // backfill; a MISSING column list writes every column, identity included.
1349        return names_column || !rest.trim_start().starts_with('(');
1350    }
1351
1352    if statement.starts_with("ALTER") {
1353        let Some(table) = token_after(statement, "ALTER TABLE ") else { return false };
1354        if !is_pinned_table(&table) || !names_column {
1355            return false;
1356        }
1357        // Declaring the column bare is how step 12 shipped it. A DEFAULT
1358        // populates every existing row; a RENAME conscripts a populated one.
1359        return statement.contains("DEFAULT") || statement.contains("RENAME");
1360    }
1361
1362    if statement.starts_with("CREATE") && statement.contains(" TABLE ") {
1363        let Some(table) = token_after(statement, " TABLE ") else { return false };
1364        // A recreate may DECLARE the column (step 23 does); it may not default it.
1365        return is_pinned_table(&table) && names_column && statement.contains("DEFAULT");
1366    }
1367
1368    false
1369}
1370
1371/// The keyword-INDEPENDENT catch-all: does `statement` write `logical_id` on a
1372/// pinned table ANYWHERE, whatever it starts with?
1373///
1374/// Every other arm anchors on the leading keyword, so any prefix that displaces
1375/// it — SQLite's optional `WITH …` / `WITH RECURSIVE …` CTE clause is the one
1376/// that actually exists, and it is legal in front of `UPDATE`, `INSERT` and
1377/// `DELETE` — slips them all. Parsing CTE parentheses to find the real head
1378/// keyword would be a SQL parser; the pin is TERMINAL-FOREVER and its stated
1379/// stance is that over-rejection is a cost worth paying and under-rejection is
1380/// not, so this fires on WRITE POSITION instead of on statement shape:
1381///
1382/// - an assignment: the ` SET ` clause (up to `WHERE` / `FROM` / `RETURNING`)
1383///   names `logical_id`, and SOME token of the statement is a pinned table;
1384/// - an insert target: the table after ` INTO ` is pinned, and the statement
1385///   either names `logical_id` or omits the column list (which writes every
1386///   column, identity included) — the same rule the `INSERT` arm applies.
1387///
1388/// READ positions are deliberately untouched, which is exactly what keeps the
1389/// four shipped accepts green: a `WHERE … logical_id IS NULL` predicate (step
1390/// 21, which writes only `source_id`), an index column list (steps 12/23), a
1391/// bare `ADD COLUMN logical_id TEXT` declaration (step 12) and a `CREATE TABLE`
1392/// re-declaration (step 23) all name the column without assigning to it. The
1393/// insert half checks the TARGET table, not mere mention, so step 23's
1394/// `INSERT … INTO _fathomdb_open_state … SELECT … FROM canonical_edges` — which
1395/// reads a pinned table — stays accepted.
1396///
1397/// The assignment half is deliberately coarser than the `UPDATE` arm: it asks
1398/// only whether a pinned table is MENTIONED, because after an arbitrary prefix
1399/// the update target cannot be located token-wise without parsing. So
1400/// `WITH x AS (SELECT 1 FROM canonical_nodes) UPDATE other SET logical_id = …`
1401/// is refused too. That is over-rejection by design, and cheap to work around
1402/// honestly (don't name a canonical table in the CTE); the reverse mistake is
1403/// not recoverable.
1404fn writes_pinned_identity_anywhere(statement: &str, names_column: bool) -> bool {
1405    if names_column
1406        && mentions_pinned_table(statement)
1407        && set_clause(statement).is_some_and(|clause| clause.contains(PINNED_IDENTITY_COLUMN))
1408    {
1409        return true;
1410    }
1411    table_after_into(statement).is_some_and(|(table, rest)| {
1412        is_pinned_table(&table) && (names_column || !rest.trim_start().starts_with('('))
1413    })
1414}
1415
1416/// The bare table name from a possibly schema-qualified token.
1417///
1418/// SQLite resolves `main.canonical_nodes` (or `temp.canonical_nodes`, or any
1419/// quoted spelling of either half — the normaliser has already unwrapped those)
1420/// to the SAME pinned table as the bare name, so the qualifier is stripped
1421/// before the comparison. SQLite permits at most ONE qualifier, so the part
1422/// after the dot is the table name. This is a STRIP, not a widening to a
1423/// substring match: `main.canonical_nodes_old` is still a different table.
1424fn bare_table_name(token: &str) -> &str {
1425    token.rsplit_once('.').map_or(token, |(_, table)| table)
1426}
1427
1428fn is_pinned_table(token: &str) -> bool {
1429    PINNED_IDENTITY_TABLES.contains(&bare_table_name(token))
1430}
1431
1432/// Does any IDENTIFIER TOKEN of `statement` name a pinned canonical table?
1433///
1434/// Token-based, never a substring: `canonical_nodes_backup` is a DIFFERENT
1435/// table, and a `contains` test would conscript every lookalike name into the
1436/// pin — rejecting legitimate backup/recreate scaffolding. The statement is cut
1437/// on every character that cannot appear in an identifier, keeping `.` so a
1438/// schema qualifier stays one token for [`is_pinned_table`] to strip.
1439///
1440/// Used by the trigger arm, whose target table can sit anywhere in the head
1441/// (`… ON <table> …`), and which — because the `;` split glues the FIRST body
1442/// statement onto the head — must also see a table named inside that body.
1443fn mentions_pinned_table(statement: &str) -> bool {
1444    statement
1445        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
1446        .any(is_pinned_table)
1447}
1448
1449/// SQLite's five `UPDATE OR <conflict-action>` / `INSERT OR <conflict-action>`
1450/// spellings (verified against SQLite 3.45.1).
1451const SQLITE_CONFLICT_ACTIONS: [&str; 5] = ["ROLLBACK", "ABORT", "FAIL", "IGNORE", "REPLACE"];
1452
1453/// The target table of an `UPDATE`, skipping SQLite's optional conflict clause.
1454///
1455/// `UPDATE OR REPLACE canonical_nodes SET logical_id = …` is a valid, running
1456/// backfill; the conflict action sits BETWEEN the keyword and the table name, so
1457/// taking the first token after `UPDATE ` yields `OR` and the pin is bypassed.
1458/// The skip is token-EXACT (the action must be a whole token followed by a
1459/// space), so a table actually named `or_ledger` is still read as the table.
1460///
1461/// The `INSERT`/`REPLACE` arm needs no equivalent: it anchors on ` INTO `, which
1462/// the conflict clause precedes (`INSERT OR IGNORE INTO canonical_nodes …`).
1463fn update_target_table(statement: &str) -> Option<String> {
1464    let mut rest = statement.strip_prefix("UPDATE ")?;
1465    if let Some(after_or) = rest.strip_prefix("OR ") {
1466        if let Some(after_action) = SQLITE_CONFLICT_ACTIONS
1467            .iter()
1468            .find_map(|action| after_or.strip_prefix(*action)?.strip_prefix(' '))
1469        {
1470            rest = after_action;
1471        }
1472    }
1473    first_token(rest)
1474}
1475
1476/// The first identifier token after `marker`, cut at whitespace or `(`.
1477fn token_after(statement: &str, marker: &str) -> Option<String> {
1478    first_token(statement.split_once(marker)?.1)
1479}
1480
1481/// The leading identifier token of `rest`, cut at whitespace, `(` or `,`.
1482fn first_token(rest: &str) -> Option<String> {
1483    let token: String =
1484        rest.chars().take_while(|c| !c.is_whitespace() && *c != '(' && *c != ',').collect();
1485    (!token.is_empty()).then_some(token)
1486}
1487
1488/// `(table, remainder-after-the-table-name)` for an `INSERT`/`REPLACE … INTO …`.
1489fn table_after_into(statement: &str) -> Option<(String, String)> {
1490    let rest = statement.split_once(" INTO ")?.1;
1491    let table: String =
1492        rest.chars().take_while(|c| !c.is_whitespace() && *c != '(' && *c != ',').collect();
1493    (!table.is_empty()).then(|| (table.clone(), rest[table.len()..].to_string()))
1494}
1495
1496/// The assignment half of an `UPDATE`: everything between ` SET ` and the first
1497/// clause keyword that ends it.
1498fn set_clause(statement: &str) -> Option<&str> {
1499    let after_set = statement.split_once(" SET ")?.1;
1500    let end = [" WHERE ", " FROM ", " RETURNING "]
1501        .iter()
1502        .filter_map(|kw| after_set.find(kw))
1503        .min()
1504        .unwrap_or(after_set.len());
1505    Some(&after_set[..end])
1506}
1507
1508pub fn check_migration_accretion(name: &str, sql: &str) -> Result<(), MigrationAccretionError> {
1509    let upper = sql.to_ascii_uppercase();
1510    let adds_schema = upper.contains("CREATE TABLE") || upper.contains("ADD COLUMN");
1511    let names_removal = upper.contains("DROP TABLE") || upper.contains("DROP COLUMN");
1512    let has_exemption = sql.contains("-- MIGRATION-ACCRETION-EXEMPTION: ");
1513
1514    if adds_schema && !names_removal && !has_exemption {
1515        return Err(MigrationAccretionError { offender: name.to_string() });
1516    }
1517
1518    Ok(())
1519}