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 = 24;
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];
890
891/// `_fathomdb_open_state` key under which step 23 reserved the pre-TC-33
892/// `canonical_edges` write-cursor high-water mark.
893///
894/// Step 23 recreates `canonical_edges` (no data migration), which can LOWER the
895/// `MAX(write_cursor)` the engine's cursor allocator derives from the canonical
896/// tables. Reusing a cursor would collide with stale projection shadow rows that
897/// still key on it, silently marking a new row as already-projected. The engine
898/// folds this reserved value into its allocation so cursors stay monotonic.
899pub const RESERVED_WRITE_CURSOR_KEY: &str = "tc33_reserved_write_cursor";
900
901pub fn migrate(conn: &Connection) -> Result<MigrationReport, MigrationError> {
902    migrate_with_steps(conn, MIGRATIONS)
903}
904
905pub fn migrate_with_steps(
906    conn: &Connection,
907    migrations: &[Migration],
908) -> Result<MigrationReport, MigrationError> {
909    migrate_with_event_sink(conn, migrations, |_| {})
910}
911
912pub fn migrate_with_event_sink(
913    conn: &Connection,
914    migrations: &[Migration],
915    mut emit: impl FnMut(&MigrationStepReport),
916) -> Result<MigrationReport, MigrationError> {
917    let before = user_version(conn)?;
918    if before > SCHEMA_VERSION {
919        return Err(MigrationError::IncompatibleSchemaVersion {
920            seen: before,
921            supported: SCHEMA_VERSION,
922        });
923    }
924
925    let mut current = before;
926    let mut reports = Vec::new();
927
928    for migration in migrations.iter().filter(|migration| migration.step_id > before) {
929        if migration.step_id != current.saturating_add(1) {
930            return Err(MigrationError::Storage {
931                message: "migration registry is not contiguous",
932            });
933        }
934
935        let started = Instant::now();
936        if let Err(_err) = apply_one(conn, migration) {
937            reports.push(MigrationStepReport {
938                step_id: migration.step_id,
939                duration_ms: Some(duration_ms(started)),
940                failed: true,
941            });
942            emit(reports.last().expect("failed step report was just pushed"));
943            let schema_version_current = user_version(conn).unwrap_or(current);
944            return Err(MigrationError::MigrationError(MigrationFailureReport {
945                schema_version_before: before,
946                schema_version_current,
947                migration_steps: reports,
948            }));
949        }
950
951        current = migration.step_id;
952        reports.push(MigrationStepReport {
953            step_id: migration.step_id,
954            duration_ms: Some(duration_ms(started)),
955            failed: false,
956        });
957        emit(reports.last().expect("successful step report was just pushed"));
958    }
959
960    Ok(MigrationReport {
961        schema_version_before: before,
962        schema_version_after: user_version(conn)?,
963        migration_steps: reports,
964    })
965}
966
967fn apply_one(conn: &Connection, migration: &Migration) -> rusqlite::Result<()> {
968    conn.execute_batch("BEGIN IMMEDIATE")?;
969    let result = (|| {
970        conn.execute_batch(migration.sql)?;
971        conn.pragma_update(None, PRAGMA_USER_VERSION, migration.step_id)?;
972        Ok(())
973    })();
974
975    match result {
976        Ok(()) => conn.execute_batch("COMMIT"),
977        Err(err) => {
978            let _ = conn.execute_batch("ROLLBACK");
979            Err(err)
980        }
981    }
982}
983
984fn user_version(conn: &Connection) -> Result<u32, MigrationError> {
985    conn.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0))
986        .map_err(|_| MigrationError::Storage { message: "could not read schema version" })
987}
988
989fn duration_ms(started: Instant) -> u64 {
990    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
991}
992
993#[derive(Clone, Debug, Eq, PartialEq)]
994pub struct MigrationAccretionError {
995    pub offender: String,
996}
997
998impl Display for MigrationAccretionError {
999    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1000        write!(f, "migration accretion guard rejected {}", self.offender)
1001    }
1002}
1003
1004impl std::error::Error for MigrationAccretionError {}
1005
1006/// 0.8.20 Slice 25 (R-20-SUR) — rejection from the TC-11 `logical_id` pin.
1007///
1008/// Carries the offending migration's `name` (as `MigrationAccretionError` does)
1009/// PLUS the normalised offending `statement`, because a migration step is a
1010/// batch: naming the file alone would leave an author hunting for which of a
1011/// dozen statements tripped the guard.
1012#[derive(Clone, Debug, Eq, PartialEq)]
1013pub struct MigrationLogicalIdPinError {
1014    /// The migration step / file name handed to the guard.
1015    pub offender: String,
1016    /// The single offending statement, comment-stripped, string-literal-elided,
1017    /// uppercased and whitespace-collapsed (the guard's normalised view).
1018    pub statement: String,
1019}
1020
1021impl Display for MigrationLogicalIdPinError {
1022    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1023        write!(
1024            f,
1025            "TC-11 logical_id pin rejected {}: a migration may never populate `logical_id` on an \
1026             existing canonical row — offending statement: {}",
1027            self.offender, self.statement
1028        )
1029    }
1030}
1031
1032impl std::error::Error for MigrationLogicalIdPinError {}
1033
1034/// The two canonical tables that carry the identity column the pin protects.
1035/// Uppercase because the guard compares against a normalised (uppercased) view.
1036const PINNED_IDENTITY_TABLES: [&str; 2] = ["CANONICAL_NODES", "CANONICAL_EDGES"];
1037
1038/// The protected column, uppercased for the same reason.
1039const PINNED_IDENTITY_COLUMN: &str = "LOGICAL_ID";
1040
1041/// 0.8.20 Slice 25 (R-20-SUR) — the **TC-11 pin's static migration guard**, and
1042/// the sibling of [`check_migration_accretion`].
1043///
1044/// # What it enforces
1045///
1046/// TC-11 pin A (HITL-ratified 2026-07-12; `dev/plans/plan-0.8.20.md` §2.1) rules
1047/// that anonymous / doc-seeded nodes stay `h:<content-hash>` **permanently** —
1048/// the anonymous-surrogate leg is CANCELLED, not deferred — and that enforcement
1049/// is "**no new column**": the record IS `canonical_nodes.logical_id`'s
1050/// null-ness. The invariant is therefore a PROHIBITION:
1051///
1052/// > No migration, backfill, or verb shall ever populate `logical_id` on an
1053/// > existing canonical row, and a stored row's id-space is NEVER re-derived.
1054///
1055/// This function is the migration half of that prohibition, checked STATICALLY —
1056/// before the SQL can ever run. Supplying a `logical_id` at WRITE time is what
1057/// makes a record governed; a migration is not a write time.
1058///
1059/// # Rejected shapes
1060///
1061/// On `canonical_nodes` / `canonical_edges` only:
1062///
1063/// - `UPDATE … SET … logical_id …` — the direct forward-mint;
1064/// - `INSERT`/`REPLACE … INTO …` that names `logical_id`, i.e. a recreate-and-copy
1065///   backfill (`SELECT COALESCE(logical_id, mint(…))`);
1066/// - `INSERT`/`REPLACE … INTO …` with NO explicit column list — it writes every
1067///   column, `logical_id` among them, without naming it;
1068/// - `ALTER TABLE … ADD COLUMN logical_id … DEFAULT …` — a default populates
1069///   every EXISTING row in one statement;
1070/// - `ALTER TABLE … RENAME … logical_id` — a rename turns an already-populated
1071///   column INTO the identity column with no write at all;
1072/// - `CREATE TRIGGER … logical_id …` — a deferred `UPDATE`;
1073/// - `CREATE TABLE …(… logical_id … DEFAULT …)` — same reasoning as `ADD COLUMN`;
1074/// - ANY of the write shapes above behind a leading `WITH … AS (…)` /
1075///   `WITH RECURSIVE …` CTE clause, which SQLite allows in FRONT of `UPDATE`,
1076///   `INSERT` and `DELETE` — see the keyword-independent catch-all below.
1077///
1078/// # Accepted shapes (the ladder already ships all four)
1079///
1080/// - DECLARING the column (`ALTER TABLE canonical_nodes ADD COLUMN logical_id
1081///   TEXT`, step 12) — declaration is not population;
1082/// - INDEXING it (`CREATE UNIQUE INDEX … ON canonical_nodes(logical_id) WHERE
1083///   superseded_at IS NULL`, steps 12/23);
1084/// - READING it as a PREDICATE (`WHERE source_id IS NULL AND logical_id IS
1085///   NULL`, step 21 — which writes only `source_id`);
1086/// - RE-DECLARING it in a `CREATE TABLE` recreate (step 23's TC-33 edge
1087///   recreate, which deliberately copies no rows).
1088///
1089/// # Deliberately conservative
1090///
1091/// The guard refuses any statement that WRITES INTO `logical_id` on a canonical
1092/// table, including a copy that would merely PRESERVE the value through a
1093/// table-recreate. Distinguishing "preserving copy" from "minting copy" needs
1094/// positional matching of an insert column list against a `SELECT` expression
1095/// list — fragile, and trivially gamed by the exact class of change the pin
1096/// exists to stop. A future recreate must follow step 23's shipped precedent
1097/// (declare the column; do not copy rows through it), or come back through the
1098/// HITL to change the pin itself.
1099///
1100/// Conservatism also decides the SCHEMA-QUALIFIED case: `main.canonical_nodes`,
1101/// `temp.canonical_nodes` and every quoted spelling of either half normalise to
1102/// the bare table name before the comparison, so qualifying the table is not an
1103/// escape. `main.canonical_nodes_old` is NOT the pinned table — the qualifier is
1104/// stripped, never widened into a substring match.
1105///
1106/// SQLite's `UPDATE OR <conflict-action> …` prefix is not an escape either. All
1107/// five actions (`ROLLBACK` / `ABORT` / `FAIL` / `IGNORE` / `REPLACE`, verified
1108/// against SQLite 3.45.1) sit BETWEEN the keyword and the table name, so the
1109/// clause is skipped — token-exactly, so a table genuinely named `or_ledger`
1110/// still reads as the table — before the comparison. The `INSERT`/`REPLACE` arm
1111/// anchors on ` INTO `, which the conflict clause precedes, so it never had the
1112/// hole.
1113///
1114/// The reverse error — over-rejection — is a real cost too, so table names are
1115/// matched by TOKEN, never by substring, on EVERY arm including the trigger one.
1116/// A `CREATE TRIGGER … ON canonical_nodes_backup` targets a different table and
1117/// is accepted; a trigger whose BODY writes `logical_id` on a real canonical
1118/// table is still rejected, because the trigger arm scans the whole fragment
1119/// (which, by the `;` split, carries the head AND the first body statement) for
1120/// a pinned table token, and later body statements are their own fragments.
1121///
1122/// Conservatism is also why the arms above are not the whole guard. Each of them
1123/// anchors on the statement's LEADING keyword, and SQLite's grammar puts an
1124/// optional `WITH …` CTE clause in front of `UPDATE` / `INSERT` / `DELETE`, so
1125/// `WITH x AS (SELECT 1) UPDATE canonical_nodes SET logical_id = …` (valid, and
1126/// verified running against SQLite 3.45.1) would match no arm at all. Rather
1127/// than teach the guard to skip balanced parentheses — a SQL parser by another
1128/// name — a keyword-INDEPENDENT catch-all runs FIRST and refuses any statement
1129/// that writes `logical_id` in an ASSIGNMENT position on a pinned table:
1130/// a ` SET ` clause naming the column while some token names a canonical table,
1131/// or an ` INTO ` target that IS a canonical table (naming the column, or
1132/// omitting the column list). Read positions — a `WHERE` predicate, an index
1133/// column list, a column declaration — are untouched, which is precisely what
1134/// keeps the four shipped accepts green. See
1135/// [`writes_pinned_identity_anywhere`], whose assignment half deliberately
1136/// over-rejects: after an arbitrary prefix the update TARGET cannot be located
1137/// without parsing, so merely MENTIONING a canonical table anywhere in a
1138/// statement that assigns `logical_id` is refused.
1139///
1140/// # No exemption escape hatch — deliberately
1141///
1142/// [`check_migration_accretion`] honours a `-- MIGRATION-ACCRETION-EXEMPTION: `
1143/// marker because accretion is a BUDGET an author may knowingly spend. The TC-11
1144/// pin is not a budget; it is TERMINAL-FOREVER. An escape hatch would defeat it,
1145/// so there is none: a marked offender is rejected identically to an unmarked
1146/// one. Changing the pin is an HITL decision, not a comment.
1147///
1148/// # Known limits (stated, not hidden)
1149///
1150/// This is a lexical guard, not a SQL parser. It normalises away comments and
1151/// string literals, unwraps `"…"` / `[…]` / `` `…` `` quoted identifiers, and
1152/// tightens whitespace around `.` so a schema qualifier is one token (SQLite
1153/// accepts `main . canonical_nodes`; verified against SQLite 3.45) — then it
1154/// splits on `;`. A `CREATE TRIGGER` body therefore splits into fragments: the
1155/// FIRST body statement stays glued to the `CREATE TRIGGER …` head (the split
1156/// point is the `;` that ends it) and is judged with the head by the trigger
1157/// arm's whole-fragment token scan; every LATER body statement is its own
1158/// fragment and is judged independently by the `UPDATE` / `INSERT` arms. It
1159/// cannot see through dynamically-built SQL, which migrations do not use
1160/// (`MIGRATIONS` holds `&'static str` literals).
1161///
1162/// A leading `WITH …` CTE is NOT among the limits — the keyword-independent
1163/// catch-all above closes that family. What remains open, stated plainly rather
1164/// than papered over, is **cross-statement table identity**: the guard judges
1165/// each `;`-separated statement ALONE and holds no model of which name refers to
1166/// which table over the course of a step. A migration that renames the pinned
1167/// table out of the way, writes `logical_id` under the new name, and renames it
1168/// back —
1169///
1170/// ```sql
1171/// ALTER TABLE canonical_nodes RENAME TO tmp_x;
1172/// UPDATE tmp_x SET logical_id = 'minted' WHERE logical_id IS NULL;
1173/// ALTER TABLE tmp_x RENAME TO canonical_nodes;
1174/// ```
1175///
1176/// — is therefore ACCEPTED, as is its sibling (`CREATE TABLE … AS SELECT …
1177/// 'minted' AS logical_id FROM canonical_nodes`, `DROP`, `RENAME TO
1178/// canonical_nodes`). Closing it means tracking table identity across
1179/// statements, which is a different guard, not a wider arm; it is recorded here
1180/// (and in the slice's reviewer record) as a KNOWN residual rather than silently
1181/// implied to be covered. Note that the second half of the TC-11 prohibition —
1182/// the runtime write path — is enforced elsewhere, so this guard being lexical
1183/// is not the pin's only defence.
1184pub fn check_migration_logical_id_pin(
1185    name: &str,
1186    sql: &str,
1187) -> Result<(), MigrationLogicalIdPinError> {
1188    for statement in normalized_statements(sql) {
1189        if statement_violates_logical_id_pin(&statement) {
1190            return Err(MigrationLogicalIdPinError { offender: name.to_string(), statement });
1191        }
1192    }
1193    Ok(())
1194}
1195
1196/// Split `sql` into per-statement NORMALISED views: comments removed, string
1197/// literals elided to `''`, quoted identifiers unwrapped to bare ones,
1198/// uppercased, whitespace collapsed to single spaces.
1199///
1200/// Scanning is strictly left-to-right so precedence is correct: a `--` inside a
1201/// string literal is data, and a `'` inside a comment is prose.
1202fn normalized_statements(sql: &str) -> Vec<String> {
1203    let mut statements = Vec::new();
1204    let mut current = String::new();
1205    let chars: Vec<char> = sql.chars().collect();
1206    let mut i = 0;
1207
1208    while i < chars.len() {
1209        let c = chars[i];
1210        match c {
1211            // Line comment: drop to end of line (the newline becomes whitespace).
1212            '-' if chars.get(i + 1) == Some(&'-') => {
1213                while i < chars.len() && chars[i] != '\n' {
1214                    i += 1;
1215                }
1216            }
1217            // Block comment: drop to the closing delimiter.
1218            '/' if chars.get(i + 1) == Some(&'*') => {
1219                i += 2;
1220                while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) {
1221                    i += 1;
1222                }
1223                i = (i + 2).min(chars.len());
1224                current.push(' ');
1225            }
1226            // String literal: elide the contents (they can never be an
1227            // identifier). `''` inside a literal is an escaped quote.
1228            '\'' => {
1229                i += 1;
1230                while i < chars.len() {
1231                    if chars[i] == '\'' {
1232                        if chars.get(i + 1) == Some(&'\'') {
1233                            i += 2;
1234                            continue;
1235                        }
1236                        i += 1;
1237                        break;
1238                    }
1239                    i += 1;
1240                }
1241                current.push_str("''");
1242            }
1243            // Quoted identifiers: UNWRAP them, so `"logical_id"` / `[logical_id]`
1244            // / `` `logical_id` `` are the same identifier to the guard that they
1245            // are to SQLite.
1246            '"' | '`' | '[' => {
1247                let close = if c == '[' { ']' } else { c };
1248                i += 1;
1249                while i < chars.len() && chars[i] != close {
1250                    current.push(chars[i].to_ascii_uppercase());
1251                    i += 1;
1252                }
1253                i += 1;
1254            }
1255            ';' => {
1256                statements.push(normalize_statement(&current));
1257                current.clear();
1258                i += 1;
1259            }
1260            _ => {
1261                current.push(c.to_ascii_uppercase());
1262                i += 1;
1263            }
1264        }
1265    }
1266    statements.push(normalize_statement(&current));
1267    statements.retain(|s| !s.is_empty());
1268    statements
1269}
1270
1271/// The per-statement tail of the normalisation: collapse whitespace, then
1272/// tighten qualified names so a schema qualifier is ONE token.
1273fn normalize_statement(raw: &str) -> String {
1274    tighten_qualified_names(&collapse_whitespace(raw))
1275}
1276
1277fn collapse_whitespace(raw: &str) -> String {
1278    raw.split_whitespace().collect::<Vec<_>>().join(" ")
1279}
1280
1281/// Remove whitespace around `.` so `main . canonical_nodes` normalises to
1282/// `MAIN.CANONICAL_NODES`.
1283///
1284/// SQLite's tokenizer accepts whitespace around the qualifier dot (verified
1285/// against SQLite 3.45: `UPDATE main . t SET …` parses and targets `main.t`),
1286/// while the token extractors below cut at whitespace — so without this the
1287/// spaced spelling would extract the bare token `MAIN` and slip the pin.
1288/// Comments are already stripped and string literals already elided to `''` by
1289/// this point, so no `.` that survives here is data.
1290fn tighten_qualified_names(statement: &str) -> String {
1291    if !statement.contains('.') {
1292        return statement.to_string();
1293    }
1294    statement.split('.').map(str::trim).collect::<Vec<_>>().join(".")
1295}
1296
1297/// Does one NORMALISED statement write into `logical_id` on a canonical table?
1298fn statement_violates_logical_id_pin(statement: &str) -> bool {
1299    let names_column = statement.contains(PINNED_IDENTITY_COLUMN);
1300
1301    // FIRST, and independent of the leading keyword: the keyword-anchored arms
1302    // below all begin `statement.starts_with(…)`, and SQLite's grammar allows a
1303    // `WITH …` CTE clause in FRONT of UPDATE / INSERT / DELETE — so
1304    // `WITH x AS (SELECT 1) UPDATE canonical_nodes SET logical_id = …` runs the
1305    // forbidden backfill while starting with none of them. This catch-all closes
1306    // that whole family without a paren-matching CTE parser, and runs BEFORE the
1307    // arms because several of them `return false` early (a non-pinned UPDATE
1308    // target, say), which would otherwise shadow it.
1309    if writes_pinned_identity_anywhere(statement, names_column) {
1310        return true;
1311    }
1312
1313    // A trigger is a deferred write; one that so much as mentions the identity
1314    // column AND names a canonical table — as its ON-target, or anywhere in the
1315    // first body statement, which the `;` split glues onto the head — is refused
1316    // whole. Later body statements split into their own fragments, which the
1317    // UPDATE / INSERT arms below catch independently.
1318    if statement.starts_with("CREATE") && statement.contains(" TRIGGER ") && names_column {
1319        return mentions_pinned_table(statement);
1320    }
1321
1322    if statement.starts_with("UPDATE") {
1323        let Some(table) = update_target_table(statement) else { return false };
1324        if !is_pinned_table(&table) {
1325            return false;
1326        }
1327        // Only the SET clause assigns. Reading `logical_id` in a WHERE predicate
1328        // (step 21) is explicitly legitimate.
1329        return set_clause(statement).is_some_and(|clause| clause.contains(PINNED_IDENTITY_COLUMN));
1330    }
1331
1332    if statement.starts_with("INSERT") || statement.starts_with("REPLACE") {
1333        let Some((table, rest)) = table_after_into(statement) else { return false };
1334        if !is_pinned_table(&table) {
1335            return false;
1336        }
1337        // Naming the column anywhere in an insert into a canonical table is a
1338        // backfill; a MISSING column list writes every column, identity included.
1339        return names_column || !rest.trim_start().starts_with('(');
1340    }
1341
1342    if statement.starts_with("ALTER") {
1343        let Some(table) = token_after(statement, "ALTER TABLE ") else { return false };
1344        if !is_pinned_table(&table) || !names_column {
1345            return false;
1346        }
1347        // Declaring the column bare is how step 12 shipped it. A DEFAULT
1348        // populates every existing row; a RENAME conscripts a populated one.
1349        return statement.contains("DEFAULT") || statement.contains("RENAME");
1350    }
1351
1352    if statement.starts_with("CREATE") && statement.contains(" TABLE ") {
1353        let Some(table) = token_after(statement, " TABLE ") else { return false };
1354        // A recreate may DECLARE the column (step 23 does); it may not default it.
1355        return is_pinned_table(&table) && names_column && statement.contains("DEFAULT");
1356    }
1357
1358    false
1359}
1360
1361/// The keyword-INDEPENDENT catch-all: does `statement` write `logical_id` on a
1362/// pinned table ANYWHERE, whatever it starts with?
1363///
1364/// Every other arm anchors on the leading keyword, so any prefix that displaces
1365/// it — SQLite's optional `WITH …` / `WITH RECURSIVE …` CTE clause is the one
1366/// that actually exists, and it is legal in front of `UPDATE`, `INSERT` and
1367/// `DELETE` — slips them all. Parsing CTE parentheses to find the real head
1368/// keyword would be a SQL parser; the pin is TERMINAL-FOREVER and its stated
1369/// stance is that over-rejection is a cost worth paying and under-rejection is
1370/// not, so this fires on WRITE POSITION instead of on statement shape:
1371///
1372/// - an assignment: the ` SET ` clause (up to `WHERE` / `FROM` / `RETURNING`)
1373///   names `logical_id`, and SOME token of the statement is a pinned table;
1374/// - an insert target: the table after ` INTO ` is pinned, and the statement
1375///   either names `logical_id` or omits the column list (which writes every
1376///   column, identity included) — the same rule the `INSERT` arm applies.
1377///
1378/// READ positions are deliberately untouched, which is exactly what keeps the
1379/// four shipped accepts green: a `WHERE … logical_id IS NULL` predicate (step
1380/// 21, which writes only `source_id`), an index column list (steps 12/23), a
1381/// bare `ADD COLUMN logical_id TEXT` declaration (step 12) and a `CREATE TABLE`
1382/// re-declaration (step 23) all name the column without assigning to it. The
1383/// insert half checks the TARGET table, not mere mention, so step 23's
1384/// `INSERT … INTO _fathomdb_open_state … SELECT … FROM canonical_edges` — which
1385/// reads a pinned table — stays accepted.
1386///
1387/// The assignment half is deliberately coarser than the `UPDATE` arm: it asks
1388/// only whether a pinned table is MENTIONED, because after an arbitrary prefix
1389/// the update target cannot be located token-wise without parsing. So
1390/// `WITH x AS (SELECT 1 FROM canonical_nodes) UPDATE other SET logical_id = …`
1391/// is refused too. That is over-rejection by design, and cheap to work around
1392/// honestly (don't name a canonical table in the CTE); the reverse mistake is
1393/// not recoverable.
1394fn writes_pinned_identity_anywhere(statement: &str, names_column: bool) -> bool {
1395    if names_column
1396        && mentions_pinned_table(statement)
1397        && set_clause(statement).is_some_and(|clause| clause.contains(PINNED_IDENTITY_COLUMN))
1398    {
1399        return true;
1400    }
1401    table_after_into(statement).is_some_and(|(table, rest)| {
1402        is_pinned_table(&table) && (names_column || !rest.trim_start().starts_with('('))
1403    })
1404}
1405
1406/// The bare table name from a possibly schema-qualified token.
1407///
1408/// SQLite resolves `main.canonical_nodes` (or `temp.canonical_nodes`, or any
1409/// quoted spelling of either half — the normaliser has already unwrapped those)
1410/// to the SAME pinned table as the bare name, so the qualifier is stripped
1411/// before the comparison. SQLite permits at most ONE qualifier, so the part
1412/// after the dot is the table name. This is a STRIP, not a widening to a
1413/// substring match: `main.canonical_nodes_old` is still a different table.
1414fn bare_table_name(token: &str) -> &str {
1415    token.rsplit_once('.').map_or(token, |(_, table)| table)
1416}
1417
1418fn is_pinned_table(token: &str) -> bool {
1419    PINNED_IDENTITY_TABLES.contains(&bare_table_name(token))
1420}
1421
1422/// Does any IDENTIFIER TOKEN of `statement` name a pinned canonical table?
1423///
1424/// Token-based, never a substring: `canonical_nodes_backup` is a DIFFERENT
1425/// table, and a `contains` test would conscript every lookalike name into the
1426/// pin — rejecting legitimate backup/recreate scaffolding. The statement is cut
1427/// on every character that cannot appear in an identifier, keeping `.` so a
1428/// schema qualifier stays one token for [`is_pinned_table`] to strip.
1429///
1430/// Used by the trigger arm, whose target table can sit anywhere in the head
1431/// (`… ON <table> …`), and which — because the `;` split glues the FIRST body
1432/// statement onto the head — must also see a table named inside that body.
1433fn mentions_pinned_table(statement: &str) -> bool {
1434    statement
1435        .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
1436        .any(is_pinned_table)
1437}
1438
1439/// SQLite's five `UPDATE OR <conflict-action>` / `INSERT OR <conflict-action>`
1440/// spellings (verified against SQLite 3.45.1).
1441const SQLITE_CONFLICT_ACTIONS: [&str; 5] = ["ROLLBACK", "ABORT", "FAIL", "IGNORE", "REPLACE"];
1442
1443/// The target table of an `UPDATE`, skipping SQLite's optional conflict clause.
1444///
1445/// `UPDATE OR REPLACE canonical_nodes SET logical_id = …` is a valid, running
1446/// backfill; the conflict action sits BETWEEN the keyword and the table name, so
1447/// taking the first token after `UPDATE ` yields `OR` and the pin is bypassed.
1448/// The skip is token-EXACT (the action must be a whole token followed by a
1449/// space), so a table actually named `or_ledger` is still read as the table.
1450///
1451/// The `INSERT`/`REPLACE` arm needs no equivalent: it anchors on ` INTO `, which
1452/// the conflict clause precedes (`INSERT OR IGNORE INTO canonical_nodes …`).
1453fn update_target_table(statement: &str) -> Option<String> {
1454    let mut rest = statement.strip_prefix("UPDATE ")?;
1455    if let Some(after_or) = rest.strip_prefix("OR ") {
1456        if let Some(after_action) = SQLITE_CONFLICT_ACTIONS
1457            .iter()
1458            .find_map(|action| after_or.strip_prefix(*action)?.strip_prefix(' '))
1459        {
1460            rest = after_action;
1461        }
1462    }
1463    first_token(rest)
1464}
1465
1466/// The first identifier token after `marker`, cut at whitespace or `(`.
1467fn token_after(statement: &str, marker: &str) -> Option<String> {
1468    first_token(statement.split_once(marker)?.1)
1469}
1470
1471/// The leading identifier token of `rest`, cut at whitespace, `(` or `,`.
1472fn first_token(rest: &str) -> Option<String> {
1473    let token: String =
1474        rest.chars().take_while(|c| !c.is_whitespace() && *c != '(' && *c != ',').collect();
1475    (!token.is_empty()).then_some(token)
1476}
1477
1478/// `(table, remainder-after-the-table-name)` for an `INSERT`/`REPLACE … INTO …`.
1479fn table_after_into(statement: &str) -> Option<(String, String)> {
1480    let rest = statement.split_once(" INTO ")?.1;
1481    let table: String =
1482        rest.chars().take_while(|c| !c.is_whitespace() && *c != '(' && *c != ',').collect();
1483    (!table.is_empty()).then(|| (table.clone(), rest[table.len()..].to_string()))
1484}
1485
1486/// The assignment half of an `UPDATE`: everything between ` SET ` and the first
1487/// clause keyword that ends it.
1488fn set_clause(statement: &str) -> Option<&str> {
1489    let after_set = statement.split_once(" SET ")?.1;
1490    let end = [" WHERE ", " FROM ", " RETURNING "]
1491        .iter()
1492        .filter_map(|kw| after_set.find(kw))
1493        .min()
1494        .unwrap_or(after_set.len());
1495    Some(&after_set[..end])
1496}
1497
1498pub fn check_migration_accretion(name: &str, sql: &str) -> Result<(), MigrationAccretionError> {
1499    let upper = sql.to_ascii_uppercase();
1500    let adds_schema = upper.contains("CREATE TABLE") || upper.contains("ADD COLUMN");
1501    let names_removal = upper.contains("DROP TABLE") || upper.contains("DROP COLUMN");
1502    let has_exemption = sql.contains("-- MIGRATION-ACCRETION-EXEMPTION: ");
1503
1504    if adds_schema && !names_removal && !has_exemption {
1505        return Err(MigrationAccretionError { offender: name.to_string() });
1506    }
1507
1508    Ok(())
1509}