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(¤t));
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(¤t));
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}