Skip to main content

fathomdb_engine/
lib.rs

1//! **FathomDB engine** — the runtime core: storage, projections, ingest and
2//! query.
3//!
4//! ⚠ **Most consumers should depend on the [`fathomdb`] facade crate instead.**
5//! `fathomdb` re-exports exactly the governed application surface and gates the
6//! operator/recovery seam behind a cargo feature; this crate is the
7//! implementation and exposes internals the facade deliberately withholds.
8//! Depend on it directly only if you are building FathomDB tooling.
9//!
10//! [`fathomdb`]: https://docs.rs/fathomdb
11//!
12//! # What it does
13//!
14//! One `Engine` owns an embedded SQLite database (FTS5 + `sqlite-vec`), the
15//! single writer thread, a thread-affine reader pool serving DEFERRED-tx
16//! snapshots, the background projection scheduler, and — optionally — an
17//! in-process embedder. There is no server and no sidecar.
18//!
19//! - **Hybrid retrieval.** A vector branch and an FTS5 branch, fused by
20//!   Reciprocal Rank Fusion on ordinal *rank* (never on raw, non-comparable
21//!   scores), with an optional CPU cross-encoder rerank and an optional
22//!   graph-BFS third arm over temporal fact edges.
23//! - **Canonical rows + projections.** Writes land as durable canonical rows;
24//!   FTS, vector and attribute indexes are engine-maintained projections
25//!   rebuildable from them.
26//! - **Record lifecycle.** Transaction-time supersession keyed on `logical_id`,
27//!   an existence axis (`transition` / `purge`), and world-time validity
28//!   windows on nodes plus `t_valid` / `t_invalid` on edges.
29//! - **Deletion on request.** `Engine::erase_source` erases every row carrying a
30//!   provenance id — including anonymous rows `Engine::purge` cannot reach —
31//!   and finishes the erasure at rest.
32//!
33//! # Provenance is mandatory
34//!
35//! `PreparedWrite::Node` and `PreparedWrite::Edge` carry `source_id: SourceId`,
36//! a newtype rather than an `Option<String>`. `Engine::erase_source` addresses
37//! rows **by** `source_id`, so a row written without one could never be erased;
38//! `SourceId::new` is the only public constructor and makes that state
39//! inexpressible.
40//!
41//! # Stability
42//!
43//! Pre-1.0, so **beta**. `SCHEMA_VERSION` is the on-disk contract; migrations
44//! run at open and only there. `PreparedWrite`, `SearchFilter` and
45//! `EngineError` are `#[non_exhaustive]` or documented as additive.
46
47pub mod lifecycle;
48mod pcache2;
49
50use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
51use std::error::Error;
52use std::fmt::{Display, Formatter};
53use std::fs::{File, OpenOptions};
54use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
55use std::path::{Path, PathBuf};
56use std::process::{Command, Stdio};
57use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
58use std::sync::mpsc::{self, Receiver, SyncSender};
59#[cfg(debug_assertions)]
60use std::sync::Barrier;
61use std::sync::Once;
62use std::sync::{Arc, Condvar, Mutex};
63use std::thread::{self, JoinHandle};
64use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
65
66use fathomdb_embedder::EmbedderEvent;
67// `MeanRecomputeTrigger` is used only by the operator-gated `recompute_mean`.
68#[cfg(feature = "operator")]
69use fathomdb_embedder::MeanRecomputeTrigger;
70use fathomdb_embedder_api::{Embedder, EmbedderError as RuntimeEmbedderError, EmbedderIdentity};
71use fathomdb_query::compile_text_query;
72use fathomdb_schema::{
73    migrate_with_event_sink, MigrationError as SchemaMigrationError, MigrationStepReport,
74    LOCK_SUFFIX, MIGRATIONS, SCHEMA_VERSION,
75};
76// `CANONICAL_TABLES` is used only by the operator-gated `dump_row_counts`.
77#[cfg(feature = "operator")]
78use fathomdb_schema::CANONICAL_TABLES;
79use jsonschema::JSONSchema;
80use rusqlite::{params, Connection, OptionalExtension};
81use serde_json::Value;
82// `sha2::Digest` + `sha2::Sha256` — used by `safe_export` (operator-gated)
83// and unconditionally by `ingest_with_extractor` (G11 logical_id derivation).
84#[cfg(feature = "operator")]
85use sha2::Digest;
86#[cfg(not(feature = "operator"))]
87use sha2::Digest as _;
88use sha2::Sha256;
89use sqlite_vec::sqlite3_vec_init;
90
91#[cfg(unix)]
92use std::os::unix::fs::OpenOptionsExt;
93
94// EU-5b lock-flip: the engine's default embedder identity is now the
95// pinned bge-small variant. Pre-existing 0.7.0 workspaces opened with
96// `EmbedderChoice::Default` will fail-closed on identity mismatch per
97// ADR-0.6.0-vector-identity-embedder-owned; callers can still hold an
98// older noop profile by supplying `EmbedderChoice::Caller(NoopEmbedder)`.
99const DEFAULT_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
100const DEFAULT_EMBEDDER_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
101const DEFAULT_EMBEDDER_DIMENSION: u32 = 384;
102
103/// Identity name of the bge-small embedder. `OpenReport.embedder_mean_centering_required`
104/// is `true` iff the live embedder identity reports this name. NoopEmbedder
105/// is `false`. Lifted out as a constant so the EU-5b lock-flip (when the
106/// engine's default identity becomes bge-small) is a single-line change.
107///
108/// TODO(EU-5b): when `DEFAULT_EMBEDDER_NAME` flips to this constant, the
109/// Default path will populate `embedder_mean_centering_required = true`
110/// without further engine work. Caller-supplied bge-small (rare today)
111/// already does the right thing.
112const BGE_SMALL_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
113
114/// REQ-006a / AC-007a default slow-statement threshold. Mutated at runtime
115/// via [`Engine::set_slow_threshold_ms`].
116const DEFAULT_SLOW_THRESHOLD_MS: u64 = 100;
117const DEFAULT_VECTOR_PROFILE: &str = "default";
118const DEFAULT_VECTOR_PARTITION: &str = "vector_default";
119
120/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — the committed 45-probe fixture
121/// (byte-identical to `fathomdb-embedder/tests/fixtures/candle_onnx_equivalence_probes.txt`;
122/// a drift-guard test pins the two copies equal). One probe per non-empty line;
123/// lines whose first non-whitespace char is `#` are comments.
124const VECTOR_EQUIVALENCE_PROBE_FIXTURE: &str = include_str!("vector_equivalence_probes.txt");
125
126/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — the FROZEN D4 tolerance floor,
127/// **P2 component**: the un-centered Phase-2 L2 epsilon. `‖reembed − reference‖₂`
128/// (un-centered, `vec_distance_l2` semantics) strictly greater than this ⇒
129/// divergence ⇒ dense refused. Named constant so the final ε (HITL look at
130/// landing) is trivially tunable. The **P1 component** (Phase-1 mean-centered
131/// `embedding_bin` sign-flip count) has an *exact-zero* floor: ANY single flip on
132/// the 45 probes ⇒ divergence (see [`VECTOR_EQUIVALENCE_P1_FLIP_FLOOR`]).
133const VECTOR_EQUIVALENCE_L2_EPSILON: f32 = 1e-5;
134
135/// 0.8.18 Slice 5 — the FROZEN D4 tolerance floor, **P1 component**: the maximum
136/// tolerated Phase-1 mean-centered `embedding_bin` sign-flip count across all 45
137/// probes. `0` = exact: any single flip ⇒ divergence ⇒ dense refused.
138const VECTOR_EQUIVALENCE_P1_FLIP_FLOOR: u64 = 0;
139
140/// 0.8.20 Slice 22 (TC-68) — `_fathomdb_open_state` key holding the
141/// [`probe_verification_fingerprint`] of the last open at which the
142/// vector-equivalence probe actually RAN and PASSED on this workspace. An open
143/// whose freshly computed fingerprint equals this value reuses that verdict and
144/// performs ZERO probe embeds; anything else re-runs the full probe.
145///
146/// It lives in `_fathomdb_open_state` — the engine's existing open-time
147/// durable-marker KV table (migration step 1) — alongside
148/// [`SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY`] and
149/// [`EDGE_VECTOR_PRUNE_MARKER_KEY`], which is exactly this shape of state. So
150/// TC-68 adds **no** table, **no** migration step and **no** `SCHEMA_VERSION`
151/// bump: an old DB simply has no row here and re-runs the probe once.
152const VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY: &str = "vector_equivalence_verified_fingerprint";
153
154/// 0.8.20 Slice 22 (TC-68) — recipe tag mixed into every verdict fingerprint.
155/// **Bump it whenever the SET of fingerprint inputs changes.** Every cached
156/// verdict in the field then stops matching and the probe re-runs once per
157/// workspace — the fail-SAFE direction, and the reason a stale recipe can never
158/// silently keep vouching for a narrower check than the current build performs.
159const VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE: &str = "fathomdb-veq-verdict-v1";
160/// Default drain budget for `rebuild_projections` / `rebuild_vec0`. The
161/// rebuild path freezes the scheduler before truncating shadow rows, so
162/// the only outstanding work is whatever workers were mid-flight when
163/// the call landed; 30 s is generous for normal job sizes and bounded
164/// for tests.
165#[cfg(feature = "operator")]
166const REBUILD_DRAIN_TIMEOUT_MS: u64 = 30_000;
167/// OPP-12 Phase-1 (0.8.19 Slice 10) — drain budget the `transition`/`purge`
168/// lifecycle verbs use to settle in-flight projection work before mutating.
169/// Same 30 s budget as `REBUILD_DRAIN_TIMEOUT_MS`, but not `operator`-gated
170/// (the lifecycle verbs are always-on governed surface).
171const LIFECYCLE_DRAIN_TIMEOUT_MS: u64 = 30_000;
172/// 0.8.0 Slice 5 (G1) — schema version that introduces the global FTS5
173/// tokenizer-default upgrade (`SCHEMA_VERSION` 11, migration step 11). A DB
174/// migrated to (or past) this version re-tokenizes `search_index` from
175/// canonical source rows on open (the drop+recreate leaves the FTS index
176/// empty). Repair is keyed off the completion marker below — NOT off crossing
177/// the step boundary — so it is crash-retryable (see
178/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY`).
179const SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION: u32 = 11;
180/// 0.8.0 Slice 5 (G1) fix-1 — `_fathomdb_open_state` key set, in the SAME
181/// transaction as the reproject DELETE+INSERT, once the post-tokenizer-upgrade
182/// re-tokenization commits durably. Step 11 commits `user_version = 11` with an
183/// EMPTY `search_index` in its own transaction; the reproject runs in a later
184/// transaction on open. A crash in that window leaves a durable `user_version =
185/// 11` + empty index. Gating repair on a boundary crossing (`before < 11`)
186/// would skip it on the next open (it sees `before == 11`), stranding the index
187/// empty forever. Gating on this marker's ABSENCE instead makes repair
188/// idempotent and crash-retryable: written atomically with the reindex, so a
189/// crash before commit leaves no marker and the next open re-runs.
190const SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY: &str =
191    "search_index_tokenizer_reproject_complete";
192/// 0.8.20 Slice 15c (TC-33) fix-6 — schema version at which the
193/// `canonical_edges` INTEGER-epoch recreate (migration step 23) runs. A DB
194/// migrated to (or past) this version has had every edge row DROPPED with NO
195/// DATA MIGRATION, and the migration removed the dropped edges'
196/// `_fathomdb_vector_rows` sidecar rows. The vec0 `vector_default` shadow it
197/// mirrors is engine-created and dim-parameterized, so the migration cannot
198/// touch it; the engine prunes the now-orphaned vec0 rows on open.
199const EDGE_TEMPORAL_EPOCH_SCHEMA_VERSION: u32 = 23;
200/// 0.8.20 Slice 15c (TC-33) fix-6 — `_fathomdb_open_state` key set once the
201/// one-time edge-vector prune commits durably (written in the SAME transaction
202/// as the vec0 DELETEs). Gating repair on this marker's ABSENCE — not on
203/// crossing the step-23 boundary — makes it crash-retryable: the step-23
204/// migration commits `user_version = 23` (edges dropped, sidecar cleared) in its
205/// own transaction, and the prune runs in a later transaction on open. A crash
206/// in that window leaves a durable `user_version = 23` with orphaned vec0 rows;
207/// a boundary-crossing gate (`before < 23`) would skip the prune forever on the
208/// next open (it sees `before == 23`). The marker is absent on any DB upgraded
209/// before this fix shipped, so the prune runs once and cleans the lingering
210/// orphans; thereafter the paired vec0/sidecar insert+delete keeps the invariant
211/// so no new orphans arise.
212const EDGE_VECTOR_PRUNE_MARKER_KEY: &str = "tc33_edge_vector_prune_complete";
213const DEFAULT_PROVENANCE_ROW_CAP: u64 = 1_000_000;
214/// 0.8.20 Slice 5b (R-20-E5) — how many times an erasure verb re-tries
215/// `PRAGMA wal_checkpoint(TRUNCATE)` before refusing with
216/// [`EngineError::ErasureIncomplete`]. Deliberately small: a concurrent reader
217/// pinning a WAL snapshot can hold it for an unbounded time, and an erasure verb
218/// must fail loudly rather than block a caller indefinitely.
219const ERASURE_WAL_TRUNCATE_ATTEMPTS: u32 = 5;
220/// 0.8.20 Slice 5b (R-20-E5) — pause between WAL-truncation attempts
221/// (~100 ms total budget across [`ERASURE_WAL_TRUNCATE_ATTEMPTS`]).
222const ERASURE_WAL_TRUNCATE_BACKOFF_MS: u64 = 25;
223/// 0.8.20 Slice 5b (R-20-E6) — the sentinel that replaces an erased
224/// `result_stable_ids` element in the telemetry sink. Positional alignment with
225/// the parallel `result_ids` array is preserved, so a redacted sink stays
226/// parseable by the gold pipeline.
227const REDACTED_STABLE_ID: &str = "[erased]";
228/// 0.8.20 Slice 5b (design `0.8.20-slice0-erasure-design.md` §2 defect D-A,
229/// HITL-ruled 2026-07-19: *"there must be an auditable record of deletion
230/// event."*) — op-store collections holding ERASURE-AUDIT records.
231///
232/// These rows are **exempt from [`enforce_provenance_retention`]**. Before this
233/// slice they were swept like any other op-store row: cap-first, oldest-`id`
234/// first, with no collection filter — and because the audit row is written
235/// *before* the workload that follows it, it was among the FIRST evicted. The
236/// proof of erasure was therefore destructible, and shared a retention pool with
237/// the very payloads it must prove erased. Accountability (demonstrating *that*
238/// an erasure occurred) is a distinct obligation from erasure itself, and a
239/// retention sweep must not silently discharge it.
240///
241/// **Guarantee:** a row in one of these collections is never removed by the
242/// retention sweep, and (0.8.20 Slice 5 fix-3) never by
243/// [`Engine::excise_collection_record`] either — see
244/// [`is_erasure_bookkeeping_collection`].
245const ERASURE_AUDIT_COLLECTIONS: &[&str] = &["excise_source_audit", "excise_record_audit"];
246/// 0.8.20 Slice 5 fix-1 (codex §9 P2) — the `operational_mutations` collection
247/// holding the DURABLE record of a telemetry redaction that is owed but not yet
248/// performed. See [`Engine::discharge_pending_redactions`].
249///
250/// Like the audit collections it is exempt from the retention sweep: an
251/// outstanding erasure obligation must not be discharged by cap pressure.
252const ERASURE_PENDING_REDACTION_COLLECTION: &str = "erasure_pending_redaction";
253/// 0.8.20 Slice 5 fix-3 (codex §9 round-3 P1) — true for the op-store
254/// collections that hold the engine's ERASURE BOOKKEEPING: the durable
255/// pending-redaction queue and the erasure-audit trail.
256///
257/// These are engine-owned invariants that happen to be *stored* as op-store
258/// records. They are not caller data, and the generic record-erasure verb
259/// [`Engine::excise_collection_record`] must refuse to target them:
260///
261/// * **The pending queue** ([`ERASURE_PENDING_REDACTION_COLLECTION`]) records a
262///   telemetry redaction the engine still OWES. Deleting the entry makes
263///   [`Engine::complete_erasure_at_rest`] see no outstanding work, so the next
264///   erasure verb reports SUCCESS while the erased `l:`/`h:` ids are still in
265///   the telemetry sink. That is the exact R-20-E5 violation — *an erasure verb
266///   must never report success on an incomplete erasure* — that the queue was
267///   introduced to close, and the verb re-opened it through an
268///   operator-reachable path (`--excise-collection erasure_pending_redaction
269///   --excise-record-key <verb>`).
270/// * **The audit trail** ([`ERASURE_AUDIT_COLLECTIONS`]) is protected by the
271///   HITL ruling of 2026-07-19: *"there must be an auditable record of deletion
272///   event."* Deleting audit rows one-by-one defeats that ruled-on guarantee as
273///   surely as a retention sweep would. Accountability is a distinct obligation
274///   from erasure, and no verb may silently discharge it.
275///
276/// Neither carries erasable payload, so refusing them costs a caller nothing: a
277/// pending-queue row holds only stable ids the engine is about to remove from
278/// the sink (and deletes itself on discharge), and an audit row holds a
279/// `source_id` bound by the non-PII rule or a SHA-256 record digest.
280///
281/// The refusal is TYPED ([`EngineError::InvalidArgument`]), never a silent
282/// no-op — an operator who aimed at the wrong collection must be told. The shape
283/// mirrors the slice's existing precedent: [`Engine::erase_source`] refuses the
284/// reserved `_`-prefixed provenance namespace while [`Engine::excise_source`]
285/// stays permissive.
286///
287/// Gated on `feature = "operator"` to match its only call site,
288/// [`Engine::excise_collection_record`], which is itself operator-only: without
289/// the matching `cfg` a non-`operator` build emits a `dead_code` warning for a
290/// helper that has nothing to guard, because the verb it guards does not exist
291/// in that build.
292#[cfg(feature = "operator")]
293fn is_erasure_bookkeeping_collection(collection: &str) -> bool {
294    collection == ERASURE_PENDING_REDACTION_COLLECTION
295        || ERASURE_AUDIT_COLLECTIONS.contains(&collection)
296}
297const PROJECTION_CURSOR_KEY: &str = "projection_cursor";
298const PROJECTION_WORKERS: usize = 2;
299/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5** default per-`embed()`
300/// watchdog deadline. Every projection-path embed runs under this timeout;
301/// a hung embed surfaces `RuntimeEmbedderError::Timeout` (engaging the
302/// existing retry/failure path) rather than parking a worker forever. The
303/// EU-5f `catch_unwind` only catches *panics*; this catches *hangs*.
304const DEFAULT_EMBED_TIMEOUT_MS: u64 = 30_000;
305/// PR-9 — embed circuit-breaker threshold: the maximum number of watchdog
306/// embed threads allowed alive at once before the breaker latches and
307/// projection jobs fail fast (see `embed_circuit_open` / `live_embed_threads`).
308/// Healthy serialized operation keeps the live count at 0–1, so reaching this
309/// many concurrently-alive embed threads means timed-out embeds are piling up
310/// (a hung/wedged embedder); the breaker then caps the abandoned-thread leak
311/// at roughly this count.
312const DEFAULT_EMBED_CIRCUIT_THRESHOLD: u64 = 8;
313const PROJECTION_COMMIT_BATCH: usize = 16;
314// Each worker should be able to grab a full commit batch while another
315// worker has the same waiting in the queue. Below this, the dispatcher
316// throttles below the workers' commit-batch capacity.
317const PROJECTION_INFLIGHT_LIMIT: usize = PROJECTION_WORKERS * PROJECTION_COMMIT_BATCH;
318// SQL fetch cap inside the dispatcher: enough to fill the in-flight
319// budget in a single scan so we don't pay one SQL roundtrip per job.
320const PROJECTION_SCAN_FETCH: usize = PROJECTION_INFLIGHT_LIMIT;
321const DEFAULT_PROJECTION_RETRY_DELAYS_MS: [u64; 3] = [1_000, 4_000, 16_000];
322
323/// G11 — the fixed projection kind every EDGE body is scheduled under
324/// (`resolve_source_type` maps it to `source_type = 'edge_fact'` in
325/// `vector_default`). Named so the fix-4 deferral can say WHICH rows it
326/// deliberately leaves on the shipped terminal path (see
327/// `projection_dispatcher_loop`).
328const EDGE_FACT_KIND: &str = "edge_fact";
329
330/// Reader pool size. Per `dev/design/engine.md` § Writer / reader split,
331/// reader connections are pooled and never serialize behind one
332/// connection. AC-021 exercises 8 concurrent readers.
333const READER_POOL_SIZE: usize = 8;
334
335/// Per-reader-connection lookaside slot size, in bytes. Pack 6.G G.1.
336/// Picked from G.0 telemetry (`allocator_lookaside` 26.67% conc cycles
337/// with 3.89× ratio) + the SQLite docs' typical-workload sizing
338/// guidance (https://www.sqlite.org/malloc.html §3): 1200-byte slots
339/// cover the small allocations from `sqlite3DbMallocRaw`,
340/// `sqlite3Fts5ExprNew`, and `vec0Filter_knn` visible at the top of the
341/// concurrent profile.
342const READER_LOOKASIDE_SLOT_SIZE: std::os::raw::c_int = 1200;
343
344/// Per-reader-connection lookaside slot count. SQLite default is 128;
345/// we use 500 to absorb the per-statement allocation footprint of the
346/// hybrid search workload across a sticky worker connection without
347/// falling back to the glibc malloc-arena mutex.
348const READER_LOOKASIDE_SLOT_COUNT: std::os::raw::c_int = 500;
349
350pub struct Engine {
351    path: PathBuf,
352    next_cursor: AtomicU64,
353    closed: AtomicBool,
354    lock: Mutex<Option<File>>,
355    connection: Mutex<Option<Connection>>,
356    reader_pool: ReaderWorkerPool,
357    counters: lifecycle::Counters,
358    subscribers: Arc<lifecycle::SubscriberRegistry>,
359    profiling_enabled: Arc<AtomicBool>,
360    slow_threshold_ms: Arc<AtomicU64>,
361    runtime_embedder: Option<Arc<dyn Embedder>>,
362    runtime_embedder_identity: EmbedderIdentity,
363    projection_runtime: ProjectionRuntime,
364    provenance_row_cap: AtomicU64,
365    /// Per-connection profile-callback contexts. Each box's pointer is
366    /// installed into the connection's `sqlite3_profile` userdata; the
367    /// box must outlive the connection so the callback never reads
368    /// freed memory. Connections are dropped before this vec on
369    /// `close`/`Drop`, so the lifetime ordering holds.
370    ///
371    /// Why `Box<ProfileContext>` and not `ProfileContext` directly: the
372    /// FFI pointer captured during `install_profile_callback` MUST
373    /// remain stable for the connection's lifetime; pushing onto a
374    /// `Vec<ProfileContext>` could reallocate and invalidate that
375    /// pointer.
376    #[allow(clippy::vec_box)]
377    profile_contexts: Mutex<Vec<Box<ProfileContext>>>,
378    /// Pack 6.G G.1 — `sqlite3_db_config(LOOKASIDE)` rc per reader
379    /// worker, captured at open time before any PRAGMA / prepare ran
380    /// on the connection. Read only by the debug-only test accessor
381    /// `reader_lookaside_config_rcs_for_test`; held in release builds
382    /// too because the field is set unconditionally at open and a cfg
383    /// gate would force two open-locked return shapes.
384    #[allow(dead_code)]
385    reader_lookaside_rcs: Vec<i32>,
386    /// 0.8.8 Slice 15 (OPP-9) — opt-in telemetry sink. `None` (default) = OFF.
387    /// Local JSONL append; no network/egress. The OFF path never takes this lock —
388    /// it is gated by `telemetry_enabled` (below).
389    telemetry: Mutex<Option<TelemetrySink>>,
390    /// 0.8.8 Slice 15 — fast OFF-path guard. `false` (default) → search does ZERO
391    /// telemetry work: a single `Relaxed` atomic load, NO mutex acquisition (the
392    /// §B.1 footprint / zero-cost gate, codex §9 P2). Set `true` by
393    /// `enable_telemetry` after the sink is installed; the `telemetry` mutex is only
394    /// ever taken when this flag is set.
395    telemetry_enabled: AtomicBool,
396    /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4/6) — degraded-open
397    /// latch, re-derived at every open by the #5 self-check. `true` ⇒ every
398    /// vector-dependent arm refuses at the `search_inner_with_stats` choke point
399    /// with `EngineError::VectorEquivalenceMismatch`. Read lock-free on the query
400    /// hot path (a single `Relaxed`/`Acquire` load); the text-only/FTS-only path
401    /// never reads it.
402    dense_disabled: AtomicBool,
403    /// R-VEQ-6 — the human-readable reason attached to the query-time refusal (and
404    /// surfaced on `OpenReport.dense_disabled_reason`). Set once at open; read only
405    /// when `dense_disabled` is `true`.
406    dense_disabled_reason: Mutex<Option<String>>,
407    /// R-VEQ-6 — telemetry counter: number of query-time vector-dependent-arm
408    /// refusals raised because the engine opened in the `dense_disabled` state.
409    /// Observable pre/post-query via `vector_equivalence_refusal_count`.
410    vector_equivalence_refusals: AtomicU64,
411    #[cfg(debug_assertions)]
412    force_next_commit_failure: AtomicBool,
413}
414
415/// 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture state (per `enable_telemetry`).
416/// Records query→result→feedback events to a local JSONL sink. Ids are
417/// `SearchHit.id` — the interim identity carrier per
418/// `ADR-0.8.0-canonical-identity-substrate` (write_cursor today; swaps to
419/// `logical_id` at the G0 keystone with no carrier reshape), consistent with
420/// `PerHitExplain.id`. Query text and `source_id` are NEVER captured (privacy, ADR
421/// §C). `query_id = "q{nonce}-{seq}"` is fully deterministic; `ts_monotonic_ms` is
422/// monotonic since enable (NOT wall-clock).
423struct TelemetrySink {
424    path: PathBuf,
425    base: Instant,
426    nonce: u64,
427    seq: u64,
428    last_query_id: Option<String>,
429}
430
431#[derive(Clone, Debug)]
432struct ProjectionJob {
433    cursor: u64,
434    kind: String,
435    body: String,
436}
437
438#[derive(Debug, Default)]
439struct ProjectionRuntimeState {
440    active_jobs: usize,
441    queued_jobs: usize,
442    frozen: bool,
443    pending_scan: bool,
444    stopping: bool,
445    in_flight: BTreeSet<u64>,
446}
447
448struct ProjectionRuntimeShared {
449    path: PathBuf,
450    embedder: Option<Arc<dyn Embedder>>,
451    embedder_identity: EmbedderIdentity,
452    /// Host-owned lifecycle diagnostics for worker failures, which occur on
453    /// background connections rather than through an `Engine` method call.
454    subscribers: Arc<lifecycle::SubscriberRegistry>,
455    state: Mutex<ProjectionRuntimeState>,
456    state_cvar: Condvar,
457    queue: Mutex<VecDeque<ProjectionJob>>,
458    queue_cvar: Condvar,
459    retry_delays_ms: Mutex<Vec<u64>>,
460    /// PR-9 — ADR-0.6.0-embedder-protocol Invariant 5 per-`embed()` watchdog
461    /// deadline (ms). Read lock-free on the projection hot path. Default
462    /// `DEFAULT_EMBED_TIMEOUT_MS` (30s); the test seam
463    /// `set_embed_timeout_ms_for_test` lowers it so the hanging-embedder
464    /// test need not wait 30s. A hung embed surfaces
465    /// `RuntimeEmbedderError::Timeout`, engaging the existing retry/failure
466    /// path in `run_projection_job`.
467    embed_timeout_ms: AtomicU64,
468    /// PR-9 — engine-side embed serialization guard. The pool runs
469    /// `PROJECTION_WORKERS` workers; this guard ensures the shared
470    /// `Arc<dyn Embedder>` is invoked by at most one worker at a time.
471    ///
472    /// Rationale is SAFETY, not throughput. The engine accepts arbitrary
473    /// caller-supplied embedders (the pyo3 / napi bridges, per ADR-0.6.0)
474    /// whose `embed` is `Sync` only by trait contract; many real impls (a
475    /// GIL-bound Python model, a non-reentrant native lib, an internal cache)
476    /// are not actually safe under concurrent calls. Serializing engine-side
477    /// makes the projection robust to embedders that are not truly
478    /// concurrency-safe, without the engine having to trust each impl. The
479    /// default `CandleBgeEmbedder` was shown safe under concurrent forwards
480    /// in the PR-9 pre-flight, so for it the guard is belt-and-suspenders.
481    ///
482    /// Throughput is ~neutral: `candle` fans every `BertModel::forward` onto a
483    /// single process-wide rayon pool, so two concurrent forwards merely
484    /// share that pool (trading per-embed latency, not aggregate work) rather
485    /// than getting 2x — serializing avoids some scheduler/cache thrash but is
486    /// not a large win. (An earlier "~13x" figure compared a debug-build
487    /// unserialized run against a release-build number and was withdrawn; a
488    /// PR-9 micro-benchmark put release embeds at ~14 ms short / ~960 ms for a
489    /// 512-token doc, watchdog overhead ~0.)
490    ///
491    /// Commit/IO stays parallel across workers (see `commit_gate`); this guard
492    /// wraps only the embed call. It is held by the worker across the watchdog
493    /// call and released here, so a timed-out (abandoned) embed frees it and
494    /// cannot stall the pool — the guard owns no data, so a panic-resumed
495    /// embed that poisons it is recovered via `into_inner`.
496    ///
497    /// Deliberate trade-off (codex PR-9 CONCERN-1, accepted): on the *timeout*
498    /// path the worker drops this guard while the abandoned detached embed
499    /// thread is still running lock-free, so serialization is briefly relaxed
500    /// until that thread finishes. This is the prescribed choice over holding
501    /// the guard inside the embed thread — which would let a genuinely-hung
502    /// embed hold it forever and deadlock the whole pool, exactly the wedge
503    /// ADR-0.6.0 Invariant 5 and this slice's spec forbid. Timeouts are the
504    /// fault path only; the embed circuit breaker (`embed_circuit_open`) caps
505    /// how many such abandoned threads can be alive at once. A future slice may
506    /// replace this hard serialize with an operator-configurable embed
507    /// concurrency limit (ADR-0.6.0 Invariant 4 pool-size override) for I/O-
508    /// or GPU-bound embedders; that knob is out of PR-9 scope.
509    embed_serialize: Mutex<()>,
510    /// PR-9 — embed circuit breaker. `live_embed_threads` counts watchdog embed
511    /// threads currently alive (incremented when one is spawned, decremented
512    /// when it finishes — see `embed_with_watchdog`). Under healthy serialized
513    /// operation this is 0 or 1; it only grows when timed-out embeds are
514    /// abandoned and keep running (ADR-0.6.0 Invariant 5 forbids aborting a
515    /// running embed). When a new embed would push the live count to
516    /// `embed_circuit_threshold`, the breaker latches `embed_circuit_open` and
517    /// projection jobs fail fast WITHOUT spawning further embeds — bounding the
518    /// abandoned-thread leak to ~threshold REGARDLESS of whether the embedder
519    /// hangs on every input or only intermittently (a returning embed
520    /// decrements the count rather than resetting a streak, so an
521    /// intermittently-hanging embedder still latches as its hung threads pile
522    /// up, and a merely-slow-but-returning embedder self-clears and never
523    /// false-trips). Latches for the engine session (a reopen resets it); a
524    /// half-open/cool-down retry is future work. `threshold == 0` disables it.
525    live_embed_threads: Arc<AtomicU64>,
526    embed_circuit_open: AtomicBool,
527    embed_circuit_threshold: AtomicU64,
528    /// EU-5b — streaming mean accumulator for the per-workspace mean
529    /// pinning lifecycle (`dev/design/embedder.md` §0.3). `Some(_)` iff
530    /// the identity is MC-required AND no mean has been pinned yet on
531    /// disk. The accumulator graduates to `None` after the at-pin
532    /// commit; subsequent docs feed nothing.
533    mean_accumulator: Mutex<Option<MeanAccumulator>>,
534    /// EU-5b — `MeanVecPinned` events queued by the projection-commit
535    /// transaction for the next test-seam drain. Production callers
536    /// consume these via the `OpenReport.embedder_events` channel; the
537    /// drain seam is `Engine::drain_mean_centering_events_for_test`.
538    pending_events: Mutex<Vec<EmbedderEvent>>,
539    /// EU-5f — serializes the body of `commit_projection_outcomes` across
540    /// the `PROJECTION_WORKERS` worker connections. Each worker commits on
541    /// its own connection; holding this gate for the whole commit makes the
542    /// commit transactions totally ordered, which is what makes the at-pin
543    /// re-quantize pass provably complete (every row is wholly before or
544    /// after the unique pin tx, so none can survive un-centered). Embedding
545    /// (`run_projection_job`) runs OUTSIDE the gate and stays parallel.
546    commit_gate: Mutex<()>,
547    /// 0.7.2 PR-2bc S1 fix-1 — overridable phase-2 rerank `LIMIT` for the
548    /// search hot path. Equals `SEARCH_RERANK_LIMIT` (10) in production; a
549    /// test seam (`set_search_limit_for_test`) can RAISE it (clamped to >=10,
550    /// so it can never shrink below production semantics) so the recall
551    /// harness can pull top-(10+slack) and exclude the self-retrieving
552    /// query-source doc before truncating to 10. Production reads this atomic
553    /// (default 10) — there is NO env var read on the hot path.
554    search_limit_override: AtomicUsize,
555    /// Slice 10 / G12-recency — dedicated recency-reweight flag, **off by
556    /// default** (NOT `fusion_mode`). When set, fused hits are reweighted toward
557    /// the more recent `write_cursor` AFTER bit-KNN. Flipped by the
558    /// `set_recency_reweight_enabled_for_test` seam; no production toggle yet.
559    recency_reweight_enabled: AtomicBool,
560    /// 0.8.16 Slice 5 / F9 — dedicated importance/confidence reweight flag,
561    /// **off by default** (mirrors `recency_reweight_enabled`; NOT `fusion_mode`).
562    /// When set, fused hits are multiplicatively reweighted by node `importance`
563    /// (`canonical_nodes.importance`) and edge `confidence`
564    /// (`canonical_edges.confidence`) AFTER bit-KNN + RRF fusion — `NULL ⇒ neutral
565    /// (1.0)`. Flipped by `set_importance_reweight_enabled_for_test`; no production
566    /// toggle yet (F9 ships OFF-by-default as a MECHANISM, no eval-quality claim).
567    importance_reweight_enabled: AtomicBool,
568    /// GA-2 / Slice-40 (◆ B-1) measurement seam, **off by default**. When set,
569    /// `read_search_in_tx` returns the pre-fusion VECTOR-branch ranking
570    /// (bit-KNN K=192 + f32 rerank) verbatim — the ANN-quantization fidelity
571    /// signal — INSTEAD of the unconditional RRF-fused result. This changes
572    /// nothing for any production caller (the flag is never set outside the
573    /// `eu7` recall harness via `set_vector_stage_only_for_test`); it does NOT
574    /// reintroduce a `fusion_mode` knob (RRF stays unconditional) and does NOT
575    /// alter `fuse_rrf` / `rerank_fused` / recency. It only lets the AC-075
576    /// recall gate measure ANN+ vector top-10 vs the exact-f32 VECTOR top-10
577    /// ground truth in isolation (the quantization-FIDELITY axis the 0.90 floor
578    /// is defined to measure), not the hybrid `search()` output.
579    vector_stage_only_for_test: AtomicBool,
580    /// 0.7.2 PR-2b — debug-only fault injection: when set, `recompute_mean_in_tx`
581    /// errors AFTER writing `mean_vec` but BEFORE finishing the re-quantize
582    /// pass, so the crash-atomicity test can prove the whole recompute rolls
583    /// back (no half-recentered corpus). One-shot (cleared on consume).
584    #[cfg(debug_assertions)]
585    force_recompute_failure: AtomicBool,
586    /// TC-91 — one-shot worker-commit fault seam. `0` is disabled, `1`
587    /// requests a synthetic SQLite busy error, and `2` a rusqlite-layer
588    /// storage error immediately before commit.
589    /// Kept entirely in the runtime and compiled only for tests.
590    #[cfg(debug_assertions)]
591    force_projection_commit_failure: AtomicUsize,
592    /// TC-91 test-only rendezvous after error reporting and before worker
593    /// cleanup. It proves a stop in that window leaves canonical pending work
594    /// for the next open rather than relying on an in-memory retry queue.
595    #[cfg(debug_assertions)]
596    projection_commit_failure_pause: Mutex<Option<(Arc<Barrier>, Arc<Barrier>)>>,
597    /// TC-91 test-only acknowledgement after `stopping` is set and before a
598    /// close joins workers, used with `projection_commit_failure_pause`.
599    #[cfg(debug_assertions)]
600    projection_stop_ack: Mutex<Option<Arc<Barrier>>>,
601}
602
603impl std::fmt::Debug for ProjectionRuntimeShared {
604    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
605        f.debug_struct("ProjectionRuntimeShared")
606            .field("path", &self.path)
607            .field("embedder_identity", &self.embedder_identity)
608            .finish_non_exhaustive()
609    }
610}
611
612#[derive(Debug)]
613struct ProjectionRuntime {
614    shared: Arc<ProjectionRuntimeShared>,
615    dispatcher: Mutex<Option<JoinHandle<()>>>,
616    workers: Mutex<Vec<JoinHandle<()>>>,
617}
618
619impl std::fmt::Debug for Engine {
620    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
621        f.debug_struct("Engine")
622            .field("path", &self.path)
623            .field("closed", &self.closed.load(Ordering::SeqCst))
624            .field("runtime_embedder_identity", &self.runtime_embedder_identity)
625            .finish_non_exhaustive()
626    }
627}
628
629/// Per-connection profile-callback context.
630///
631/// Holds the registry handle the callback dispatches to, plus shared
632/// references to the engine's profiling toggle and slow-statement
633/// threshold. The `Arc` clones here mirror the same atomics held by
634/// `Engine`, so `set_profiling` / `set_slow_threshold_ms` mutations are
635/// visible inside the callback without restart (REQ-006a / AC-005a /
636/// AC-007b runtime-toggle contract).
637#[derive(Debug)]
638struct ProfileContext {
639    subscribers: Arc<lifecycle::SubscriberRegistry>,
640    profiling_enabled: Arc<AtomicBool>,
641    slow_threshold_ms: Arc<AtomicU64>,
642}
643
644/// Thread-affine reader worker pool (Pack 6 F.0).
645///
646/// Per `dev/design/engine.md` § Writer / reader split, reader connections
647/// must not serialize behind a single mutex. Each worker thread owns
648/// exactly one read-only `Connection` for its lifetime; `Connection`
649/// objects never cross thread boundaries after startup. `Engine::search`
650/// dispatches a request via a per-worker bounded channel using a
651/// lock-free round-robin counter on the hot path.
652struct ReaderWorkerPool {
653    senders: Vec<SyncSender<ReaderRequest>>,
654    handles: Mutex<Option<Vec<JoinHandle<()>>>>,
655    next: AtomicUsize,
656    shutdown: AtomicBool,
657    live_workers: Arc<AtomicUsize>,
658}
659
660impl std::fmt::Debug for ReaderWorkerPool {
661    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
662        f.debug_struct("ReaderWorkerPool")
663            .field("worker_count", &self.senders.len())
664            .field("live_workers", &self.live_workers.load(Ordering::Relaxed))
665            .field("shutdown", &self.shutdown.load(Ordering::Relaxed))
666            .finish()
667    }
668}
669
670/// One request handled by exactly one reader worker. The response is
671/// returned through a fresh oneshot channel so requests cannot be
672/// routed to or duplicated across workers.
673enum ReaderRequest {
674    /// Slice 60 — property-FTS search stays on a reader-owned connection, never
675    /// the writer connection. It shares the snapshot-local filter validation of
676    /// the hybrid search path.
677    SearchProjectedText {
678        query: String,
679        name: String,
680        filter: Option<Box<SearchFilter>>,
681        limit: usize,
682        view: ReadView,
683        respond: SyncSender<ProjectedTextReaderResponse>,
684    },
685    Search {
686        compiled: fathomdb_query::CompiledQuery,
687        /// Un-centered f32 query vector serialized for `vec_f32`. Phase 2
688        /// f32 rerank uses this verbatim.
689        query_vector: Option<String>,
690        /// EU-5a2 — (possibly centered) f32 query vector for the phase 1
691        /// `vec_quantize_binary` sign-quant. Equal to `query_vector` for
692        /// non-MC-required identities (the EU-5a2 default).
693        query_vector_bin: Option<String>,
694        /// 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank `LIMIT`. Read from
695        /// `ProjectionRuntimeShared::search_limit_override` (default
696        /// `SEARCH_RERANK_LIMIT` = 10, clamped >=10) by `search_inner`
697        /// before dispatch, so the worker never reads any env var.
698        search_limit: usize,
699        /// G10 — optional closed metadata filter (`None` = unfiltered, the
700        /// byte-identical-to-0.7.2 path). Applied in the phase-1 candidates
701        /// statement (vector branch) and as a Rust post-filter (text branch).
702        /// Boxed so the `ReaderRequest::Search` variant stays small (the request
703        /// rides a `Result<(), ReaderRequest>` retry channel).
704        filter: Option<Box<SearchFilter>>,
705        /// G12-recency — whether the dedicated recency reweight is enabled for
706        /// this request (read from `recency_reweight_enabled`, off by default).
707        recency_enabled: bool,
708        /// F9 (0.8.16 Slice 5) — whether the dedicated importance/confidence
709        /// reweight is enabled for this request (read from
710        /// `importance_reweight_enabled`, off by default).
711        importance_enabled: bool,
712        /// GA-2 / Slice-40 (◆ B-1) measurement seam — when true the worker
713        /// returns the pre-fusion vector-branch ranking instead of the fused
714        /// result (read from `vector_stage_only_for_test`, off by default).
715        vector_stage_only: bool,
716        /// 0.8.1 Slice 10 (R1) — raw query text for the CE reranker. Passed
717        /// from `search_inner` to `read_search_in_tx` → `rerank_fused`.
718        /// FIX-4: `Box<str>` (16 bytes) instead of `String` (24 bytes) to keep
719        /// the Search variant smaller (mirroring the boxed `filter` field).
720        raw_query: Box<str>,
721        /// 0.8.1 Slice 10 (R1) — per-request rerank depth (snapshot of
722        /// `ProjectionRuntimeShared::rerank_depth`). `0` = identity path.
723        rerank_depth: usize,
724        /// 0.8.1 Slice 30 (R3) — when `true`, run the graph-BFS arm (seeded
725        /// from top-10 fused hits, depth ≤ 3, cap 50, temporal filter) and
726        /// fuse its candidates into the final ranking via `fuse_three_arms`.
727        /// When `false` (the default), the graph arm pool is `vec![]` and
728        /// results are byte-identical to the pre-Slice-30 two-arm pipeline.
729        use_graph_arm: bool,
730        /// 0.8.5 (EXP-0) — CE-blend weight (clamped to `[0,1]` in `ce_rerank`).
731        /// `0.3` is the byte-identical default; `1.0` is the measured-parity config.
732        alpha: f64,
733        /// 0.8.5 (EXP-0) — reranked-pool size (clamped to the hit count). The
734        /// binding resolves `pool_n.unwrap_or(rerank_depth)` before dispatch.
735        pool_n: usize,
736        /// 0.8.8 EXP-OBS (Slice 5) — when `true`, capture per-arm ranks + the
737        /// fused/CE score breakdown + query trace into a `SearchResult`
738        /// `Explanation` sidecar. `false` (the default for `search`/`search_filtered`/
739        /// `search_reranked`) does ZERO extra work and returns `explanation = None`
740        /// (R-OBS-2 zero-cost; byte-identical `results`).
741        explain: bool,
742        /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the VALIDITY view the
743        /// node-hydration SELECTs filter by. `ReadView::default()` reproduces
744        /// the pre-fix predicate on any corpus that never authored a window
745        /// (step 22 back-filled NULL/NULL with no DEFAULT, and `validity_sql`
746        /// treats NULL as unbounded ⇒ the conjunct is a provable no-op there).
747        /// The existence axis is refused upstream, never carried here.
748        view: ReadView,
749        respond: SyncSender<ReaderResponse>,
750    },
751    /// Slice 30 (G2) — active-only point lookup by `logical_id`. Returns one
752    /// slot per requested id, in request order, `None` where no active row
753    /// carries that id. Its own typed `respond` channel keeps the `Search`
754    /// `ReaderResponse` byte-identical (no Search regression).
755    GetById {
756        logical_ids: Vec<String>,
757        /// R-20-RV — the read view this lookup runs under. `ReadView::default()`
758        /// is the strict (pre-slice) view.
759        view: ReadView,
760        respond: SyncSender<rusqlite::Result<Vec<Option<NodeRecord>>>>,
761    },
762    /// Slice 30 (G3) — paginated op-store read-back over `operational_mutations`
763    /// for a `collection`, `ORDER BY id`, with a MANDATORY (already-clamped)
764    /// limit + optional after-id cursor.
765    ReadCollection {
766        collection: String,
767        after_id: Option<i64>,
768        limit: usize,
769        respond: SyncSender<rusqlite::Result<Vec<OpStoreRow>>>,
770    },
771    /// Slice 35 (G4) — list active canonical nodes of a `kind`, filtered by
772    /// zero or more `Predicate`s (AND-combined), up to `limit` rows.
773    /// Path validation already happened at `Predicate` construction time;
774    /// the worker only compiles + executes parameterized SQL.
775    ReadList {
776        kind: String,
777        predicates: Vec<Predicate>,
778        limit: usize,
779        /// R-20-RV — the read view this listing runs under.
780        view: ReadView,
781        respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
782    },
783    /// Slice 20 (G5) — bounded BFS from a single root node over
784    /// `canonical_edges`. Returns the set of reachable nodes (excluding the
785    /// root) within `depth` hops, limited to the hard cap 50.
786    GraphNeighbors {
787        root_logical_id: String,
788        depth: u32,
789        direction: TraversalDirection,
790        /// R-20-RV — the read view applied at EVERY node position of the BFS
791        /// CTE (anchor, recursive join, final projection), for every direction.
792        view: ReadView,
793        respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
794    },
795    /// 0.8.20 Slice 10b (R-20-NV) — nodes that crossed a validity boundary in
796    /// `(since, view-instant]`.
797    CrossedBoundarySince {
798        since: i64,
799        view: ReadView,
800        respond: SyncSender<rusqlite::Result<Vec<BoundaryCrossing>>>,
801    },
802    /// Slice 20 (G6) — compose the previous search result with BFS expansion.
803    /// Resolves search hit `write_cursor`s to `logical_id`s, runs G5 traversal
804    /// for each root, deduplicates, and returns a `SearchExpandResult`.
805    SearchExpand {
806        search_hits: Vec<SearchHit>,
807        depth: u32,
808        respond: SyncSender<rusqlite::Result<SearchExpandResult>>,
809    },
810    /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL for
811    /// the given root/depth/direction and return the plan detail lines.
812    #[doc(hidden)]
813    ExplainGraphNeighbors {
814        root_logical_id: String,
815        depth: u32,
816        direction: TraversalDirection,
817        respond: SyncSender<rusqlite::Result<Vec<String>>>,
818    },
819    Shutdown,
820    /// Pack 6.G G.1 — debug-only request that asks a worker to read its
821    /// own connection's `SQLITE_DBSTATUS_LOOKASIDE_USED` and return the
822    /// high-water mark (`hiwtr` out-param). Used solely by the integration
823    /// test that asserts post-warmup lookaside slots were consumed; not
824    /// on any production path.
825    #[cfg(debug_assertions)]
826    LookasideStatus {
827        respond: SyncSender<i32>,
828    },
829    /// Pack 6.G G.3.5 — debug-only request that asks a worker to read
830    /// `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and `_CACHE_USED`
831    /// off its own connection and return them as `(hit, miss, used_bytes)`.
832    /// `snapshot_label` is opaque to the worker; the caller uses it to
833    /// distinguish pre/post snapshots in its own bookkeeping.
834    #[cfg(debug_assertions)]
835    CacheStatus {
836        snapshot_label: String,
837        respond: SyncSender<(String, i32, i32, i32)>,
838    },
839    /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — debug-only request
840    /// that asks a worker to read its own connection's `PRAGMA secure_delete`
841    /// and return it (`0`/`1`). Used solely by the gap-4 test that asserts the
842    /// standing secure_delete flag is ON at EVERY open, not just the writer.
843    #[cfg(debug_assertions)]
844    SecureDeleteStatus {
845        respond: SyncSender<i64>,
846    },
847}
848
849// G0 Phase-2: the Search response carries a 4th element — the graph-arm frontier
850// meter (`GraphFrontierStats`). It rides the internal channel but is dropped before
851// `SearchResult` is built (kept OFF the governed surface); the
852// `_graph_frontier_stats_for_test` seam captures it. Default (all-zero) on non-graph paths.
853// 0.8.8 EXP-OBS (Slice 5): the Search response carries a 5th element — the opt-in
854// retrieval `Explanation` (`None` on every default `explain=false` path; `Some`
855// only on the `search_explained` path). Like the `GraphFrontierStats` 4th element
856// it rides the internal channel as a side-channel; unlike it, the explanation IS
857// surfaced (onto `SearchResult.explanation`) when requested.
858type ReaderResponse = Result<
859    (u64, Option<SoftFallback>, Vec<SearchHit>, GraphFrontierStats, Option<Explanation>),
860    SearchReaderError,
861>;
862
863type ProjectedTextReaderResponse = Result<SearchResult, SearchReaderError>;
864
865/// 0.8.20 keystone closeout fix-3 (codex §9 [P2], TOCTOU) — the error a Search
866/// reader worker can return. Two arms:
867///   * `Sqlite` — a backend/storage failure (the pre-fix-3 `rusqlite::Result`
868///     behaviour verbatim; the caller emits the internal-error event and maps to
869///     `EngineError::Storage`);
870///   * `InvalidFilter` — a filter naming an UNDECLARED `filterable` attribute,
871///     detected on the reader's OWN transaction snapshot (see
872///     [`validate_filter_attributes_on_snapshot`]). The caller re-raises it as the
873///     EXISTING `EngineError::InvalidFilter { reason }` typed variant.
874///
875/// Why a channel-carried variant and not a pre-dispatch check on the writer
876/// connection: fix-2 validated on `self.connection` BEFORE dispatch, then the
877/// reader prepared the vec0 query on a DIFFERENT connection/snapshot. A
878/// `configure_projections` DROP landing in that window let the vec0 `attr_<hex>`
879/// column vanish AFTER validation passed → an opaque `no such column` `Storage`
880/// error (the exact untyped failure fix-2 meant to prevent). Validating INSIDE
881/// the reader transaction that also compiles+executes the search binds the check
882/// and the query to ONE snapshot, closing the race; carrying the typed reason
883/// back through this variant keeps the outcome `InvalidFilter`, never `Storage`.
884enum SearchReaderError {
885    Sqlite(rusqlite::Error),
886    InvalidFilter(String),
887}
888
889impl From<rusqlite::Error> for SearchReaderError {
890    fn from(err: rusqlite::Error) -> Self {
891        SearchReaderError::Sqlite(err)
892    }
893}
894
895/// Pack 6.G G.3.5 — per-worker cache-pressure snapshot. Carried only on
896/// the debug-only `CacheStatus` broadcast path and the test accessor;
897/// not part of the public 0.6.0 surface.
898#[cfg(debug_assertions)]
899#[doc(hidden)]
900#[derive(Clone, Debug)]
901pub struct CacheStatusReply {
902    pub worker_idx: usize,
903    pub snapshot_label: String,
904    pub cache_hit: i32,
905    pub cache_miss: i32,
906    pub cache_used_bytes: i32,
907}
908
909/// Per-worker outbound channel capacity. Round-robin dispatch keeps
910/// queue depth at ~0 on hot paths; the small slack absorbs jitter
911/// without a runtime mutex.
912const READER_WORKER_CHANNEL_CAPACITY: usize = 4;
913
914impl ReaderWorkerPool {
915    fn new(connections: Vec<Connection>) -> Self {
916        let live_workers = Arc::new(AtomicUsize::new(0));
917        let mut senders = Vec::with_capacity(connections.len());
918        let mut handles = Vec::with_capacity(connections.len());
919        for (idx, connection) in connections.into_iter().enumerate() {
920            let (tx, rx) = mpsc::sync_channel::<ReaderRequest>(READER_WORKER_CHANNEL_CAPACITY);
921            let live = Arc::clone(&live_workers);
922            let handle = thread::Builder::new()
923                .name(format!("fathomdb-reader-{idx}"))
924                .spawn(move || reader_worker_loop(connection, rx, live))
925                .expect("spawn reader worker");
926            senders.push(tx);
927            handles.push(handle);
928        }
929        Self {
930            senders,
931            handles: Mutex::new(Some(handles)),
932            next: AtomicUsize::new(0),
933            shutdown: AtomicBool::new(false),
934            live_workers,
935        }
936    }
937
938    fn worker_count(&self) -> usize {
939        self.senders.len()
940    }
941
942    fn live_count(&self) -> usize {
943        self.live_workers.load(Ordering::SeqCst)
944    }
945
946    /// Pack 6.G G.1 — broadcast a `LookasideStatus` request to every
947    /// worker (not round-robin) and collect each worker's
948    /// `SQLITE_DBSTATUS_LOOKASIDE_USED`. Used only by the debug
949    /// integration test for post-warmup lookaside-slot consumption.
950    #[cfg(debug_assertions)]
951    fn lookaside_used_per_worker(&self) -> Vec<i32> {
952        let mut results = Vec::with_capacity(self.senders.len());
953        for sender in &self.senders {
954            let (tx, rx) = mpsc::sync_channel::<i32>(1);
955            if sender.send(ReaderRequest::LookasideStatus { respond: tx }).is_ok() {
956                results.push(rx.recv().unwrap_or(-1));
957            } else {
958                results.push(-1);
959            }
960        }
961        results
962    }
963
964    /// Pack 6.G G.3.5 — broadcast a `CacheStatus` request to every
965    /// worker and collect each worker's `(cache_hit, cache_miss,
966    /// cache_used_bytes)` triple. Same broadcast pattern as G.1's
967    /// `lookaside_used_per_worker`. Returns one `CacheStatusReply` per
968    /// worker in worker-index order.
969    #[cfg(debug_assertions)]
970    fn cache_status_per_worker(&self, snapshot_label: &str) -> Vec<CacheStatusReply> {
971        let mut results = Vec::with_capacity(self.senders.len());
972        for (idx, sender) in self.senders.iter().enumerate() {
973            let (tx, rx) = mpsc::sync_channel::<(String, i32, i32, i32)>(1);
974            let request = ReaderRequest::CacheStatus {
975                snapshot_label: snapshot_label.to_string(),
976                respond: tx,
977            };
978            if sender.send(request).is_ok() {
979                if let Ok((label, hit, miss, used)) = rx.recv() {
980                    results.push(CacheStatusReply {
981                        worker_idx: idx,
982                        snapshot_label: label,
983                        cache_hit: hit,
984                        cache_miss: miss,
985                        cache_used_bytes: used,
986                    });
987                    continue;
988                }
989            }
990            results.push(CacheStatusReply {
991                worker_idx: idx,
992                snapshot_label: snapshot_label.to_string(),
993                cache_hit: -1,
994                cache_miss: -1,
995                cache_used_bytes: -1,
996            });
997        }
998        results
999    }
1000
1001    /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — broadcast a
1002    /// `SecureDeleteStatus` request to every worker and collect each worker's
1003    /// `PRAGMA secure_delete` value. Same broadcast pattern as G.1's
1004    /// `lookaside_used_per_worker`. Proves the standing secure_delete flag is ON
1005    /// on the reader-pool connections, not just the writer.
1006    #[cfg(debug_assertions)]
1007    fn secure_delete_per_worker(&self) -> Vec<i64> {
1008        let mut results = Vec::with_capacity(self.senders.len());
1009        for sender in &self.senders {
1010            let (tx, rx) = mpsc::sync_channel::<i64>(1);
1011            if sender.send(ReaderRequest::SecureDeleteStatus { respond: tx }).is_ok() {
1012                results.push(rx.recv().unwrap_or(-1));
1013            } else {
1014                results.push(-1);
1015            }
1016        }
1017        results
1018    }
1019
1020    /// Hot path. Lock-free dispatch: `AtomicUsize::fetch_add` selects
1021    /// the worker, then a single `SyncSender::send` enqueues the
1022    /// request. No global mutex is taken on the request path.
1023    // The `Search` variant contains a SyncSender and boxed fields (filter, raw_query);
1024    // even after FIX-4 (raw_query: Box<str>), the variant remains large due to the
1025    // SyncSender channel ownership. The Err return is only ever a no-worker/shutdown
1026    // signal, never heap-allocated repeatedly, so the allow is justified by the
1027    // channel ownership model.
1028    #[allow(clippy::result_large_err)]
1029    fn dispatch(&self, request: ReaderRequest) -> Result<(), ReaderRequest> {
1030        if self.shutdown.load(Ordering::Relaxed) {
1031            return Err(request);
1032        }
1033        let n = self.senders.len();
1034        if n == 0 {
1035            return Err(request);
1036        }
1037        let idx = self.next.fetch_add(1, Ordering::Relaxed) % n;
1038        self.senders[idx].send(request).map_err(|err| err.0)
1039    }
1040
1041    /// Signal every worker to exit and join its thread. Idempotent —
1042    /// safe to call from `Engine::close` and again from
1043    /// `ReaderWorkerPool::Drop`.
1044    fn shutdown(&self) {
1045        if self.shutdown.swap(true, Ordering::SeqCst) {
1046            return;
1047        }
1048        for sender in &self.senders {
1049            let _ = sender.send(ReaderRequest::Shutdown);
1050        }
1051        if let Ok(mut slot) = self.handles.lock() {
1052            if let Some(handles) = slot.take() {
1053                for handle in handles {
1054                    let _ = handle.join();
1055                }
1056            }
1057        }
1058    }
1059}
1060
1061impl Drop for ReaderWorkerPool {
1062    fn drop(&mut self) {
1063        self.shutdown();
1064    }
1065}
1066
1067fn reader_worker_loop(
1068    mut connection: Connection,
1069    rx: Receiver<ReaderRequest>,
1070    live_workers: Arc<AtomicUsize>,
1071) {
1072    live_workers.fetch_add(1, Ordering::SeqCst);
1073    // Drop guard so the live counter decrements even on panic.
1074    struct LiveGuard(Arc<AtomicUsize>);
1075    impl Drop for LiveGuard {
1076        fn drop(&mut self) {
1077            self.0.fetch_sub(1, Ordering::SeqCst);
1078        }
1079    }
1080    let _guard = LiveGuard(live_workers);
1081
1082    while let Ok(request) = rx.recv() {
1083        match request {
1084            ReaderRequest::Shutdown => break,
1085            ReaderRequest::SearchProjectedText { query, name, filter, limit, view, respond } => {
1086                let result = read_projected_text_in_tx(
1087                    &mut connection,
1088                    &query,
1089                    &name,
1090                    filter.as_deref(),
1091                    limit,
1092                    view,
1093                );
1094                let _ = respond.send(result);
1095            }
1096            ReaderRequest::Search {
1097                compiled,
1098                query_vector,
1099                query_vector_bin,
1100                search_limit,
1101                filter,
1102                recency_enabled,
1103                importance_enabled,
1104                vector_stage_only,
1105                raw_query,
1106                rerank_depth,
1107                use_graph_arm,
1108                alpha,
1109                pool_n,
1110                explain,
1111                view,
1112                respond,
1113            } => {
1114                let result = read_search_in_tx(
1115                    &mut connection,
1116                    &compiled,
1117                    query_vector.as_deref(),
1118                    query_vector_bin.as_deref(),
1119                    search_limit,
1120                    filter.as_deref(),
1121                    recency_enabled,
1122                    importance_enabled,
1123                    vector_stage_only,
1124                    &raw_query,
1125                    rerank_depth,
1126                    use_graph_arm,
1127                    alpha,
1128                    pool_n,
1129                    explain,
1130                    view,
1131                );
1132                // Receiver may have been dropped if the caller went
1133                // away; nothing to do in that case.
1134                let _ = respond.send(result);
1135            }
1136            ReaderRequest::GetById { logical_ids, view, respond } => {
1137                let result = read_get_by_id_in_tx(&mut connection, &logical_ids, &view);
1138                let _ = respond.send(result);
1139            }
1140            ReaderRequest::ReadCollection { collection, after_id, limit, respond } => {
1141                let result = read_collection_in_tx(&mut connection, &collection, after_id, limit);
1142                let _ = respond.send(result);
1143            }
1144            ReaderRequest::ReadList { kind, predicates, limit, view, respond } => {
1145                let result = read_list_in_tx(&mut connection, &kind, &predicates, limit, &view);
1146                let _ = respond.send(result);
1147            }
1148            ReaderRequest::GraphNeighbors { root_logical_id, depth, direction, view, respond } => {
1149                let result = graph_neighbors_in_tx(
1150                    &mut connection,
1151                    &root_logical_id,
1152                    depth,
1153                    direction,
1154                    &view,
1155                );
1156                let _ = respond.send(result);
1157            }
1158            ReaderRequest::CrossedBoundarySince { since, view, respond } => {
1159                let result = crossed_boundary_since_in_tx(&mut connection, since, &view);
1160                let _ = respond.send(result);
1161            }
1162            ReaderRequest::SearchExpand { search_hits, depth, respond } => {
1163                let result = search_expand_in_tx(&mut connection, &search_hits, depth);
1164                let _ = respond.send(result);
1165            }
1166            ReaderRequest::ExplainGraphNeighbors { root_logical_id, depth, direction, respond } => {
1167                let result = explain_graph_neighbors_in_tx(
1168                    &mut connection,
1169                    &root_logical_id,
1170                    depth,
1171                    direction,
1172                );
1173                let _ = respond.send(result);
1174            }
1175            #[cfg(debug_assertions)]
1176            ReaderRequest::LookasideStatus { respond } => {
1177                let _ = respond.send(read_lookaside_used_hiwtr(&connection));
1178            }
1179            #[cfg(debug_assertions)]
1180            ReaderRequest::CacheStatus { snapshot_label, respond } => {
1181                let (hit, miss, used) = read_cache_status(&connection);
1182                let _ = respond.send((snapshot_label, hit, miss, used));
1183            }
1184            #[cfg(debug_assertions)]
1185            ReaderRequest::SecureDeleteStatus { respond } => {
1186                let value: i64 =
1187                    connection.query_row("PRAGMA secure_delete", [], |r| r.get(0)).unwrap_or(-1);
1188                let _ = respond.send(value);
1189            }
1190        }
1191    }
1192
1193    // Per `dev/design/engine.md` § Close path, uninstall the profile
1194    // callback before dropping the connection so SQLite cannot fire
1195    // one last callback against a `ProfileContext` whose Box is about
1196    // to free.
1197    uninstall_profile_callback(&connection);
1198    drop(connection);
1199}
1200
1201/// 0.8.20 keystone closeout fix-3 — a test-only rendezvous hook fired at the TOP
1202/// of [`read_search_in_tx`], BEFORE the reader opens its deferred transaction.
1203///
1204/// It exists ONLY to make the validate/execute TOCTOU race deterministic: a test
1205/// arms a closure that parks the reader worker here (after the caller-side search
1206/// setup, before the reader pins its snapshot), performs a concurrent
1207/// `configure_projections` DROP of a `filterable` attribute on the writer
1208/// connection, then releases the reader. The reader then pins a snapshot that
1209/// INCLUDES the drop — exactly the window that used to yield an opaque `no such
1210/// column` `Storage` error and now yields a typed `InvalidFilter`. Kept OFF the
1211/// governed surface (`_for_test`), mirroring the sanctioned
1212/// `set_vector_stage_only_for_test` seam pattern. Disarmed by default: a single
1213/// `Relaxed` atomic load per search (same class as the four hot-path atomics
1214/// already read here), fires at most once (the closure is `take`n), and is a
1215/// no-op in production because nothing ever arms it.
1216mod reader_search_hook {
1217    use std::sync::atomic::{AtomicBool, Ordering};
1218    use std::sync::Mutex;
1219
1220    static ARMED: AtomicBool = AtomicBool::new(false);
1221    #[allow(clippy::type_complexity)]
1222    static HOOK: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None);
1223
1224    pub(crate) fn arm(hook: Box<dyn Fn() + Send>) {
1225        *HOOK.lock().expect("reader-search hook mutex") = Some(hook);
1226        ARMED.store(true, Ordering::SeqCst);
1227    }
1228
1229    pub(crate) fn clear() {
1230        ARMED.store(false, Ordering::SeqCst);
1231        *HOOK.lock().expect("reader-search hook mutex") = None;
1232    }
1233
1234    /// Fire the armed hook exactly ONCE, then disarm. Cheap early-out when
1235    /// disarmed (the production and common-test path).
1236    pub(crate) fn fire() {
1237        if !ARMED.load(Ordering::SeqCst) {
1238            return;
1239        }
1240        // Disarm first so a re-entrant / second reader never re-fires.
1241        ARMED.store(false, Ordering::SeqCst);
1242        let hook = HOOK.lock().expect("reader-search hook mutex").take();
1243        if let Some(hook) = hook {
1244            hook();
1245        }
1246    }
1247}
1248
1249/// 0.8.20 keystone closeout fix-3 — arm the [`reader_search_hook`] (test-only).
1250/// See that module's docs. `#[doc(hidden)]`, `_for_test`; never re-exported from
1251/// the `fathomdb` facade.
1252#[doc(hidden)]
1253pub fn arm_reader_search_hook_for_test(hook: Box<dyn Fn() + Send>) {
1254    reader_search_hook::arm(hook);
1255}
1256
1257/// 0.8.20 keystone closeout fix-3 — disarm the [`reader_search_hook`] (test-only).
1258#[doc(hidden)]
1259pub fn clear_reader_search_hook_for_test() {
1260    reader_search_hook::clear();
1261}
1262
1263impl ProjectionRuntime {
1264    fn new(
1265        path: PathBuf,
1266        embedder: Option<Arc<dyn Embedder>>,
1267        embedder_identity: EmbedderIdentity,
1268        mean_already_pinned: bool,
1269        subscribers: Arc<lifecycle::SubscriberRegistry>,
1270    ) -> Self {
1271        // EU-5b/EU-5f — only allocate the streaming accumulator when the
1272        // workspace's identity is MC-required AND no mean has been pinned
1273        // yet on disk. Allocating it for an already-pinned workspace would
1274        // let a later 256-doc run RE-pin and overwrite the compute-once
1275        // mean (violating `dev/design/embedder.md` §0.3). Other identities
1276        // pay no memory cost (`Option::None`).
1277        let mc_required = identity_requires_mean_centering(&embedder_identity);
1278        let mean_accumulator = if mc_required && !mean_already_pinned {
1279            Some(MeanAccumulator::new(embedder_identity.dimension as usize))
1280        } else {
1281            None
1282        };
1283        let shared = Arc::new(ProjectionRuntimeShared {
1284            path,
1285            embedder,
1286            embedder_identity,
1287            subscribers,
1288            state: Mutex::new(ProjectionRuntimeState::default()),
1289            state_cvar: Condvar::new(),
1290            queue: Mutex::new(VecDeque::new()),
1291            queue_cvar: Condvar::new(),
1292            retry_delays_ms: Mutex::new(DEFAULT_PROJECTION_RETRY_DELAYS_MS.to_vec()),
1293            embed_timeout_ms: AtomicU64::new(DEFAULT_EMBED_TIMEOUT_MS),
1294            embed_serialize: Mutex::new(()),
1295            live_embed_threads: Arc::new(AtomicU64::new(0)),
1296            embed_circuit_open: AtomicBool::new(false),
1297            embed_circuit_threshold: AtomicU64::new(DEFAULT_EMBED_CIRCUIT_THRESHOLD),
1298            mean_accumulator: Mutex::new(mean_accumulator),
1299            pending_events: Mutex::new(Vec::new()),
1300            commit_gate: Mutex::new(()),
1301            search_limit_override: AtomicUsize::new(SEARCH_RERANK_LIMIT),
1302            recency_reweight_enabled: AtomicBool::new(false),
1303            importance_reweight_enabled: AtomicBool::new(false),
1304            vector_stage_only_for_test: AtomicBool::new(false),
1305            #[cfg(debug_assertions)]
1306            force_recompute_failure: AtomicBool::new(false),
1307            #[cfg(debug_assertions)]
1308            force_projection_commit_failure: AtomicUsize::new(0),
1309            #[cfg(debug_assertions)]
1310            projection_commit_failure_pause: Mutex::new(None),
1311            #[cfg(debug_assertions)]
1312            projection_stop_ack: Mutex::new(None),
1313        });
1314
1315        let dispatcher_shared = Arc::clone(&shared);
1316        let dispatcher = thread::spawn(move || projection_dispatcher_loop(dispatcher_shared));
1317
1318        let mut workers = Vec::with_capacity(PROJECTION_WORKERS);
1319        for _ in 0..PROJECTION_WORKERS {
1320            let worker_shared = Arc::clone(&shared);
1321            workers.push(thread::spawn(move || projection_worker_loop(worker_shared)));
1322        }
1323
1324        Self { shared, dispatcher: Mutex::new(Some(dispatcher)), workers: Mutex::new(workers) }
1325    }
1326
1327    fn notify_new_work(&self) {
1328        if let Ok(mut state) = self.shared.state.lock() {
1329            state.pending_scan = true;
1330            self.shared.state_cvar.notify_all();
1331        }
1332    }
1333
1334    fn set_frozen(&self, frozen: bool) {
1335        if let Ok(mut state) = self.shared.state.lock() {
1336            state.frozen = frozen;
1337            if !frozen {
1338                state.pending_scan = true;
1339            }
1340            self.shared.state_cvar.notify_all();
1341        }
1342    }
1343
1344    fn wait_for_idle(&self, timeout_ms: u64) -> bool {
1345        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
1346        let mut state = match self.shared.state.lock() {
1347            Ok(state) => state,
1348            Err(_) => return false,
1349        };
1350        loop {
1351            if state.active_jobs == 0 && state.queued_jobs == 0 {
1352                drop(state);
1353                if !database_has_pending_projection_work(&self.shared.path).unwrap_or(true) {
1354                    return true;
1355                }
1356                state = match self.shared.state.lock() {
1357                    Ok(state) => state,
1358                    Err(_) => return false,
1359                };
1360            }
1361            let now = Instant::now();
1362            if now >= deadline {
1363                return false;
1364            }
1365            let wait = deadline.saturating_duration_since(now);
1366            let Ok((next_state, _)) = self.shared.state_cvar.wait_timeout(state, wait) else {
1367                return false;
1368            };
1369            state = next_state;
1370        }
1371    }
1372
1373    fn set_retry_delays_for_test(&self, delays_ms: &[u64]) {
1374        if let Ok(mut delays) = self.shared.retry_delays_ms.lock() {
1375            *delays = delays_ms.to_vec();
1376        }
1377    }
1378
1379    #[cfg(debug_assertions)]
1380    fn force_next_projection_commit_failure_for_test(&self) {
1381        self.shared.force_projection_commit_failure.store(1, Ordering::SeqCst);
1382    }
1383
1384    #[cfg(debug_assertions)]
1385    fn force_next_projection_storage_failure_for_test(&self) {
1386        self.shared.force_projection_commit_failure.store(2, Ordering::SeqCst);
1387    }
1388
1389    #[cfg(debug_assertions)]
1390    fn pause_projection_commit_failure_cleanup_for_test(
1391        &self,
1392        reported: Arc<Barrier>,
1393        release: Arc<Barrier>,
1394    ) {
1395        *self
1396            .shared
1397            .projection_commit_failure_pause
1398            .lock()
1399            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((reported, release));
1400    }
1401
1402    #[cfg(debug_assertions)]
1403    fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
1404        *self.shared.projection_stop_ack.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) =
1405            Some(acknowledged);
1406    }
1407
1408    fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
1409        self.shared.embed_timeout_ms.store(timeout_ms, Ordering::Relaxed);
1410    }
1411
1412    fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
1413        self.shared.embed_circuit_threshold.store(threshold, Ordering::Relaxed);
1414    }
1415
1416    fn embed_circuit_open_for_test(&self) -> bool {
1417        self.shared.embed_circuit_open.load(Ordering::Relaxed)
1418    }
1419
1420    fn stop(&self) {
1421        if let Ok(mut state) = self.shared.state.lock() {
1422            if state.stopping {
1423                return;
1424            }
1425            state.stopping = true;
1426            state.pending_scan = false;
1427            self.shared.state_cvar.notify_all();
1428        }
1429        #[cfg(debug_assertions)]
1430        if let Some(acknowledged) = self
1431            .shared
1432            .projection_stop_ack
1433            .lock()
1434            .unwrap_or_else(|poisoned| poisoned.into_inner())
1435            .take()
1436        {
1437            acknowledged.wait();
1438        }
1439        if let Ok(mut queue) = self.shared.queue.lock() {
1440            queue.clear();
1441            self.shared.queue_cvar.notify_all();
1442        }
1443
1444        if let Ok(mut dispatcher) = self.dispatcher.lock() {
1445            if let Some(handle) = dispatcher.take() {
1446                let _ = handle.join();
1447            }
1448        }
1449        if let Ok(mut workers) = self.workers.lock() {
1450            for handle in workers.drain(..) {
1451                let _ = handle.join();
1452            }
1453        }
1454    }
1455}
1456
1457#[derive(Clone, Debug, Eq, PartialEq)]
1458pub struct OpenReport {
1459    pub schema_version_before: u32,
1460    pub schema_version_after: u32,
1461    pub migration_steps: Vec<MigrationStepReport>,
1462    pub embedder_warmup_ms: u64,
1463    pub query_backend: &'static str,
1464    pub default_embedder: EmbedderIdentity,
1465    /// Total wall time the loader spent materializing default-embedder
1466    /// weights — covers HF GETs, sha256 verification, atomic rename,
1467    /// parent-dir fsync (POSIX), and cache directory writes. This is
1468    /// the "engine open paid by the embedder" envelope, useful for SLA
1469    /// budgeting; it is intentionally wider than just the bytes-flowing
1470    /// time so callers see the full first-use cost.
1471    ///
1472    /// `Some(ms)` when network bytes flowed (`bytes_downloaded > 0`);
1473    /// `None` for caller-supplied embedders (loader bypassed) and on
1474    /// full cache hits (no bytes flowed). For pure per-file network
1475    /// analysis, use the `DefaultEmbedderDownload` events on
1476    /// [`embedder_events`](Self::embedder_events) — each event carries
1477    /// the file's bytes + sha256 + cache path.
1478    pub embedder_download_ms: Option<u64>,
1479    /// Structured loader events (`dev/design/embedder.md` §7). Empty for
1480    /// caller-supplied embedders; populated from `LoadedWeights.events`
1481    /// for the Default path.
1482    pub embedder_events: Vec<EmbedderEvent>,
1483    /// Static identity capability (`dev/design/embedder.md` §0.6). True
1484    /// iff the live embedder identity is the bge-small default, which is
1485    /// the only identity that ships with the EU-5a2 mean-centering apply
1486    /// paths. `false` for `fathomdb-noop` and for any other
1487    /// caller-supplied identity. EU-5b's identity flip makes the Default
1488    /// path return `true` here.
1489    pub embedder_mean_centering_required: bool,
1490    /// Dynamic workspace state (`dev/design/embedder.md` §0.6). True iff
1491    /// `_fathomdb_embedder_profiles.mean_vec IS NOT NULL` for the default
1492    /// profile. EU-5a2 reads from the schema column added in migration
1493    /// step 10; the value is dimension-validated (§0.2) at open time
1494    /// and fails closed via `EmbedderIdentityMismatch` on drift.
1495    pub embedder_mean_vec_pinned: bool,
1496    /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-6) — degraded-open
1497    /// observability. `true` iff the open-time #5 self-check re-embedded the 45
1498    /// committed probes and found a divergence beyond the frozen D4 floor (a
1499    /// Phase-1 mean-centered `embedding_bin` sign flip OR a Phase-2 un-centered
1500    /// L2 over `VECTOR_EQUIVALENCE_L2_EPSILON`). When `true`, `Engine::open`
1501    /// SUCCEEDED but every vector-dependent arm refuses at query time with
1502    /// `EngineError::VectorEquivalenceMismatch`; the text-only/FTS-only path stays
1503    /// serviceable. The state is RE-DERIVED at every open (the probe re-runs), so
1504    /// a reopen with a still-divergent backend stays degraded (never silently
1505    /// re-enables dense) and a reopen with a matching backend clears it.
1506    pub dense_disabled: bool,
1507    /// R-VEQ-6 — human-readable reason for `dense_disabled` (which representation
1508    /// tripped: P1 flip count or P2 L2). `None` when `dense_disabled == false`.
1509    pub dense_disabled_reason: Option<String>,
1510}
1511
1512#[derive(Debug)]
1513pub struct OpenedEngine {
1514    pub engine: Engine,
1515    pub report: OpenReport,
1516}
1517
1518/// EU-5b — loader-supplied open-time telemetry threaded into
1519/// `OpenReport.embedder_download_ms` and `OpenReport.embedder_events`.
1520#[derive(Clone, Debug)]
1521struct LoaderInfo {
1522    download_ms: Option<u64>,
1523    events: Vec<EmbedderEvent>,
1524}
1525
1526#[derive(Clone, Debug, Eq, PartialEq)]
1527pub struct WriteReceipt {
1528    /// The batch high-water cursor — the `write_cursor` of the last row written
1529    /// (also the engine's new `next_cursor`). Unchanged from 0.7.x.
1530    pub cursor: u64,
1531    /// G0 (Slice 15) — the per-row `write_cursor` of each row in the batch, 1:1
1532    /// with input order. This is the `write_cursor`-as-row-id identity carrier
1533    /// (HITL-accepted for 0.8.0; a dedicated `row_id` is deferred). For an
1534    /// N-row batch this is `[cursor-N+1, …, cursor]`.
1535    pub row_cursors: Vec<u64>,
1536    /// G8 (Slice 20 / F10) — count of edge endpoints in this batch that point at
1537    /// a non-existent **or superseded** canonical node. An endpoint is dangling
1538    /// when no **active** node (`superseded_at IS NULL`) carries its `logical_id`;
1539    /// `from_id` and `to_id` are probed independently, so one edge contributes 0,
1540    /// 1, or 2. This is **informational** (default FLAG-AND-COUNT: the batch
1541    /// commits regardless) and `0` whenever the batch committed no active edges.
1542    pub dangling_edge_endpoints: u64,
1543}
1544
1545/// Soft-fallback signal carried on hybrid `search` results.
1546///
1547/// Per `dev/design/retrieval.md` § Soft-fallback signal, this record is
1548/// present only when one non-essential branch could not contribute. Total
1549/// request failure is not expressed via this carrier.
1550#[derive(Clone, Debug, Eq, PartialEq)]
1551pub struct SoftFallback {
1552    pub branch: SoftFallbackBranch,
1553}
1554
1555/// Which retrieval branch produced a hit (or could not contribute).
1556///
1557/// `Vector` = ANN vector branch (node bodies); `Text` = node-body FTS branch;
1558/// `TextEdge` = edge-body hit (FTS via `search_index_edges` OR vector-projected
1559/// edge facts — both produce the same kind="edge_fact" row shape and share the
1560/// same downstream handling in `search_expand_in_tx`). `Vector`/`Text` also
1561/// used as soft-fallback signal when the respective branch is empty.
1562/// `GraphArm` = R3 (Slice 30) BFS-reachable node from the temporal fact-edge
1563/// graph arm. Owned by `dev/design/retrieval.md`.
1564#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1565pub enum SoftFallbackBranch {
1566    Vector,
1567    Text,
1568    /// G11 (Slice 15) — edge-body hit from `search_index_edges` FTS or from
1569    /// `vector_default` edge-fact projection. `kind = "edge_fact"` in both cases.
1570    TextEdge,
1571    /// R3 (Slice 30) — BFS-reachable node from the temporal fact-edge graph arm.
1572    /// Only present when `use_graph_arm = true`. Nodes in the graph arm were NOT
1573    /// in the initial vector/text fused result (newly-reached nodes only).
1574    GraphArm,
1575}
1576
1577/// A single structured search hit (G1 / AC-057a-clean).
1578///
1579/// Both retrieval branches emit this shape. `id` is a typed [`IdSpace`] — the
1580/// **permanent** caller-facing identity since C-2 (0.8.19 / TC-8), NOT the
1581/// interim `write_cursor: u64` the pre-0.8.19 releases carried and NOT an
1582/// interim carrier awaiting a later swap. The positional `write_cursor` field
1583/// below survives as engine-internal book-keeping and the SDK bindings do not
1584/// surface it. See the field docs on [`SearchHit::id`] / [`IdSpace`].
1585/// `score` is the **G9 RRF-fused** relevance (`Σ 1/(RRF_K + rank)` over the
1586/// branches that surfaced this body; higher = more relevant), optionally
1587/// recency-reweighted when the dedicated recency flag is on. Raw `vec_distance_l2`
1588/// and `bm25()` are fused on **rank**, never compared raw (they are not
1589/// comparable). `branch` tags which retrieval branch produced the representative
1590/// hit (vector-first when a body is surfaced by both).
1591///
1592/// `source_id` (G0 Phase-2 / BLOCK-2; generalised by TC-31 in 0.8.20 Slice 10a)
1593/// carries the source-document provenance of a hit — the identifier
1594/// [`Engine::erase_source`] consumes. It is populated on **every** hit path:
1595/// - **Node hits** (text/BM25F, vector, and the pre-step-12 legacy text
1596///   fallback) carry the **node's own** `canonical_nodes.source_id`.
1597/// - **Edge hits** (edge-FTS from `search_index_edges`, and edge-fact hits
1598///   hydrated by the vector arm) carry the **edge's own**
1599///   `canonical_edges.source_id`.
1600/// - **GraphArm** hits carry the **traversed edge's** `source_id` (the session
1601///   the fact-edge was extracted from) — unchanged by TC-31 — enabling
1602///   `doc_id_of` to resolve a graph-reached entity back to a gold session id.
1603///
1604/// Before TC-31 only the GraphArm branch populated this, which left
1605/// `erase_source` shipping with its argument unreachable from a text or vector
1606/// hit (0.8.19 also stopped surfacing `write_cursor` to the SDKs, removing the
1607/// only fallback route). It stays `Option<String>`: a row written before 0.8.20,
1608/// or a GOVERNED row deliberately spared by the step-21 backfill under the TC-11
1609/// pin, legitimately carries NULL at rest and must read back as `None` rather
1610/// than a fabricated value.
1611///
1612/// The field never participates in ranking, so result order and scores are
1613/// unaffected.
1614///
1615/// C-2 (0.8.19 / OPP-12 record-lifecycle Phase-1, TC-8) — the **id-space** of a
1616/// [`SearchHit::id`]. A closed, typed enum (NOT a magic-prefixed string) — the
1617/// C-2 binding ratified in the OPP-12 protocol:
1618/// - [`Logical`](IdSpaceKind::Logical) — `"l:"`, a governed/canonical node keyed
1619///   by its `logical_id` (the only lifecycle-addressable space).
1620/// - [`Content`](IdSpaceKind::Content) — `"h:"`, a doc-seeded/anonymous node
1621///   keyed by a content hash of its body (the dominant corpus hit class).
1622/// - [`Passage`](IdSpaceKind::Passage) — `"p:"`, a synthetic `rerank_passages`
1623///   hit keyed by the caller-supplied passage ordinal.
1624#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1625pub enum IdSpaceKind {
1626    /// `"l:"` — governed/canonical node (its `logical_id`).
1627    Logical,
1628    /// `"h:"` — doc-seeded/anonymous node (content hash of the body).
1629    Content,
1630    /// `"p:"` — synthetic rerank passage (caller-supplied ordinal).
1631    Passage,
1632}
1633
1634impl IdSpaceKind {
1635    /// The two-char id-space prefix (`"l:"` / `"h:"` / `"p:"`) used in the
1636    /// prefixed string form. Byte-identical to the pre-swap `derive_stable_id`
1637    /// tags so real-gold keying stays a no-op.
1638    #[must_use]
1639    pub fn prefix(self) -> &'static str {
1640        match self {
1641            Self::Logical => "l:",
1642            Self::Content => "h:",
1643            Self::Passage => "p:",
1644        }
1645    }
1646
1647    /// The lowercase discriminant (`"logical"` / `"content"` / `"passage"`)
1648    /// surfaced through the SDK bindings as the `IdSpace.space` field (mirrors
1649    /// how `SoftFallbackBranch` is surfaced as a `branch` string).
1650    #[must_use]
1651    pub fn as_str(self) -> &'static str {
1652        match self {
1653            Self::Logical => "logical",
1654            Self::Content => "content",
1655            Self::Passage => "passage",
1656        }
1657    }
1658}
1659
1660/// C-2 (0.8.19 / OPP-12 Phase-1, TC-8) — the typed, non-null, id-space-**total**
1661/// carrier for [`SearchHit::id`]. Subsumes the interim `write_cursor` id AND the
1662/// additive Cause-A `stable_id` field of prior releases: the `value` is the BARE
1663/// id (prefix stripped), and [`to_prefixed`](IdSpace::to_prefixed) reproduces the
1664/// pre-swap `stable_id` string byte-for-byte (`l:`/`h:` unchanged) so
1665/// cross-session real-gold keying continues on `id` as a true no-op.
1666///
1667/// Lifecycle-addressability is a type check consumed downstream by the
1668/// `transition`/`purge` verbs: only [`Logical`](IdSpaceKind::Logical) is
1669/// lifecycle-addressable; `Content`/`Passage` are total-but-not-addressable.
1670#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1671pub struct IdSpace {
1672    /// The typed id-space (`Logical`/`Content`/`Passage`).
1673    pub space: IdSpaceKind,
1674    /// The bare id value (id-space prefix stripped).
1675    pub value: String,
1676}
1677
1678impl IdSpace {
1679    /// A `Logical` (`"l:"`) id carrying `value` (a `logical_id`).
1680    pub fn logical(value: impl Into<String>) -> Self {
1681        Self { space: IdSpaceKind::Logical, value: value.into() }
1682    }
1683
1684    /// A `Content` (`"h:"`) id carrying `value` (a content hash).
1685    pub fn content(value: impl Into<String>) -> Self {
1686        Self { space: IdSpaceKind::Content, value: value.into() }
1687    }
1688
1689    /// A `Passage` (`"p:"`) id carrying `value` (a caller-supplied ordinal).
1690    pub fn passage(value: impl Into<String>) -> Self {
1691        Self { space: IdSpaceKind::Passage, value: value.into() }
1692    }
1693
1694    /// The prefixed string form (`{prefix}{value}`) — byte-identical to the
1695    /// pre-swap `derive_stable_id` output for `l:`/`h:`.
1696    #[must_use]
1697    pub fn to_prefixed(&self) -> String {
1698        format!("{}{}", self.space.prefix(), self.value)
1699    }
1700
1701    /// Parse the prefixed string form back into a typed `IdSpace`. Round-trip
1702    /// stable: `IdSpace::parse(&x.to_prefixed()) == Some(x)`. Only the FIRST
1703    /// two-char id-space prefix is stripped, so a value that itself contains
1704    /// `":"` round-trips unchanged. Returns `None` for an untagged string.
1705    #[must_use]
1706    pub fn parse(s: &str) -> Option<Self> {
1707        if let Some(v) = s.strip_prefix("l:") {
1708            Some(Self::logical(v))
1709        } else if let Some(v) = s.strip_prefix("h:") {
1710            Some(Self::content(v))
1711        } else {
1712            s.strip_prefix("p:").map(Self::passage)
1713        }
1714    }
1715}
1716
1717impl std::fmt::Display for IdSpace {
1718    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1719        write!(f, "{}{}", self.space.prefix(), self.value)
1720    }
1721}
1722
1723/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — `score: f64` forbids
1724/// total equality.
1725#[derive(Clone, Debug, PartialEq)]
1726pub struct SearchHit {
1727    /// C-2 (0.8.19 / TC-8) — the typed, non-null, id-space-total hit id
1728    /// ([`IdSpace`]). Was the interim `write_cursor: u64` in prior releases; now
1729    /// carries the cross-session-stable key: `value` is the BARE (prefix-stripped)
1730    /// id, and [`to_prefixed`](IdSpace::to_prefixed) (== `{prefix}{value}`)
1731    /// reproduces the pre-swap `stable_id` byte-for-byte (the real-gold-keying
1732    /// no-op). Governed hits are `l:`, doc-seeded hits `h:`, synthetic
1733    /// passages `p:`. This is the caller-facing identity; the positional
1734    /// `write_cursor` below is engine-internal book-keeping.
1735    pub id: IdSpace,
1736    /// Engine-internal positional cursor (the value `id` carried before the C-2
1737    /// swap). Reassigned on every re-projection/re-ingest — NOT cross-session
1738    /// stable, NOT the caller-facing id. Retained because the engine still needs
1739    /// a positional cursor for its own book-keeping (vector rowid mapping, the
1740    /// `state='active'` filter lookups, RRF recency/importance reweight keys,
1741    /// telemetry `result_ids` keying, `search_expand` re-resolution). The SDK
1742    /// bindings do NOT surface it.
1743    pub write_cursor: u64,
1744    pub kind: String,
1745    pub body: String,
1746    pub score: f64,
1747    pub branch: SoftFallbackBranch,
1748    pub source_id: Option<String>,
1749    /// 0.8.5 (EXP-0) — per-candidate cross-encoder score `ce_norm =
1750    /// sigmoid(ce_logit) ∈ [0,1]`. `Some` ONLY for hits inside the reranked pool
1751    /// (the top `pool_n` when the CE model is loaded); `None` for the unreranked
1752    /// remainder, the `rerank_depth == 0` identity path, an empty list, and the
1753    /// no-CE-model soft-fallback. Additive + nullable: it never participates in
1754    /// ranking, so default-path ordering/scores stay byte-stable.
1755    pub ce_score: Option<f64>,
1756}
1757
1758/// G0 Phase-2 (E0a / BLOCK-1) — graph-arm frontier instrumentation. A
1759/// **side-channel** meter (deliberately NOT a `SearchResult`/`SearchHit` field —
1760/// byte stability) that proves whether the graph arm seeds a non-empty frontier.
1761/// Under the current doc-seeded path the frontier is empty (doc nodes carry
1762/// `logical_id = NULL`), so `seeds_resolved == 0` and `resolved_seed_rate == 0.0`
1763/// — this meter is the measurement that proves it (and, post-C1, the 0→>0 flip).
1764///
1765/// `resolved_seed_rate = seeds_resolved / seeds_considered`, with `0/0 → 0.0`.
1766#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1767pub struct GraphFrontierStats {
1768    /// Hits inspected as seed candidates (the `take(SEED_N)` window, skipping TextEdge).
1769    pub seeds_considered: u32,
1770    /// Seed candidates that resolved to an active `logical_id` (pushed onto the frontier).
1771    pub seeds_resolved: u32,
1772    /// Whether the BFS frontier was non-empty after seeding.
1773    pub frontier_nonempty: bool,
1774    /// Number of graph-arm `SearchHit`s emitted (reachable, not already in the two-arm result).
1775    pub graph_candidates_emitted: u32,
1776}
1777
1778impl GraphFrontierStats {
1779    /// `seeds_resolved / seeds_considered`, defined as `0.0` when nothing was considered.
1780    pub fn resolved_seed_rate(&self) -> f64 {
1781        if self.seeds_considered == 0 {
1782            0.0
1783        } else {
1784            f64::from(self.seeds_resolved) / f64::from(self.seeds_considered)
1785        }
1786    }
1787}
1788
1789/// Slice 30 (G2) — an active canonical node row returned by `read.get` /
1790/// `read.get_many`.
1791///
1792/// `logical_id` is the queried stable identity (echoed). `write_cursor` is the
1793/// interim id carrier (same column `SearchHit.id` carries). Only ACTIVE rows
1794/// (`superseded_at IS NULL`) are ever materialised into this shape; a missing or
1795/// superseded `logical_id` is a normal absence (`None`), never an error.
1796#[derive(Clone, Debug, Eq, PartialEq)]
1797pub struct NodeRecord {
1798    pub logical_id: String,
1799    pub kind: String,
1800    pub body: String,
1801    pub write_cursor: u64,
1802}
1803
1804/// 0.8.20 Slice 10b (R-20-RV / R-20-NV) — the **read view**: the single knob
1805/// that decides which `canonical_nodes` rows a read verb may see.
1806///
1807/// Every field is a *relaxation*: `ReadView::default()` is the STRICT view and
1808/// compiles to exactly the predicates the five read verbs carried before this
1809/// slice (`superseded_at IS NULL AND state = 'active'`), so the default read
1810/// path is behaviourally unchanged. Flags compose INDEPENDENTLY — each one
1811/// drops exactly one conjunct and no other.
1812///
1813/// The view is applied UNIFORMLY by [`Engine::read_get`],
1814/// [`Engine::read_get_many`], [`Engine::read_list`],
1815/// [`Engine::read_list_filter`] and [`Engine::graph_neighbors`] — and, inside
1816/// `graph_neighbors`, at EVERY position of EVERY direction's recursive CTE
1817/// (anchor, recursive join, final projection), so a relaxation cannot silently
1818/// apply on one traversal position and not another.
1819///
1820/// # World-time only
1821///
1822/// `valid_as_of` selects along the **world-time** (validity) axis only.
1823/// Transaction-time / `history_as_of` is explicitly OUT OF SCOPE — this type
1824/// deliberately has no way to ask "what did the database believe at time T".
1825#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1826pub struct ReadView {
1827    /// Relax `superseded_at IS NULL` — include superseded (historical) versions
1828    /// of a row, not just the current one. `false` (default) keeps the shipped
1829    /// current-version-only behaviour.
1830    ///
1831    /// On the point-lookup verbs ([`Engine::read_get`] /
1832    /// [`Engine::read_get_many`]) a `logical_id` can now match several rows;
1833    /// the slot resolves DETERMINISTICALLY to the highest `write_cursor` (the
1834    /// most recent version). Use [`Engine::read_list`] to enumerate history.
1835    pub include_superseded: bool,
1836
1837    /// Relax `state = 'active'` — include rows in a non-`active` lifecycle
1838    /// state (`pending` / `deleted` / `purged`). `false` (default) keeps the
1839    /// shipped active-only behaviour.
1840    pub include_inactive: bool,
1841
1842    /// Relax the validity-window predicate ENTIRELY — return rows whatever
1843    /// their `[valid_from, valid_until)` window, ignoring `valid_as_of`.
1844    /// `false` (default) filters to rows valid at the selected instant.
1845    ///
1846    /// Note this is a NO-OP on any row with an unbounded (NULL/NULL) window,
1847    /// which is every row that predates schema step 22.
1848    pub include_out_of_window: bool,
1849
1850    /// The instant (INTEGER epoch SECONDS, UTC) at which validity is evaluated.
1851    /// `None` (default) resolves to *now* at query time.
1852    ///
1853    /// This is the **`:now` seam**: whichever way it resolves, the instant is
1854    /// compiled as a BOUND PARAMETER, never a `datetime('now')` SQL literal —
1855    /// which is what makes node validity deterministically testable without
1856    /// clock games. (The shipped EDGE temporal filter still inlines
1857    /// `datetime('now')`; that path is untouched by this slice.)
1858    pub valid_as_of: Option<i64>,
1859}
1860
1861impl ReadView {
1862    /// The instant to bind for the validity predicate, or `None` when the view
1863    /// relaxes validity entirely (in which case no `:now` parameter is emitted
1864    /// and none must be bound).
1865    fn now_param(&self) -> Option<i64> {
1866        if self.include_out_of_window {
1867            return None;
1868        }
1869        Some(self.valid_as_of.unwrap_or_else(current_epoch_seconds))
1870    }
1871
1872    /// The existence conjunct for node-table `alias`. Each flag drops exactly
1873    /// one conjunct; the strict view reproduces the pre-slice predicate pair
1874    /// verbatim. Always begins with ` AND ` (or is empty), so every call site
1875    /// must already have a preceding `WHERE` predicate.
1876    fn existence_sql(&self, alias: &str) -> String {
1877        let mut sql = String::new();
1878        if !self.include_superseded {
1879            sql.push_str(&format!(" AND {alias}.superseded_at IS NULL"));
1880        }
1881        if !self.include_inactive {
1882            sql.push_str(&format!(" AND {alias}.state = 'active'"));
1883        }
1884        sql
1885    }
1886
1887    /// The validity conjunct for node-table `alias`, bound to positional
1888    /// parameter `?{now_idx}`. Empty when validity is relaxed.
1889    ///
1890    /// Encodes the HALF-OPEN window `[valid_from, valid_until)` with NULL
1891    /// meaning unbounded on that side — so a NULL/NULL row is valid at every
1892    /// instant and this conjunct never changes its visibility.
1893    fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
1894        if self.include_out_of_window {
1895            return String::new();
1896        }
1897        format!(
1898            " AND ({alias}.valid_from IS NULL OR {alias}.valid_from <= ?{now_idx}) \
1899             AND ({alias}.valid_until IS NULL OR {alias}.valid_until > ?{now_idx})"
1900        )
1901    }
1902
1903    /// The full node predicate (existence + validity) for `alias`. This is the
1904    /// ONE function every read site calls, so no site can drift from another.
1905    fn node_sql(&self, alias: &str, now_idx: usize) -> String {
1906        format!("{}{}", self.existence_sql(alias), self.validity_sql(alias, now_idx))
1907    }
1908
1909    /// 0.8.20 Slice 15b fix-3 (F2) — resolve this view's validity instant ONCE
1910    /// and hand back a [`FrozenView`] that carries the resolved value.
1911    ///
1912    /// This is the ONLY constructor of a `FrozenView`, and therefore the only
1913    /// point on the search path where the wall clock is read.
1914    fn freeze(self) -> FrozenView {
1915        // TC-33: resolve the instant ONCE, unconditionally, and derive both
1916        // axes from it. `valid_as_of.unwrap_or_else(current_epoch_seconds)` is
1917        // exactly what `now_param()` computes, so the clock is read the same
1918        // number of times as before on every path that reads it at all.
1919        let resolved = self.valid_as_of.unwrap_or_else(current_epoch_seconds);
1920        let now = if self.include_out_of_window { None } else { Some(resolved) };
1921        FrozenView { view: self, now, edge_now: resolved }
1922    }
1923
1924    /// 0.8.20 Slice 15b fix-2 — the `search` path honours the VALIDITY axis of a
1925    /// `ReadView` and refuses the EXISTENCE axis. See [`Engine::search_view`] for
1926    /// why refusing beats silently ignoring.
1927    fn reject_existence_relaxation_on_search(&self) -> Result<(), EngineError> {
1928        let relaxed = match (self.include_superseded, self.include_inactive) {
1929            (true, true) => "include_superseded + include_inactive",
1930            (true, false) => "include_superseded",
1931            (false, true) => "include_inactive",
1932            (false, false) => return Ok(()),
1933        };
1934        Err(EngineError::InvalidArgument {
1935            msg: format!(
1936                "ReadView.{relaxed} is not supported on the search path; search hydrates from \
1937                 projection indexes that are not version-complete, so only the validity axis \
1938                 (valid_as_of / include_out_of_window) is honoured. Use read_list for history."
1939            ),
1940        })
1941    }
1942}
1943
1944/// 0.8.20 Slice 10b (R-20-NV) — one node that crossed a validity boundary
1945/// inside the interrogated interval, as reported by
1946/// [`Engine::crossed_boundary_since`].
1947///
1948/// A node can cross BOTH boundaries in the same interval (a window that opened
1949/// and closed inside it), so the two fields are independent `Option`s rather
1950/// than one enum.
1951#[derive(Clone, Debug, Eq, PartialEq)]
1952pub struct BoundaryCrossing {
1953    /// The node that crossed.
1954    pub node: NodeRecord,
1955    /// `Some(valid_from)` when the node BECAME VALID inside the interval.
1956    pub became_valid_at: Option<i64>,
1957    /// `Some(valid_until)` when the node BECAME INVALID inside the interval.
1958    pub became_invalid_at: Option<i64>,
1959}
1960
1961/// 0.8.20 Slice 15b fix-3 (F2) — a [`ReadView`] whose validity instant has
1962/// ALREADY been resolved, produced only by [`ReadView::freeze`].
1963///
1964/// R-20-NV requires `:now` to bind ONCE PER QUERY — not per row, and not per
1965/// ARM. The multi-arm search path made that easy to violate: each arm held a
1966/// `ReadView` and could call `now_param()`, which for the default view
1967/// (`valid_as_of == None`) reads the wall clock. Two arms, two instants, and a
1968/// query straddling a validity boundary gets nondeterministic membership.
1969///
1970/// The fix is TYPE-LEVEL rather than a comment asking future arms to behave:
1971/// the instant is resolved once at the top of `read_search_in_tx` and every arm
1972/// receives a `FrozenView`, which stores the resolved value in `now` and has NO
1973/// path back to the clock. An arm cannot re-resolve the instant because it
1974/// never holds anything that could — the failure mode is unreachable, not
1975/// merely discouraged.
1976#[derive(Clone, Copy, Debug)]
1977struct FrozenView {
1978    /// The underlying view — consulted for SQL SHAPE only (which conjuncts to
1979    /// emit), never to re-resolve the instant.
1980    view: ReadView,
1981    /// The instant resolved at freeze time. `None` ⇔ the view relaxes validity
1982    /// entirely, in which case no conjunct is emitted and nothing is bound.
1983    now: Option<i64>,
1984    /// TC-33 — the instant EDGE validity is evaluated at. Always present.
1985    ///
1986    /// The EXISTENCE-relaxation flag `include_out_of_window` belongs to the NODE
1987    /// validity axis and does NOT relax edge recency: an edge invalidated in the
1988    /// past stays excluded regardless. So this is the resolved instant even when
1989    /// `now` is `None`, and it is resolved from the SAME clock read.
1990    edge_now: i64,
1991}
1992
1993impl FrozenView {
1994    /// The instant to bind, resolved at freeze time. Unlike
1995    /// [`ReadView::now_param`] this is a stored value: calling it a second time
1996    /// cannot yield a different answer, and it never touches the clock.
1997    fn now_param(&self) -> Option<i64> {
1998        self.now
1999    }
2000
2001    /// TC-33 — the instant to bind for the EDGE-validity conjunct
2002    /// ([`edge_validity_sql`]). Frozen, like [`FrozenView::now_param`].
2003    ///
2004    /// Honouring `valid_as_of` here is what finally UNIFIES the node and edge
2005    /// temporal axes: step 22 recorded "the shipped EDGE path still inlines
2006    /// `datetime('now')`" as the reason they could not be unified. For the
2007    /// DEFAULT view (`valid_as_of == None`) this is the wall clock, i.e. exactly
2008    /// the pre-TC-33 behaviour.
2009    fn edge_now(&self) -> i64 {
2010        self.edge_now
2011    }
2012
2013    /// The validity conjunct — delegated to the one generator every read site
2014    /// shares, so the search arms cannot drift from the five read verbs.
2015    fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
2016        self.view.validity_sql(alias, now_idx)
2017    }
2018}
2019
2020/// 0.8.20 Slice 15b fix-3 (F2) — how many times [`current_epoch_seconds`] has
2021/// been called in this process. Test-only observation; see
2022/// [`clock_reads_for_test`].
2023static CLOCK_READS: AtomicU64 = AtomicU64::new(0);
2024
2025/// Test seam — the process-wide count of wall-clock reads on the validity path.
2026/// Kept OFF the governed surface (`#[doc(hidden)]`, `_for_test`), mirroring the
2027/// sanctioned `set_vector_stage_only_for_test` / `vector_phase1_sql_for_test`
2028/// pattern; it is never re-exported from the `fathomdb` facade.
2029///
2030/// The counter is PROCESS-WIDE, so a test asserting on a delta must hold a
2031/// lock that excludes every other clock-reading test in its binary (test
2032/// binaries are separate processes, so only intra-binary contention matters).
2033/// `slice15b_search_validity_recall.rs` does this with a file-local mutex.
2034#[doc(hidden)]
2035#[must_use]
2036pub fn clock_reads_for_test() -> u64 {
2037    CLOCK_READS.load(Ordering::Relaxed)
2038}
2039
2040/// Wall-clock now as INTEGER epoch SECONDS (UTC), saturating at 0 before the
2041/// Unix epoch. The single place the node-validity path reads the clock — and it
2042/// is read in RUST, then BOUND, never inlined into SQL as `datetime('now')`.
2043fn current_epoch_seconds() -> i64 {
2044    // 0.8.20 Slice 15b fix-3 (F2) — meter every wall-clock read on the validity
2045    // path. R-20-NV requires `:now` to bind ONCE PER QUERY (not per row, not per
2046    // ARM): if two arms of one query each resolve *now*, a query that straddles
2047    // a validity boundary can have its arms disagree about which side they are
2048    // on. That is invisible to a result-shape assertion and unreachable by a
2049    // deterministic test — you cannot assert on a race. Counting the reads makes
2050    // the property testable WITHOUT racing the clock, and keeps failing for any
2051    // arm added later that re-reads it. `Relaxed` is sufficient: the counter is
2052    // an observation, never a synchronization point.
2053    CLOCK_READS.fetch_add(1, Ordering::Relaxed);
2054    std::time::SystemTime::now()
2055        .duration_since(std::time::UNIX_EPOCH)
2056        .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
2057        .unwrap_or(0)
2058}
2059
2060/// TC-33 — the edge-validity conjunct, bound to positional parameter
2061/// `?{now_idx}`. THE one generator for "is this edge valid at `:now`", so no
2062/// read site can drift from another (the same discipline
2063/// [`ReadView::validity_sql`] applies to node validity).
2064///
2065/// An edge is valid at `t` iff it has no invalid-time, or its invalid-time is
2066/// strictly in the future. `t_invalid` is INTEGER epoch seconds since step 23,
2067/// so this is a direct integer comparison — no `datetime()` conversion per row.
2068///
2069/// **`:now` is a BOUND PARAMETER, never `datetime('now')`.** Before TC-33 every
2070/// edge read site inlined `datetime('now')`, which made the predicate
2071/// non-deterministic, untestable, and re-evaluated per row; step 22's comment
2072/// flagged that as the reason node and edge validity could not be unified. They
2073/// are unified now.
2074///
2075/// Always begins with ` AND `, so every call site must already have a preceding
2076/// `WHERE` predicate.
2077fn edge_validity_sql(alias: &str, now_idx: usize) -> String {
2078    format!(" AND ({alias}.t_invalid IS NULL OR {alias}.t_invalid > ?{now_idx})")
2079}
2080
2081/// TC-33 — parse one ISO-8601 timestamp to INTEGER epoch seconds using SQLite's
2082/// own date parser, via a BOUND parameter.
2083///
2084/// Returns `None` when SQLite cannot resolve the value — `strftime` yields SQL
2085/// NULL for junk (`'not a date'`, `''`, `'2020-13-45T99:99:99Z'`, a bare epoch
2086/// string, whitespace-padded input, non-ASCII digits) and a digit string for
2087/// anything it understands.
2088///
2089/// **Why SQLite and not a date crate:** there is no `chrono`/`time` dependency
2090/// anywhere in the workspace, and HITL directed this spelling rather than adding
2091/// one. The value is BOUND, never interpolated.
2092///
2093/// **This does not violate the inline-clock rule.** That rule forbids
2094/// `datetime('now')` / `strftime('%s','now')` — an inline CLOCK. Parsing a bound
2095/// user value is deterministic and reads no clock. The current instant still
2096/// comes from the bound `:now` seam ([`current_epoch_seconds`]).
2097///
2098/// The `CAST` matters: `strftime('%s', ...)` returns TEXT (a digit string), not
2099/// an integer, and the column is `typeof(...) = 'integer'`-checked.
2100///
2101/// # TC-33 fix-5 — strict ISO-8601 SHAPE gate before delegating to SQLite
2102///
2103/// `strftime('%s', ?)` alone is NOT an ISO-8601 validator: SQLite's date parser
2104/// is MORE lenient than the declared wire contract. A bare number is read as a
2105/// **Julian day** (`strftime('%s','2451545.0')` → `946728000`, i.e. year 2000)
2106/// and `strftime('%s','0')` resolves to a pre-year-0000 epoch — so non-ISO input
2107/// was ACCEPTED and stored as an unrelated instant despite the "hard-reject
2108/// ISO-8601" contract HITL ratified (2026-07-21). [`is_iso8601_shape`] runs
2109/// FIRST and returns `None` for anything that is not a strict ISO-8601
2110/// date/datetime shape, so the existing hard-reject path fires. The shape gate
2111/// does NOT replace SQLite's calendar math — a shape-valid but impossible date
2112/// (`2025-13-45T00:00:00Z`) still `None`s out via `strftime` and hard-rejects.
2113///
2114/// # TC-47 — the calendar-DATE ROUND-TRIP backstop (keystone terminal codex P2)
2115///
2116/// The shape gate checks FORMAT, not CALENDAR VALIDITY, and `strftime('%s', ?)`
2117/// does NOT fully validate the calendar: it **rolls over an impossible DAY**
2118/// rather than returning NULL — `strftime('%s','2025-02-30T00:00:00Z')` yields
2119/// the epoch for `2025-03-02`, and `2025-04-31` yields `2025-05-01`. So a
2120/// shape-valid Feb-30 would parse to a DIFFERENT instant than the provider
2121/// supplied, bypassing the hard-reject contract. (An impossible MONTH like
2122/// `2025-13-01`, and impossible TIMES like `25:00:00` / `:60` / `:61`, already
2123/// NULL out; only impossible DAYS roll over — that is the sole residue.)
2124///
2125/// The `WHERE` clause is the round-trip: the literal calendar DATE component of
2126/// the input (`substr(?1, 1, 10)` — the `YYYY-MM-DD` the shape gate guarantees is
2127/// present) must survive SQLite's own calendar math UNCHANGED. If it rolled over,
2128/// `strftime('%Y-%m-%d', substr(?1,1,10))` differs from the literal substring and
2129/// the `WHERE` yields zero rows => `query_row` -> `QueryReturnedNoRows` -> `None`
2130/// => the existing hard-reject fires. This is a superset of the shape gate: it
2131/// also rejects the TC-44 Julian string `2451545.0` (its `substr(1,10)` renders
2132/// to `2000-01-01`, not itself).
2133///
2134/// **Why the DATE component and not the raw string or the UTC-rendered instant:**
2135/// a raw-string or `unixepoch`-rendered comparison would FALSE-REJECT valid
2136/// equivalent forms. `Z` vs `+00:00`, a non-UTC offset like `+05:00`, date-only,
2137/// and fractional seconds are all valid and store the correct (offset-shifted)
2138/// epoch — but a UTC re-render shifts the wall clock, so its date can differ from
2139/// the input's literal date. Comparing ONLY the literal DATE field is
2140/// tz-INVARIANT (the offset never alters the input's own `YYYY-MM-DD` text) while
2141/// still catching every DAY rollover, because the rollover happens in the
2142/// calendar math BEFORE any offset is applied. **Pure SQL — no date crate.**
2143fn iso8601_to_epoch_seconds(connection: &Connection, raw: &str) -> Option<i64> {
2144    if !is_iso8601_shape(raw) {
2145        return None;
2146    }
2147    connection
2148        .query_row(
2149            "SELECT CAST(strftime('%s', ?1) AS INTEGER) \
2150             WHERE strftime('%Y-%m-%d', substr(?1, 1, 10)) IS substr(?1, 1, 10)",
2151            params![raw],
2152            |r| r.get::<_, Option<i64>>(0),
2153        )
2154        .ok()
2155        .flatten()
2156}
2157
2158/// TC-33 fix-5 — strict ISO-8601 date/datetime SHAPE gate. Hand-rolled on ASCII
2159/// bytes (NO new dependency: the workspace has no `chrono`/`time`, and `regex`
2160/// is only a transitive dep of `jsonschema`, not a direct one — adding either as
2161/// a direct dep would violate the "no new dependency" constraint).
2162///
2163/// Accepts EXACTLY:
2164/// - `YYYY-MM-DD` (date only); optionally followed by
2165/// - a `T` **or** a single space separator, then `HH:MM:SS`; optionally followed
2166///   by `.fff` fractional seconds (one or more digits); optionally followed by
2167///   a zone: `Z`, or `±HH:MM`, or `±HHMM`.
2168///
2169/// Rejects bare numbers (`0`, `2451545.0`), partial junk, whitespace, non-ASCII
2170/// digits, and anything with trailing characters. All digit positions require
2171/// ASCII `0..=9` (`u8::is_ascii_digit`), so non-ASCII digit look-alikes cannot
2172/// slip through. This is a SHAPE check only — calendar validity (e.g. month 13,
2173/// day 45) is still enforced by SQLite's `strftime` after this gate passes.
2174fn is_iso8601_shape(s: &str) -> bool {
2175    let b = s.as_bytes();
2176    let d = |c: u8| c.is_ascii_digit();
2177
2178    // Date: YYYY-MM-DD (exactly 10 bytes).
2179    if b.len() < 10 {
2180        return false;
2181    }
2182    if !(d(b[0])
2183        && d(b[1])
2184        && d(b[2])
2185        && d(b[3])
2186        && b[4] == b'-'
2187        && d(b[5])
2188        && d(b[6])
2189        && b[7] == b'-'
2190        && d(b[8])
2191        && d(b[9]))
2192    {
2193        return false;
2194    }
2195    if b.len() == 10 {
2196        return true; // date-only
2197    }
2198
2199    // Separator (`T` or a single space) + time HH:MM:SS (indices 10..=18).
2200    if b[10] != b'T' && b[10] != b' ' {
2201        return false;
2202    }
2203    if b.len() < 19 {
2204        return false;
2205    }
2206    if !(d(b[11])
2207        && d(b[12])
2208        && b[13] == b':'
2209        && d(b[14])
2210        && d(b[15])
2211        && b[16] == b':'
2212        && d(b[17])
2213        && d(b[18]))
2214    {
2215        return false;
2216    }
2217
2218    let mut i = 19;
2219
2220    // Optional fractional seconds `.fff` (one or more digits).
2221    if i < b.len() && b[i] == b'.' {
2222        i += 1;
2223        let start = i;
2224        while i < b.len() && d(b[i]) {
2225            i += 1;
2226        }
2227        if i == start {
2228            return false; // `.` with no digits
2229        }
2230    }
2231
2232    // Optional zone.
2233    if i == b.len() {
2234        return true; // no zone
2235    }
2236    match b[i] {
2237        b'Z' => i += 1,
2238        b'+' | b'-' => {
2239            i += 1;
2240            // `HH`
2241            if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2242                return false;
2243            }
2244            i += 2;
2245            // `:MM` or `MM`
2246            if i < b.len() && b[i] == b':' {
2247                i += 1;
2248            }
2249            if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2250                return false;
2251            }
2252            i += 2;
2253        }
2254        _ => return false,
2255    }
2256
2257    i == b.len() // no trailing junk
2258}
2259
2260/// TC-33 — render INTEGER epoch seconds back to an ISO-8601 UTC string for the
2261/// BYO-LLM wire. The exact inverse of [`iso8601_to_epoch_seconds`].
2262///
2263/// Storage and the governed SDK are epoch seconds, but the harness protocols
2264/// (`fathomdb.extract.v1` and the consolidation harness) carry ISO-8601 — LLMs
2265/// reason about dates as text, and pushing epoch integers onto them would make
2266/// the wire hostile to the very providers it exists to serve. So the boundary
2267/// converts in BOTH directions and the representation split stays a boundary
2268/// concern rather than leaking into the protocol.
2269fn epoch_seconds_to_iso8601(connection: &Connection, epoch: i64) -> Option<String> {
2270    connection
2271        .query_row("SELECT strftime('%Y-%m-%dT%H:%M:%SZ', ?1, 'unixepoch')", params![epoch], |r| {
2272            r.get::<_, Option<String>>(0)
2273        })
2274        .ok()
2275        .flatten()
2276}
2277
2278/// TC-33 fix-1 — the inclusive epoch-seconds range SQLite's
2279/// `strftime(..., 'unixepoch')` can render back to ISO-8601. SQLite's date
2280/// functions cover years 0000..=9999 ONLY, so:
2281/// - `MIN` = `0000-01-01T00:00:00Z`
2282/// - `MAX` = `9999-12-31T23:59:59Z`
2283///
2284/// TC-33 fix-5 makes these the ACTUAL rejection predicate (a numeric
2285/// `[MIN, MAX]` bounds check), not just message text. Renderability was too
2286/// weak: `strftime(..., 'unixepoch')` renders a below-`MIN` value like
2287/// `-62_167_219_201` to `-001-12-31T23:59:59Z` (NON-NULL), so a pre-year-0000
2288/// epoch slipped the renderability guard even though it is outside the declared
2289/// years 0000..=9999. Both bounds are verified against SQLite to correspond
2290/// EXACTLY to the first/last renderable instant:
2291/// `strftime('%Y-%m-%dT%H:%M:%SZ', MIN, 'unixepoch') = 0000-01-01T00:00:00Z` and
2292/// `= 9999-12-31T23:59:59Z` for `MAX` (`MAX+1` and `MIN-1` are the first
2293/// out-of-range instants).
2294const MIN_RENDERABLE_EPOCH: i64 = -62_167_219_200; // 0000-01-01T00:00:00Z
2295const MAX_RENDERABLE_EPOCH: i64 = 253_402_300_799; // 9999-12-31T23:59:59Z
2296
2297/// TC-33 fix-1 — reject an edge epoch that SQLite cannot render back to
2298/// ISO-8601, at the governed write boundary, so it is UNSTORABLE.
2299///
2300/// # Why this is the primary layer
2301///
2302/// Storage and `PreparedWrite::Edge` carry INTEGER epoch seconds and accept an
2303/// arbitrary `i64`. The consolidation path renders each candidate's
2304/// `t_valid`/`t_invalid` to ISO-8601 for the LLM via `strftime(..., 'unixepoch')`,
2305/// which only spans years 0000..=9999. An epoch outside that range renders to
2306/// NULL, and the render site would then send a silent `null` for a timestamp
2307/// that is actually stored NON-NULL — the OUTBOUND twin of the fail-open TC-33
2308/// removes. A `null` `t_invalid` reads as "still valid", and the consolidation
2309/// reference stub echoes a winner's `t_valid` straight back as the verdict's
2310/// `t_invalid`, so the `null` round-trips through the inbound normaliser as
2311/// "still valid": an invalidated edge silently resurrected.
2312///
2313/// Inbound ISO normalisation can never MINT such an epoch (a 4-digit-year ISO
2314/// string maxes at 9999), so the governed integer surface is the only ingress —
2315/// which is exactly where this guard sits. Like the inbound
2316/// [`normalize_extractor_timestamp`] hard-reject, it is a typed
2317/// [`EngineError::InvalidArgument`] naming the offending value and the bound,
2318/// never a silent coercion.
2319///
2320/// ⚠ It no longer mirrors the `validate_write` Node branch's
2321/// `valid_from >= valid_until` refusal: decision #18 (0.8.20 Slice 22) moved
2322/// THAT refusal onto the message-less [`EngineError::WriteValidation`] unit
2323/// variant, which carries no value at all. The two refusals are deliberately in
2324/// different families now — a malformed submitted write SHAPE is
2325/// `WriteValidation`; an out-of-domain scalar on this render path stays
2326/// `InvalidArgument`. Do not restate them as one pattern.
2327fn reject_unrenderable_edge_epoch(field: &str, value: Option<i64>) -> Result<(), EngineError> {
2328    // TC-33 fix-5 — an explicit numeric MIN/MAX bounds check, NOT a renderability
2329    // test. `strftime(..., 'unixepoch')` renders a below-`MIN` epoch (e.g.
2330    // `-62_167_219_201`, year -0001) to a NON-NULL string, so a renderability
2331    // guard would let pre-year-0000 values through even though they are outside
2332    // the declared years 0000..=9999. No `Connection` is needed now that the
2333    // predicate is pure integer arithmetic.
2334    if let Some(ts) = value {
2335        if !(MIN_RENDERABLE_EPOCH..=MAX_RENDERABLE_EPOCH).contains(&ts) {
2336            return Err(EngineError::InvalidArgument {
2337                msg: format!(
2338                    "edge field `{field}` = {ts} is outside the epoch-seconds range SQLite can \
2339                     render to ISO-8601 ([{MIN_RENDERABLE_EPOCH}, {MAX_RENDERABLE_EPOCH}], i.e. \
2340                     years 0000..=9999). REJECTED rather than stored: such an epoch renders to a \
2341                     silent NULL (or a nonsensical out-of-range instant) on the consolidation \
2342                     wire, and a NULL `t_invalid` reads as \"still valid\" — resurrecting an \
2343                     invalidated edge."
2344                ),
2345            });
2346        }
2347    }
2348    Ok(())
2349}
2350
2351/// The JSON type name of `value`, for diagnosing a mistyped extractor field.
2352fn json_type_name(value: &Value) -> &'static str {
2353    match value {
2354        Value::Null => "null",
2355        Value::Bool(_) => "boolean",
2356        Value::Number(_) => "number",
2357        Value::String(_) => "string",
2358        Value::Array(_) => "array",
2359        Value::Object(_) => "object",
2360    }
2361}
2362
2363/// TC-33 — normalise one timestamp arriving on the **BYO-LLM extractor
2364/// boundary** (`fathomdb.extract.v1`) into the INTEGER epoch seconds the storage
2365/// and governed-SDK layers use. **HARD-REJECTS** anything it cannot normalise.
2366///
2367/// This is the layering boundary HITL ratified on 2026-07-21:
2368/// - the **extractor wire format stays ISO-8601 strings** — LLMs emit text, and
2369///   this function is the one place that changes;
2370/// - **storage and the governed SDK surface are INTEGER epoch seconds.**
2371///
2372/// # Why rejection, not coercion — fail-open is the defect
2373///
2374/// A NULL `t_invalid` means **"still valid"**. So any path that turns an
2375/// unparseable timestamp into NULL silently **resurrects an invalidated edge**.
2376/// Two distinct fail-opens are closed here:
2377///
2378/// 1. **Malformed strings.** Previously NOTHING parsed or validated these; junk
2379///    went verbatim into the INSERT. Under the old TEXT column it then failed
2380///    CLOSED by accident (`datetime('junk')` → NULL ⇒ the read disjunct is
2381///    falsy ⇒ the row vanished). Under INTEGER that polarity would INVERT.
2382/// 2. **Non-string JSON — a fail-open that PREDATES TC-33.** The old site read
2383///    `edge.get("t_invalid").and_then(|v| v.as_str())`, and `as_str()` returns
2384///    `None` for a JSON number/bool/object. So `"t_invalid": 1710000000` — a
2385///    plausible mistake, and exactly the epoch form storage now uses — had its
2386///    invalidation SILENTLY DISCARDED and the edge stored as "still valid".
2387///
2388/// `None`/JSON `null`/absent is the ONLY sanctioned way to say "unknown"; it
2389/// maps to `Ok(None)` and keeps the NULL-means-still-valid semantic.
2390///
2391/// Refuses with a typed [`EngineError::InvalidArgument`] CARRYING the offending
2392/// value, so a caller can see what was rejected.
2393///
2394/// ⚠ This is NOT the same pattern as the `validate_write` `Node` branch's
2395/// `valid_from >= valid_until` check, which that comment used to cite: decision
2396/// #18 (0.8.20 Slice 22) moved that refusal onto the message-less
2397/// [`EngineError::WriteValidation`] unit variant, which carries **no value at
2398/// all**. Both the family AND the carry-the-value property differ.
2399fn normalize_extractor_timestamp(
2400    connection: &Connection,
2401    field: &str,
2402    raw: Option<&Value>,
2403) -> Result<Option<i64>, EngineError> {
2404    match raw {
2405        None | Some(Value::Null) => Ok(None),
2406        Some(Value::String(text)) => match iso8601_to_epoch_seconds(connection, text) {
2407            Some(epoch) => Ok(Some(epoch)),
2408            None => Err(EngineError::InvalidArgument {
2409                msg: format!(
2410                    "extractor edge field `{field}` must be a valid, calendar-real ISO-8601 \
2411                     timestamp; got {text:?}, which either `strftime('%s', ?)` resolves to NULL \
2412                     or fails the calendar round-trip (a shape-valid but impossible DAY like \
2413                     `2025-02-30` that SQLite would silently ROLL OVER to a different instant). \
2414                     REJECTED rather than stored: a NULL `t_invalid` reads as \"still valid\" and \
2415                     a rolled-over date stores the WRONG instant — both breach the hard-reject \
2416                     contract. Use JSON null for \"unknown\"."
2417                ),
2418            }),
2419        },
2420        Some(other) => Err(EngineError::InvalidArgument {
2421            msg: format!(
2422                "extractor edge field `{field}` must be an ISO-8601 string or JSON null; got a \
2423                 JSON {kind} ({other}). The `fathomdb.extract.v1` wire format carries ISO-8601 at \
2424                 this boundary — INTEGER epoch seconds are the STORAGE representation, not the \
2425                 wire one. REJECTED rather than coerced to NULL, which reads as \"still valid\".",
2426                kind = json_type_name(other)
2427            ),
2428        }),
2429    }
2430}
2431
2432/// Slice 30 (G3) — one `operational_mutations` row returned by `read.collection`
2433/// / `read.mutations`. `id` is the autoincrement PK (the after-id cursor key).
2434#[derive(Clone, Debug, Eq, PartialEq)]
2435pub struct OpStoreRow {
2436    pub id: i64,
2437    pub collection: String,
2438    pub record_key: String,
2439    pub op_kind: String,
2440    pub payload: String,
2441    pub schema_id: Option<String>,
2442    pub write_cursor: u64,
2443}
2444
2445/// Hybrid `search` result. `results` carries structured [`SearchHit`]s in
2446/// vector-first, dedup-on-body order. Derives `Clone, Debug, PartialEq` but
2447/// **not `Eq`** — each hit carries a `score: f64`.
2448#[derive(Clone, Debug, PartialEq)]
2449// 0.8.8 EXP-OBS (field-set ratification): non_exhaustive so future additive fields
2450// (e.g. the deferred QueryTrace.timings_ms, Q3) are non-breaking. All construction
2451// is in-crate (engine + tests); external crates read fields only.
2452#[non_exhaustive]
2453pub struct SearchResult {
2454    pub projection_cursor: u64,
2455    pub soft_fallback: Option<SoftFallback>,
2456    pub results: Vec<SearchHit>,
2457    /// 0.8.8 EXP-OBS (Slice 5) — opt-in retrieval explanation **sidecar**.
2458    /// `Some` ONLY on the `search_explained` path; `None` for every default
2459    /// (`explain=false`) search, so `results` + `projection_cursor` stay
2460    /// byte-identical to the pre-0.8.8 shape (R-OBS-2 zero-cost contract,
2461    /// HITL-ratified sidecar carrier — see
2462    /// `dev/design/0.8.8-explain-and-telemetry-adr.md` §A.2). Field-set is
2463    /// PROPOSED/ratification-pending; additive inside `Explanation` so later
2464    /// amendments do not reshape `SearchResult`/`SearchHit`.
2465    pub explanation: Option<Explanation>,
2466}
2467
2468/// 0.8.8 EXP-OBS (Slice 5) — the opt-in retrieval explanation payload returned
2469/// behind `search_explained` (the `explain=true` surface). Built from the
2470/// engine's OWN fusion/rerank machinery (`fuse_three_arms` per-arm ranks,
2471/// `ce_rerank` blend components) — no parallel machinery (R-OBS-3). Carries a
2472/// query-level [`QueryTrace`] plus a per-hit breakdown parallel to (and in the
2473/// same order as) `SearchResult.results`.
2474///
2475/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2476#[derive(Clone, Debug, PartialEq)]
2477#[non_exhaustive] // 0.8.8 field-set ratification — additive-safe sidecar
2478pub struct Explanation {
2479    pub trace: QueryTrace,
2480    pub per_hit: Vec<PerHitExplain>,
2481}
2482
2483/// 0.8.8 EXP-OBS (Slice 5) — query-level retrieval trace. Reuses the existing
2484/// `search_reranked` knobs + the active embedder identity; timings are coarse
2485/// per-stage wall-clock (monotonic) captured only on the explain path.
2486#[derive(Clone, Debug, PartialEq)]
2487// 0.8.8 field-set ratification — HARD: leaf absorbs the deferred `timings_ms` (Q3)
2488// and any future trace field without a contract break.
2489#[non_exhaustive]
2490pub struct QueryTrace {
2491    /// Query LENGTH only (chars) — never the query text (privacy; ADR §C).
2492    pub query_chars: u32,
2493    /// Final result limit (`SEARCH_RERANK_LIMIT`-derived `final_limit`).
2494    pub k: u32,
2495    pub rerank_depth: u32,
2496    pub pool_n: u32,
2497    pub alpha: f64,
2498    pub use_graph_arm: bool,
2499    /// Recency reweight (the dedicated G12 flag) was applied.
2500    pub recency: bool,
2501    /// Active embedder identity `name@revision` (+ dim), or empty when none.
2502    pub embedder_id: String,
2503    /// The CE cross-encoder actually reranked the pool (model loaded + depth>0).
2504    pub ce_active: bool,
2505    /// Per-arm input hit counts (pre-fusion).
2506    pub vector_hits: u32,
2507    pub text_hits: u32,
2508    pub graph_hits: u32,
2509    /// Edge-FTS candidates rejected only because an attribute predicate is
2510    /// node-scoped. Present on the opt-in explanation so this deliberate
2511    /// filtering never looks like an absent corpus.
2512    pub dropped_edge_hits: u32,
2513}
2514
2515/// 0.8.8 EXP-OBS (Slice 5) — per-hit provenance + score breakdown. One entry per
2516/// returned `SearchHit`, same order. `*_rank` is the 0-based rank the hit's body
2517/// held in that arm's pre-fusion list (`None` = absent from that arm).
2518///
2519/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2520#[derive(Clone, Debug, PartialEq)]
2521// 0.8.8 field-set ratification — HARD: leaf absorbs future arms / score components.
2522#[non_exhaustive]
2523pub struct PerHitExplain {
2524    /// The hit's engine-internal positional `write_cursor` (the pre-C-2
2525    /// `SearchHit.id`). Post-0.8.19 the caller-facing `SearchHit.id` is a typed
2526    /// [`IdSpace`]; this field keeps carrying the positional cursor so the explain
2527    /// sidecar cross-references the telemetry `result_ids` space. Correlate a
2528    /// `PerHitExplain` to its `SearchHit` by position (both lists are 1:1, same
2529    /// order).
2530    pub id: u64,
2531    /// Winning arm after RRF dedup (vector-first), == `SearchHit.branch`.
2532    pub arm: SoftFallbackBranch,
2533    pub vector_rank: Option<u32>,
2534    pub text_rank: Option<u32>,
2535    pub graph_rank: Option<u32>,
2536    /// Raw RRF fused score AFTER recency reweight, BEFORE CE blend (the value
2537    /// `ce_rerank` normalizes). Faithful to the engine computation — downstream
2538    /// may normalize. (ADR §A.4 Q1: raw exposed; normalization deferred.)
2539    pub fused_score: f64,
2540    /// In-pool cross-encoder score `sigmoid(ce_logit) ∈ [0,1]`, == the returned
2541    /// `SearchHit.ce_score`; `None` outside the reranked pool / no-CE path.
2542    pub ce_score: Option<f64>,
2543    /// Final blended score, == the returned `SearchHit.score`.
2544    pub blended: f64,
2545    /// 0.8.16 Slice 5 / F9 — the node `importance` scalar applied to this hit's
2546    /// fused contribution when the importance reweight is ON, else the raw stored
2547    /// value. `None` = never assigned (graceful-absent, ranks NEUTRAL). Additive
2548    /// (`#[non_exhaustive]` leaf absorbs the new score component).
2549    pub importance: Option<f64>,
2550    /// 0.8.16 Slice 5 / F9 — the edge `confidence` scalar applied to this hit's
2551    /// graph-arm contribution when the importance reweight is ON, else the raw
2552    /// stored value. `None` for node hits / edges without a confidence
2553    /// (graceful-absent, ranks NEUTRAL).
2554    pub confidence: Option<f64>,
2555}
2556
2557// ===== G4 filter grammar types (Slice 35) ===============================
2558
2559/// G4 (Slice 35) — scalar value for [`Predicate`] comparisons.
2560///
2561/// Shared vocabulary with G10 — defined once at the `fathomdb-engine` crate
2562/// root so reserved-gap 37 (full G4↔G10 unification) can import it without a
2563/// path change. Derives `Clone, Debug, PartialEq` per the ADR contract
2564/// (D-F1 exhaustiveness: exactly `{Text, Integer, Bool}`).
2565#[derive(Clone, Debug, PartialEq)]
2566pub enum ScalarValue {
2567    Text(String),
2568    Integer(i64),
2569    Bool(bool),
2570}
2571
2572/// G4 (Slice 35) — comparison operator for [`Predicate::JsonPathCompare`].
2573///
2574/// Shared vocabulary (same crate-root export as `ScalarValue`). Closed
2575/// enum: `{Gt, Gte, Lt, Lte}` per D-F1. Derives `Clone, Debug, PartialEq`.
2576#[derive(Clone, Debug, PartialEq)]
2577pub enum ComparisonOp {
2578    Gt,
2579    Gte,
2580    Lt,
2581    Lte,
2582}
2583
2584/// Allowed JSON paths for [`Predicate`] constructors. The SQL compilation in
2585/// [`Engine::read_list`] uses the **allowlist constant** (a server-side literal),
2586/// never the caller-supplied string, so only paths in this set reach
2587/// `json_extract`. Callers receive [`EngineError::InvalidFilter`] for any
2588/// non-allowlisted path — no passthrough, no panic.
2589///
2590/// To extend: add an entry here. No API change is needed; the constructor
2591/// accepts the new path string once it appears in this array.
2592const PREDICATE_PATH_ALLOWLIST: &[&str] =
2593    &["$.status", "$.priority", "$.tags", "$.kind", "$.created_at", "$.action_kind"];
2594
2595/// G4 (Slice 35) — closed typed predicate for [`Engine::read_list`] filter.
2596///
2597/// Exactly two variants per ADR D-F1 (`{JsonPathEq, JsonPathCompare}`).
2598/// The fused variants (`JsonPathFused*`) and all `*_unchecked` builders are
2599/// explicitly EXCLUDED (ADR D-F2). Use the validated constructors
2600/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`]; they
2601/// enforce the path allowlist at construction time.
2602///
2603/// Multiple predicates in [`Engine::read_list`] are combined by implicit AND
2604/// (D-F5). Compilation target: `json_extract(body, '$.field') <op> ?` with
2605/// a bound parameter (never interpolated — injection-safe per D-F4).
2606#[derive(Clone, Debug, PartialEq)]
2607pub enum Predicate {
2608    /// `json_extract(body, path) = ?` (equality).
2609    JsonPathEq { path: String, value: ScalarValue },
2610    /// `json_extract(body, path) <op> ?` (inequality).
2611    JsonPathCompare { path: String, op: ComparisonOp, value: ScalarValue },
2612}
2613
2614impl Predicate {
2615    /// Construct a `JsonPathEq` predicate with allowlist validation.
2616    ///
2617    /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2618    /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2619    pub fn json_path_eq(path: impl Into<String>, value: ScalarValue) -> Result<Self, EngineError> {
2620        let path = path.into();
2621        if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2622            return Err(EngineError::InvalidFilter {
2623                reason: format!("path '{path}' is not in the predicate path allowlist"),
2624            });
2625        }
2626        Ok(Self::JsonPathEq { path, value })
2627    }
2628
2629    /// Construct a `JsonPathCompare` predicate with allowlist validation.
2630    ///
2631    /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2632    /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2633    pub fn json_path_compare(
2634        path: impl Into<String>,
2635        op: ComparisonOp,
2636        value: ScalarValue,
2637    ) -> Result<Self, EngineError> {
2638        let path = path.into();
2639        if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2640            return Err(EngineError::InvalidFilter {
2641                reason: format!("path '{path}' is not in the predicate path allowlist"),
2642            });
2643        }
2644        Ok(Self::JsonPathCompare { path, op, value })
2645    }
2646
2647    /// Return the validated path string for use in SQL compilation.
2648    /// This always returns a path that is in `PREDICATE_PATH_ALLOWLIST`.
2649    fn path(&self) -> &str {
2650        match self {
2651            Self::JsonPathEq { path, .. } => path.as_str(),
2652            Self::JsonPathCompare { path, .. } => path.as_str(),
2653        }
2654    }
2655
2656    /// Compile this predicate to a SQL WHERE clause fragment.
2657    /// The path is validated at construction time and is always an allowlist
2658    /// constant — never the raw caller-supplied string.
2659    fn to_sql_clause(&self, param_idx: usize) -> String {
2660        // The path is already validated against the allowlist at construction.
2661        // We use the allowlist entry (the stored path) directly as a SQL literal.
2662        // The VALUE is always a bound `?` parameter (injection-safe).
2663        //
2664        // Type guards prevent cross-type matches caused by SQLite's json_extract
2665        // coercing JSON booleans to integer 1/0:
2666        //   - Bool predicates: AND json_type IN ('true', 'false') — exclude integers
2667        //   - Integer predicates: AND json_type = 'integer' — exclude booleans
2668        // Text predicates need no guard: json_extract returns TEXT for strings and
2669        // the coercion never conflates TEXT with integer/bool.
2670        let path = self.path();
2671        match self {
2672            Self::JsonPathEq { value, .. } => match value {
2673                ScalarValue::Bool(_) => format!(
2674                    "json_extract(body, '{path}') = ?{param_idx} \
2675                     AND json_type(body, '{path}') IN ('true', 'false')"
2676                ),
2677                ScalarValue::Integer(_) => format!(
2678                    "json_extract(body, '{path}') = ?{param_idx} \
2679                     AND json_type(body, '{path}') = 'integer'"
2680                ),
2681                ScalarValue::Text(_) => {
2682                    format!("json_extract(body, '{path}') = ?{param_idx}")
2683                }
2684            },
2685            Self::JsonPathCompare { op, value, .. } => {
2686                let op_str = match op {
2687                    ComparisonOp::Gt => ">",
2688                    ComparisonOp::Gte => ">=",
2689                    ComparisonOp::Lt => "<",
2690                    ComparisonOp::Lte => "<=",
2691                };
2692                match value {
2693                    ScalarValue::Bool(_) => format!(
2694                        "json_extract(body, '{path}') {op_str} ?{param_idx} \
2695                         AND json_type(body, '{path}') IN ('true', 'false')"
2696                    ),
2697                    ScalarValue::Integer(_) => format!(
2698                        "json_extract(body, '{path}') {op_str} ?{param_idx} \
2699                         AND json_type(body, '{path}') = 'integer'"
2700                    ),
2701                    ScalarValue::Text(_) => format!(
2702                        "json_extract(body, '{path}') {op_str} ?{param_idx} \
2703                         AND json_type(body, '{path}') = 'text'"
2704                    ),
2705                }
2706            }
2707        }
2708    }
2709
2710    /// Bind the value of this predicate as a rusqlite parameter.
2711    fn bind_value(&self) -> rusqlite::types::Value {
2712        let value = match self {
2713            Self::JsonPathEq { value, .. } => value,
2714            Self::JsonPathCompare { value, .. } => value,
2715        };
2716        match value {
2717            ScalarValue::Text(s) => rusqlite::types::Value::Text(s.clone()),
2718            ScalarValue::Integer(i) => rusqlite::types::Value::Integer(*i),
2719            ScalarValue::Bool(b) => rusqlite::types::Value::Integer(i64::from(*b)),
2720        }
2721    }
2722}
2723
2724// ===== Slice 20 (G5/G6) — graph traversal types =========================
2725
2726/// Slice 20 (G5) — direction of graph traversal for
2727/// [`Engine::graph_neighbors`] / [`Engine::search_expand`].
2728///
2729/// `Outgoing` follows edges where the root is the `from_id` (source).
2730/// `Incoming` follows edges where the root is the `to_id` (target).
2731/// `Both` follows edges in either direction.
2732#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2733pub enum TraversalDirection {
2734    Outgoing,
2735    Incoming,
2736    Both,
2737}
2738
2739/// Slice 20 (G6) — result of [`Engine::search_expand`]: initial search hits
2740/// plus nodes reached by bounded BFS expansion that are not already in the
2741/// search hit set.
2742#[derive(Clone, Debug)]
2743pub struct SearchExpandResult {
2744    /// Original RRF-scored search results (G1+G9 hybrid).
2745    pub search_hits: Vec<SearchHit>,
2746    /// Nodes reached by graph traversal but NOT already in `search_hits`.
2747    /// Each entry is `(node, hop_count)` where `hop_count` is the BFS depth
2748    /// from the nearest search hit that reached this node.
2749    pub expanded: Vec<(NodeRecord, u32)>,
2750    /// Deduplicated union of all logical_ids (search hits first, then expanded).
2751    pub all_logical_ids: Vec<String>,
2752}
2753
2754/// G10 — closed metadata filter for [`Engine::search_filtered`] (Slice 10).
2755///
2756/// All fields are optional; a `None` field imposes no constraint, and an
2757/// all-`None` filter (or `None` filter) is the unfiltered path whose phase-1 SQL
2758/// is byte-identical to 0.7.2. This is a **closed struct**, not an open filter
2759/// DSL (ADR-0.8.0-agent-memory-retrieval-and-identity Q1); the filter-grammar /
2760/// `list` decision stays a later-slice concern.
2761///
2762/// `created_after` is a `created_at >= bound` lower bound in unix seconds.
2763/// `status` is wired through to the vec0 `status` metadata column. vec0 TEXT
2764/// metadata columns are **NOT NULL-able**, so the "no real population yet" state
2765/// is an **empty-string sentinel** `''` (a forced deviation from the planned
2766/// "NULL plumbing"; a real population source is reserved-gap candidate 13). A
2767/// `status = Some("open")`-style filter therefore prunes every row until that
2768/// population slice lands.
2769// 0.8.20 Slice 15e fix-2 (Finding 2) — `#[non_exhaustive]`: the `attributes`
2770// field was added additively in 0.8.20. Marking the struct non-exhaustive means
2771// EXTERNAL crates can no longer use a struct literal `SearchFilter { .. }` and
2772// must go through `..Default::default()` (or a constructor), so a FUTURE field
2773// add is not a source break for them. Internal (in-workspace) construction is
2774// unaffected — `#[non_exhaustive]` only constrains other crates — and every
2775// in-crate literal already spreads `..Default::default()`. Governed-surface
2776// status: PROPOSED / NOT SIGNED.
2777#[derive(Clone, Debug, Default, Eq, PartialEq)]
2778#[non_exhaustive]
2779pub struct SearchFilter {
2780    pub source_type: Option<String>,
2781    pub kind: Option<String>,
2782    pub created_after: Option<i64>,
2783    pub status: Option<String>,
2784    /// 0.8.20 Slice 15e (R-20-PR, ADR-0.8.11 D3) — declared-`filterable`-attribute
2785    /// equality predicates, each `(attribute_name, value)`. Lowered into the
2786    /// **indexed pre-KNN** vec0 metadata column `attr_<hex>` by
2787    /// [`vector_filter_clause`] (NOT a post-KNN `json_extract`). Empty ⇒ the
2788    /// byte-identical unfiltered path is preserved. `attribute_name` is the
2789    /// registry projection name; the encoded column is derived by
2790    /// [`attr_vec0_column`].
2791    pub attributes: Vec<(String, String)>,
2792}
2793
2794impl SearchFilter {
2795    /// True when no field constrains the search — equivalent to `None`. Used to
2796    /// keep the unfiltered code path (and its byte-identical SQL) on the
2797    /// all-`None` struct.
2798    fn is_unfiltered(&self) -> bool {
2799        self.source_type.is_none()
2800            && self.kind.is_none()
2801            && self.created_after.is_none()
2802            && self.status.is_none()
2803            && self.attributes.is_empty()
2804    }
2805}
2806
2807// ===== 0.8.11 Slice 40 (#17) — unified filter grammar (G4 + G10) =========
2808
2809/// 0.8.11 Slice 40 (#17) — a single closed `FilterTerm` of the **unified**
2810/// filter grammar (ADR-0.8.11-filter-grammar-unification, Option A; closes
2811/// reserved-gap 37). Exactly **five** variants: the four G10 shorthand metadata
2812/// fields (`SourceType`/`Kind`/`CreatedAfter`/`Status`) plus the general G4
2813/// json-path [`Predicate`] (`Json`). The shorthand fields are dedicated typed
2814/// variants — NOT `Json(Predicate)` over `$.source_type` etc. — precisely so the
2815/// vec0 search backend can lower them to the *indexed* pre-KNN metadata columns
2816/// while typed-rejecting an arbitrary `Json` term (D3: no demotion to post-KNN
2817/// `json_extract`).
2818///
2819/// The grammar stays **closed** (inherits ADR-0.8.0 D-F1/D-F2/D-F4/D-F5): no
2820/// DSL, no caller SQL, no `JsonPathFused*`, no `*_unchecked`, no OR/nesting
2821/// (implicit AND only); `Json` terms are built ONLY via the validated
2822/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`] constructors
2823/// (path allowlist enforced at construction). The shipped `ScalarValue` /
2824/// `ComparisonOp` / `Predicate` vocabulary is reused verbatim — no new grammar.
2825#[derive(Clone, Debug, PartialEq)]
2826pub enum FilterTerm {
2827    /// vec0 partition-key metadata column `source_type` (pre-KNN). On
2828    /// `read.list` it **constant-folds** against `resolve_source_type(kind)`
2829    /// (the column does not exist in `canonical_nodes`).
2830    SourceType(String),
2831    /// `kind` — the vec0 metadata column (pre-KNN). On `read.list` it
2832    /// constant-folds against the partition `kind` argument (D1 impl decision:
2833    /// constant-fold, the simpler total option vs a redundant column clause).
2834    Kind(String),
2835    /// `created_at >= bound` (unix seconds). vec0 metadata column (pre-KNN);
2836    /// lowers to `json_extract(body,'$.created_at') >= ?` on `read.list`.
2837    CreatedAfter(i64),
2838    /// vec0 metadata column `status` (pre-KNN); lowers to
2839    /// `json_extract(body,'$.status') = ?` on `read.list`.
2840    Status(String),
2841    /// The general G4 json-path predicate (unchanged shipped grammar). Resolves
2842    /// **only** on the `read.list` (canonical_nodes) backend; **typed-rejected**
2843    /// on `search_filtered` because it would require a post-KNN `json_extract`
2844    /// that defeats the indexed pre-KNN filter (D3 no-demotion guarantee).
2845    Json(Predicate),
2846}
2847
2848/// 0.8.11 Slice 40 (#17) — the unified closed `Filter` contract. ONE superset
2849/// type with implicit-AND [`FilterTerm`]s, dispatched to one of **two** internal
2850/// compilation backends (Option A — the TYPE unifies, the COMPILATION
2851/// dispatches): the vec0-metadata indexed pre-KNN `WHERE` for `search_filtered`,
2852/// and `json_extract` over `canonical_nodes.body` for `read.list`. The shipped
2853/// `SearchFilter` (G10) and `Predicate` lists (G4) re-express as sugar that
2854/// lowers into this type (D4); the `filter=None` byte-identical-0.7.2-SQL pin is
2855/// preserved because the vec0 lowering routes back through the shipped
2856/// `vector_filter_clause` compilation verbatim.
2857#[derive(Clone, Debug, Default, PartialEq)]
2858pub struct Filter {
2859    /// AND-combined terms (implicit AND, inherits D-F5). Empty = unfiltered.
2860    pub terms: Vec<FilterTerm>,
2861}
2862
2863impl TryFrom<&SearchFilter> for Filter {
2864    type Error = EngineError;
2865
2866    /// D4 sugar lowering — the shipped G10 [`SearchFilter`] re-expressed as the
2867    /// unified [`Filter`]. Attribute equality is intentionally not part of the
2868    /// unified grammar, so it is refused rather than silently discarded. Field
2869    /// → term uses canonical order (`source_type`, `kind`, `created_after`,
2870    /// `status`) so an attribute-free round-trip stays byte-identical.
2871    fn try_from(sf: &SearchFilter) -> Result<Self, Self::Error> {
2872        if !sf.attributes.is_empty() {
2873            return Err(EngineError::InvalidFilter {
2874                reason:
2875                    "projected attribute predicates are not supported by the unified Filter grammar"
2876                        .to_string(),
2877            });
2878        }
2879        let mut terms = Vec::new();
2880        if let Some(s) = &sf.source_type {
2881            terms.push(FilterTerm::SourceType(s.clone()));
2882        }
2883        if let Some(k) = &sf.kind {
2884            terms.push(FilterTerm::Kind(k.clone()));
2885        }
2886        if let Some(c) = sf.created_after {
2887            terms.push(FilterTerm::CreatedAfter(c));
2888        }
2889        if let Some(s) = &sf.status {
2890            terms.push(FilterTerm::Status(s.clone()));
2891        }
2892        Ok(Filter { terms })
2893    }
2894}
2895
2896impl Filter {
2897    /// Backend dispatch for `search_filtered` (vec0 — indexed pre-KNN). Lowers
2898    /// the metadata subset `{SourceType, Kind, CreatedAfter, Status}` back into a
2899    /// [`SearchFilter`] (which the shipped `vector_filter_clause` compiles to the
2900    /// pre-KNN `WHERE`), and **typed-rejects** a [`FilterTerm::Json`] term with
2901    /// [`EngineError::InvalidFilter`] — the explicit no-demotion guarantee (D3).
2902    /// Field-by-variant assignment makes the output canonical-order-independent
2903    /// of `terms` ordering (hand-built router filters included). A later
2904    /// duplicate metadata term overwrites the earlier (last-wins).
2905    pub fn to_search_filter(&self) -> Result<SearchFilter, EngineError> {
2906        let mut sf = SearchFilter::default();
2907        for term in &self.terms {
2908            match term {
2909                FilterTerm::SourceType(s) => sf.source_type = Some(s.clone()),
2910                FilterTerm::Kind(k) => sf.kind = Some(k.clone()),
2911                FilterTerm::CreatedAfter(c) => sf.created_after = Some(*c),
2912                FilterTerm::Status(s) => sf.status = Some(s.clone()),
2913                FilterTerm::Json(_) => {
2914                    return Err(EngineError::InvalidFilter {
2915                        reason: "arbitrary json-path predicate not supported on search_filtered; \
2916                                 it would require a post-KNN json_extract that defeats the \
2917                                 indexed pre-KNN filter (ADR-0.8.11 D3 no-demotion guarantee)"
2918                            .to_string(),
2919                    });
2920                }
2921            }
2922        }
2923        Ok(sf)
2924    }
2925
2926    /// Backend dispatch for `read.list` (canonical_nodes — `json_extract`). The
2927    /// full set resolves here. Returns:
2928    /// - `Ok(Some(preds))` — the implicit-AND [`Predicate`] list to run; or
2929    /// - `Ok(None)` — a constant-folded **guaranteed-empty** result (a `Kind` or
2930    ///   `SourceType` term that cannot match this partition), so the caller
2931    ///   returns an empty `Vec` without touching SQL; or
2932    /// - `Err(InvalidFilter)` — a non-allowlisted path (defense-in-depth; the
2933    ///   shorthand lowerings only ever use allowlisted paths).
2934    ///
2935    /// Lowering (D3): `Json(p)` → `p`; `Status(s)` →
2936    /// `json_path_eq("$.status", Text(s))`; `CreatedAfter(b)` →
2937    /// `json_path_compare("$.created_at", Gte, Integer(b))`; `Kind(k)` →
2938    /// constant-fold vs the partition `kind` arg (no-op if equal, empty if not);
2939    /// `SourceType(s)` → constant-fold vs `resolve_source_type(kind)` (no-op if
2940    /// equal, empty otherwise — the column does not exist in `body`).
2941    fn lower_for_read_list(&self, kind: &str) -> Result<Option<Vec<Predicate>>, EngineError> {
2942        let mut preds = Vec::new();
2943        for term in &self.terms {
2944            match term {
2945                FilterTerm::Json(p) => preds.push(p.clone()),
2946                FilterTerm::Status(s) => {
2947                    preds.push(Predicate::json_path_eq("$.status", ScalarValue::Text(s.clone()))?);
2948                }
2949                FilterTerm::CreatedAfter(b) => {
2950                    preds.push(Predicate::json_path_compare(
2951                        "$.created_at",
2952                        ComparisonOp::Gte,
2953                        ScalarValue::Integer(*b),
2954                    )?);
2955                }
2956                FilterTerm::Kind(k) => {
2957                    // Constant-fold vs the partition argument (D1 impl decision).
2958                    if k != kind {
2959                        return Ok(None);
2960                    }
2961                }
2962                FilterTerm::SourceType(s) => {
2963                    // source_type is NOT a canonical_nodes column; it is a pure
2964                    // function of `kind`. Constant-fold (D2/D3).
2965                    match resolve_source_type(kind) {
2966                        Ok(resolved) if resolved == s.as_str() => {}
2967                        _ => return Ok(None),
2968                    }
2969                }
2970            }
2971        }
2972        Ok(Some(preds))
2973    }
2974
2975    /// 0.8.11 Slice 40 — test seam: expose the vec0 backend dispatch so the
2976    /// unification suite can pin the typed-rejection (RED→GREEN) and that a
2977    /// metadata-only Filter lowers losslessly. Returns the lowered
2978    /// [`SearchFilter`] (or `InvalidFilter` for a `Json` term).
2979    #[doc(hidden)]
2980    pub fn to_search_filter_for_test(&self) -> Result<SearchFilter, EngineError> {
2981        self.to_search_filter()
2982    }
2983
2984    /// 0.8.11 Slice 40 — test seam: expose the `read.list` backend lowering so
2985    /// the unification suite can pin total dispatch incl. the `SourceType`/`Kind`
2986    /// constant-folds. `Ok(None)` == constant-folded-empty.
2987    #[doc(hidden)]
2988    pub fn lower_for_read_list_for_test(
2989        &self,
2990        kind: &str,
2991    ) -> Result<Option<Vec<Predicate>>, EngineError> {
2992        self.lower_for_read_list(kind)
2993    }
2994}
2995
2996/// G11 (Slice 15) — a document sent to a BYO-LLM extraction harness via
2997/// [`Engine::ingest_with_extractor`].
2998#[derive(Clone, Debug)]
2999pub struct ExtractDocument {
3000    /// Stable opaque identifier for this document. Used as `source_id` on
3001    /// ingested edges and for provenance tracking.
3002    pub source_doc_id: String,
3003    /// Full text body of the document to extract entities and relationships from.
3004    pub body: String,
3005}
3006
3007/// G11 (Slice 15) — receipt returned by [`Engine::ingest_with_extractor`].
3008#[derive(Clone, Debug, Default)]
3009pub struct IngestWithExtractorReceipt {
3010    /// Number of `canonical_nodes` rows written (new entity insertions; skipped
3011    /// for entities that already have a matching active logical_id).
3012    pub nodes_written: u64,
3013    /// Number of `canonical_edges` rows written (new fact-edge insertions;
3014    /// superseded prior edges are ALSO counted as rows written).
3015    pub edges_written: u64,
3016    /// Number of documents processed (including no-facts documents).
3017    pub docs_processed: u64,
3018}
3019
3020/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one (subject-entity, relation) axis to
3021/// consolidate via [`Engine::consolidate_with_provider`]. FathomDB assembles the
3022/// competing fact-edge cluster for this axis DETERMINISTICALLY (CPU-only, no
3023/// LLM) by querying active `canonical_edges` where `from_id = subject_logical_id`
3024/// AND `kind = relation`.
3025#[derive(Clone, Debug)]
3026pub struct ConsolidateAxis {
3027    /// Stable `logical_id` of the subject entity (edge `from_id`).
3028    pub subject_logical_id: String,
3029    /// The relation/edge `kind` whose competing fact-edges form the cluster.
3030    pub relation: String,
3031}
3032
3033/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one competing fact-edge in a candidate
3034/// cluster sent to the consolidation harness. Assembled deterministically from
3035/// `canonical_edges`; sent to the harness as the request payload; the harness's
3036/// verdict references edges back by `edge_ref` (the edge's stable `logical_id`).
3037#[derive(Clone, Debug)]
3038pub struct ConsolidateCandidateEdge {
3039    /// The edge's stable `logical_id` — the ref the harness uses in its verdict.
3040    pub edge_ref: String,
3041    /// The fact/relationship text (never rewritten by consolidation — §2.1).
3042    pub body: Option<String>,
3043    /// Event valid-time as INTEGER epoch seconds (UTC), if known.
3044    ///
3045    /// TC-33: epoch seconds, NOT ISO-8601. ISO-8601 lives only on the BYO-LLM
3046    /// extractor wire; `normalize_extractor_timestamp` is the one boundary.
3047    pub t_valid: Option<i64>,
3048    /// Event invalid-time as INTEGER epoch seconds (UTC), if already
3049    /// invalidated. `None` = still valid.
3050    pub t_invalid: Option<i64>,
3051    /// Extraction confidence ∈ [0.0, 1.0], if known.
3052    pub confidence: Option<f64>,
3053    /// Provenance: originating document id.
3054    pub source_doc_id: Option<String>,
3055    /// Provenance: extractor model id from the original BYO-LLM ingest.
3056    pub extractor_model_id: Option<String>,
3057}
3058
3059/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — receipt returned by
3060/// [`Engine::consolidate_with_provider`]. Consolidation records supersession /
3061/// recency METADATA only (§2.1): edge bodies are never rewritten and no row is
3062/// ever deleted, so these counts describe metadata transitions, not content
3063/// changes.
3064#[derive(Clone, Debug, Default)]
3065pub struct ConsolidateReceipt {
3066    /// Number of (subject, relation) axes with a non-empty cluster that were
3067    /// dispatched to the harness.
3068    pub clusters_processed: u64,
3069    /// Number of candidate edges presented across all clusters.
3070    pub edges_examined: u64,
3071    /// Number of edges the harness ruled `keep` (no metadata change).
3072    pub edges_kept: u64,
3073    /// Number of edges the harness ruled `invalidate` (t_invalid set; row + body
3074    /// preserved).
3075    pub edges_invalidated: u64,
3076    /// Number of edges the harness ruled `supersede`/`merge` (marked superseded
3077    /// via the existing G0 tombstone column; row + body preserved).
3078    pub edges_superseded: u64,
3079}
3080
3081/// 0.8.20 Slice 5c (R-20-E3) — the provenance of a canonical row: which source
3082/// document it is attributable to, and therefore what `excise_source` must erase
3083/// when that source is withdrawn.
3084///
3085/// **Why a newtype and not `Option<String>`.** Erasure runs through provenance:
3086/// a row whose `source_id` is NULL is reachable by NO `excise_source` call and
3087/// is therefore **un-erasable**. Before 0.8.20 the public `PreparedWrite`
3088/// carried `source_id: Option<String>`, so a caller could express "no
3089/// provenance" and silently create such a row. A *runtime* rejection would not
3090/// have closed this: the facade crate re-exports `PreparedWrite` and
3091/// `Engine::write` is `pub`, so a caller can build the value directly and skip
3092/// any validation the engine performs. Replacing the field's type is what makes
3093/// the absence of provenance **inexpressible** rather than merely rejected —
3094/// the guarantee is enforced by `rustc`, not by a branch. `tests/ui/` in the
3095/// facade crate holds the compile-fail witness.
3096///
3097/// **This is a BREAKING change**, shipped ON by default as part of the 0.8.20
3098/// coordinated breaking-pair release. There is deliberately no compatibility
3099/// shim and no deprecation window: a shim would re-open the hole it closes.
3100///
3101/// **Reserved namespace.** Ids beginning with `_` belong to the engine and are
3102/// rejected by [`SourceId::new`]. Two are currently minted internally:
3103///
3104/// * [`SourceId::ENGINE_PREFIX`] (`_engine:`) — rows the engine derives for
3105///   itself (EXP-S coverage/graph substrate rows), which never pass through
3106///   `PreparedWrite` (design §4 item 6).
3107/// * [`SourceId::LEGACY_PRE_0_8_20`] (`_legacy:pre-0.8.20`) — stamped by schema
3108///   migration step 21 onto pre-0.8.20 rows that were stored with NULL
3109///   provenance, so they become erasable (R-20-E8). **Gated to UNGOVERNED rows
3110///   only** (`logical_id IS NULL`); a governed row keeps NULL `source_id` and
3111///   stays `purge`-addressable by its `logical_id` (TC-11 pin).
3112///
3113/// **`source_id` must not be PII.** It survives the erasure it authorises: the
3114/// `excise_source` audit row in `operational_mutations` records it verbatim, and
3115/// while 0.8.20 makes that audit row durable (design §2 defect D-A) the rule was
3116/// always that the handle you erase BY must not itself be the thing needing
3117/// erasure. Use an opaque document id, not an email address.
3118#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
3119pub struct SourceId(String);
3120
3121impl SourceId {
3122    /// Reserved prefix for engine-derived rows (design §4 item 6).
3123    pub const ENGINE_PREFIX: &'static str = "_engine:";
3124
3125    /// Reserved provenance stamped by schema migration step 21 onto pre-0.8.20
3126    /// UNGOVERNED rows that were stored with NULL provenance (R-20-E8).
3127    pub const LEGACY_PRE_0_8_20: &'static str = "_legacy:pre-0.8.20";
3128
3129    /// The single public constructor. Rejects the two ways a caller could
3130    /// express "effectively no provenance":
3131    ///
3132    /// * an empty or whitespace-only id — it names no source, and
3133    ///   `excise_source` already refuses the empty string, so such a row would
3134    ///   be un-erasable in practice;
3135    /// * an id in the engine's reserved `_`-prefixed namespace — a caller who
3136    ///   could mint `_legacy:pre-0.8.20` could hide rows among the migration's
3137    ///   back-filled ones, or mint `_engine:` rows that read as engine
3138    ///   substrate.
3139    ///
3140    /// # Errors
3141    ///
3142    /// [`EngineError::WriteValidation`] for either rejection above.
3143    pub fn new(id: impl Into<String>) -> Result<Self, EngineError> {
3144        let id = id.into();
3145        if id.trim().is_empty() || id.starts_with('_') {
3146            return Err(EngineError::WriteValidation);
3147        }
3148        Ok(Self(id))
3149    }
3150
3151    /// Mint a reserved `_engine:*` provenance for an engine-derived row. Crate
3152    /// -internal by construction: the reserved namespace is exactly what
3153    /// [`SourceId::new`] refuses, so a caller cannot reach this spelling.
3154    pub(crate) fn engine_derived(role: &str) -> Self {
3155        Self(format!("{}{role}", Self::ENGINE_PREFIX))
3156    }
3157
3158    /// The on-disk `source_id` text.
3159    #[must_use]
3160    pub fn as_str(&self) -> &str {
3161        &self.0
3162    }
3163
3164    /// Consume into the owned on-disk text.
3165    #[must_use]
3166    pub fn into_string(self) -> String {
3167        self.0
3168    }
3169}
3170
3171impl AsRef<str> for SourceId {
3172    fn as_ref(&self) -> &str {
3173        &self.0
3174    }
3175}
3176
3177impl Display for SourceId {
3178    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3179        f.write_str(&self.0)
3180    }
3181}
3182
3183impl TryFrom<String> for SourceId {
3184    type Error = EngineError;
3185
3186    fn try_from(value: String) -> Result<Self, Self::Error> {
3187        Self::new(value)
3188    }
3189}
3190
3191impl TryFrom<&str> for SourceId {
3192    type Error = EngineError;
3193
3194    fn try_from(value: &str) -> Result<Self, Self::Error> {
3195        Self::new(value)
3196    }
3197}
3198
3199/// Batch input shape for [`Engine::write`].
3200///
3201/// Marked `#[non_exhaustive]` per ADR-0.6.0-prepared-write-shape; new
3202/// entity variants land in 0.6.x without a major bump. Adding fields to
3203/// existing variants remains a binding-coordination change.
3204#[non_exhaustive]
3205#[derive(Clone, Debug, PartialEq)]
3206pub enum PreparedWrite {
3207    Node {
3208        kind: String,
3209        body: String,
3210        /// REQ-026 / AC-028 / AC-042 recovery seam, made **structurally
3211        /// mandatory** in 0.8.20 (R-20-E3). Was `Option<String>`; a `None`
3212        /// landed NULL on disk and produced a row no `excise_source` call could
3213        /// reach. See [`SourceId`] for why the fix is a type change rather than
3214        /// a validation check.
3215        source_id: SourceId,
3216        /// G0 (Slice 15) — stable cross-re-ingestion identity. `Some(id)`
3217        /// makes this write a transaction-time supersession of the prior
3218        /// active version of `(logical_id, kind)` (tombstone-then-insert).
3219        /// `None` is the legacy/own-identity default: a plain insert with a
3220        /// NULL `logical_id` (NULL-safe — never collides with other NULLs).
3221        logical_id: Option<String>,
3222        /// OPP-12 Phase-1 (0.8.19 Slice 5) — the create-time existence state.
3223        /// `InitialState::Active` (the [`Default`]) is the back-compat default and
3224        /// lands `state = 'active'` on disk (value-identical to the migration
3225        /// step-20 column DEFAULT). `InitialState::Pending` creates a quarantined
3226        /// node excluded from default retrieval. A `deleted`/`purged` node is
3227        /// UNREPRESENTABLE at create time (the [`InitialState`] type is the typed
3228        /// rejection) — those states are reachable only via the Slice-10
3229        /// `transition`/`purge` verbs.
3230        state: InitialState,
3231        /// OPP-12 Phase-1 (0.8.19 Slice 5) — advisory cause for the create-time
3232        /// `state` (e.g. the quarantine cause for a `pending` node), stored
3233        /// verbatim in `canonical_nodes.reason`. Engine never interprets it. `None`
3234        /// lands NULL (the back-compat default).
3235        reason: Option<String>,
3236        /// 0.8.20 Slice 15b (TC-34) — world-time validity window, INCLUSIVE lower
3237        /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded below.
3238        ///
3239        /// Slice 10b added the `valid_from`/`valid_until` columns, the [`ReadView`]
3240        /// validity predicate and [`Engine::crossed_boundary_since`] but NO writer,
3241        /// so a window could only be authored with raw SQL. These two fields are
3242        /// that writer. They are deliberately FIELDS rather than a new verb,
3243        /// exactly as [`PreparedWrite::Edge`] already carries `t_valid`/`t_invalid`:
3244        /// the governed command surface is unchanged.
3245        ///
3246        /// The pair is validated together — see `valid_until`.
3247        valid_from: Option<i64>,
3248        /// 0.8.20 Slice 15b (TC-34) — world-time validity window, EXCLUSIVE upper
3249        /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded above.
3250        ///
3251        /// The window is half-open `[valid_from, valid_until)`, matching the read
3252        /// predicate in `ReadView::validity_sql` exactly. Because it is half-open,
3253        /// a pair with `valid_from >= valid_until` describes an EMPTY window that no
3254        /// instant can ever satisfy — so [`Engine::write`] refuses it with
3255        /// [`EngineError::WriteValidation`] rather than storing a row that no
3256        /// default read could ever return. A ONE-SIDED window is never empty and is
3257        /// never refused, however extreme its single bound.
3258        ///
3259        /// **BREAKING (0.8.20 Slice 22, decision #18).** This refusal used to be
3260        /// [`EngineError::InvalidArgument`] NAMING both bounds. It is now the
3261        /// message-less `WriteValidation` unit variant — the one family the
3262        /// taxonomy of record assigns to a malformed submitted write SHAPE — so
3263        /// **the offending bounds are no longer carried in the error**. A caller
3264        /// that parsed them out must validate the pair before calling.
3265        valid_until: Option<i64>,
3266    },
3267    Edge {
3268        kind: String,
3269        from: String,
3270        to: String,
3271        /// REQ-026 / AC-028 / AC-042 recovery seam — see Node. Structurally
3272        /// mandatory since 0.8.20 (R-20-E3).
3273        source_id: SourceId,
3274        /// G0 (Slice 15) — see Node. Supersession semantics are identical on
3275        /// edges (keyed by `(logical_id, kind)`).
3276        logical_id: Option<String>,
3277        /// G11 (Slice 15) — the fact/relationship text. When `Some`, triggers
3278        /// FTS projection into `search_index_edges` and vector projection via
3279        /// the projection scheduler (kind `"edge_fact"`). Also triggers
3280        /// invalidate-not-accumulate on `(from_id, to_id, kind)`.
3281        body: Option<String>,
3282        /// G11 (Slice 15) — event valid-time. NULL = unknown / still valid.
3283        ///
3284        /// **TC-33 (HITL-RATIFIED 2026-07-21): INTEGER epoch seconds (UTC), not
3285        /// ISO-8601.** This is the GOVERNED SDK WRITE SURFACE, which carries the
3286        /// same representation as storage. ISO-8601 survives ONLY on the BYO-LLM
3287        /// extractor wire (`fathomdb.extract.v1`), where
3288        /// `normalize_extractor_timestamp` converts it with hard rejection.
3289        t_valid: Option<i64>,
3290        /// G11 (Slice 15) — event invalid-time. NULL = still valid.
3291        ///
3292        /// **TC-33: INTEGER epoch seconds (UTC)** — see `t_valid`. The
3293        /// NULL-means-still-valid semantic is load-bearing and unchanged, which
3294        /// is why the schema pins the type with a `typeof` CHECK rather than
3295        /// `NOT NULL`.
3296        t_invalid: Option<i64>,
3297        /// G11 (Slice 15) — extraction confidence ∈ [0.0, 1.0]. NULL for
3298        /// non-BYO-LLM-ingested edges.
3299        confidence: Option<f64>,
3300        /// G11 (Slice 15) — opaque model/provider id from the BYO-LLM harness
3301        /// `ready.model` field. NULL for non-BYO-LLM edges.
3302        extractor_model_id: Option<String>,
3303        /// R3 (Slice 30, SCHEMA-GATE-1, HITL-SIGNED 2026-06-13) — set when the
3304        /// ELPS extractor defaulted this edge's `t_valid` to `created_at` rather
3305        /// than deriving it from the document text. Such edges have untrustworthy
3306        /// event times and are excluded from graph-arm BFS temporal queries.
3307        /// `None`/`false` = not a fallback; `Some(true)` = fallback.
3308        temporal_fallback: Option<bool>,
3309    },
3310    OpStore {
3311        collection: String,
3312        record_key: String,
3313        schema_id: Option<String>,
3314        body: String,
3315    },
3316    AdminSchema {
3317        name: String,
3318        kind: String,
3319        schema_json: String,
3320        retention_json: String,
3321    },
3322}
3323
3324/// EXP-S (0.8.14 Slice 5, D1) — structural-role tag for a canonical row.
3325///
3326/// A SEPARATE axis from the doc-type `kind` (email/article/paper/meeting/
3327/// note/todo/doc/edge_fact): `row_kind` describes *what structural role* a row
3328/// plays in the "one store, many indexes" substrate, not what document type it
3329/// carries. Stored in `canonical_nodes.row_kind` (schema migration step 16).
3330///
3331/// `Leaf` is the default (a normal record; every existing/normal write is a
3332/// leaf — back-compat preserving). `Coverage` = coverage/summary rows;
3333/// `Graph` = graph structural rows. Engine-internal in 0.8.14 — there is NO
3334/// public Py/TS SDK surface for `row_kind` this release (`Leaf` for all normal
3335/// writes; `Coverage`/`Graph` are set only by internal paths). Cross-binding
3336/// parity (X1) is a Slice-40 concern.
3337#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3338pub enum RowKind {
3339    Leaf,
3340    Coverage,
3341    Graph,
3342}
3343
3344impl RowKind {
3345    /// On-disk `canonical_nodes.row_kind` spelling. Must match the migration
3346    /// step-16 `DEFAULT 'leaf'` and the schema vocabulary (D1).
3347    #[must_use]
3348    pub fn as_str(self) -> &'static str {
3349        match self {
3350            RowKind::Leaf => "leaf",
3351            RowKind::Coverage => "coverage",
3352            RowKind::Graph => "graph",
3353        }
3354    }
3355}
3356
3357/// OPP-12 record-lifecycle Phase-1 (0.8.19 Slice 5) — the existence axis.
3358///
3359/// One mutually-exclusive typed enum stored as TEXT in the `canonical_nodes.state`
3360/// column (schema migration step-20). Semantics (design §2 / plan §1):
3361///   `Pending` = present + versioned but NOT admitted to default retrieval
3362///               (quarantine / promotion gate);
3363///   `Active`  = admitted to default retrieval (the shipped-corpus default);
3364///   `Deleted` = soft-deleted, retained + recoverable, excluded from default
3365///               reads, stays indexed behind the flag;
3366///   `Purged`  = terminal, physically erased.
3367/// `Deleted`/`Purged` are reachable only through the Phase-2/Slice-10
3368/// `transition`/`purge` verbs — they can NEVER be a create-time state (see
3369/// [`InitialState`]).
3370#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3371pub enum LifecycleState {
3372    Pending,
3373    Active,
3374    Deleted,
3375    Purged,
3376}
3377
3378impl LifecycleState {
3379    /// On-disk `canonical_nodes.state` spelling. Must match the migration step-20
3380    /// `DEFAULT 'active'` and the `state = 'active'` default-read exclusion.
3381    #[must_use]
3382    pub fn as_str(self) -> &'static str {
3383        match self {
3384            LifecycleState::Pending => "pending",
3385            LifecycleState::Active => "active",
3386            LifecycleState::Deleted => "deleted",
3387            LifecycleState::Purged => "purged",
3388        }
3389    }
3390
3391    /// Parse the on-disk spelling back into the typed enum. `None` for any value
3392    /// outside the closed vocabulary (a corrupt/foreign `state`).
3393    #[must_use]
3394    pub fn from_str_opt(value: &str) -> Option<Self> {
3395        match value {
3396            "pending" => Some(LifecycleState::Pending),
3397            "active" => Some(LifecycleState::Active),
3398            "deleted" => Some(LifecycleState::Deleted),
3399            "purged" => Some(LifecycleState::Purged),
3400            _ => None,
3401        }
3402    }
3403
3404    /// OPP-12 Phase-1 (0.8.19 Slice 10) — the target states legally reachable from
3405    /// `self` via the `transition` VERB (design §2 legal-transition table). This
3406    /// is the verb-specific enumeration reported by `IllegalTransitionError.legal`:
3407    ///   `Pending` → `[Active, Deleted]`   (promote / reject)
3408    ///   `Active`  → `[Deleted]`           (soft-delete)
3409    ///   `Deleted` → `[Active]`            (undelete)
3410    ///   `Purged`  → `[]`                  (terminal; nothing is reachable)
3411    /// `Purged` is DELIBERATELY excluded even from `Deleted`: reaching `purged` is
3412    /// the `purge` verb's job (see [`Engine::purge`]), NOT a legal `transition`
3413    /// target, so reporting it here would mislead a caller into thinking
3414    /// `transition(deleted → purged)` is legal when it is not. Likewise `Pending`
3415    /// is create-time-only and is never a `transition` target. Derived directly
3416    /// from [`is_legal_transition_move`] so this can never drift from the table.
3417    #[must_use]
3418    pub fn legal_next_states(self) -> Vec<LifecycleState> {
3419        [
3420            LifecycleState::Pending,
3421            LifecycleState::Active,
3422            LifecycleState::Deleted,
3423            LifecycleState::Purged,
3424        ]
3425        .into_iter()
3426        .filter(|&to| is_legal_transition_move(self, to))
3427        .collect()
3428    }
3429}
3430
3431/// OPP-12 Phase-1 (0.8.19 Slice 10) — whether `(from, to)` is one of the four
3432/// legal `transition`-verb moves (design §2 table): `pending→active` (promote),
3433/// `pending→deleted` (reject), `active→deleted` (soft-delete), `deleted→active`
3434/// (undelete). Every other pair — self-loops, any move to `Purged` (purge-only)
3435/// or `Pending` (create-only), or from `Purged` — is illegal via `transition`.
3436#[must_use]
3437fn is_legal_transition_move(from: LifecycleState, to: LifecycleState) -> bool {
3438    matches!(
3439        (from, to),
3440        (LifecycleState::Pending, LifecycleState::Active)
3441            | (LifecycleState::Pending, LifecycleState::Deleted)
3442            | (LifecycleState::Active, LifecycleState::Deleted)
3443            | (LifecycleState::Deleted, LifecycleState::Active)
3444    )
3445}
3446
3447/// OPP-12 Phase-1 (0.8.19 Slice 5) — the CREATE-TIME subset of [`LifecycleState`].
3448///
3449/// A write can only bring a node into existence as `Pending` or `Active` (design
3450/// §2 / gap-6). You CANNOT create a `Deleted`/`Purged` node — those states are
3451/// reachable only via the `transition`/`purge` verbs (Slice 10). Making the
3452/// create-time surface a separate two-variant type is the TYPED rejection: a
3453/// `deleted`/`purged` create is simply unrepresentable in the Rust API (the SDK
3454/// bindings map an out-of-subset string to a typed write-validation error).
3455#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
3456pub enum InitialState {
3457    Pending,
3458    /// The back-compat default: every pre-lifecycle write lands `Active`, matching
3459    /// the migration step-20 `DEFAULT 'active'`.
3460    #[default]
3461    Active,
3462}
3463
3464impl InitialState {
3465    /// On-disk `canonical_nodes.state` spelling for a create-time state.
3466    #[must_use]
3467    pub fn as_str(self) -> &'static str {
3468        match self {
3469            InitialState::Pending => "pending",
3470            InitialState::Active => "active",
3471        }
3472    }
3473
3474    /// The full [`LifecycleState`] this create-time state corresponds to.
3475    #[must_use]
3476    pub fn to_lifecycle_state(self) -> LifecycleState {
3477        match self {
3478            InitialState::Pending => LifecycleState::Pending,
3479            InitialState::Active => LifecycleState::Active,
3480        }
3481    }
3482
3483    /// Parse a caller-supplied create-time `state` string into the create-time
3484    /// subset. `Some(state)` for `"pending"`/`"active"`; `None` for `"deleted"`,
3485    /// `"purged"`, or any unknown value — the SDK bindings turn `None` into a
3486    /// typed write-validation rejection (you cannot CREATE a deleted/purged node).
3487    #[must_use]
3488    pub fn from_create_str(value: &str) -> Option<Self> {
3489        match value {
3490            "pending" => Some(InitialState::Pending),
3491            "active" => Some(InitialState::Active),
3492            _ => None,
3493        }
3494    }
3495}
3496
3497/// F5 (0.8.14 Slice 10) — per-field BM25F weights for the `search_index_v2`
3498/// multi-column FTS index. One weight per indexed field
3499/// (`kind`/`body`/`status`), applied as the field's contribution multiplier in
3500/// the BM25F weighted-term-frequency accumulation.
3501///
3502/// The default is uniform (`1.0` each) — the "unweighted" baseline the R-F5-1
3503/// acceptance test contrasts against. Boosting a field (e.g. `kind`) makes a
3504/// match in that field outrank a same-strength match in a lower-weighted field.
3505/// Engine-internal for 0.8.14: there is NO public Py/TS SDK surface for these
3506/// tunables this release (cross-binding parity is a Slice-40/X1 concern).
3507#[derive(Clone, Copy, Debug, PartialEq)]
3508pub struct Bm25fFieldWeights {
3509    pub kind: f64,
3510    pub body: f64,
3511    pub status: f64,
3512}
3513
3514impl Default for Bm25fFieldWeights {
3515    fn default() -> Self {
3516        Self { kind: 1.0, body: 1.0, status: 1.0 }
3517    }
3518}
3519
3520/// F5 (0.8.14 Slice 10) — the compiled BM25F query plan for the fielded lexical
3521/// arm (`ADR-0.8.1` §3.2 `BM25fQueryPlan`). Carries the tunable per-field
3522/// `weights` and the tunable length-normalization `b` (and the term-saturation
3523/// `k1`).
3524///
3525/// NOTE on `b`: SQLite FTS5's built-in `bm25()` auxiliary function pins its
3526/// internal `k1`/`b` and exposes ONLY per-column weights — it cannot express a
3527/// tunable `b`. So the score is computed in-engine (a textbook BM25F over the
3528/// FTS5-recalled candidates) rather than delegated to the built-in `bm25()`:
3529/// that is what makes `b` (and `k1`) genuinely tunable here, not a dead
3530/// parameter. The `search_index_v2` FTS5 index is still load-bearing — it does
3531/// the candidate recall (`MATCH`) that the scorer then ranks.
3532///
3533/// Defaults match Robertson/SQLite BM25 (`b = 0.75`, `k1 = 1.2`) with uniform
3534/// field weights. Engine-internal for 0.8.14 (no SDK surface).
3535#[derive(Clone, Copy, Debug, PartialEq)]
3536pub struct Bm25fQueryPlan {
3537    pub weights: Bm25fFieldWeights,
3538    pub b: f64,
3539    pub k1: f64,
3540}
3541
3542impl Default for Bm25fQueryPlan {
3543    fn default() -> Self {
3544        Self { weights: Bm25fFieldWeights::default(), b: 0.75, k1: 1.2 }
3545    }
3546}
3547
3548/// Snapshot of engine-internal counters returned by [`Engine::counters`].
3549///
3550/// Public key set is owned by `dev/design/lifecycle.md` § Public key set
3551/// and locked by AC-004a. Reading a snapshot is non-perturbing per
3552/// AC-004c. The 0.6.0 surface exposes exactly these seven fields.
3553#[derive(Clone, Debug, Default, Eq, PartialEq)]
3554pub struct CounterSnapshot {
3555    pub queries: u64,
3556    pub writes: u64,
3557    pub write_rows: u64,
3558    pub errors_by_code: BTreeMap<String, u64>,
3559    pub admin_ops: u64,
3560    pub cache_hit: u64,
3561    pub cache_miss: u64,
3562}
3563
3564pub use lifecycle::Subscription;
3565
3566/// Stable corruption-on-open detail carried by
3567/// [`EngineOpenError::Corruption`].
3568///
3569/// Layout owned by `dev/design/errors.md` § Corruption detail owner.
3570#[derive(Clone, Debug, Eq, PartialEq)]
3571pub struct CorruptionDetail {
3572    pub kind: CorruptionKind,
3573    pub stage: OpenStage,
3574    pub locator: CorruptionLocator,
3575    pub recovery_hint: RecoveryHint,
3576}
3577
3578/// Open-path corruption category.
3579///
3580/// 0.6.0 emits exactly the four members below; per
3581/// `dev/design/errors.md` § Engine.open corruption table, doctor-only
3582/// finding codes are not represented here.
3583#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3584pub enum CorruptionKind {
3585    WalReplayFailure,
3586    HeaderMalformed,
3587    SchemaInconsistent,
3588    EmbedderIdentityDrift,
3589}
3590
3591/// `Engine.open` stage at which corruption was detected.
3592///
3593/// Per ADR-0.6.0-corruption-open-behavior, `LockAcquisition` is intentionally
3594/// not a member here; lock contention is surfaced via
3595/// [`EngineOpenError::DatabaseLocked`].
3596#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3597pub enum OpenStage {
3598    WalReplay,
3599    HeaderProbe,
3600    SchemaProbe,
3601    EmbedderIdentity,
3602}
3603
3604/// Locator pointing at the corrupted region of the database file.
3605///
3606/// Variant set owned by `dev/design/errors.md` § CorruptionLocator
3607/// ownership. `OpaqueSqliteError` is the required fallback when SQLite
3608/// surfaces corruption without a usable structured locator.
3609#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3610pub enum CorruptionLocator {
3611    FileOffset { offset: u64 },
3612    PageId { page: u32 },
3613    TableRow { table: &'static str, rowid: i64 },
3614    Vec0ShadowRow { partition: &'static str, rowid: i64 },
3615    MigrationStep { from: u32, to: u32 },
3616    OpaqueSqliteError { sqlite_extended_code: i32 },
3617}
3618
3619/// Recovery dispatch surface attached to a corruption detail.
3620///
3621/// `code` is the stable dispatch key used by bindings and doctor output;
3622/// `doc_anchor` points at the documentation section that explains the
3623/// remediation path.
3624#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3625pub struct RecoveryHint {
3626    pub code: &'static str,
3627    pub doc_anchor: &'static str,
3628}
3629
3630#[derive(Clone, Debug, Eq, PartialEq)]
3631pub enum EngineOpenError {
3632    DatabaseLocked {
3633        holder_pid: Option<u32>,
3634    },
3635    Corruption(CorruptionDetail),
3636    IncompatibleSchemaVersion {
3637        seen: u32,
3638        supported: u32,
3639    },
3640    MigrationError {
3641        schema_version_before: u32,
3642        schema_version_current: u32,
3643        step_id: u32,
3644    },
3645    EmbedderIdentityMismatch {
3646        stored: EmbedderIdentity,
3647        supplied: EmbedderIdentity,
3648    },
3649    EmbedderDimensionMismatch {
3650        stored: u32,
3651        supplied: u32,
3652    },
3653    /// Embedder runtime returned a typed error during `Engine::open`.
3654    Embedder(RuntimeEmbedderError),
3655    Io {
3656        message: String,
3657    },
3658}
3659
3660/// Caller-facing selector for the embedder used by an opened engine
3661/// (`dev/design/embedder.md` §0).
3662#[derive(Clone)]
3663pub enum EmbedderChoice {
3664    /// Use the engine's default embedder. With the `default-embedder`
3665    /// Cargo feature enabled, this materializes a `CandleBgeEmbedder`
3666    /// via the EU-3 loader at `Engine::open`; on first use the loader
3667    /// downloads pinned bge-small-en-v1.5 weights from HuggingFace per
3668    /// `ADR-0.7.1-default-embedder-weight-fetch`. Without the feature,
3669    /// this returns `EmbedderError::Failed` directing the caller to
3670    /// rebuild with `--features default-embedder` or supply
3671    /// `EmbedderChoice::Caller`.
3672    Default,
3673    /// Caller supplies the embedder instance. The supplied embedder's
3674    /// `identity()` becomes the workspace's default-profile identity.
3675    Caller(Arc<dyn Embedder>),
3676    /// No embedder configured. Engine opens; subsequent vector writes
3677    /// fail with `EngineError::EmbedderNotConfigured`. Useful for
3678    /// read-only or canonical-only flows.
3679    None,
3680}
3681
3682impl Display for EngineOpenError {
3683    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3684        match self {
3685            Self::DatabaseLocked { holder_pid } => match holder_pid {
3686                Some(pid) => write!(f, "database is locked by process {pid}"),
3687                None => write!(f, "database is locked by another engine instance"),
3688            },
3689            Self::Corruption(detail) => {
3690                write!(
3691                    f,
3692                    "engine corruption at {:?} stage: {}",
3693                    detail.stage, detail.recovery_hint.code
3694                )
3695            }
3696            Self::IncompatibleSchemaVersion { seen, supported } => write!(
3697                f,
3698                "database schema version {seen} is incompatible with supported version {supported}"
3699            ),
3700            Self::MigrationError {
3701                schema_version_before,
3702                schema_version_current,
3703                step_id,
3704            } => write!(
3705                f,
3706                "schema migration failed at step {step_id}; schema version remained between {schema_version_before} and {schema_version_current}"
3707            ),
3708            Self::EmbedderIdentityMismatch { stored, supplied } => write!(
3709                f,
3710                "embedder identity mismatch: stored {}@{}, supplied {}@{}",
3711                stored.name, stored.revision, supplied.name, supplied.revision,
3712            ),
3713            Self::EmbedderDimensionMismatch { stored, supplied } => write!(
3714                f,
3715                "embedder vector dimension mismatch: stored {stored}, supplied {supplied}",
3716            ),
3717            Self::Embedder(err) => match err {
3718                RuntimeEmbedderError::Timeout => write!(f, "embedder timeout during open"),
3719                RuntimeEmbedderError::Failed { message } => {
3720                    write!(f, "embedder failure during open: {message}")
3721                }
3722            },
3723            Self::Io { message } => write!(f, "database I/O error: {message}"),
3724        }
3725    }
3726}
3727
3728impl Error for EngineOpenError {}
3729
3730#[derive(Clone, Debug, Eq, PartialEq)]
3731pub enum EngineError {
3732    Storage,
3733    Projection,
3734    Vector,
3735    Embedder,
3736    EmbedderNotConfigured,
3737    KindNotVectorIndexed,
3738    EmbedderDimensionMismatch {
3739        expected: u32,
3740        actual: u32,
3741    },
3742    Scheduler,
3743    OpStore,
3744    WriteValidation,
3745    SchemaValidation,
3746    Overloaded,
3747    Closing,
3748    /// G11 (Slice 15) — BYO-LLM extractor subprocess error (protocol mismatch,
3749    /// spawn failure, or harness-returned error code).
3750    Extractor,
3751    /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM consolidation provider
3752    /// error (protocol mismatch, spawn/handshake failure, task not advertised in
3753    /// `supported_tasks`, or a malformed/out-of-cluster verdict). Rides the SAME
3754    /// `provider_session` transport as `Extractor`; this is the task-specific leaf.
3755    Consolidator,
3756    /// G4 (Slice 35) — filter predicate construction error: non-allowlisted
3757    /// path or invalid filter argument. NOT a panic — returned as a typed error
3758    /// from [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`].
3759    InvalidFilter {
3760        reason: String,
3761    },
3762    /// Slice 20 (G5/G6) — an argument is out of the accepted range (e.g.
3763    /// `depth > 3` for graph traversal). The `msg` field carries a
3764    /// human-readable explanation; it is intentionally non-exhaustive so the
3765    /// binding layer can forward it as a `ValueError` / `TypeError`.
3766    InvalidArgument {
3767        msg: String,
3768    },
3769    /// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
3770    /// self-check re-embedded the 45 committed probes with the live backend and
3771    /// found a divergence beyond the frozen D4 floor (a Phase-1 mean-centered
3772    /// `embedding_bin` sign flip, OR a Phase-2 un-centered L2 distance over
3773    /// `VECTOR_EQUIVALENCE_L2_EPSILON`). `Engine::open` succeeded into a degraded
3774    /// state (`dense_disabled = true`); this query-time error is raised at the
3775    /// single choke point [`Engine::search_inner_with_stats`] BEFORE any embedding
3776    /// / vector SQL / graph seeding / CE rerank, refusing EVERY vector-dependent
3777    /// arm (`search`, `search_expand`, explain/rerank, graph-arm). The explicit
3778    /// text-only/FTS-only path ([`Engine::search_text_only`]) stays serviceable.
3779    /// Sibling of the open-time `EngineOpenError::EmbedderIdentityMismatch`; per
3780    /// ADR-0.8.18 codex R2 U1-1 the refusal surfaces as an `EngineError` (queries
3781    /// never surface `EngineOpenError`). `reason` carries a human-readable summary.
3782    VectorEquivalenceMismatch {
3783        reason: String,
3784    },
3785    /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle `transition`/`purge` move
3786    /// that the engine-enforced legal-transition table (design §2) forbids.
3787    /// Raised for an illegal `transition` target (`purged`/`pending` are never
3788    /// `transition` targets; self-loops; a from→to pair not in the table) AND for
3789    /// a `purge` precondition failure (purge is legal only from `deleted`).
3790    /// `from_state`/`to_state` use the FULL, parity-safe field names (S7 — `from`
3791    /// is a Python reserved word); `legal` enumerates the target states reachable
3792    /// from `from_state` in the full state machine.
3793    IllegalTransition {
3794        from_state: LifecycleState,
3795        to_state: LifecycleState,
3796        legal: Vec<LifecycleState>,
3797    },
3798    /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle verb (`transition`/`purge`)
3799    /// was addressed with a non-`Logical` id space (a `Content`/`h:` doc-seeded or
3800    /// `Passage`/`p:` synthetic id). Only the `Logical` (`l:`) space is
3801    /// lifecycle-addressable (design §3); this is a typed refusal, never a panic
3802    /// or a silent no-op. `id_space` carries the offending [`IdSpaceKind`].
3803    NotLifecycleAddressable {
3804        id_space: IdSpaceKind,
3805    },
3806    /// 0.8.20 Slice 5b (R-20-E5, design `0.8.20-slice0-erasure-design.md` §4
3807    /// item 4) — an erasure verb (`purge` / `excise_source` /
3808    /// `excise_collection_record`) deleted its rows but could NOT complete the
3809    /// erasure **at rest**, so it refuses to report success.
3810    ///
3811    /// The motivating case is the write-ahead log. `PRAGMA secure_delete=ON`
3812    /// zeroes pages freed inside the database file, but the erased content also
3813    /// sits in the WAL as committed frames from the ORIGINAL insert: an erasure
3814    /// DELETE appends new frames, it never rewrites old ones. Only a
3815    /// `wal_checkpoint(TRUNCATE)` removes them, and a concurrent reader pinning a
3816    /// WAL snapshot makes that checkpoint return `busy`. After a bounded retry
3817    /// the verb raises THIS error rather than returning `Ok` over erased bytes
3818    /// that are still `grep`-able on disk.
3819    ///
3820    /// **Contract: an erasure verb must never report success on an incomplete
3821    /// erasure.** The row deletions are committed and durable when this is
3822    /// raised; what failed is the at-rest scrub. The remedy is to retry the verb
3823    /// (or `recover --truncate-wal`) once the blocking reader has finished.
3824    /// `stage` names the uncompleted step (e.g. `"wal_checkpoint"`,
3825    /// `"telemetry_redaction"`); `detail` is a human-readable summary.
3826    ErasureIncomplete {
3827        stage: String,
3828        detail: String,
3829    },
3830    /// 0.8.20 Slice 15d (R-20-PR) — `configure_projections` refused an
3831    /// incompatible/DESTRUCTIVE change to an existing projection `name` that was
3832    /// NOT accompanied by an explicit `drop`. Omission from the spec never drops
3833    /// (C3, `api-surface.md:27`); a role REMOVAL or a tokenizer/embedder change
3834    /// on a live projection would silently discard an expensive-to-rebuild
3835    /// resource, so it is refused with the destructive `delta` surfaced. The
3836    /// caller re-issues with `drop: [name]` to consciously rebuild.
3837    ProjectionDestructive {
3838        name: String,
3839        delta: String,
3840    },
3841}
3842
3843impl Display for EngineError {
3844    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3845        match self {
3846            Self::Storage => write!(f, "storage error"),
3847            Self::Projection => write!(f, "projection error"),
3848            Self::Vector => write!(f, "vector error"),
3849            Self::Embedder => write!(f, "embedder error"),
3850            Self::EmbedderNotConfigured => write!(f, "embedder is not configured"),
3851            Self::KindNotVectorIndexed => write!(f, "kind is not configured for vector indexing"),
3852            Self::EmbedderDimensionMismatch { expected, actual } => {
3853                write!(f, "embedder dimension mismatch: expected {expected}, actual {actual}")
3854            }
3855            Self::Scheduler => write!(f, "scheduler error"),
3856            Self::OpStore => write!(f, "op-store error"),
3857            Self::WriteValidation => write!(f, "write validation error"),
3858            Self::SchemaValidation => write!(f, "schema validation error"),
3859            Self::Overloaded => write!(f, "engine overloaded"),
3860            Self::Closing => write!(f, "engine is closing"),
3861            Self::Extractor => write!(f, "extractor error"),
3862            Self::Consolidator => write!(f, "consolidator error"),
3863            Self::InvalidFilter { reason } => write!(f, "invalid filter: {reason}"),
3864            Self::InvalidArgument { msg } => write!(f, "invalid argument: {msg}"),
3865            Self::VectorEquivalenceMismatch { reason } => {
3866                write!(f, "vector-equivalence self-check failed; dense retrieval refused: {reason}")
3867            }
3868            Self::IllegalTransition { from_state, to_state, legal } => {
3869                let legal_list = legal.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
3870                write!(
3871                    f,
3872                    "illegal lifecycle transition {} -> {}; legal targets from {}: [{}]",
3873                    from_state.as_str(),
3874                    to_state.as_str(),
3875                    from_state.as_str(),
3876                    legal_list,
3877                )
3878            }
3879            Self::NotLifecycleAddressable { id_space } => write!(
3880                f,
3881                "id space {:?} ({}) is not lifecycle-addressable; only the logical (l:) space is",
3882                id_space,
3883                id_space.prefix(),
3884            ),
3885            Self::ErasureIncomplete { stage, detail } => write!(
3886                f,
3887                "erasure incomplete at stage '{stage}': the rows were deleted but the erasure \
3888                 could not be completed at rest ({detail})",
3889            ),
3890            Self::ProjectionDestructive { name, delta } => write!(
3891                f,
3892                "configure_projections refused a destructive change to projection '{name}' \
3893                 without an explicit drop ({delta}); re-issue with drop: [\"{name}\"] to rebuild",
3894            ),
3895        }
3896    }
3897}
3898
3899impl EngineError {
3900    /// Stable machine-readable code for `errors_by_code` keys.
3901    ///
3902    /// Names match the binding-facing class stems in
3903    /// `dev/design/errors.md` § Binding-facing class matrix.
3904    fn stable_code(&self) -> &'static str {
3905        match self {
3906            Self::Storage => "StorageError",
3907            Self::Projection => "ProjectionError",
3908            Self::Vector => "VectorError",
3909            Self::Embedder => "EmbedderError",
3910            Self::EmbedderNotConfigured => "EmbedderNotConfiguredError",
3911            Self::KindNotVectorIndexed => "KindNotVectorIndexedError",
3912            Self::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
3913            Self::Scheduler => "SchedulerError",
3914            Self::OpStore => "OpStoreError",
3915            Self::WriteValidation => "WriteValidationError",
3916            Self::SchemaValidation => "SchemaValidationError",
3917            Self::Overloaded => "OverloadedError",
3918            Self::Closing => "ClosingError",
3919            Self::Extractor => "ExtractorError",
3920            Self::Consolidator => "ConsolidatorError",
3921            Self::InvalidFilter { .. } => "InvalidFilterError",
3922            Self::InvalidArgument { .. } => "InvalidArgumentError",
3923            Self::VectorEquivalenceMismatch { .. } => "VectorEquivalenceMismatchError",
3924            Self::IllegalTransition { .. } => "IllegalTransitionError",
3925            Self::NotLifecycleAddressable { .. } => "NotLifecycleAddressableError",
3926            Self::ErasureIncomplete { .. } => "ErasureIncompleteError",
3927            Self::ProjectionDestructive { .. } => "ProjectionDestructiveError",
3928        }
3929    }
3930}
3931
3932impl Error for EngineError {}
3933
3934/// Doctor `check-integrity` invocation flags. `quick` and `round_trip`
3935/// are accepted in 0.6.0 but treated as default; only `full` activates
3936/// `PRAGMA integrity_check`. Per `dev/design/recovery.md` § Doctor-only
3937/// flags.
3938#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
3939pub struct CheckIntegrityOpts {
3940    pub quick: bool,
3941    pub full: bool,
3942    pub round_trip: bool,
3943}
3944
3945/// One section of an [`IntegrityReport`]. Either every check in the
3946/// section was clean, or one or more typed [`Finding`]s describe the
3947/// detected issue. Per AC-043b.
3948#[derive(Clone, Debug, Eq, PartialEq)]
3949pub enum Section {
3950    Clean,
3951    Findings(Vec<Finding>),
3952}
3953
3954/// Single doctor finding record. Stable report-shape per AC-043c. The
3955/// `code` and `doc_anchor` strings are stable dispatch keys owned by
3956/// `dev/design/recovery.md` § Code-to-operator-action cross-reference.
3957#[derive(Clone, Debug, Eq, PartialEq)]
3958pub struct Finding {
3959    pub code: &'static str,
3960    pub stage: &'static str,
3961    pub locator: CorruptionLocator,
3962    pub doc_anchor: &'static str,
3963    pub detail: String,
3964}
3965
3966/// Three-section integrity report. AC-043a pins exactly these three
3967/// keys.
3968#[derive(Clone, Debug, Eq, PartialEq)]
3969pub struct IntegrityReport {
3970    pub physical: Section,
3971    pub logical: Section,
3972    pub semantic: Section,
3973}
3974
3975/// Result of a successful [`Engine::safe_export`] call. The returned
3976/// `manifest_sha256` equals the SHA-256 of the export file bytes (per
3977/// AC-039a) and matches the `sha256` field written into the manifest
3978/// JSON.
3979#[derive(Clone, Debug, Eq, PartialEq)]
3980pub struct SafeExportArtifact {
3981    pub export_path: PathBuf,
3982    pub manifest_path: PathBuf,
3983    pub manifest_sha256: String,
3984}
3985
3986/// Phase 9 Pack B trace report (AC-042). One event per canonical row
3987/// attributable to the requested `source_id`, ordered by `write_cursor`
3988/// ascending.
3989#[derive(Clone, Debug, Eq, PartialEq)]
3990pub struct TraceReport {
3991    pub source_ref: String,
3992    pub events: Vec<TraceEvent>,
3993}
3994
3995/// Single canonical-row tracing record. `table` is one of
3996/// `"canonical_nodes"` or `"canonical_edges"`.
3997#[derive(Clone, Debug, Eq, PartialEq)]
3998pub struct TraceEvent {
3999    pub write_cursor: u64,
4000    pub kind: String,
4001    pub table: &'static str,
4002}
4003
4004/// Which shadow-state surface a [`RebuildReport`] describes.
4005/// `Projections` covers the full FTS5 + vec0 + projection-terminal
4006/// rebuild emitted by [`Engine::rebuild_projections`]. `Vec0` covers
4007/// the vec0-only path emitted by [`Engine::rebuild_vec0`].
4008#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4009pub enum RebuildKind {
4010    Projections,
4011    Vec0,
4012}
4013
4014/// Structured result of a rebuild operation. `rows_invalidated` is the
4015/// total shadow-state rows truncated before re-derivation; `rows_rebuilt`
4016/// is the count of rows the synchronous rebuild loop re-materialised
4017/// (asynchronous re-enqueue work performed by the projection scheduler is
4018/// not counted here). `projection_cursor_after` is the post-rebuild value
4019/// of the projection cursor.
4020#[derive(Clone, Debug, Eq, PartialEq)]
4021pub struct RebuildReport {
4022    pub kind: RebuildKind,
4023    pub rows_invalidated: u64,
4024    pub rows_rebuilt: u64,
4025    pub projection_cursor_after: u64,
4026}
4027
4028/// Phase 9 Pack B excise report (AC-028a/b/c). Counts are post-excise
4029/// totals; `projections_invalidated` reports the shadow-row invalidation
4030/// total (FTS5 + vec0 + projection terminal) for the excised source.
4031#[derive(Clone, Debug, Eq, PartialEq)]
4032pub struct ExciseReport {
4033    pub source_ref: String,
4034    pub nodes_excised: u64,
4035    pub edges_excised: u64,
4036    pub projections_invalidated: u64,
4037}
4038
4039/// 0.8.20 Slice 15d (R-20-PR, C-1) — one member of a [`ProjectionSpec`]'s role
4040/// set. **Exactly three members** (HITL-ratified S8, `api-surface.md:87`):
4041/// `searchable→FTS` and `searchable→vector` are NOT roles — they are tier labels
4042/// carried by the `fts`/`vector` sub-objects of the spec, so an attribute is
4043/// `Searchable` once and the sub-objects select FTS-only / vector-only / both.
4044#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4045pub enum ProjectionRole {
4046    /// Projects into the EAV store + its `(attr_name, attr_value)` composite
4047    /// index — cheap equality/range, built same-transaction.
4048    Filterable,
4049    /// The F9 importance/recency signal. **Graceful-absent (Q6a):** declaring
4050    /// it is legal and never errors, but the engine DEFERS the build until F9
4051    /// exists and grafts it on the next idempotent `configure_projections`.
4052    Rankable,
4053    /// Full-text / dense recall of the meaning text. The `fts`/`vector`
4054    /// sub-objects select the sub-target.
4055    Searchable,
4056}
4057
4058impl ProjectionRole {
4059    #[must_use]
4060    pub fn as_str(self) -> &'static str {
4061        match self {
4062            ProjectionRole::Filterable => "filterable",
4063            ProjectionRole::Rankable => "rankable",
4064            ProjectionRole::Searchable => "searchable",
4065        }
4066    }
4067
4068    #[must_use]
4069    pub fn from_str_opt(value: &str) -> Option<Self> {
4070        match value {
4071            "filterable" => Some(ProjectionRole::Filterable),
4072            "rankable" => Some(ProjectionRole::Rankable),
4073            "searchable" => Some(ProjectionRole::Searchable),
4074            _ => None,
4075        }
4076    }
4077}
4078
4079/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→FTS` sub-target selector.
4080#[derive(Clone, Debug, Default, Eq, PartialEq)]
4081pub struct ProjectionFts {
4082    /// Optional tokenizer override; `None` ⇒ the engine default FTS5 tokenizer
4083    /// (`body`-FTS's `porter unicode61 remove_diacritics 2`). A custom
4084    /// per-attr tokenizer is the ≥0.9.x multi-field FTS work — recorded but
4085    /// not honoured here (graceful-graft later, same as `rankable`).
4086    pub tokenizer: Option<String>,
4087}
4088
4089/// 0.8.20 Slice 20 (R-20-DR) — the ENGINE-SET readiness of the
4090/// `searchable→vector` projection, per
4091/// `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
4092/// §3.
4093///
4094/// **Exactly two members.** `filterable` and `searchable→FTS` are
4095/// same-transaction (non-stale on commit) so they need no readiness axis at all;
4096/// `searchable→vector` is **async, rebuild-durable**, so it carries one.
4097///
4098/// **Naming discipline (load-bearing).** The token **`pending` is RESERVED for
4099/// the admission axis** (quarantine/trust — an app judgment). Index-readiness is
4100/// a DIFFERENT, orthogonal dimension (a record can be
4101/// `active ∧ is_latest ∧ admissible` yet `dense_readiness = embedding`), so this
4102/// enum deliberately does **not** reuse that word: the non-ready member is
4103/// `Embedding`, never `Pending`.
4104#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4105pub enum DenseReadiness {
4106    /// Every row in the vector projection's row set has reached a projection
4107    /// terminal — the dense arm is caught up. Because the vector INSERT and the
4108    /// terminal record are written in ONE transaction
4109    /// ([`commit_projection_outcomes`]), `Ready` can never be observed with the
4110    /// vector row absent (design §4.1 invariant 1).
4111    Ready,
4112    /// At least one row in the projection's row set has not yet reached a
4113    /// projection terminal — embedding is outstanding. This is the ONLY
4114    /// tolerable torn state: readiness `embedding` with the vector absent (the
4115    /// dense arm reads as partial and RRF under-ranks; it does not hide).
4116    Embedding,
4117}
4118
4119impl DenseReadiness {
4120    #[must_use]
4121    pub fn as_str(self) -> &'static str {
4122        match self {
4123            DenseReadiness::Ready => "ready",
4124            DenseReadiness::Embedding => "embedding",
4125        }
4126    }
4127
4128    /// The two accepted spellings. `"pending"` is DELIBERATELY not one of them
4129    /// (reserved for the admission axis) and so parses to `None`.
4130    #[must_use]
4131    pub fn from_str_opt(value: &str) -> Option<Self> {
4132        match value {
4133            "ready" => Some(DenseReadiness::Ready),
4134            "embedding" => Some(DenseReadiness::Embedding),
4135            _ => None,
4136        }
4137    }
4138}
4139
4140/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→vector` sub-target selector.
4141///
4142/// **Slice 20 (R-20-DR) attached `dense_readiness` HERE, additively:** this
4143/// sub-object is STORED by 15d (so the shape exists and a caller can declare a
4144/// vector projection); Slice 20 hangs the READ-METADATA readiness flag off it.
4145/// Nothing in 15d's persisted shape changed (the registry columns
4146/// `vector_embedder` + `vector_declared` still round-trip the declaration) —
4147/// **readiness is DERIVED, never stored**, so there is no schema step and no
4148/// separate flag that could tear (see [`derive_dense_readiness`]).
4149#[derive(Clone, Debug, Default, Eq, PartialEq)]
4150pub struct ProjectionVector {
4151    /// Optional embedder override; `None` ⇒ the engine's shipped default.
4152    pub embedder: Option<String>,
4153    /// 0.8.20 Slice 20 (R-20-DR) — **READ METADATA, engine-set.** Populated by
4154    /// [`Engine::read_projections`]; `None` on every caller-authored spec.
4155    ///
4156    /// It is **not part of the declaration**: `configure_projections` neither
4157    /// stores nor honours it (see [`StoredProjection::from_spec`], which reads
4158    /// only `embedder`), so a value supplied here is INERT — the engine always
4159    /// reports the derived truth. This is deliberately accept-inert rather than
4160    /// hard-reject so `read.projections` output stays feedable straight back
4161    /// into `configure_projections` (the fix-4 read→configure round-trip, which
4162    /// both bindings pin with a test).
4163    ///
4164    /// **0.8.20 Slice 23 (`R-20-SV`) correction (TC-39 class).** This doc used to
4165    /// justify accept-inert by analogy with "the already-audited accept-inert
4166    /// ruling on an `fts`/`vector` sub-object declared without the `searchable`
4167    /// role". **That ruling is OVERRULED** — the HITL ruled the shape an INVALID
4168    /// SPEC on 2026-07-24 and [`apply_projection_config`] now rejects it with
4169    /// [`EngineError::WriteValidation`]. `dense_readiness` accept-inert is
4170    /// UNCHANGED and stands on its own footing: it is engine-set READ METADATA,
4171    /// never part of the declaration, so there is nothing about it to reject.
4172    ///
4173    /// The bindings still HARD-REJECT the shapes that could
4174    /// not round-trip: a readiness supplied with `vector = false`, and any
4175    /// spelling outside `{ready, embedding}`.
4176    pub dense_readiness: Option<DenseReadiness>,
4177}
4178
4179/// 0.8.20 Slice 15d (R-20-PR / C-1) — a single declarative projection
4180/// declaration. HITL-ratified shape (`api-surface.md:85-89`):
4181/// `{ name, roles: Set<ProjectionRole>, fts?, vector? }`. `roles` carries SET
4182/// semantics (dedup + membership; an attribute can be `Filterable` AND
4183/// `Searchable`) — encoded here as a sorted, de-duplicated `BTreeSet`. Named
4184/// `roles`, not `kind` (`kind` is the node/edge type discriminator).
4185#[derive(Clone, Debug, Eq, PartialEq)]
4186pub struct ProjectionSpec {
4187    pub name: String,
4188    pub roles: BTreeSet<ProjectionRole>,
4189    pub fts: Option<ProjectionFts>,
4190    pub vector: Option<ProjectionVector>,
4191    /// Optional ordered literal object-member path in the canonical node body.
4192    /// `None` preserves the legacy direct top-level lookup by `name`.
4193    pub source: Option<Vec<String>>,
4194}
4195
4196/// 0.8.20 Slice 15d (R-20-PR) — the diff [`Engine::configure_projections`]
4197/// applied. Idempotent re-registration yields `unchanged == true` with all
4198/// vecs empty (the "re-registration is a no-op" acceptance signal). A
4199/// destructive change without an explicit `drop` is an `Err`, not a delta.
4200#[derive(Clone, Debug, Default, Eq, PartialEq)]
4201pub struct ProjectionDelta {
4202    /// Attribute names whose same-transaction projections (EAV / property-FTS)
4203    /// were (re)built by this apply.
4204    pub built: Vec<String>,
4205    /// Attribute names dropped (explicit `drop` list) — their EAV + property-FTS
4206    /// rows and registry row removed.
4207    pub dropped: Vec<String>,
4208    /// Attribute names whose declared roles were persisted but NOT built:
4209    /// `rankable` (F9 not yet live) and the `searchable→vector` sub-target
4210    /// (Slice 20). These graft on a future idempotent apply. No error.
4211    pub deferred: Vec<String>,
4212    /// True iff nothing was built, dropped, or newly deferred — the whole apply
4213    /// diffed to a no-op.
4214    pub unchanged: bool,
4215    /// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **node KINDS, not attribute
4216    /// names.** The vector-eligible node kinds present in the corpus that the
4217    /// vector writer can NEVER commit, so no `searchable→vector` declaration
4218    /// will ever produce an embedding for them.
4219    ///
4220    /// # Why this field exists — the silence it replaces
4221    ///
4222    /// [`kind_is_vector_committable`] (Slice 20c fix-2) restricted enrolment to
4223    /// the kinds [`resolve_source_type`] maps, because enrolling any other kind
4224    /// is a permanent liveness wedge. That fix was correct and is unchanged —
4225    /// but it made the exclusion **silent**: the declaration persists, its name
4226    /// is pushed onto [`ProjectionDelta::deferred`], and the caller cannot tell
4227    /// "waiting on the embedder" (transient) from "this kind will never be
4228    /// embedded" (permanent). Per the HITL ruling on TC-67 the remedy is
4229    /// option **(c) REPORT** — the vocabulary is NOT grown and the Pack-1 D3
4230    /// partition-key lock is NOT touched (`dev/design/0.7.0-vector-quant-pack1.md`).
4231    ///
4232    /// # Axis, and why the name is what it is
4233    ///
4234    /// `built` / `dropped` / `deferred` are all lists of **projection attribute
4235    /// names**. This one is a list of **node kinds** — a different axis entirely,
4236    /// so the name says `kinds` explicitly and is prefixed `vector_` to bind it
4237    /// to the dense arm (an unsupported kind is still fully FTS/lexically
4238    /// searchable). Sorted and de-duplicated (`SELECT DISTINCT … ORDER BY kind`).
4239    ///
4240    /// # It is a STATE report, not a diff
4241    ///
4242    /// Unlike the other three vectors it does not describe what this call
4243    /// changed; it describes the corpus as it stands. So it is populated on an
4244    /// idempotent re-apply too (where `unchanged == true` and the other three
4245    /// are empty), and it deliberately does NOT feed [`ProjectionDelta::unchanged`].
4246    /// That is what makes the declare-time residual cheap to live with: to
4247    /// refresh the report after writing new kinds, re-apply the same spec — a
4248    /// no-op that still returns a current report.
4249    ///
4250    /// # Independent of the embedder
4251    ///
4252    /// Computed whenever a `searchable→vector` projection is declared, whether
4253    /// or not this session has a live embedder. The vocabulary is static, so
4254    /// "this kind can never be embedded" is true in a no-embedder session too —
4255    /// and must not be conflated with the Q6a graceful-absent deferral, which is
4256    /// transient and is reported through `deferred`.
4257    ///
4258    /// Empty (never absent) when there is nothing to report.
4259    pub vector_unsupported_kinds: Vec<String>,
4260}
4261
4262/// 0.8.20 Slice 5b (R-20-E7) — outcome of
4263/// [`Engine::excise_collection_record`]. `records_excised` counts the erased
4264/// `operational_mutations` versions (an append-only-log collection keeps every
4265/// version of a key); `state_rows_excised` counts the erased
4266/// `operational_state` row (0 or 1).
4267///
4268/// `record_digest` is `SHA-256(collection + 0x1F + record_key)` — the audit
4269/// handle. The raw `record_key` is deliberately NOT carried: it is arbitrary
4270/// caller-supplied text and may itself be the identifier being erased, so
4271/// echoing it into a durable audit row would defeat the erasure.
4272#[derive(Clone, Debug, Eq, PartialEq)]
4273pub struct ExciseRecordReport {
4274    pub collection: String,
4275    pub record_digest: String,
4276    pub records_excised: u64,
4277    pub state_rows_excised: u64,
4278}
4279
4280/// Typed outcome of [`Engine::verify_embedder`]. Mismatches do not raise
4281/// `EngineError`; the operator workflow needs to see the stored vs.
4282/// supplied pair to decide on next action.
4283#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4284pub enum VerifyEmbedderStatus {
4285    Match,
4286    IdentityMismatch,
4287    DimensionMismatch,
4288    BothMismatch,
4289}
4290
4291/// Result of [`Engine::verify_embedder`]. `stored_identity` is the
4292/// `name:revision` pair persisted in `_fathomdb_embedder_profiles`;
4293/// `supplied_identity` echoes the operator's input verbatim.
4294#[derive(Clone, Debug, Eq, PartialEq)]
4295pub struct VerifyEmbedderReport {
4296    pub stored_identity: String,
4297    pub stored_dimension: u32,
4298    pub supplied_identity: String,
4299    pub supplied_dimension: u32,
4300    pub status: VerifyEmbedderStatus,
4301}
4302
4303/// Single table or index entry emitted by [`Engine::dump_schema`].
4304#[derive(Clone, Debug, Eq, PartialEq)]
4305pub struct SchemaObject {
4306    pub name: String,
4307    pub sql: String,
4308}
4309
4310/// Result of [`Engine::dump_schema`]. `user_version` is the
4311/// `PRAGMA user_version` sentinel. Canonical tables appear first per
4312/// [`fathomdb_schema::CANONICAL_TABLES`], then remaining non-`sqlite_*`
4313/// tables alphabetically. Indexes follow the same alphabetical rule.
4314#[derive(Clone, Debug, Eq, PartialEq)]
4315pub struct DumpSchemaReport {
4316    pub user_version: u32,
4317    pub tables: Vec<SchemaObject>,
4318    pub indexes: Vec<SchemaObject>,
4319}
4320
4321/// Single canonical-table row count emitted by [`Engine::dump_row_counts`].
4322#[derive(Clone, Debug, Eq, PartialEq)]
4323pub struct TableRowCount {
4324    pub name: String,
4325    pub rows: u64,
4326}
4327
4328/// Result of [`Engine::dump_row_counts`]. Canonical tables only;
4329/// projection / FTS / vec0 shadow tables are excluded. Order matches
4330/// [`fathomdb_schema::CANONICAL_TABLES`].
4331#[derive(Clone, Debug, Eq, PartialEq)]
4332pub struct DumpRowCountsReport {
4333    pub counts: Vec<TableRowCount>,
4334}
4335
4336/// 0.8.20 Slice 5d (R-20-E8) — one `source_id` bucket in an
4337/// [`OrphanProvenanceReport`]. `source_id` is `None` for the NULL-provenance
4338/// bucket, which after migration step 21 should contain ONLY governed NODES.
4339#[derive(Clone, Debug, Eq, PartialEq)]
4340pub struct OrphanProvenanceSource {
4341    /// `None` = the NULL-`source_id` bucket.
4342    pub source_id: Option<String>,
4343    /// Canonical rows (nodes + edges) carrying this provenance.
4344    pub rows: u64,
4345    /// How many of `rows` carry a `logical_id`.
4346    ///
4347    /// NOT the same thing as "purge-addressable": only a NODE's `logical_id`
4348    /// confers purge-addressability. An EDGE's `logical_id` is a supersession
4349    /// identity and reaches no erasure verb (see
4350    /// [`Engine::orphan_provenance`]), so governed edges are counted here but
4351    /// are NOT subtracted from
4352    /// [`OrphanProvenanceReport::unerasable_rows`].
4353    pub governed_rows: u64,
4354    /// True for the engine's reserved `_`-prefixed namespace (`_engine:*`,
4355    /// `_legacy:pre-0.8.20`). Reserved buckets are reachable only through the
4356    /// operator seam `excise_source`, never through the governed
4357    /// [`Engine::erase_source`].
4358    pub reserved: bool,
4359}
4360
4361/// Result of [`Engine::orphan_provenance`] — the per-`source_id` census behind
4362/// `fathomdb doctor orphan-provenance` (design §4 item 11).
4363///
4364/// `unerasable_rows` is the load-bearing field: canonical rows carrying
4365/// NEITHER a `source_id` NOR a `logical_id`. Such a row is reachable by no
4366/// erasure verb at all — `purge` keys on `logical_id`, `erase_source` keys on
4367/// `source_id` — so it can never be deleted on request. Slice 5c made that
4368/// state unwritable and migration step 21 back-filled the historical cases, so
4369/// a non-zero count means the invariant has been violated and the verb exits
4370/// `DOCTOR_FOUND_ISSUES`.
4371#[derive(Clone, Debug, Eq, PartialEq)]
4372pub struct OrphanProvenanceReport {
4373    /// Per-`source_id` buckets, ordered by descending `rows` then `source_id`
4374    /// so the output is deterministic (a diagnostic that reorders between runs
4375    /// cannot be diffed).
4376    pub sources: Vec<OrphanProvenanceSource>,
4377    /// Total canonical rows surveyed.
4378    pub total_rows: u64,
4379    /// Rows with NO `source_id` AND NO `logical_id` — un-erasable by any verb.
4380    pub unerasable_rows: u64,
4381}
4382
4383/// Result of [`Engine::dump_profile`]. Mirrors the open-time embedder
4384/// posture + the per-kind vector configuration registered in
4385/// `_fathomdb_vector_kinds`.
4386#[derive(Clone, Debug, Eq, PartialEq)]
4387pub struct DumpProfileReport {
4388    pub embedder_identity: String,
4389    pub embedder_dimension: u32,
4390    pub vectorized_kinds: Vec<String>,
4391}
4392
4393/// 0.7.2 PR-2b — result of [`Engine::recompute_mean`] (the manual
4394/// `doctor recompute-mean` path) and of the shared in-transaction
4395/// recompute core. `drift_cos_before` is the cosine between the freshly
4396/// derived corpus mean and the previously-pinned mean (1.0 when nothing
4397/// was pinned yet, i.e. a first pin). `mean_was_pinned` distinguishes a
4398/// refresh of an existing mean from an initial pin. See
4399/// `dev/design/embedder.md` §0.3.
4400#[derive(Clone, Debug, PartialEq)]
4401pub struct MeanRecomputeReport {
4402    pub dim: u32,
4403    pub old_doc_count: u64,
4404    pub doc_count_requantized: u64,
4405    pub drift_cos_before: f32,
4406    pub mean_was_pinned: bool,
4407    pub elapsed_ms: u64,
4408}
4409
4410/// Typed outcome of [`Engine::truncate_wal`]. `Done` matches SQLite's
4411/// `busy = 0` return from `PRAGMA wal_checkpoint(TRUNCATE)`; any other
4412/// value surfaces as `Busy`.
4413#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4414pub enum TruncateWalStatus {
4415    Done,
4416    Busy,
4417}
4418
4419/// Result of [`Engine::truncate_wal`]. Carries the three counters
4420/// returned by `PRAGMA wal_checkpoint(TRUNCATE)`: `busy`, `log_frames`,
4421/// `checkpointed_frames`.
4422#[derive(Clone, Debug, Eq, PartialEq)]
4423pub struct TruncateWalReport {
4424    pub status: TruncateWalStatus,
4425    pub busy: u32,
4426    pub log_frames: u32,
4427    pub checkpointed_frames: u32,
4428}
4429
4430impl Drop for Engine {
4431    fn drop(&mut self) {
4432        let _ = self.close();
4433    }
4434}
4435
4436impl Engine {
4437    pub fn open(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4438        Self::open_with_embedder_and_subscriber(
4439            path,
4440            default_embedder_identity(),
4441            None,
4442            None,
4443            None,
4444            &mut |_| {},
4445        )
4446    }
4447
4448    /// Open an engine with an explicit [`EmbedderChoice`].
4449    ///
4450    /// Per `dev/design/embedder.md` §0 + the 0.7.1 EU-5 campaign, this is
4451    /// the canonical entry point for selecting how the workspace's
4452    /// default embedder is supplied. See [`EmbedderChoice`] for the
4453    /// semantics of each variant; in particular `Default` materializes
4454    /// the pinned BGE embedder via the loader when the `default-embedder`
4455    /// feature is enabled.
4456    pub fn open_with_choice(
4457        path: impl Into<PathBuf>,
4458        choice: EmbedderChoice,
4459    ) -> Result<OpenedEngine, EngineOpenError> {
4460        match choice {
4461            EmbedderChoice::Default => Self::open_default_embedder(path),
4462            EmbedderChoice::Caller(embedder) => {
4463                let identity = embedder.identity();
4464                Self::open_with_embedder_and_subscriber(
4465                    path,
4466                    identity,
4467                    Some(embedder),
4468                    None,
4469                    None,
4470                    &mut |_| {},
4471                )
4472            }
4473            EmbedderChoice::None => Self::open_with_embedder_and_subscriber(
4474                path,
4475                default_embedder_identity(),
4476                None,
4477                None,
4478                None,
4479                &mut |_| {},
4480            ),
4481        }
4482    }
4483
4484    /// EU-5b: materialize the engine's pinned default embedder
4485    /// (`CandleBgeEmbedder` backed by the EU-3 loader) and open the
4486    /// workspace with it. Without the `default-embedder` feature, fails
4487    /// with a typed `Embedder` error rather than touching the network.
4488    #[cfg(feature = "default-embedder")]
4489    fn open_default_embedder(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4490        use std::time::Instant as DownloadInstant;
4491        let download_start = DownloadInstant::now();
4492        let weights = fathomdb_embedder::loader::load_pinned_default_embedder().map_err(|err| {
4493            EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4494                message: format!("default embedder loader: {err}"),
4495            })
4496        })?;
4497        let events = weights.events.clone();
4498        let download_ms = if weights.bytes_downloaded > 0 {
4499            Some(u64::try_from(download_start.elapsed().as_millis()).unwrap_or(u64::MAX))
4500        } else {
4501            None
4502        };
4503        let embedder =
4504            fathomdb_embedder::CandleBgeEmbedder::new_from_weights(weights).map_err(|err| {
4505                EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4506                    message: format!("default embedder construct: {err}"),
4507                })
4508            })?;
4509        let embedder: Arc<dyn Embedder> = Arc::new(embedder);
4510        let identity = embedder.identity();
4511        let loader_info = LoaderInfo { download_ms, events };
4512        Self::open_with_embedder_and_subscriber(
4513            path,
4514            identity,
4515            Some(embedder),
4516            Some(loader_info),
4517            None,
4518            &mut |_| {},
4519        )
4520    }
4521
4522    #[cfg(not(feature = "default-embedder"))]
4523    fn open_default_embedder(_path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4524        Err(EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4525            message: "EmbedderChoice::Default requires the `default-embedder` Cargo feature"
4526                .to_string(),
4527        }))
4528    }
4529
4530    pub fn open_with_migration_event_sink(
4531        path: impl Into<PathBuf>,
4532        mut emit_migration_event: impl FnMut(&MigrationStepReport),
4533    ) -> Result<OpenedEngine, EngineOpenError> {
4534        Self::open_with_embedder_and_subscriber(
4535            path,
4536            default_embedder_identity(),
4537            None,
4538            None,
4539            None,
4540            &mut emit_migration_event,
4541        )
4542    }
4543
4544    #[cfg(debug_assertions)]
4545    #[doc(hidden)]
4546    pub fn open_with_migrations_for_test(
4547        path: impl Into<PathBuf>,
4548        migrations: &'static [fathomdb_schema::Migration],
4549        mut emit_migration_event: impl FnMut(&MigrationStepReport),
4550    ) -> Result<OpenedEngine, EngineOpenError> {
4551        Self::open_with_migrations(
4552            path,
4553            migrations,
4554            default_embedder_identity(),
4555            None,
4556            None,
4557            &mut emit_migration_event,
4558            None,
4559        )
4560    }
4561
4562    #[doc(hidden)]
4563    pub fn open_with_subscriber_for_test(
4564        path: impl Into<PathBuf>,
4565        subscriber: Arc<dyn lifecycle::Subscriber>,
4566    ) -> Result<OpenedEngine, EngineOpenError> {
4567        Self::open_with_embedder_and_subscriber(
4568            path,
4569            default_embedder_identity(),
4570            None,
4571            None,
4572            Some(subscriber),
4573            &mut |_| {},
4574        )
4575    }
4576
4577    #[doc(hidden)]
4578    pub fn open_without_embedder_for_test(
4579        path: impl Into<PathBuf>,
4580    ) -> Result<OpenedEngine, EngineOpenError> {
4581        Self::open_with_embedder_and_subscriber(
4582            path,
4583            default_embedder_identity(),
4584            None,
4585            None,
4586            None,
4587            &mut |_| {},
4588        )
4589    }
4590
4591    #[doc(hidden)]
4592    pub fn open_with_embedder_for_test(
4593        path: impl Into<PathBuf>,
4594        embedder: Arc<dyn Embedder>,
4595    ) -> Result<OpenedEngine, EngineOpenError> {
4596        let identity = embedder.identity();
4597        Self::open_with_embedder_and_subscriber(
4598            path,
4599            identity,
4600            Some(embedder),
4601            None,
4602            None,
4603            &mut |_| {},
4604        )
4605    }
4606
4607    fn open_with_embedder_and_subscriber(
4608        path: impl Into<PathBuf>,
4609        embedder_identity: EmbedderIdentity,
4610        runtime_embedder: Option<Arc<dyn Embedder>>,
4611        loader_info: Option<LoaderInfo>,
4612        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4613        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4614    ) -> Result<OpenedEngine, EngineOpenError> {
4615        Self::open_with_migrations(
4616            path,
4617            MIGRATIONS,
4618            embedder_identity,
4619            runtime_embedder,
4620            loader_info,
4621            emit_migration_event,
4622            initial_subscriber,
4623        )
4624    }
4625
4626    fn open_with_migrations(
4627        path: impl Into<PathBuf>,
4628        migrations: &'static [fathomdb_schema::Migration],
4629        embedder_identity: EmbedderIdentity,
4630        runtime_embedder: Option<Arc<dyn Embedder>>,
4631        loader_info: Option<LoaderInfo>,
4632        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4633        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4634    ) -> Result<OpenedEngine, EngineOpenError> {
4635        let canonical_path = canonical_database_path(&path.into())?;
4636        let lock = acquire_lock(&canonical_path)?;
4637        let open_result = Self::open_locked(
4638            canonical_path.clone(),
4639            migrations,
4640            &embedder_identity,
4641            emit_migration_event,
4642        );
4643
4644        match open_result {
4645            Ok((connection, readers, mut report, reader_lookaside_rcs)) => {
4646                // EU-5b — splice the loader's measurements + structured
4647                // events into the report. The loader path is the only
4648                // surface that produces these today; caller-supplied
4649                // embedders and EmbedderChoice::None leave them as the
4650                // open_locked defaults (None / empty).
4651                if let Some(info) = loader_info {
4652                    if info.download_ms.is_some() {
4653                        report.embedder_download_ms = info.download_ms;
4654                    }
4655                    if !info.events.is_empty() {
4656                        report.embedder_events = info.events;
4657                    }
4658                }
4659
4660                // 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — run the
4661                // open-time self-check on the FINAL post-recovery connection (the
4662                // mean is already pinned/recovered inside open_locked, U1-b). First
4663                // registration persists the 45 UN-centered f32 references; a
4664                // subsequent open re-embeds + asserts P1 (mean-centered flip count,
4665                // floor 0) and P2 (un-centered L2 ε). Divergence ⇒ degraded-open
4666                // (`dense_disabled=true`), surfaced on the OpenReport (R-VEQ-6); the
4667                // query-time refusal fires later at `search_inner_with_stats`.
4668                let veq = run_vector_equivalence_probe(
4669                    &connection,
4670                    runtime_embedder.as_deref(),
4671                    &embedder_identity,
4672                    report.embedder_mean_vec_pinned,
4673                );
4674                report.dense_disabled = veq.dense_disabled;
4675                report.dense_disabled_reason = veq.reason.clone();
4676
4677                let next_cursor = load_next_cursor(&connection);
4678                let subscribers = Arc::new(lifecycle::SubscriberRegistry::new());
4679                let profiling_enabled = Arc::new(AtomicBool::new(false));
4680                let slow_threshold_ms = Arc::new(AtomicU64::new(DEFAULT_SLOW_THRESHOLD_MS));
4681                let mut profile_contexts: Vec<Box<ProfileContext>> = Vec::new();
4682                let projection_runtime = ProjectionRuntime::new(
4683                    canonical_path.clone(),
4684                    runtime_embedder.clone(),
4685                    embedder_identity.clone(),
4686                    report.embedder_mean_vec_pinned,
4687                    Arc::clone(&subscribers),
4688                );
4689
4690                install_profile_callback(
4691                    &connection,
4692                    &subscribers,
4693                    &profiling_enabled,
4694                    &slow_threshold_ms,
4695                    &mut profile_contexts,
4696                );
4697                for reader in &readers {
4698                    install_profile_callback(
4699                        reader,
4700                        &subscribers,
4701                        &profiling_enabled,
4702                        &slow_threshold_ms,
4703                        &mut profile_contexts,
4704                    );
4705                }
4706
4707                let opened = OpenedEngine {
4708                    engine: Self {
4709                        path: canonical_path.clone(),
4710                        next_cursor: AtomicU64::new(next_cursor),
4711                        closed: AtomicBool::new(false),
4712                        lock: Mutex::new(Some(lock)),
4713                        connection: Mutex::new(Some(connection)),
4714                        reader_pool: ReaderWorkerPool::new(readers),
4715                        counters: lifecycle::Counters::new(),
4716                        subscribers,
4717                        profiling_enabled,
4718                        slow_threshold_ms,
4719                        runtime_embedder,
4720                        runtime_embedder_identity: embedder_identity,
4721                        projection_runtime,
4722                        provenance_row_cap: AtomicU64::new(DEFAULT_PROVENANCE_ROW_CAP),
4723                        profile_contexts: Mutex::new(profile_contexts),
4724                        reader_lookaside_rcs,
4725                        telemetry: Mutex::new(None),
4726                        telemetry_enabled: AtomicBool::new(false),
4727                        dense_disabled: AtomicBool::new(veq.dense_disabled),
4728                        dense_disabled_reason: Mutex::new(veq.reason),
4729                        vector_equivalence_refusals: AtomicU64::new(0),
4730                        #[cfg(debug_assertions)]
4731                        force_next_commit_failure: AtomicBool::new(false),
4732                    },
4733                    report,
4734                };
4735                if let Some(subscriber) = initial_subscriber {
4736                    opened.engine.subscribers.attach_persistent(subscriber);
4737                }
4738                if database_has_pending_projection_work(&canonical_path).unwrap_or(false) {
4739                    opened.engine.projection_runtime.notify_new_work();
4740                }
4741                Ok(opened)
4742            }
4743            Err(err) => {
4744                if let Some(subscriber) = initial_subscriber {
4745                    emit_open_error_event(&subscriber, &err);
4746                }
4747                drop(lock);
4748                Err(err)
4749            }
4750        }
4751    }
4752
4753    fn open_locked(
4754        path: PathBuf,
4755        migrations: &'static [fathomdb_schema::Migration],
4756        embedder_identity: &EmbedderIdentity,
4757        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4758    ) -> Result<(Connection, Vec<Connection>, OpenReport, Vec<i32>), EngineOpenError> {
4759        init_perf_experiments_runtime();
4760        register_sqlite_vec_extension();
4761        let mut connection = Connection::open(&path)
4762            .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
4763        // Order pinned by `dev/design/errors.md` § OpenStage matrix: each
4764        // step routes its own SQLite-level error to a distinct
4765        // `CorruptionKind` (Header → WalReplay → Schema → EmbedderIdentity).
4766        // The schema and WAL probes both happen BEFORE `pragma WAL`
4767        // because that pragma also reads page 1 — letting it run first
4768        // would reclassify schema-side corruption as a WAL replay
4769        // failure, breaking the AC-035b stable-code contract.
4770        probe_database_header(&connection)?;
4771        probe_open_integrity(&connection)?;
4772        probe_wal_sidecar(&path)?;
4773        // 0.7.0 perf-experiments: apply writer-side experiment PRAGMAs
4774        // (page_size, etc.) BEFORE journal_mode + migrations. page_size
4775        // is silently ignored once any table exists; this is the only
4776        // legal window to set it on a fresh DB. Gated on
4777        // FATHOMDB_PERF_EXPERIMENTS=1; no-op in production.
4778        apply_perf_experiment_writer_pragmas(&connection);
4779        // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — standing
4780        // `secure_delete=ON` on the writer, applied at EVERY open (fresh + migrated).
4781        // It zeroes every page freed by a future DELETE, so the Slice-10 `purge`
4782        // hard-erase is complete WITHOUT a per-purge `VACUUM`. It is a connection
4783        // PRAGMA (not schema DDL), so it belongs here, not in the 19→20 migration.
4784        // RESIDUAL (documented, not forced): pages freed on a pre-20 DB BEFORE this
4785        // was enabled are not retroactively scrubbed; there is no migration-time
4786        // full `VACUUM` (O(db-size)). NOTE: this is a standing pragma set at EVERY
4787        // connection open (writer here, plus the reader-pool and
4788        // `open_runtime_connection`), NOT the writer alone — non-writer connections
4789        // also free pages (projection / vector-rewrite DELETEs), so a writer-only
4790        // `secure_delete` would leak freed content on disk. See the matching
4791        // reader/runtime open comment (~lines 3335-3336).
4792        connection
4793            .pragma_update(None, "secure_delete", "ON")
4794            .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4795        connection
4796            .pragma_update(None, "journal_mode", "WAL")
4797            .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4798
4799        reject_legacy_shape(&connection)?;
4800        let migration = migrate_with_event_sink(&connection, migrations, emit_migration_event)
4801            .map_err(map_migration_error)?;
4802        // 0.8.0 Slice 5 (G1) — global FTS5 tokenizer-default upgrade. Step 11
4803        // drops + recreates `search_index` with the new tokenizer, leaving it
4804        // EMPTY on a migrated DB. The projection scheduler will NOT
4805        // repopulate it (`database_has_pending_projection_work` keys "pending"
4806        // off `_fathomdb_projection_terminal`, which the migration does not
4807        // clear). Re-tokenize from the canonical source rows here, on the
4808        // writer connection, single-threaded, before readers spawn —
4809        // projection-only, no source-record migration.
4810        //
4811        // Crash-retryable (fix-1): step 11 commits `user_version = 11` with an
4812        // empty index in its OWN transaction; this reproject commits in a
4813        // LATER transaction. A crash in that window leaves a durable v11 + empty
4814        // index, on which a boundary-crossing guard (`before < 11`) is FALSE,
4815        // skipping repair forever. So gate on the completion marker's ABSENCE
4816        // (written atomically with the reindex) instead: idempotent, and a
4817        // crash before the reindex commit simply re-runs on the next open.
4818        if migration.schema_version_after >= SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION
4819            && !search_index_tokenizer_reproject_complete(&connection).map_err(|_| {
4820                EngineOpenError::Io {
4821                    message: "could not read search_index tokenizer reproject marker".to_string(),
4822                }
4823            })?
4824        {
4825            reproject_search_index_after_tokenizer_upgrade(&connection).map_err(|_| {
4826                EngineOpenError::Io {
4827                    message: "could not re-tokenize search_index after tokenizer upgrade"
4828                        .to_string(),
4829                }
4830            })?;
4831        }
4832        let mut embedder_mean_vec_pinned = check_embedder_profile(&connection, embedder_identity)?;
4833        ensure_vector_partition(&mut connection, embedder_identity.dimension).map_err(|_| {
4834            EngineOpenError::Io { message: "could not initialize vector partition".to_string() }
4835        })?;
4836
4837        // 0.8.20 Slice 15c (TC-33) fix-6 [codex §9 P1] — the step-23
4838        // `canonical_edges` recreate drops every edge row (NO DATA MIGRATION) and
4839        // removes their `_fathomdb_vector_rows` sidecar rows, but the vec0
4840        // `vector_default` shadow those mirror is engine-created + dim-aware, so
4841        // the migration cannot delete its rows. Left behind, an orphaned edge vec0
4842        // row (whose `canonical_edges` row is gone) still occupies a top-K KNN
4843        // candidate slot — `build_vector_phase1_sql` reads candidates DIRECTLY
4844        // from `vector_default` before hydrating them through the canonical tables
4845        // — and is then discarded at hydration, so an upgraded DB silently returns
4846        // too few / no vector results. Prune the orphans now that
4847        // `ensure_vector_partition` guarantees `vector_default` exists, BEFORE the
4848        // mean-vec row-count recovery below (so the count excludes them). One-time
4849        // and crash-retryable via the durable completion marker; a no-op on any
4850        // healthy corpus (every vec0 row has a sidecar entry), so recall / eu7
4851        // fidelity are unchanged on a DB that never dropped edges.
4852        if migration.schema_version_after >= EDGE_TEMPORAL_EPOCH_SCHEMA_VERSION
4853            && !edge_vector_prune_complete(&connection).map_err(|_| EngineOpenError::Io {
4854                message: "could not read edge-vector prune marker".to_string(),
4855            })?
4856        {
4857            prune_orphaned_edge_vectors(&connection).map_err(|_| EngineOpenError::Io {
4858                message: "could not prune orphaned edge vector rows".to_string(),
4859            })?;
4860        }
4861
4862        // 0.8.20 Slice 15d (R-20-PR, Q5) — boot re-derive the projection registry
4863        // (the engine `ProjectionSpec` is a derived cache). For every persisted
4864        // declaration, clear + backfill its EAV / property-FTS rows from the
4865        // canonical nodes so a crash window (registry row survives, projection
4866        // rows partial) self-heals idempotently. A no-op single empty-table read
4867        // on every DB that has not declared a projection. On the writer
4868        // connection, single-threaded, before readers spawn — like the tokenizer
4869        // reproject above. Runs after the fix-6 edge-vector prune above; the two
4870        // are independent boot reconciliations.
4871        rederive_projections_on_boot(&connection).map_err(|_| EngineOpenError::Io {
4872            message: "could not re-derive projection registry on boot".to_string(),
4873        })?;
4874
4875        // 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — bring an
4876        // ALREADY-ENROLLED inert vector kind into agreement with the role-aware
4877        // decision. Slice 21c closed the three forward doors, but a database that
4878        // already ran the old code under `{roles:[filterable], vector:{}}` keeps
4879        // its `_fathomdb_vector_kinds` rows — `vector_kind_needs_enrolment`
4880        // short-circuits on `kind_is_vector_indexed` and never reaches the new
4881        // predicate, and `project_canonical_node_row` reads only the registry
4882        // membership — so upgrading did not actually stop the unwanted embeddings.
4883        // Narrowly authorised (registry EXISTS, declares a `vector` sub-object,
4884        // and declares no `searchable→vector` projection) so a LEGACY workspace
4885        // with a working dense arm is never touched; see
4886        // [`registry_governs_an_inert_dense_arm`]. Deletes no embedding. Runs
4887        // BEFORE `run_vector_equivalence_probe` (which fires after `open_locked`
4888        // returns), so a database whose only enrolment was the inert one pays no
4889        // probe embeds on the healing open. Another boot reconciliation on the
4890        // writer connection, single-threaded, before readers spawn.
4891        reconcile_inert_vector_enrolments_on_boot(&connection).map_err(|_| {
4892            EngineOpenError::Io {
4893                message: "could not reconcile inert vector kind enrolments on boot".to_string(),
4894            }
4895        })?;
4896
4897        // 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns
4898        // with the registry's `filterable` set. On a DB whose vec0 shape already
4899        // matches the registry (the common case, incl. every reopen of a DB that
4900        // declared filterable projections in a prior session) this is a pure
4901        // no-op: the diff is empty, so boot never re-inserts and NEVER silently
4902        // wipes the corpus. It converges only a shape that drifted from the
4903        // registry (e.g. a restored registry row). A no-op when the table is
4904        // absent (no embedder). Runs on the writer connection, single-threaded,
4905        // before readers spawn — like the boot re-derive above.
4906        {
4907            let tx = connection.transaction().map_err(|_| EngineOpenError::Io {
4908                message: "could not begin vector-attr reconcile on boot".to_string(),
4909            })?;
4910            reconcile_vector_attr_columns(&tx, embedder_identity.dimension).map_err(|_| {
4911                EngineOpenError::Io {
4912                    message: "could not reconcile vector attribute columns on boot".to_string(),
4913                }
4914            })?;
4915            tx.commit().map_err(|_| EngineOpenError::Io {
4916                message: "could not commit vector-attr reconcile on boot".to_string(),
4917            })?;
4918        }
4919
4920        // EU-5f — recovery pin (`dev/design/embedder.md` §0.3, Hazard 4). If
4921        // the identity is MC-required, no mean is pinned, yet the workspace
4922        // already holds >= MEAN_VEC_PIN_THRESHOLD vector rows (e.g. a crash
4923        // between the threshold-crossing write and its pin commit), derive
4924        // the mean from the existing un-centered rows and pin+re-quantize
4925        // now, single-threaded, before the projection workers spawn. The
4926        // NULL guard makes this idempotent on subsequent opens.
4927        if identity_requires_mean_centering(embedder_identity) && !embedder_mean_vec_pinned {
4928            let row_count: u64 = connection
4929                .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get(0))
4930                .unwrap_or(0);
4931            if row_count >= MEAN_VEC_PIN_THRESHOLD {
4932                recover_mean_vec_pin(&mut connection, embedder_identity).map_err(|_| {
4933                    EngineOpenError::Io {
4934                        message: "could not recover mean-centering pin".to_string(),
4935                    }
4936                })?;
4937                embedder_mean_vec_pinned = true;
4938            }
4939        }
4940
4941        let warmup_started = Instant::now();
4942        // Static identity capability — see `dev/design/embedder.md`
4943        // §0.6. Today only the bge-small identity reports `true`; the
4944        // noop scaffolding identity is `false`. EU-5b's identity flip
4945        // makes the Default path return `true` here automatically.
4946        let embedder_mean_centering_required = embedder_identity.name == BGE_SMALL_EMBEDDER_NAME;
4947        // EU-5a2 — populated from `_fathomdb_embedder_profiles.mean_vec`
4948        // by `check_embedder_profile` above (was hard-coded `false` in
4949        // EU-5a1). Dimension invariant (§0.2) enforced by that check.
4950        let report = OpenReport {
4951            schema_version_before: migration.schema_version_before,
4952            schema_version_after: migration.schema_version_after,
4953            migration_steps: migration.migration_steps,
4954            embedder_warmup_ms: u64::try_from(warmup_started.elapsed().as_millis())
4955                .unwrap_or(u64::MAX),
4956            query_backend: "fathomdb-query + sqlite-vec",
4957            default_embedder: embedder_identity.clone(),
4958            // TODO(EU-5b): surface `LoadedWeights.download_ms` from the
4959            // loader once the Default path materializes through it.
4960            embedder_download_ms: None,
4961            // TODO(EU-5b): surface `LoadedWeights.events` from the loader.
4962            embedder_events: Vec::new(),
4963            embedder_mean_centering_required,
4964            embedder_mean_vec_pinned,
4965            // 0.8.18 Slice 5 — set by the #5 self-check in `open_with_migrations`
4966            // (which has the runtime embedder in scope). `open_locked` returns the
4967            // non-degraded default; the probe runs after this returns.
4968            dense_disabled: false,
4969            dense_disabled_reason: None,
4970        };
4971
4972        let mut readers = Vec::with_capacity(READER_POOL_SIZE);
4973        let mut lookaside_rcs: Vec<i32> = Vec::with_capacity(READER_POOL_SIZE);
4974        for _ in 0..READER_POOL_SIZE {
4975            let reader = Connection::open(&path)
4976                .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
4977            // Pack 6.G G.1: configure per-connection lookaside BEFORE
4978            // any PRAGMA / prepare runs on this reader. Reordering this
4979            // after the journal-mode / query_only PRAGMAs would let
4980            // SQLite silently ignore the lookaside setting.
4981            let rc: i32 = configure_reader_lookaside(&reader);
4982            debug_assert_eq!(
4983                rc,
4984                rusqlite::ffi::SQLITE_OK,
4985                "sqlite3_db_config(LOOKASIDE) must return SQLITE_OK on a freshly opened reader",
4986            );
4987            lookaside_rcs.push(rc);
4988            reader
4989                .pragma_update(None, "journal_mode", "WAL")
4990                .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4991            // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON`
4992            // at EVERY connection open, not just the writer. `secure_delete` is a
4993            // per-connection pager flag, so a reader-pool connection that frees a
4994            // page (vector-rewrite / projection DELETEs run off non-writer
4995            // connections) would otherwise leave that freed content on disk,
4996            // defeating GDPR erasure. Set BEFORE `query_only=ON` so the ordering is
4997            // unambiguous (the flag is a pager setting, not a DB write).
4998            reader
4999                .pragma_update(None, "secure_delete", "ON")
5000                .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
5001            reader
5002                .pragma_update(None, "query_only", "ON")
5003                .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))?;
5004            apply_perf_experiment_reader_pragmas(&reader);
5005            readers.push(reader);
5006        }
5007
5008        Ok((connection, readers, report, lookaside_rcs))
5009    }
5010
5011    #[must_use]
5012    pub fn path(&self) -> &Path {
5013        &self.path
5014    }
5015
5016    pub fn write(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
5017        let category = if batch_is_admin(batch) {
5018            lifecycle::EventCategory::Admin
5019        } else {
5020            lifecycle::EventCategory::Writer
5021        };
5022        self.emit_event(lifecycle::Phase::Started, category, None);
5023        let started = Instant::now();
5024        let outcome = self.write_inner(batch);
5025        self.detect_slow(started, category);
5026        match outcome {
5027            Ok(receipt) => {
5028                let rows = u64::try_from(batch.len()).unwrap_or(u64::MAX);
5029                if batch_is_admin(batch) {
5030                    self.counters.record_admin();
5031                } else {
5032                    self.counters.record_write(rows);
5033                }
5034                self.emit_event(lifecycle::Phase::Finished, category, None);
5035                Ok(receipt)
5036            }
5037            Err(err) => {
5038                let code = err.stable_code();
5039                self.counters.record_error(code);
5040                // AC-003d: capture-ordinal < raise-ordinal — Failed and Error
5041                // events both fire before the EngineError returns to the caller.
5042                self.emit_event(lifecycle::Phase::Failed, category, Some(code));
5043                self.emit_event(
5044                    lifecycle::Phase::Failed,
5045                    lifecycle::EventCategory::Error,
5046                    Some(code),
5047                );
5048                Err(err)
5049            }
5050        }
5051    }
5052
5053    /// 0.8.20 Slice 20c (R-20-DR remainder) — **late enrolment**, the write-path
5054    /// half of the C4 rider.
5055    ///
5056    /// [`enqueue_declared_vector_backfill`] enrols the kinds the corpus held AT
5057    /// DECLARATION TIME. A kind first written AFTERWARDS would otherwise fall
5058    /// through [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate
5059    /// straight onto a permanent `'up_to_date'` terminal and be silently,
5060    /// irrecoverably un-embedded — the identical false-ready barrier, reached by
5061    /// writing second instead of declaring second.
5062    ///
5063    /// **Gated on a LIVE embedder** (`runtime_embedder`), which is exactly why
5064    /// this lives on `Engine` and not inside the projector: with
5065    /// `EmbedderChoice::None` there is no dense arm at all, so enrolling a kind
5066    /// would only queue embeds that retry to a `failed` terminal and pollute
5067    /// `projection_failures`. The declaration still PERSISTS without an embedder
5068    /// — it simply defers, and grafts on the next idempotent apply in a session
5069    /// that has one (the shipped Q6a graceful-absent/graceful-graft contract,
5070    /// same as `rankable`). It mirrors the `run_vector_equivalence_probe` gate:
5071    /// "no live embedder ⇒ no dense arm to guard".
5072    ///
5073    /// Enrolment is an idempotent `INSERT OR IGNORE`, so running it on the writer
5074    /// connection just OUTSIDE the batch transaction is safe: if the batch then
5075    /// fails, the workspace is left having enrolled a kind for which no row
5076    /// exists — inert.
5077    ///
5078    /// fix-1 (codex §9 [P2]) — the `vector_projection_declared` probe below is
5079    /// what stops this path re-enrolling immediately after
5080    /// [`unenrol_registry_vector_node_kinds`] has run: the inverse removes the
5081    /// registry row, and with no active declaration this returns without
5082    /// re-adding it. (The kind registry DOES now have delete paths: that one, plus
5083    /// the Slice-21 fix-1 reconciliation
5084    /// [`reconcile_inert_vector_enrolments_on_boot`]. Both are gated on the SAME
5085    /// predicate this probe reads, so neither can be undone by a later write.)
5086    ///
5087    /// Cost: the registry probe is skipped entirely once the kind is enrolled, so
5088    /// a workspace with a live dense arm pays nothing; a workspace that never
5089    /// declared a vector projection pays two `prepare_cached` `EXISTS` probes per
5090    /// batch-row of an unenrolled kind. (Slice 21c / `TC-71` made that probe
5091    /// require the `searchable` ROLE, and deliberately kept the second `EXISTS`
5092    /// as a fast negative so this cost is unchanged — see
5093    /// [`vector_projection_declared`].)
5094    ///
5095    /// fix-2 (codex §9 [P2]) — a late enrolment now runs the SAME stranded-row
5096    /// treatment the declare-time door runs ([`reenqueue_stranded_vector_rows`]),
5097    /// and returns `true` iff that re-enqueued anything. Enrolling a kind while
5098    /// enqueueing ONLY the batch's own row left every earlier row of that kind
5099    /// holding its permanent `'up_to_date'` terminal with no vector, so once the
5100    /// new row drained readiness reported `ready` with pre-existing vector-eligible
5101    /// rows unembedded — a FALSE READY. Reached, for instance, by a database that
5102    /// persisted the declaration while opened WITHOUT an embedder and then reopened
5103    /// WITH one and wrote before re-applying the projection.
5104    ///
5105    /// fix-5 (codex §9 round 4 [P2]) — the registry INSERT and the un-stranding
5106    /// commit as ONE `BEGIN IMMEDIATE`…`COMMIT` (the shape
5107    /// [`rederive_projections_on_boot`] and
5108    /// [`reproject_search_index_after_tokenizer_upgrade`] already use). fix-2 ran
5109    /// them as two, and that window is not benign: a crash or a failed repair in
5110    /// between leaves the kind REGISTERED with the older rows still holding their
5111    /// `'up_to_date'` terminals and no vectors — and that state is SELF-SEALING,
5112    /// because `kind_is_vector_indexed` is then true, so every later write skips
5113    /// this path and therefore skips the repair, while readiness reads `ready` for
5114    /// rows nothing will ever embed. Only a manual re-apply of the projection
5115    /// recovers it. No marker table and no new recovery path: the two statements
5116    /// simply share a transaction.
5117    ///
5118    /// That transaction is opened on the writer connection just OUTSIDE the batch
5119    /// transaction: if the batch then fails, the workspace is left having enrolled
5120    /// a kind whose rows are correctly queued for the dense arm the registry does
5121    /// declare — inert, and self-healing on the next write or apply.
5122    fn enrol_batch_vector_kinds(
5123        &self,
5124        connection: &Connection,
5125        batch: &[PreparedWrite],
5126    ) -> Result<bool, EngineError> {
5127        if self.runtime_embedder.is_none() {
5128            return Ok(false);
5129        }
5130        // READ-ONLY pre-pass. Nothing is written here, so the overwhelmingly
5131        // common case — every kind in the batch already enrolled, or no vector
5132        // projection declared at all — still pays only the probes it paid before
5133        // and never takes a write lock.
5134        let mut to_enrol: Vec<&str> = Vec::new();
5135        for write in batch {
5136            // Only `Node` writes: edge bodies enrol `'edge_fact'` themselves in
5137            // `project_canonical_edge_row` (G11), unconditionally and already.
5138            let PreparedWrite::Node { kind, .. } = write else { continue };
5139            if to_enrol.contains(&kind.as_str()) {
5140                continue;
5141            }
5142            if self.vector_kind_needs_enrolment(connection, kind, RowKind::Leaf)? {
5143                to_enrol.push(kind);
5144            }
5145        }
5146        if to_enrol.is_empty() {
5147            return Ok(false);
5148        }
5149        self.enrol_and_unstrand(connection, &to_enrol)
5150    }
5151
5152    /// 0.8.20 Slice 20c — would enrolling `kind` be correct here? The READ-ONLY
5153    /// half of a late enrolment; [`Engine::enrol_and_unstrand`] is the write half.
5154    /// The live-embedder precondition is the CALLER's (see
5155    /// [`Engine::enrol_batch_vector_kinds`]).
5156    fn vector_kind_needs_enrolment(
5157        &self,
5158        connection: &Connection,
5159        kind: &str,
5160        row_kind: RowKind,
5161    ) -> Result<bool, EngineError> {
5162        // `graph` rows are lexically searchable but NEVER embedded
5163        // (`index_targets_for_row_kind`), so they must not drag their kind into
5164        // the vector registry — that would start embedding every other row of
5165        // that kind.
5166        if !index_targets_for_row_kind(row_kind).vector {
5167            return Ok(false);
5168        }
5169        // fix-2 (codex §9 [P1]) — the SAME restriction the declare-time door
5170        // applies, from the SAME predicate, so the two cannot drift: a kind the
5171        // vector writer cannot commit must never be enrolled, or the projection
5172        // worker wedges on it forever. See [`kind_is_vector_committable`].
5173        if !kind_is_vector_committable(kind) {
5174            return Ok(false);
5175        }
5176        if kind_is_vector_indexed(connection, kind)? {
5177            return Ok(false);
5178        }
5179        if !vector_projection_declared(connection).map_err(|_| EngineError::Storage)? {
5180            return Ok(false);
5181        }
5182        Ok(true)
5183    }
5184
5185    /// 0.8.20 Slice 20c fix-5 (codex §9 round 4 [P2]) — the WRITE half of a LATE
5186    /// enrolment: register the kinds AND repair the rows they strand, in ONE
5187    /// transaction. Returns `true` iff the repair re-enqueued anything (the caller
5188    /// must then `notify_new_work()`, since those rows are outside its batch).
5189    ///
5190    /// Split out so both write-path doors ([`Engine::enrol_batch_vector_kinds`]
5191    /// and the `#[doc(hidden)]` `write_canonical_row_with_kind_for_test`) share it
5192    /// verbatim, and so neither can register a kind without owing the repair.
5193    ///
5194    /// `register_vector_kind` is `INSERT OR IGNORE` and
5195    /// [`reenqueue_stranded_vector_rows`] is idempotent, so the read-only pre-pass
5196    /// that chose `kinds` does not need re-validating under the write lock: the
5197    /// worst a stale decision costs is one no-op `MIN` probe.
5198    fn enrol_and_unstrand(
5199        &self,
5200        connection: &Connection,
5201        kinds: &[&str],
5202    ) -> Result<bool, EngineError> {
5203        connection.execute_batch("BEGIN IMMEDIATE").map_err(|_| EngineError::Storage)?;
5204        let result = (|| -> rusqlite::Result<bool> {
5205            for kind in kinds {
5206                register_vector_kind(connection, kind)?;
5207            }
5208            reenqueue_stranded_vector_rows(connection)
5209        })();
5210        match result {
5211            Ok(enqueued) => {
5212                connection.execute_batch("COMMIT").map_err(|_| EngineError::Storage)?;
5213                Ok(enqueued)
5214            }
5215            Err(_) => {
5216                let _ = connection.execute_batch("ROLLBACK");
5217                Err(EngineError::Storage)
5218            }
5219        }
5220    }
5221
5222    fn write_inner(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
5223        self.ensure_open()?;
5224
5225        if batch.is_empty() {
5226            return Err(EngineError::WriteValidation);
5227        }
5228
5229        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5230        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
5231        let plans = validate_batch(connection, batch)?;
5232        validate_nested_projection_sources_for_write(connection, batch)?;
5233        // 0.8.20 Slice 20c (R-20-DR remainder) — LATE ENROLMENT, before
5234        // `collect_projection_jobs` reads the vector-kind registry to decide
5235        // whether the dispatcher needs waking. See
5236        // `Engine::enrol_batch_vector_kinds`.
5237        //
5238        // fix-2 (codex §9 [P2]) — the flag says the enrolment ALSO un-stranded
5239        // rows outside this batch. Those rows are not in `projection_jobs` (that
5240        // only walks the batch), so it is OR-ed into `pending_projection` below:
5241        // `drain` is a passive barrier and never a trigger (C4 rider), so work
5242        // enqueued without a wake would sit until the next unrelated write.
5243        let unstranded = self.enrol_batch_vector_kinds(connection, batch)?;
5244        let projection_jobs = collect_projection_jobs(connection, batch)?;
5245        #[cfg(debug_assertions)]
5246        if self.force_next_commit_failure.swap(false, Ordering::SeqCst) {
5247            return Err(EngineError::Storage);
5248        }
5249        // One cursor per row. `base_cursor` is the last committed cursor;
5250        // row i in the batch gets cursor `base_cursor + i + 1`, and the
5251        // batch's final cursor (returned in WriteReceipt and stored as
5252        // the new `next_cursor`) is `base_cursor + batch.len()`. Sharing
5253        // one cursor across the batch previously collapsed every vec0
5254        // INSERT onto the same rowid via `INSERT OR IGNORE` — see
5255        // `dev/notes/0.7.0-engine-batch-vec0-collapse.md`.
5256        let base_cursor = self.next_cursor.load(Ordering::SeqCst);
5257        let increment = u64::try_from(batch.len()).unwrap_or(u64::MAX);
5258        let last_cursor = base_cursor.saturating_add(increment);
5259        // G11 (Slice 15) — edge bodies also need projection-runtime notification.
5260        // `collect_projection_jobs` only tracks Node items (pre-fetched for
5261        // cursor assignment); edge bodies update `_fathomdb_projection_state` in
5262        // `commit_batch` but need the scanner to wake up via `notify_new_work`.
5263        let has_edge_body_work =
5264            batch.iter().any(|w| matches!(w, PreparedWrite::Edge { body: Some(_), .. }));
5265        let pending_projection = !projection_jobs.is_empty() || has_edge_body_work || unstranded;
5266
5267        let dangling_edge_endpoints = match commit_batch(
5268            connection,
5269            batch,
5270            &plans,
5271            base_cursor,
5272            self.provenance_row_cap.load(Ordering::Relaxed),
5273        ) {
5274            Ok(count) => count,
5275            Err(err) => {
5276                self.emit_sqlite_internal_error(&err);
5277                return Err(EngineError::Storage);
5278            }
5279        };
5280        self.next_cursor.store(last_cursor, Ordering::SeqCst);
5281        if pending_projection {
5282            self.projection_runtime.notify_new_work();
5283        }
5284
5285        // G0 — surface the per-row cursors (1:1 with input order). Row i got
5286        // `base_cursor + i + 1`, matching the allocation in `commit_batch`.
5287        let row_cursors = (0..batch.len())
5288            .map(|i| base_cursor.saturating_add((i as u64).saturating_add(1)))
5289            .collect();
5290        Ok(WriteReceipt { cursor: last_cursor, row_cursors, dangling_edge_endpoints })
5291    }
5292
5293    /// G11 (Slice 15) — BYO-LLM ingest: spawn an external extraction harness
5294    /// speaking the `fathomdb.extract.v1` NDJSON-over-stdio protocol, send
5295    /// documents for extraction, and write the resulting entities
5296    /// (→ `canonical_nodes`) and fact-edges (→ `canonical_edges` with G11
5297    /// enrichment columns) to the store.
5298    ///
5299    /// `cmd` is argv (first element = program, rest = args). Documents are
5300    /// batched per the harness's `max_docs_per_request`. Entity `logical_id`
5301    /// is derived as `sha256("<type>:<name>")` (lowercase, hex-encoded) for
5302    /// stable cross-re-ingestion identity. Edge `logical_id` is derived as
5303    /// `sha256("<from_lid>:<to_lid>:<relation>")`. Both are consistent with
5304    /// G0 supersession: re-ingesting the same document yields the same ids,
5305    /// triggering tombstone-then-insert rather than accumulation.
5306    ///
5307    /// Returns [`EngineError::Extractor`] on protocol errors (bad handshake,
5308    /// subprocess spawn failure, JSON decode error). `no_facts` warnings from
5309    /// the harness are not errors and do not affect the receipt counts.
5310    pub fn ingest_with_extractor(
5311        &self,
5312        cmd: &[&str],
5313        documents: &[ExtractDocument],
5314    ) -> Result<IngestWithExtractorReceipt, EngineError> {
5315        // 0.8.6 Slice 5 (ADR-0.8.6): the spawn + hello/ready handshake +
5316        // request_id framing + error mapping now live in the reusable
5317        // `provider_session` transport seam, parameterized by `ProviderTask`.
5318        // `ingest_with_extractor` is the thin extract caller: it opens a session
5319        // for `ProviderTask::Extract` then runs the extract-specific payload
5320        // build + DB writes. The session owns child reaping via Drop.
5321        let mut session = self.provider_session(ProviderTask::Extract, cmd)?;
5322        self.run_extract_session(&mut session, documents)
5323    }
5324
5325    /// 0.8.6 Slice 5 (ADR-0.8.6) — open a provider session: spawn the caller
5326    /// subprocess, run the `hello`/`ready` handshake for `task`, and negotiate
5327    /// `supported_tasks`. The transport (NDJSON over stdio, the detached stdout
5328    /// drainer, the bounded-recv timeout, the `request_id` framing, and the
5329    /// catch-all `EngineError::Extractor` mapping) is identical across tasks;
5330    /// only the protocol string (`fathomdb.<task>.v1`) and the negotiated task
5331    /// name differ. For `ProviderTask::Extract` the wire is byte-identical to the
5332    /// pre-0.8.6 `fathomdb.extract.v1` path.
5333    fn provider_session(
5334        &self,
5335        task: ProviderTask,
5336        cmd: &[&str],
5337    ) -> Result<ProviderSession, EngineError> {
5338        let (program, args) = cmd.split_first().ok_or(EngineError::Extractor)?;
5339        let mut child = Command::new(program)
5340            .args(args)
5341            .stdin(Stdio::piped())
5342            .stdout(Stdio::piped())
5343            .stderr(Stdio::inherit())
5344            .spawn()
5345            .map_err(|_| EngineError::Extractor)?;
5346
5347        let child_stdin = match child.stdin.take() {
5348            Some(s) => s,
5349            None => {
5350                let _ = child.kill();
5351                let _ = child.wait();
5352                return Err(EngineError::Extractor);
5353            }
5354        };
5355        let child_stdout = match child.stdout.take() {
5356            Some(s) => s,
5357            None => {
5358                let _ = child.kill();
5359                let _ = child.wait();
5360                return Err(EngineError::Extractor);
5361            }
5362        };
5363
5364        // fix-35 [P1/P2]: drain stdout on a dedicated thread so (a) every read can
5365        // be bounded with a timeout — a hung harness can no longer block ingest
5366        // forever — and (b) the child's stdout pipe is drained continuously,
5367        // preventing a large-request deadlock (parent blocked writing stdin while
5368        // the child blocks writing a full stdout pipe). The handle is detached:
5369        // joining could hang if a misbehaving child holds stdout open past its
5370        // stdin EOF, so the session's `Drop` (child.kill()) is what guarantees
5371        // thread exit.
5372        let io_timeout = extractor_io_timeout();
5373        let (line_tx, line_rx) = mpsc::channel::<std::io::Result<String>>();
5374        thread::spawn(move || {
5375            let mut reader = BufReader::new(child_stdout);
5376            loop {
5377                let mut buf = String::new();
5378                match reader.read_line(&mut buf) {
5379                    Ok(0) => break,
5380                    Ok(_) => {
5381                        if line_tx.send(Ok(buf)).is_err() {
5382                            break;
5383                        }
5384                    }
5385                    Err(e) => {
5386                        let _ = line_tx.send(Err(e));
5387                        break;
5388                    }
5389                }
5390            }
5391        });
5392
5393        let mut session = ProviderSession {
5394            task,
5395            child,
5396            writer: std::io::BufWriter::new(child_stdin),
5397            line_rx,
5398            io_timeout,
5399            model: None,
5400            max_docs_per_request: 8,
5401        };
5402        // On any handshake/negotiation error the session is dropped here, which
5403        // reaps the child (Drop) — matching the prior outer kill/wait semantics.
5404        session.handshake()?;
5405        Ok(session)
5406    }
5407
5408    /// 0.8.6 Slice 5 — extract-specific driver over a `ProviderSession`. The
5409    /// payload build (documents → entities/edges) and DB writes are byte-identical
5410    /// to the pre-0.8.6 inner loop; only the spawn/handshake/framing moved into
5411    /// the shared session.
5412    fn run_extract_session(
5413        &self,
5414        session: &mut ProviderSession,
5415        documents: &[ExtractDocument],
5416    ) -> Result<IngestWithExtractorReceipt, EngineError> {
5417        let extractor_model_id = session.model.clone();
5418        let max_docs = session.max_docs_per_request;
5419
5420        // --- per-batch extract → write loop ---
5421        let mut nodes_written: u64 = 0;
5422        let mut edges_written: u64 = 0;
5423        let docs_processed = documents.len() as u64;
5424
5425        for (batch_idx, batch) in documents.chunks(max_docs).enumerate() {
5426            let request_id = format!("req-{batch_idx}");
5427            let docs_json: Vec<Value> = batch
5428                .iter()
5429                .map(|d| {
5430                    serde_json::json!({
5431                        "source_doc_id": d.source_doc_id,
5432                        "body": d.body,
5433                    })
5434                })
5435                .collect();
5436
5437            // Send the framed extract request and receive its matching `result`.
5438            // The session adds protocol/type/request_id and validates the
5439            // type=="result" + matching request_id envelope (fix-24 [P2]).
5440            let result = session
5441                .request(&request_id, vec![("documents".to_string(), Value::Array(docs_json))])?;
5442
5443            // R-20-E2 (0.8.20 Slice 5c, design §4 item 10) — every row this batch
5444            // produces takes its provenance from the CALLER's
5445            // `ExtractDocument.source_doc_id`, NEVER from the model's echo of that
5446            // field. The echo is attacker-/error-controlled: a harness that omits
5447            // it used to yield rows with NULL `source_id`, which no
5448            // `excise_source` call can reach — the model could make a row
5449            // permanently un-erasable simply by dropping a key.
5450            //
5451            // `resolve_provenance` therefore admits the echo only as a SELECTOR
5452            // among ids the caller already supplied in THIS batch, and never as a
5453            // value:
5454            //
5455            //   * single-document batch — attribution is unambiguous, so the
5456            //     caller's id is used and the echo is ignored outright;
5457            //   * multi-document batch — the echo must name one of the batch's
5458            //     caller-supplied ids (the caller's own copy of the string is
5459            //     then stored). An absent or unrecognised echo is a protocol
5460            //     violation and fails the ingest LOUDLY with
5461            //     `EngineError::Extractor`, because the alternative — guessing an
5462            //     attribution — would silently mis-file the row under a document
5463            //     whose erasure would then not remove it.
5464            let batch_provenance = batch
5465                .iter()
5466                .map(|d| SourceId::new(d.source_doc_id.clone()))
5467                .collect::<Result<Vec<_>, _>>()?;
5468            let resolve_provenance = |echo: Option<&str>| -> Result<SourceId, EngineError> {
5469                if let [only] = batch_provenance.as_slice() {
5470                    return Ok(only.clone());
5471                }
5472                let echo = echo.ok_or(EngineError::Extractor)?;
5473                batch_provenance
5474                    .iter()
5475                    .find(|caller_id| caller_id.as_str() == echo)
5476                    .cloned()
5477                    .ok_or(EngineError::Extractor)
5478            };
5479
5480            // --- map entities → PreparedWrite::Node with stable logical_id ---
5481            let entities =
5482                result.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5483            let raw_edges =
5484                result.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5485
5486            // R3 (SCHEMA-GATE-1): collect substituted_t_valid values from
5487            // temporal_fallback warnings. An edge whose t_valid matches one of
5488            // these values had its event time defaulted to created_at (not
5489            // text-grounded) and must be flagged so BFS can exclude it.
5490            //
5491            // TC-33: kept as RAW `Value`s here and normalised below, together
5492            // with the edge side, through the SAME function. See the
5493            // normalisation block for why that is load-bearing.
5494            let raw_fallback_dates: Vec<&Value> = result
5495                .get("warnings")
5496                .and_then(|v| v.as_array())
5497                .map(|ws| {
5498                    ws.iter()
5499                        .filter(|w| {
5500                            w.get("kind").and_then(|k| k.as_str()) == Some("temporal_fallback")
5501                        })
5502                        .filter_map(|w| w.get("substituted_t_valid"))
5503                        .collect()
5504                })
5505                .unwrap_or_default();
5506
5507            if !entities.is_empty() {
5508                let node_batch: Vec<PreparedWrite> = entities
5509                    .iter()
5510                    .map(|entity| -> Result<PreparedWrite, EngineError> {
5511                        let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5512                        let kind = entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity");
5513                        // R-20-E2: caller-grounded, echo used only as a selector.
5514                        let source_doc_id = resolve_provenance(
5515                            entity.get("source_doc_id").and_then(|v| v.as_str()),
5516                        )?;
5517                        // fix-34 [P1]: derive_logical_id now rejects an empty name
5518                        // or a ':' in kind — inputs that would collide distinct
5519                        // entities onto one identity and silently drop one.
5520                        let logical_id = derive_logical_id(kind, name)?;
5521                        Ok(PreparedWrite::Node {
5522                            kind: kind.to_string(),
5523                            body: name.to_string(),
5524                            source_id: source_doc_id,
5525                            logical_id: Some(logical_id),
5526                            state: InitialState::Active,
5527                            reason: None,
5528                            valid_from: None,
5529                            valid_until: None,
5530                        })
5531                    })
5532                    .collect::<Result<Vec<_>, _>>()?;
5533
5534                // fix-29/fix-34 [P2]: deduplicate within the batch by logical_id so
5535                // a harness that returns the same entity twice does not write a row
5536                // that immediately supersedes its sibling (shared with the edge arm).
5537                let node_batch = dedup_prepared_by_logical_id(node_batch);
5538
5539                // fix-23 [P2]: skip entities whose logical_id is already active
5540                // to avoid needless supersede churn on re-ingest.
5541                let ids: Vec<String> = node_batch
5542                    .iter()
5543                    .filter_map(|w| {
5544                        if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5545                            Some(id.clone())
5546                        } else {
5547                            None
5548                        }
5549                    })
5550                    .collect();
5551                let existing: std::collections::HashSet<String> = self
5552                    // Internal existence probe: STRICT view — this must see
5553                    // exactly the rows the pre-slice code saw.
5554                    .read_get_many(&ids, &ReadView::default())?
5555                    .into_iter()
5556                    .zip(ids)
5557                    .filter_map(|(opt, id)| opt.map(|_| id))
5558                    .collect();
5559                let new_nodes: Vec<PreparedWrite> = node_batch
5560                    .into_iter()
5561                    .filter(|w| {
5562                        if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5563                            !existing.contains(id)
5564                        } else {
5565                            true
5566                        }
5567                    })
5568                    .collect();
5569                if !new_nodes.is_empty() {
5570                    let n = new_nodes.len() as u64;
5571                    self.write(&new_nodes)?;
5572                    nodes_written = nodes_written.saturating_add(n);
5573                }
5574            }
5575
5576            // --- map edges → PreparedWrite::Edge with G11 columns ---
5577            if !raw_edges.is_empty() {
5578                // fix-33 [P1]: the protocol gives edges NO endpoint types —
5579                // `from_entity`/`to_entity` reference entities BY NAME (or alias).
5580                // Build a name+alias → (canonical name, type) index from the same
5581                // result's `entities[]` so each endpoint's logical_id matches the
5582                // node's. (Nodes derive id from the entity's real type; defaulting
5583                // the edge endpoint kind to "entity" orphaned every contract-faithful
5584                // edge from its nodes and tripped the G8 dangling probe.)
5585                //
5586                // Two passes so a canonical NAME always wins over a (different
5587                // entity's) ALIAS regardless of `entities[]` order: pass 1 inserts
5588                // all canonical names, pass 2 fills aliases only where no name
5589                // already claims that key. (Name↔name clashes remain first-wins —
5590                // contradictory input; no principled resolution exists.)
5591                let mut entity_index: std::collections::HashMap<String, (String, String)> =
5592                    std::collections::HashMap::new();
5593                for entity in &entities {
5594                    let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5595                    if name.is_empty() {
5596                        continue;
5597                    }
5598                    let kind =
5599                        entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5600                    entity_index
5601                        .entry(name.to_lowercase())
5602                        .or_insert_with(|| (name.to_string(), kind));
5603                }
5604                for entity in &entities {
5605                    let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5606                    if name.is_empty() {
5607                        continue;
5608                    }
5609                    let kind =
5610                        entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5611                    if let Some(aliases) = entity.get("aliases").and_then(|v| v.as_array()) {
5612                        for alias in aliases.iter().filter_map(|a| a.as_str()) {
5613                            if !alias.is_empty() {
5614                                entity_index
5615                                    .entry(alias.to_lowercase())
5616                                    .or_insert_with(|| (name.to_string(), kind.clone()));
5617                            }
5618                        }
5619                    }
5620                }
5621
5622                // TC-33 — normalise EVERY extractor timestamp here, in ONE pass,
5623                // under ONE connection lock, BEFORE any edge is built. Both the
5624                // edge side (`t_valid`/`t_invalid`) and the temporal_fallback
5625                // warning side (`substituted_t_valid`) go through the SAME
5626                // function, and any value that cannot be normalised HARD-REJECTS
5627                // the whole ingest.
5628                //
5629                // **Normalising both sides is load-bearing, and nothing would
5630                // have caught it.** `temporal_fallback` is decided by comparing
5631                // the edge's t_valid against the warnings' substituted_t_valid.
5632                // That was a RAW BYTE-FOR-BYTE STRING MATCH with
5633                // `.unwrap_or(false)` on the miss path, and `substituted_t_valid`
5634                // is a FREE-FORM JSON key on the ELPS warnings envelope, not a
5635                // Rust struct field. So normalising only the edge side would
5636                // leave the set never matching, `.unwrap_or(false)` firing, and
5637                // EVERY fallback edge silently becoming a TRUSTED edge — with no
5638                // compile error anywhere. That flag is the only thing excluding
5639                // untrustworthy-time edges from graph BFS and graph seeding.
5640                //
5641                // Normalising both sides also FIXES a pre-existing brittleness:
5642                // `2025-03-20T09:30:00Z` and `2025-03-20T09:30:00+00:00` are the
5643                // same instant but MISS each other under a byte comparison. They
5644                // now compare equal as epochs.
5645                //
5646                // A malformed `substituted_t_valid` rejects rather than being
5647                // skipped: skipping it would leave the edge unflagged, i.e.
5648                // treated as TRUSTED — the same fail-open in a different place.
5649                //
5650                // The lock is taken and released HERE; `self.write(...)` below
5651                // re-acquires it, so no lock is held across the write.
5652                // (t_valid, t_invalid) epoch pair per edge, in `raw_edges` order.
5653                type EdgeTimes = Vec<(Option<i64>, Option<i64>)>;
5654                let (edge_times, fallback_epochs): (EdgeTimes, std::collections::HashSet<i64>) = {
5655                    let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5656                    let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5657
5658                    let mut times = Vec::with_capacity(raw_edges.len());
5659                    for edge in &raw_edges {
5660                        times.push((
5661                            normalize_extractor_timestamp(
5662                                connection,
5663                                "t_valid",
5664                                edge.get("t_valid"),
5665                            )?,
5666                            normalize_extractor_timestamp(
5667                                connection,
5668                                "t_invalid",
5669                                edge.get("t_invalid"),
5670                            )?,
5671                        ));
5672                    }
5673
5674                    let mut epochs = std::collections::HashSet::new();
5675                    for raw in &raw_fallback_dates {
5676                        if let Some(epoch) = normalize_extractor_timestamp(
5677                            connection,
5678                            "substituted_t_valid",
5679                            Some(raw),
5680                        )? {
5681                            epochs.insert(epoch);
5682                        }
5683                    }
5684                    (times, epochs)
5685                };
5686
5687                let edge_batch: Vec<PreparedWrite> = raw_edges
5688                    .iter()
5689                    .zip(&edge_times)
5690                    .map(|(edge, &(t_valid, t_invalid))| -> Result<PreparedWrite, EngineError> {
5691                        let from_entity =
5692                            edge.get("from_entity").and_then(|v| v.as_str()).unwrap_or("");
5693                        let to_entity =
5694                            edge.get("to_entity").and_then(|v| v.as_str()).unwrap_or("");
5695                        let relation =
5696                            edge.get("relation").and_then(|v| v.as_str()).unwrap_or("related_to");
5697                        let body = edge.get("body").and_then(|v| v.as_str()).map(str::to_string);
5698                        // TC-33: `t_valid`/`t_invalid` were normalised (and any
5699                        // malformed or non-string value hard-rejected) in the
5700                        // pass above; they arrive here as epoch seconds.
5701                        // fix-26 [P2]: validate confidence is in [0.0, 1.0] at the
5702                        // protocol boundary; reject out-of-range values.
5703                        let confidence = match edge.get("confidence").and_then(|v| v.as_f64()) {
5704                            Some(c) if !(0.0..=1.0).contains(&c) => {
5705                                return Err(EngineError::Extractor);
5706                            }
5707                            c => c,
5708                        };
5709                        // R-20-E2: caller-grounded, echo used only as a selector.
5710                        let source_doc_id =
5711                            resolve_provenance(edge.get("source_doc_id").and_then(|v| v.as_str()))?;
5712
5713                        // fix-33 [P1]: resolve each endpoint via the entities[]
5714                        // index (by name or alias) → the entity's canonical
5715                        // (name, type); fall back to kind "entity" only for a truly
5716                        // unlisted name (synthesized dangling endpoints ARE listed,
5717                        // so this is the defensive path). derive_logical_id (fix-34)
5718                        // still rejects an empty name / ':' in kind.
5719                        let (from_name, from_kind) = entity_index
5720                            .get(&from_entity.to_lowercase())
5721                            .cloned()
5722                            .unwrap_or_else(|| (from_entity.to_string(), "entity".to_string()));
5723                        let (to_name, to_kind) = entity_index
5724                            .get(&to_entity.to_lowercase())
5725                            .cloned()
5726                            .unwrap_or_else(|| (to_entity.to_string(), "entity".to_string()));
5727                        let from_lid = derive_logical_id(&from_kind, &from_name)?;
5728                        let to_lid = derive_logical_id(&to_kind, &to_name)?;
5729                        let edge_key = format!("{from_lid}:{to_lid}:{relation}");
5730                        let edge_lid = derive_logical_id("edge", &edge_key)?;
5731
5732                        // TC-33: BOTH sides are now epochs from the SAME
5733                        // normalisation, so this compares instants rather than
5734                        // byte strings.
5735                        let is_temporal_fallback =
5736                            t_valid.is_some_and(|tv| fallback_epochs.contains(&tv));
5737                        Ok(PreparedWrite::Edge {
5738                            kind: relation.to_string(),
5739                            from: from_lid,
5740                            to: to_lid,
5741                            source_id: source_doc_id,
5742                            logical_id: Some(edge_lid),
5743                            body,
5744                            t_valid,
5745                            t_invalid,
5746                            confidence,
5747                            extractor_model_id: extractor_model_id.clone(),
5748                            temporal_fallback: if is_temporal_fallback { Some(true) } else { None },
5749                        })
5750                    })
5751                    .collect::<Result<Vec<_>, _>>()?;
5752                // fix-34 [P2]: dedup edges by logical_id, mirroring the node arm
5753                // (fix-29) — a duplicate edge in one harness response would
5754                // otherwise write a row that immediately supersedes its sibling.
5755                let edge_batch = dedup_prepared_by_logical_id(edge_batch);
5756                let n = edge_batch.len() as u64;
5757                self.write(&edge_batch)?;
5758                edges_written = edges_written.saturating_add(n);
5759            }
5760        }
5761
5762        // The `ProviderSession` (and its writer/child) is dropped by the caller
5763        // when `ingest_with_extractor` returns: Drop sends stdin EOF and reaps
5764        // the child, matching the prior explicit drop(writer)+kill/wait.
5765        Ok(IngestWithExtractorReceipt { nodes_written, edges_written, docs_processed })
5766    }
5767
5768    /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM CONSOLIDATION / RECENCY.
5769    ///
5770    /// The SECOND consumer of the one `provider_session` transport (ADR-0.8.6):
5771    /// consolidation reuses the exact NDJSON-over-stdio transport, hello/ready
5772    /// handshake, `supported_tasks` negotiation, `request_id` framing, and
5773    /// bounded-recv timeout — only the protocol string
5774    /// (`fathomdb.consolidate.v1`) and the task-specific payload differ. There is
5775    /// NO second transport and NO second handshake.
5776    ///
5777    /// For each `(subject, relation)` axis, FathomDB assembles a candidate
5778    /// cluster of competing active fact-edges DETERMINISTICALLY (CPU-only, no
5779    /// LLM), sends it to the caller-supplied harness, and applies the returned
5780    /// verdicts. **CALLER-SIDE BYO-LLM**: the harness is the caller's subprocess;
5781    /// the library never embeds or calls an LLM and makes NO network egress.
5782    ///
5783    /// **Load-bearing semantic (ADR-0.8.12 §2.1):** consolidation records
5784    /// supersession / recency METADATA only — `invalidate` sets `t_invalid`,
5785    /// `supersede`/`merge` marks the row superseded via the existing G0 tombstone
5786    /// column. Edge BODIES are NEVER rewritten and NO row is ever deleted (the
5787    /// 0.8.3 lesson: blind content-merge HURT accuracy). The original rows
5788    /// survive; the engine stays deterministic.
5789    ///
5790    /// Returns [`EngineError::Consolidator`] on any transport/handshake/protocol
5791    /// fault or a malformed / out-of-cluster verdict.
5792    pub fn consolidate_with_provider(
5793        &self,
5794        cmd: &[&str],
5795        axes: &[ConsolidateAxis],
5796    ) -> Result<ConsolidateReceipt, EngineError> {
5797        // Reuse the shared transport verbatim; remap its (Extractor-flavoured)
5798        // transport error to the task-specific Consolidator leaf.
5799        let mut session = self
5800            .provider_session(ProviderTask::Consolidate, cmd)
5801            .map_err(|_| EngineError::Consolidator)?;
5802        self.run_consolidate_session(&mut session, axes)
5803    }
5804
5805    /// 0.8.12 Slice 15 — consolidate-specific driver over a `ProviderSession`.
5806    /// Mirrors [`run_extract_session`][Engine::run_extract_session]: assemble the
5807    /// task payload, run the framed request over the shared session, apply the
5808    /// task-specific DB effect. The cluster assembly + verdict application are
5809    /// CPU-only/deterministic.
5810    fn run_consolidate_session(
5811        &self,
5812        session: &mut ProviderSession,
5813        axes: &[ConsolidateAxis],
5814    ) -> Result<ConsolidateReceipt, EngineError> {
5815        let mut receipt = ConsolidateReceipt::default();
5816
5817        for (i, axis) in axes.iter().enumerate() {
5818            // 1. Deterministically assemble the candidate cluster (CPU-only, no LLM).
5819            let cluster = self.assemble_consolidate_cluster(axis)?;
5820            if cluster.is_empty() {
5821                continue;
5822            }
5823            receipt.clusters_processed = receipt.clusters_processed.saturating_add(1);
5824            receipt.edges_examined = receipt.edges_examined.saturating_add(cluster.len() as u64);
5825
5826            // 2. Send the cluster; receive the verdict envelope. The session adds
5827            //    protocol/type/request_id and validates type=="result" + matching
5828            //    request_id. Any transport/protocol fault → Consolidator.
5829            let request_id = format!("req-{i}");
5830            // TC-33: storage and `ConsolidateCandidateEdge` are INTEGER epoch
5831            // seconds, but the harness WIRE is ISO-8601 — the same split as the
5832            // extractor boundary. Render on the way out; the verdict's
5833            // `t_invalid` is normalised back on the way in. Without this the
5834            // harness would receive epoch integers and (since the reference stub
5835            // echoes the winner's `t_valid` straight back as `t_invalid`) its
5836            // reply would be rejected by our own inbound normaliser.
5837            let edges_json: Vec<Value> = {
5838                let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5839                let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5840                // TC-33 fix-1 backstop [DEFENSIVE — unreachable]. A stored
5841                // `Some(ts)` that fails to render must NOT become a silent
5842                // `null`: that is exactly the "still valid" resurrection vector.
5843                // With `reject_unrenderable_edge_epoch` guarding the write
5844                // boundary, no unrenderable epoch can reach storage — so this is
5845                // a hard-assert upholding that invariant STRUCTURALLY, not the
5846                // primary defence. `None` (unknown) still renders to JSON null;
5847                // only a NON-NULL stored epoch that fails to render is an error.
5848                let render = |field: &str, value: Option<i64>| -> Result<Value, EngineError> {
5849                    match value {
5850                        None => Ok(Value::Null),
5851                        Some(ts) => match epoch_seconds_to_iso8601(connection, ts) {
5852                            Some(iso) => Ok(Value::from(iso)),
5853                            None => Err(EngineError::InvalidArgument {
5854                                msg: format!(
5855                                    "INVARIANT VIOLATION (TC-33 fix-1): stored edge `{field}` = \
5856                                     {ts} is unrenderable to ISO-8601 and would have gone to the \
5857                                     consolidation wire as a silent null (\"still valid\"). The \
5858                                     write boundary should have made this unstorable."
5859                                ),
5860                            }),
5861                        },
5862                    }
5863                };
5864                cluster
5865                    .iter()
5866                    .map(|e| {
5867                        Ok::<Value, EngineError>(serde_json::json!({
5868                            "edge_ref": e.edge_ref,
5869                            "body": e.body,
5870                            "t_valid": render("t_valid", e.t_valid)?,
5871                            "t_invalid": render("t_invalid", e.t_invalid)?,
5872                            "confidence": e.confidence,
5873                            "source_doc_id": e.source_doc_id,
5874                            "extractor_model_id": e.extractor_model_id,
5875                        }))
5876                    })
5877                    .collect::<Result<Vec<Value>, EngineError>>()?
5878            };
5879            let cluster_json = serde_json::json!({
5880                "subject": axis.subject_logical_id,
5881                "relation": axis.relation,
5882                "edges": edges_json,
5883            });
5884            let result = session
5885                .request(&request_id, vec![("cluster".to_string(), cluster_json)])
5886                .map_err(|_| EngineError::Consolidator)?;
5887
5888            // 3. Apply the verdicts (metadata-only; original rows + bodies survive).
5889            let verdicts = result
5890                .get("verdicts")
5891                .and_then(|v| v.as_array())
5892                .ok_or(EngineError::Consolidator)?
5893                .clone();
5894            self.apply_consolidate_verdicts(&cluster, &verdicts, &mut receipt)?;
5895        }
5896
5897        Ok(receipt)
5898    }
5899
5900    /// 0.8.12 Slice 15 — assemble the competing fact-edge cluster for one
5901    /// `(subject, relation)` axis, deterministically, from active `canonical_edges`
5902    /// (`from_id = subject AND kind = relation AND superseded_at IS NULL`), ordered
5903    /// by `write_cursor` (stable insertion order). CPU-only; no network, no LLM.
5904    fn assemble_consolidate_cluster(
5905        &self,
5906        axis: &ConsolidateAxis,
5907    ) -> Result<Vec<ConsolidateCandidateEdge>, EngineError> {
5908        self.ensure_open()?;
5909        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5910        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5911        let mut stmt = connection
5912            .prepare(
5913                "SELECT logical_id, body, t_valid, t_invalid, confidence, source_id, \
5914                        extractor_model_id \
5915                 FROM canonical_edges \
5916                 WHERE from_id = ?1 AND kind = ?2 AND superseded_at IS NULL \
5917                 ORDER BY write_cursor",
5918            )
5919            .map_err(|_| EngineError::Storage)?;
5920        let rows = stmt
5921            .query_map(params![axis.subject_logical_id, axis.relation], |r| {
5922                Ok(ConsolidateCandidateEdge {
5923                    edge_ref: r.get::<_, Option<String>>(0)?.unwrap_or_default(),
5924                    body: r.get(1)?,
5925                    t_valid: r.get(2)?,
5926                    t_invalid: r.get(3)?,
5927                    confidence: r.get(4)?,
5928                    source_doc_id: r.get(5)?,
5929                    extractor_model_id: r.get(6)?,
5930                })
5931            })
5932            .map_err(|_| EngineError::Storage)?;
5933        let out: rusqlite::Result<Vec<ConsolidateCandidateEdge>> = rows.collect();
5934        // Skip any edge with a NULL/empty logical_id (no stable ref to round-trip).
5935        Ok(out
5936            .map_err(|_| EngineError::Storage)?
5937            .into_iter()
5938            .filter(|e| !e.edge_ref.is_empty())
5939            .collect())
5940    }
5941
5942    /// 0.8.12 Slice 15 — apply the harness verdicts as METADATA-ONLY transitions
5943    /// (ADR-0.8.12 §2.1). NEVER rewrites a body, NEVER deletes a row. A verdict
5944    /// referencing an edge not in the presented cluster, or an unknown verdict
5945    /// kind, is a protocol fault → [`EngineError::Consolidator`].
5946    fn apply_consolidate_verdicts(
5947        &self,
5948        cluster: &[ConsolidateCandidateEdge],
5949        verdicts: &[Value],
5950        receipt: &mut ConsolidateReceipt,
5951    ) -> Result<(), EngineError> {
5952        let known: std::collections::HashSet<&str> =
5953            cluster.iter().map(|e| e.edge_ref.as_str()).collect();
5954        // fix-1 [P2] bijection: the verdict set must cover the presented cluster
5955        // EXACTLY — every presented edge ruled on, none ruled on twice.
5956        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
5957
5958        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5959        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
5960        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
5961
5962        for v in verdicts {
5963            let edge_ref =
5964                v.get("edge_ref").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
5965            // The harness may only rule on edges FathomDB presented in the cluster.
5966            if !known.contains(edge_ref) {
5967                return Err(EngineError::Consolidator);
5968            }
5969            // fix-1 [P2]: a repeated edge_ref is a protocol fault (not a bijection).
5970            if !seen.insert(edge_ref) {
5971                return Err(EngineError::Consolidator);
5972            }
5973            let verdict =
5974                v.get("verdict").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
5975            // Look up the active edge's projection cursor BEFORE any UPDATE so a
5976            // supersede (which clears `superseded_at IS NULL`) can still find it.
5977            let active_cursor = Self::active_edge_write_cursor(&tx, edge_ref)?;
5978            match verdict {
5979                "keep" => {
5980                    receipt.edges_kept = receipt.edges_kept.saturating_add(1);
5981                }
5982                "invalidate" => {
5983                    // Recency metadata: set t_invalid; the row and its body are
5984                    // left intact (this is NOT a destructive content rewrite).
5985                    //
5986                    // TC-33: the CONSOLIDATION harness is the same class of
5987                    // BYO-LLM boundary as the extractor, so it carries ISO-8601
5988                    // on the wire and is normalised here with the SAME hard
5989                    // rejection. Previously the raw string went straight into the
5990                    // UPDATE with no validation whatsoever.
5991                    // fix-3 [P2]: consolidation is a BYO-LLM PROVIDER boundary, so
5992                    // a malformed / non-string `t_invalid` is a PROVIDER protocol
5993                    // fault → `Consolidator`, NOT the extractor/user `InvalidArgument`
5994                    // that `normalize_extractor_timestamp` emits. Remap it to match
5995                    // the two sibling failure modes on this same value (missing key
5996                    // and null/unparseable-to-None, both `Consolidator`). Consistent,
5997                    // not a diagnostic loss: `Consolidator` is a unit variant and the
5998                    // adjacent `.ok_or(EngineError::Consolidator)` cases already
5999                    // discard any message.
6000                    let ts = normalize_extractor_timestamp(
6001                        &tx,
6002                        "t_invalid",
6003                        Some(v.get("t_invalid").ok_or(EngineError::Consolidator)?),
6004                    )
6005                    .map_err(|_| EngineError::Consolidator)?
6006                    .ok_or(EngineError::Consolidator)?;
6007                    tx.execute(
6008                        "UPDATE canonical_edges SET t_invalid = ?1 \
6009                         WHERE logical_id = ?2 AND superseded_at IS NULL",
6010                        params![ts, edge_ref],
6011                    )
6012                    .map_err(|_| EngineError::Storage)?;
6013                    // fix-1 [P1]: prune the STATIC projection shadow rows so the
6014                    // consolidated-away edge stops surfacing in FTS/vector — but
6015                    // ONLY when the edge is ended as of the engine's "now",
6016                    // mirroring the graph-traversal filter `edge_validity_sql`.
6017                    // A future-dated t_invalid keeps the edge valid ⇒ keep the
6018                    // projection. NON-DESTRUCTIVE: the canonical_edges row + body
6019                    // survive (ADR-0.8.12 §2.1).
6020                    //
6021                    // TC-33: this used to be `SELECT datetime(?1) <= datetime('now')`
6022                    // — an inline clock, AND a misleading error class: junk made
6023                    // the SELECT yield SQL NULL, so `r.get::<bool>` failed as
6024                    // `EngineError::Storage`. Both timestamps are integers now, so
6025                    // the comparison is plain Rust against the bound `:now` seam.
6026                    if let Some(cursor) = active_cursor {
6027                        let ended = ts <= current_epoch_seconds();
6028                        if ended {
6029                            // fix-2 [P2]: KEEP the projection terminal row. The
6030                            // canonical_edges row stays NON-superseded (invalidate
6031                            // is metadata-only), and `database_has_pending_projection_work`
6032                            // flags any non-superseded edge that has a body but no
6033                            // terminal as pending; since `next_pending_projection_jobs`
6034                            // only scans cursors ABOVE the stored projection cursor, an
6035                            // already-projected invalidated edge would never be requeued
6036                            // and `drain()`/`wait_for_idle` would hang forever. Dropping
6037                            // the FTS/vec shadows (below) hides it from active retrieval;
6038                            // retaining the terminal keeps the scheduler idle.
6039                            Self::prune_edge_projection_shadows(&tx, cursor, true)?;
6040                        }
6041                    }
6042                    receipt.edges_invalidated = receipt.edges_invalidated.saturating_add(1);
6043                }
6044                // `merge` maps cleanly to supersede + metadata (ADR-0.8.12 §3):
6045                // the loser is marked superseded; the winner ("by"/"into") is the
6046                // surviving active row. No body is merged.
6047                "supersede" | "merge" => {
6048                    // Mark superseded via the existing G0 tombstone column; the row
6049                    // survives (invalidate-not-delete). Use a fresh monotonic cursor.
6050                    let cursor = self.next_cursor.fetch_add(1, Ordering::SeqCst).saturating_add(1);
6051                    tx.execute(
6052                        "UPDATE canonical_edges SET superseded_at = ?1 \
6053                         WHERE logical_id = ?2 AND superseded_at IS NULL",
6054                        params![cursor, edge_ref],
6055                    )
6056                    .map_err(|_| EngineError::Storage)?;
6057                    // fix-1 [P1]: a superseded edge is unconditionally out of the
6058                    // active set (graph traversal filters `superseded_at IS NULL`),
6059                    // so prune its FTS/vector projection shadow rows to match.
6060                    if let Some(active_cursor) = active_cursor {
6061                        // A superseded row is excluded from the pending-work check
6062                        // (`superseded_at IS NOT NULL`), so dropping its terminal too
6063                        // is safe (matches the excise pattern) and cannot phantom-pend.
6064                        Self::prune_edge_projection_shadows(&tx, active_cursor, false)?;
6065                    }
6066                    receipt.edges_superseded = receipt.edges_superseded.saturating_add(1);
6067                }
6068                _ => return Err(EngineError::Consolidator),
6069            }
6070        }
6071
6072        // fix-1 [P2]: bijection completeness — every presented cluster edge must
6073        // have received exactly one verdict.
6074        if seen.len() != known.len() {
6075            return Err(EngineError::Consolidator);
6076        }
6077
6078        tx.commit().map_err(|_| EngineError::Storage)?;
6079        Ok(())
6080    }
6081
6082    /// fix-1 [P1] — the active (non-superseded) row's projection `write_cursor`
6083    /// for a fact-edge `logical_id`, or `None` if there is no active row. The
6084    /// cursor keys the STATIC projection shadow rows (FTS `search_index_edges`,
6085    /// vec0 `vector_default` by rowid, `_fathomdb_vector_rows`,
6086    /// `_fathomdb_projection_terminal`).
6087    fn active_edge_write_cursor(
6088        tx: &rusqlite::Transaction<'_>,
6089        edge_ref: &str,
6090    ) -> Result<Option<i64>, EngineError> {
6091        tx.query_row(
6092            "SELECT write_cursor FROM canonical_edges \
6093             WHERE logical_id = ?1 AND superseded_at IS NULL",
6094            params![edge_ref],
6095            |r| r.get::<_, i64>(0),
6096        )
6097        .optional()
6098        .map_err(|_| EngineError::Storage)
6099    }
6100
6101    /// fix-1 [P1] — prune the STATIC projection shadow rows for a canonical
6102    /// row's `write_cursor` so a consolidated-away edge stops surfacing in
6103    /// FTS/vector retrieval. Mirrors the excision pattern at
6104    /// `excise_source_inner` (invalidate-not-delete: the canonical row + body
6105    /// are NEVER touched here).
6106    ///
6107    /// `keep_terminal` retains the `_fathomdb_projection_terminal` marker — set it
6108    /// when the canonical row stays NON-superseded (an `invalidate` verdict), so the
6109    /// projection scheduler still treats the cursor as done (fix-2 [P2]); clear it
6110    /// when the row is superseded (excluded from the pending-work scan anyway).
6111    ///
6112    /// FIXED (0.8.12 Slice A, R-CON-2 named default-ON blocker; Slice-20 codex
6113    /// §9 [P2]): a full `rebuild_projections` re-projects every non-superseded
6114    /// edge with a body from `canonical_edges` — this used to re-materialise an
6115    /// invalidated edge's FTS/vec shadows even though graph traversal excludes
6116    /// it via the `t_invalid > now` filter. The FTS rebuild SELECT
6117    /// (`rebuild_shadow_state`), the vec projection queue
6118    /// (`next_pending_projection_jobs`), and the pending-work probe
6119    /// (`database_has_pending_projection_work`) now all carry the same
6120    /// `edge_validity_sql` filter as graph traversal (TC-33: INTEGER compare
6121    /// against the bound `:now`, formerly `datetime(t_invalid) > datetime('now')`),
6122    /// so a rebuild is durable across the recency exclusion.
6123    fn prune_edge_projection_shadows(
6124        tx: &rusqlite::Transaction<'_>,
6125        cursor: i64,
6126        keep_terminal: bool,
6127    ) -> Result<(), EngineError> {
6128        tx.execute("DELETE FROM search_index_edges WHERE write_cursor = ?1", [cursor])
6129            .map_err(|_| EngineError::Storage)?;
6130        // vec0 rowid is the canonical row's write_cursor. TC-76: via the one
6131        // vec0-delete primitive ([`delete_vector_partition_row`]).
6132        delete_vector_partition_row(tx, cursor).map_err(|_| EngineError::Storage)?;
6133        tx.execute("DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1", [cursor])
6134            .map_err(|_| EngineError::Storage)?;
6135        if !keep_terminal {
6136            tx.execute(
6137                "DELETE FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
6138                [cursor],
6139            )
6140            .map_err(|_| EngineError::Storage)?;
6141        }
6142        Ok(())
6143    }
6144
6145    pub fn search(&self, query: &str) -> Result<SearchResult, EngineError> {
6146        self.search_filtered(query, None)
6147    }
6148
6149    /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — `search` under an explicit
6150    /// [`ReadView`], the escape hatch matching the one the five read verbs got in
6151    /// Slice 10b. `search(query)` is exactly `search_view(query, &ReadView::default())`.
6152    ///
6153    /// **Scope: the VALIDITY axis only.** `include_out_of_window` and
6154    /// `valid_as_of` are honoured; the EXISTENCE flags (`include_superseded`,
6155    /// `include_inactive`) are **refused** with
6156    /// [`EngineError::InvalidArgument`] rather than silently ignored. Relaxing
6157    /// `superseded_at IS NULL` on a retrieval path would resurrect the stale-body
6158    /// leak the Slice-15 fix-1 review closed, and search hydrates from projection
6159    /// indexes (`search_index`, `vector_default`) that are not version-complete —
6160    /// so "include superseded" has no truthful answer here. Refusing says that;
6161    /// ignoring would be the dead surface this fix exists to remove.
6162    ///
6163    /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6164    pub fn search_view(&self, query: &str, view: &ReadView) -> Result<SearchResult, EngineError> {
6165        self.search_reranked_with_explain(query, None, 0, false, 0.3, 0, false, *view)
6166    }
6167
6168    /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the FULL-arity view entry
6169    /// point: [`search_reranked`][Engine::search_reranked] /
6170    /// [`search_explained`][Engine::search_explained] under an explicit
6171    /// [`ReadView`]. This is what the Python and TypeScript `search(..., view=)`
6172    /// bindings call, so a caller can combine a content filter, the CE knobs and
6173    /// a validity view in one query — passing `view` must not silently disable
6174    /// the filter, and passing a filter must not silently disable `view`.
6175    ///
6176    /// `search_reranked(q, f, d, g, a, p)` is exactly
6177    /// `search_reranked_view(q, f, d, g, a, p, false, &ReadView::default())`.
6178    ///
6179    /// Validity axis only; existence flags are refused. See
6180    /// [`search_view`][Engine::search_view].
6181    ///
6182    /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6183    #[allow(clippy::too_many_arguments)] // mirrors search_explained + the view
6184    pub fn search_reranked_view(
6185        &self,
6186        query: &str,
6187        filter: Option<SearchFilter>,
6188        rerank_depth: usize,
6189        use_graph_arm: bool,
6190        alpha: f64,
6191        pool_n: usize,
6192        explain: bool,
6193        view: &ReadView,
6194    ) -> Result<SearchResult, EngineError> {
6195        self.search_reranked_with_explain(
6196            query,
6197            filter,
6198            rerank_depth,
6199            use_graph_arm,
6200            alpha,
6201            pool_n,
6202            explain,
6203            *view,
6204        )
6205    }
6206
6207    /// G10 — hybrid `search` with an optional closed [`SearchFilter`]. `None`
6208    /// (or an all-`None` filter) is the unfiltered path whose phase-1 SQL is
6209    /// byte-identical to 0.7.2. The filter prunes the vector branch in the
6210    /// single phase-1 candidates statement and constrains the text branch by the
6211    /// same metadata. Ranking is the unconditional G9 RRF fusion.
6212    pub fn search_filtered(
6213        &self,
6214        query: &str,
6215        filter: Option<SearchFilter>,
6216    ) -> Result<SearchResult, EngineError> {
6217        // 0.8.11 Slice 40 (R-FIL-2): re-express the shipped G10 `SearchFilter`
6218        // sugar through the unified `Filter` type, then lower back to the vec0
6219        // backend's `SearchFilter` (D4). The round-trip is lossless +
6220        // canonical-order-preserving, so the produced phase-1 SQL stays
6221        // byte-identical to 0.7.2 on the `None`/all-`None` path. `SearchFilter`
6222        // never carries a `Json` term, so `to_search_filter` never rejects here.
6223        //
6224        // 0.8.20 Slice 15e — the unified `Filter`/`FilterTerm` grammar does not yet
6225        // carry `filterable`-attribute terms (a later slice adds that surface), so
6226        // the round-trip would drop `SearchFilter.attributes`. Carry them across
6227        // explicitly: they already route pre-KNN through `vector_filter_clause`.
6228        let lowered = filter
6229            .map(|mut sf| {
6230                let attributes = sf.attributes.clone();
6231                // Attribute predicates are intentionally absent from the unified
6232                // grammar, but this legacy/hybrid entry point owns their existing
6233                // pre-KNN lowering. Remove them only for the metadata round-trip,
6234                // then restore them on its `SearchFilter` output.
6235                sf.attributes.clear();
6236                Filter::try_from(&sf).and_then(|filter| {
6237                    filter.to_search_filter().map(|mut lo| {
6238                        lo.attributes = attributes;
6239                        lo
6240                    })
6241                })
6242            })
6243            .transpose()?;
6244        // FIX-6: delegate to search_reranked(depth=0, use_graph_arm=false) to eliminate the
6245        // ~26-line duplicate body that would otherwise drift with search_reranked.
6246        // 0.8.5: depth=0 is inert, so the α/pool_n defaults (0.3, 0) never reach the blend.
6247        self.search_reranked(query, lowered, 0, false, 0.3, 0)
6248    }
6249
6250    /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the vec0 search
6251    /// backend. Lowers the metadata subset to the indexed pre-KNN `WHERE` and
6252    /// **typed-rejects** a [`FilterTerm::Json`] term with
6253    /// [`EngineError::InvalidFilter`] (D3 no-demotion guarantee). This is the
6254    /// unified surface the 0.8.15 router `constraints` block reasons over; the
6255    /// shipped [`Engine::search_filtered`]`(query, Option<SearchFilter>)` stays
6256    /// as sugar over the same path.
6257    pub fn search_filter(&self, query: &str, filter: &Filter) -> Result<SearchResult, EngineError> {
6258        let sf = filter.to_search_filter()?;
6259        self.search_reranked(query, Some(sf), 0, false, 0.3, 0)
6260    }
6261
6262    /// 0.8.1 Slice 10 (R1) / Slice 30 (R3) — `search_reranked`: hybrid search
6263    /// with optional CE reranking and optional graph-BFS third arm. `rerank_depth
6264    /// = 0` is the identity (soft-fallback) path, byte-identical to
6265    /// [`search_filtered`][Engine::search_filtered]. `rerank_depth = N > 0`
6266    /// applies the cross-encoder over the top-N fused hits (when the
6267    /// `default-reranker` feature is enabled and the model is loaded); without the
6268    /// model, the call falls back to the fused order.
6269    ///
6270    /// `use_graph_arm = false` (the default) produces byte-identical results to
6271    /// the pre-Slice-30 two-arm pipeline. `use_graph_arm = true` seeds a BFS over
6272    /// temporal fact-edges from the top-10 fused hits and fuses the reachable
6273    /// nodes as a third RRF arm.
6274    ///
6275    /// Governed surface: re-exported from `fathomdb` facade.
6276    pub fn search_reranked(
6277        &self,
6278        query: &str,
6279        filter: Option<SearchFilter>,
6280        rerank_depth: usize,
6281        use_graph_arm: bool,
6282        alpha: f64,
6283        pool_n: usize,
6284    ) -> Result<SearchResult, EngineError> {
6285        // explain=false → `SearchResult.explanation == None`, byte-identical results.
6286        self.search_reranked_with_explain(
6287            query,
6288            filter,
6289            rerank_depth,
6290            use_graph_arm,
6291            alpha,
6292            pool_n,
6293            false,
6294            ReadView::default(),
6295        )
6296    }
6297
6298    /// 0.8.8 EXP-OBS (Slice 5) — `search_explained`: the opt-in `explain=true`
6299    /// surface. Identical retrieval to [`search_reranked`][Engine::search_reranked]
6300    /// (same fused/CE ranking, same `results`), additionally returning a
6301    /// [`Explanation`] sidecar on `SearchResult.explanation` with per-hit arm
6302    /// provenance + score breakdown + a query-level [`QueryTrace`]. The default
6303    /// `search`/`search_filtered`/`search_reranked` paths are unaffected and stay
6304    /// byte-identical (R-OBS-2).
6305    ///
6306    /// Governed surface: re-exported from `fathomdb` facade.
6307    pub fn search_explained(
6308        &self,
6309        query: &str,
6310        filter: Option<SearchFilter>,
6311        rerank_depth: usize,
6312        use_graph_arm: bool,
6313        alpha: f64,
6314        pool_n: usize,
6315    ) -> Result<SearchResult, EngineError> {
6316        self.search_reranked_with_explain(
6317            query,
6318            filter,
6319            rerank_depth,
6320            use_graph_arm,
6321            alpha,
6322            pool_n,
6323            true,
6324            ReadView::default(),
6325        )
6326    }
6327
6328    /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the explicit
6329    /// **text-only / FTS-only** search path. It does NOT embed the query and does
6330    /// NOT route through the vector-dependent choke point
6331    /// [`search_inner_with_stats`][Engine::search_inner_with_stats], so it NEVER
6332    /// raises [`EngineError::VectorEquivalenceMismatch`] and stays serviceable when
6333    /// the engine opened in the degraded `dense_disabled` state (the D2 "keep FTS
6334    /// servable" contract; codex R2 U1-2). Results come from the node-body FTS
6335    /// branch only — no vector recall, no CE rerank, no graph arm. Available
6336    /// regardless of degraded state; when dense is healthy it is simply a
6337    /// text-only view of the same corpus.
6338    ///
6339    /// Governed surface: re-exported from the `fathomdb` facade + Py/TS bindings.
6340    pub fn search_text_only(&self, query: &str) -> Result<SearchResult, EngineError> {
6341        self.search_text_only_view(query, &ReadView::default())
6342    }
6343
6344    /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — [`search_text_only`][Engine::search_text_only]
6345    /// under an explicit [`ReadView`]. Same validity-axis-only scope, and the same
6346    /// typed refusal of the existence flags, as [`search_view`][Engine::search_view].
6347    ///
6348    /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6349    pub fn search_text_only_view(
6350        &self,
6351        query: &str,
6352        view: &ReadView,
6353    ) -> Result<SearchResult, EngineError> {
6354        self.ensure_open()?;
6355        view.reject_existence_relaxation_on_search()?;
6356        if query.trim().is_empty() {
6357            return Err(EngineError::WriteValidation);
6358        }
6359        let compiled = compile_text_query(query);
6360        let search_limit = self
6361            .projection_runtime
6362            .shared
6363            .search_limit_override
6364            .load(Ordering::SeqCst)
6365            .max(SEARCH_RERANK_LIMIT);
6366        let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
6367        // `query_vector = None` ⇒ `read_search_in_tx` skips the vector branch
6368        // entirely (no embed, no phase-1 bit-KNN, no phase-2 L2) and returns the
6369        // text/FTS branch — exactly the un-embedded fallback the hybrid path already
6370        // takes on an embed miss.
6371        let request = ReaderRequest::Search {
6372            compiled,
6373            query_vector: None,
6374            query_vector_bin: None,
6375            search_limit,
6376            filter: None,
6377            recency_enabled: false,
6378            importance_enabled: false,
6379            vector_stage_only: false,
6380            raw_query: Box::from(query),
6381            rerank_depth: 0,
6382            use_graph_arm: false,
6383            alpha: 0.3,
6384            pool_n: 0,
6385            explain: false,
6386            view: *view,
6387            respond: response_tx,
6388        };
6389        if self.reader_pool.dispatch(request).is_err() {
6390            return Err(EngineError::Closing);
6391        }
6392        let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
6393        let (cursor, soft_fallback, results, _graph_stats, explanation) = match search_result {
6394            Ok(result) => result,
6395            // fix-3 (codex §9 [P2]) — carry the reader-snapshot validation verdict
6396            // through: an undeclared `filterable` attribute is the EXISTING typed
6397            // `InvalidFilter`, never collapsed to `Storage`. (This path takes
6398            // `filter = None`, so it never fires here, but the match stays total.)
6399            Err(SearchReaderError::InvalidFilter(reason)) => {
6400                return Err(EngineError::InvalidFilter { reason });
6401            }
6402            Err(SearchReaderError::Sqlite(err)) => {
6403                self.emit_sqlite_internal_error(&err);
6404                return Err(EngineError::Storage);
6405            }
6406        };
6407        Ok(SearchResult { projection_cursor: cursor, soft_fallback, results, explanation })
6408    }
6409
6410    /// Search one declared `searchable→FTS` projection without invoking body
6411    /// search, vector search, score fusion, or a fallback arm. Results carry the
6412    /// ordinary text branch shape and are ordered by property-FTS bm25 ascending
6413    /// then write cursor ascending.
6414    pub fn search_projected_text(
6415        &self,
6416        query: &str,
6417        name: &str,
6418        filter: Option<SearchFilter>,
6419        view: &ReadView,
6420    ) -> Result<SearchResult, EngineError> {
6421        self.ensure_open()?;
6422        view.reject_existence_relaxation_on_search()?;
6423        if query.trim().is_empty() {
6424            return Err(EngineError::WriteValidation);
6425        }
6426
6427        let limit = self
6428            .projection_runtime
6429            .shared
6430            .search_limit_override
6431            .load(Ordering::SeqCst)
6432            .max(SEARCH_RERANK_LIMIT);
6433        let (response_tx, response_rx) = mpsc::sync_channel(1);
6434        let request = ReaderRequest::SearchProjectedText {
6435            query: query.to_string(),
6436            name: name.to_string(),
6437            filter: filter.map(Box::new),
6438            limit,
6439            view: *view,
6440            respond: response_tx,
6441        };
6442        if self.reader_pool.dispatch(request).is_err() {
6443            return Err(EngineError::Closing);
6444        }
6445        match response_rx.recv().map_err(|_| EngineError::Storage)? {
6446            Ok(result) => Ok(result),
6447            Err(SearchReaderError::InvalidFilter(reason)) => {
6448                Err(EngineError::InvalidFilter { reason })
6449            }
6450            Err(SearchReaderError::Sqlite(err)) => {
6451                self.emit_sqlite_internal_error(&err);
6452                Err(EngineError::Storage)
6453            }
6454        }
6455    }
6456
6457    /// 0.8.18 Slice 5 (R-VEQ-6) — degraded-open observability accessor. `true` iff
6458    /// the open-time #5 self-check found a vector-equivalence divergence and every
6459    /// vector-dependent arm is refusing. Mirrors `OpenReport.dense_disabled`; read
6460    /// lock-free.
6461    #[must_use]
6462    pub fn dense_disabled(&self) -> bool {
6463        self.dense_disabled.load(Ordering::Acquire)
6464    }
6465
6466    /// 0.8.18 Slice 5 (R-VEQ-6) — the human-readable reason for the degraded state
6467    /// (which representation tripped), or `None` when dense is healthy.
6468    #[must_use]
6469    pub fn dense_disabled_reason(&self) -> Option<String> {
6470        self.dense_disabled_reason.lock().ok().and_then(|g| g.clone())
6471    }
6472
6473    /// 0.8.18 Slice 5 (R-VEQ-6) — telemetry counter: number of query-time
6474    /// vector-dependent-arm refusals raised because the engine opened degraded.
6475    /// Observable pre/post-query.
6476    #[must_use]
6477    pub fn vector_equivalence_refusal_count(&self) -> u64 {
6478        self.vector_equivalence_refusals.load(Ordering::Relaxed)
6479    }
6480
6481    /// Shared event-wrapped body for [`search_reranked`][Engine::search_reranked]
6482    /// (`explain=false`) and [`search_explained`][Engine::search_explained]
6483    /// (`explain=true`). Keeps the Started/Finished/Failed lifecycle emissions +
6484    /// slow detection in one place.
6485    #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6486    fn search_reranked_with_explain(
6487        &self,
6488        query: &str,
6489        filter: Option<SearchFilter>,
6490        rerank_depth: usize,
6491        use_graph_arm: bool,
6492        alpha: f64,
6493        pool_n: usize,
6494        explain: bool,
6495        view: ReadView,
6496    ) -> Result<SearchResult, EngineError> {
6497        // fix-2: refuse an existence-relaxing view BEFORE any work (and before the
6498        // Started event), so the refusal is a pure argument error rather than a
6499        // half-emitted query lifecycle.
6500        view.reject_existence_relaxation_on_search()?;
6501        self.emit_event(lifecycle::Phase::Started, lifecycle::EventCategory::Search, None);
6502        let started = Instant::now();
6503        let outcome = self.search_inner(
6504            query,
6505            filter,
6506            rerank_depth,
6507            use_graph_arm,
6508            alpha,
6509            pool_n,
6510            explain,
6511            view,
6512        );
6513        self.detect_slow(started, lifecycle::EventCategory::Search);
6514        match outcome {
6515            Ok(result) => {
6516                self.counters.record_query();
6517                // 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture. No-op + no
6518                // allocation when telemetry is OFF (the default).
6519                self.capture_telemetry(query, &result);
6520                self.emit_event(lifecycle::Phase::Finished, lifecycle::EventCategory::Search, None);
6521                Ok(result)
6522            }
6523            Err(err) => {
6524                let code = err.stable_code();
6525                self.counters.record_error(code);
6526                self.emit_event(
6527                    lifecycle::Phase::Failed,
6528                    lifecycle::EventCategory::Search,
6529                    Some(code),
6530                );
6531                self.emit_event(
6532                    lifecycle::Phase::Failed,
6533                    lifecycle::EventCategory::Error,
6534                    Some(code),
6535                );
6536                Err(err)
6537            }
6538        }
6539    }
6540
6541    /// 0.8.8 Slice 15 (OPP-9) — enable opt-in telemetry capture to a local JSONL
6542    /// `sink_path` (append-only). Off by default; once enabled, each `search`
6543    /// records a query→result event and `record_feedback` appends agent labels.
6544    /// Local file only — no network/egress. `query_id` + `ts_monotonic_ms` are
6545    /// reset deterministically on enable. Idempotent re-enable resets the seq.
6546    pub fn enable_telemetry(&self, sink_path: &str) -> Result<(), EngineError> {
6547        // Touch the sink (create + validate writable) before arming capture, so a
6548        // bad path fails loudly here rather than silently dropping events.
6549        std::fs::OpenOptions::new()
6550            .create(true)
6551            .append(true)
6552            .open(sink_path)
6553            .map_err(|_| EngineError::Storage)?;
6554        let mut guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6555        *guard = Some(TelemetrySink {
6556            path: PathBuf::from(sink_path),
6557            base: Instant::now(),
6558            nonce: 0,
6559            seq: 0,
6560            last_query_id: None,
6561        });
6562        // Arm the fast OFF-path guard LAST (after the sink is installed) so a
6563        // concurrent search either sees telemetry fully off or fully on.
6564        self.telemetry_enabled.store(true, Ordering::Release);
6565        Ok(())
6566    }
6567
6568    /// 0.8.8 Slice 15 — the most-recent captured `query_id` (for `record_feedback`).
6569    /// `None` when telemetry is off or no query has been captured yet.
6570    pub fn last_telemetry_query_id(&self) -> Option<String> {
6571        self.telemetry.lock().ok()?.as_ref().and_then(|s| s.last_query_id.clone())
6572    }
6573
6574    /// 0.8.8 Slice 15 — capture a query→result telemetry event. No-op (no alloc,
6575    /// no I/O) when telemetry is off (the default). Best-effort: a sink write error
6576    /// never fails the search. Captures ONLY ids, arms, and the query LENGTH —
6577    /// never the query text or `source_id` (privacy, ADR §C).
6578    ///
6579    /// ID-SPACES (Cause-A, 0.8.11.2 — honest record). `result_ids` is the interim
6580    /// `SearchHit.id` == `write_cursor`: within-session consistent but NOT
6581    /// cross-session-stable (reassigned on re-projection/re-ingest). `arm_of` is
6582    /// keyed by that same `write_cursor`. Cause-A adds a NEW PARALLEL field
6583    /// `result_stable_ids` carrying the cross-session-stable id
6584    /// ([`SearchHit::stable_id`], `logical_id` / content-hash) in the SAME order as
6585    /// `result_ids`; the existing `write_cursor` keys are RETAINED unchanged so
6586    /// pre-Cause-A gold and sink byte-output stay valid (the F-8a `id_space` flip
6587    /// is a separate, conscious step — see
6588    /// `dev/plans/runs/NOTE-0.8.8-to-steward-id-contract.md`).
6589    fn capture_telemetry(&self, query: &str, result: &SearchResult) {
6590        // Fast OFF path (codex §9 P2): a single atomic load when telemetry has
6591        // never been enabled — NO mutex acquisition, NO contention with the search
6592        // hot path.
6593        if !self.telemetry_enabled.load(Ordering::Acquire) {
6594            return;
6595        }
6596        let Ok(mut guard) = self.telemetry.lock() else { return };
6597        let Some(sink) = guard.as_mut() else { return };
6598        let query_id = format!("q{}-{}", sink.nonce, sink.seq);
6599        let ts_monotonic_ms = sink.base.elapsed().as_millis() as u64;
6600        let mut arm_of = serde_json::Map::new();
6601        for h in &result.results {
6602            // Keyed on the engine-internal positional cursor (the pre-C-2
6603            // `SearchHit.id` == `write_cursor`), byte-unchanged so `record_feedback`
6604            // + the gold pipeline keep keying on the same `result_ids` space.
6605            arm_of
6606                .insert(h.write_cursor.to_string(), serde_json::Value::from(branch_str(h.branch)));
6607        }
6608        let event = serde_json::json!({
6609            "type": "event",
6610            "schema_version": 1,
6611            "ts_monotonic_ms": ts_monotonic_ms,
6612            "query_id": query_id,
6613            "query_chars": query.chars().count() as u64,
6614            "result_ids": result.results.iter().map(|h| h.write_cursor).collect::<Vec<u64>>(),
6615            // Cause-A / C-2: parallel cross-session-stable ids, SAME order as
6616            // result_ids. Post-C-2 the stable id lives on `SearchHit.id` (its
6617            // prefixed form == the pre-swap `stable_id` value byte-for-byte), so
6618            // the emitted bytes are unchanged and the `write_cursor` result_ids
6619            // keys are retained unchanged (pre-Cause-A gold stays valid).
6620            "result_stable_ids": result
6621                .results
6622                .iter()
6623                .map(|h| h.id.to_prefixed())
6624                .collect::<Vec<String>>(),
6625            "arm_of": arm_of,
6626        });
6627        let _ = append_jsonl(&sink.path, &event);
6628        sink.seq += 1;
6629        sink.last_query_id = Some(query_id);
6630    }
6631
6632    /// 0.8.8 Slice 15 — append an agent-supplied relevance-label record for a
6633    /// previously-captured `query_id`. `label_source` is the only exogenous string
6634    /// (caller-declared, e.g. `"agent:hermes"`).
6635    ///
6636    /// ID-SPACE (Cause-A, 0.8.11.2 — honest record). `relevant_ids` /
6637    /// `irrelevant_ids` are the interim `SearchHit.id` == `write_cursor` (the same
6638    /// space as the captured event's `result_ids`), NOT `logical_id`. The
6639    /// signature is left byte-stable: the gold pipeline maps these `write_cursor`
6640    /// keys to the cross-session-stable id via the capture event's parallel
6641    /// `result_ids` ↔ `result_stable_ids` arrays (`eval/gold_capture.py`), so no
6642    /// new feedback parameter — and no binding-signature churn — is required.
6643    /// Errors if telemetry is off.
6644    pub fn record_feedback(
6645        &self,
6646        query_id: &str,
6647        relevant_ids: &[u64],
6648        irrelevant_ids: &[u64],
6649        label_source: &str,
6650    ) -> Result<(), EngineError> {
6651        let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6652        let sink = guard
6653            .as_ref()
6654            .ok_or(EngineError::InvalidArgument { msg: "telemetry is not enabled".to_string() })?;
6655        // codex §9 [P1] (privacy): `query_id` is an exogenous caller string. Only a
6656        // deterministic id that `capture_telemetry` has ALREADY emitted may be
6657        // persisted — otherwise a caller could smuggle query text / a `source_id`
6658        // into the sink under the `query_id` key. Require the canonical
6659        // `q{nonce}-{seq}` form with `nonce == sink.nonce` AND `seq < sink.seq`
6660        // (a seq the capture path has issued). Reject (writing nothing) otherwise.
6661        let is_issued_id = query_id
6662            .strip_prefix('q')
6663            .and_then(|rest| rest.split_once('-'))
6664            .and_then(|(nonce, seq)| Some((nonce.parse::<u64>().ok()?, seq.parse::<u64>().ok()?)))
6665            .is_some_and(|(nonce, seq)| nonce == sink.nonce && seq < sink.seq);
6666        if !is_issued_id {
6667            return Err(EngineError::InvalidArgument { msg: "unknown query_id".to_string() });
6668        }
6669        let record = serde_json::json!({
6670            "type": "feedback",
6671            "schema_version": 1,
6672            "query_id": query_id,
6673            "relevant_ids": relevant_ids,
6674            "irrelevant_ids": irrelevant_ids,
6675            "label_source": label_source,
6676        });
6677        append_jsonl(&sink.path, &record).map_err(|_| EngineError::Storage)
6678    }
6679
6680    fn detect_slow(&self, started: Instant, category: lifecycle::EventCategory) {
6681        let elapsed = started.elapsed();
6682        let threshold = self.slow_threshold_ms.load(Ordering::Relaxed);
6683        let threshold_duration = std::time::Duration::from_millis(threshold);
6684        if elapsed > threshold_duration {
6685            // `dev/design/lifecycle.md` § Slow and heartbeat policy: a slow
6686            // operation produces TWO correlated facts. The
6687            // statement-level slow-statement signal is dispatched by the
6688            // sqlite3_profile callback (`profile_callback_trampoline`).
6689            // This site emits the lifecycle `Phase::Slow` event for the
6690            // outer operation envelope (AC-008).
6691            self.emit_event(lifecycle::Phase::Slow, category, None);
6692        }
6693    }
6694
6695    fn emit_event(
6696        &self,
6697        phase: lifecycle::Phase,
6698        category: lifecycle::EventCategory,
6699        code: Option<&'static str>,
6700    ) {
6701        let event =
6702            lifecycle::Event { phase, source: lifecycle::EventSource::Engine, category, code };
6703        self.subscribers.dispatch(&event);
6704    }
6705
6706    /// Emit a `(SqliteInternal, Error, code: <SQLITE_*>)` lifecycle
6707    /// event for a rusqlite error. Per `dev/design/lifecycle.md`
6708    /// § Diagnostic source and category, SQLite-originated diagnostics
6709    /// route through the same host subscriber as engine-originated
6710    /// events with `source` preserved. AC-021 dispatches on
6711    /// `code == "SQLITE_SCHEMA"`.
6712    fn emit_sqlite_internal_error(&self, err: &rusqlite::Error) {
6713        if let Some(code) = sqlite_extended_code_name(err) {
6714            let event = lifecycle::Event {
6715                phase: lifecycle::Phase::Failed,
6716                source: lifecycle::EventSource::SqliteInternal,
6717                category: lifecycle::EventCategory::Error,
6718                code: Some(code),
6719            };
6720            self.subscribers.dispatch(&event);
6721        }
6722    }
6723
6724    /// Thin wrapper: the production search path that discards the G0 Phase-2
6725    /// frontier meter (it never reaches `SearchResult` / the governed surface).
6726    #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6727    fn search_inner(
6728        &self,
6729        query: &str,
6730        filter: Option<SearchFilter>,
6731        rerank_depth: usize,
6732        use_graph_arm: bool,
6733        alpha: f64,
6734        pool_n: usize,
6735        explain: bool,
6736        view: ReadView,
6737    ) -> Result<SearchResult, EngineError> {
6738        self.search_inner_with_stats(
6739            query,
6740            filter,
6741            rerank_depth,
6742            use_graph_arm,
6743            alpha,
6744            pool_n,
6745            explain,
6746            view,
6747        )
6748        .map(|(result, _stats)| result)
6749    }
6750
6751    /// G0 Phase-2: the search body, additionally returning the graph-arm frontier
6752    /// meter. Only the `_graph_frontier_stats_for_test` seam consumes the stats;
6753    /// `search_inner` (and thus `search_reranked` / `search`) drops them.
6754    #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6755    fn search_inner_with_stats(
6756        &self,
6757        query: &str,
6758        filter: Option<SearchFilter>,
6759        rerank_depth: usize,
6760        use_graph_arm: bool,
6761        alpha: f64,
6762        pool_n: usize,
6763        explain: bool,
6764        view: ReadView,
6765    ) -> Result<(SearchResult, GraphFrontierStats), EngineError> {
6766        self.ensure_open()?;
6767        // 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the SINGLE
6768        // vector-dependent choke point. If the open-time self-check found a
6769        // divergence beyond the D4 floor, refuse EVERY vector-dependent arm
6770        // (search / search_expand / explain-rerank / graph-arm all funnel here)
6771        // BEFORE any embedding / vector SQL / graph seeding / CE rerank — no
6772        // silent partial results. The text-only/FTS-only path
6773        // (`search_text_only`) does NOT route through here, so FTS stays
6774        // serviceable in degraded mode.
6775        if self.dense_disabled.load(Ordering::Acquire) {
6776            self.vector_equivalence_refusals.fetch_add(1, Ordering::Relaxed);
6777            let reason =
6778                self.dense_disabled_reason.lock().ok().and_then(|g| g.clone()).unwrap_or_else(
6779                    || "open-time #5 vector-equivalence self-check failed".to_string(),
6780                );
6781            return Err(EngineError::VectorEquivalenceMismatch { reason });
6782        }
6783        if query.trim().is_empty() {
6784            return Err(EngineError::WriteValidation);
6785        }
6786
6787        // 0.8.20 Slice 15e fix-2 finding 1 [P2] — every filter attribute name is
6788        // validated against the declared `filterable` registry set BEFORE any arm
6789        // runs, so an UNDECLARED name is a typed `InvalidFilter` rejection instead
6790        // of an opaque `no such column` `Storage` crash (vector arm) or a silent
6791        // no-match (FTS arm). ADR-0.8.11 D3: every filter term has a DEFINED outcome
6792        // ("compiles" or "typed rejection") IDENTICAL across arms.
6793        //
6794        // keystone closeout fix-3 (codex §9 [P2], TOCTOU): that validation is NO
6795        // LONGER performed here on `self.connection` before dispatch. fix-2 checked
6796        // the registry on the WRITER connection and then let the reader prepare the
6797        // vec0 query on a DIFFERENT connection/snapshot — a `configure_projections`
6798        // DROP landing in the window between the check and the reader snapshot could
6799        // still make the `attr_<hex>` column vanish AFTER validation passed, i.e. the
6800        // exact untyped `Storage` failure fix-2 meant to prevent. The check now runs
6801        // INSIDE the reader's deferred transaction (see
6802        // `validate_filter_attributes_on_snapshot`, called from `read_search_in_tx`),
6803        // so the registry it reads and the vec0 columns the query compiles against are
6804        // ONE snapshot — the race is closed and BOTH arms still see the same typed
6805        // `InvalidFilter`. Moving it there also removes a per-filtered-search writer
6806        // lock and the fix-2 concurrent-ADD false-reject (the reader snapshot sees a
6807        // freshly-added declaration and accepts).
6808        let compiled = compile_text_query(query);
6809        // REQ-013 / AC-059b / REQ-055: the cursor returned with a search
6810        // MUST be derived from the same WAL snapshot the data was read
6811        // from. Loading `next_cursor` from the writer-side atomic before
6812        // the reader transaction acquires its snapshot races against
6813        // concurrent writers — see `dev/design/engine.md` § Cursor
6814        // contract. Run cursor probe + body query inside one read tx
6815        // (BEGIN DEFERRED on a `query_only=ON` connection in WAL mode is
6816        // a snapshot-stable read).
6817        // EU-5a2 mean-centering apply path (query side). `query_vector`
6818        // is ALWAYS un-centered (used by the f32 vec_distance_l2 rerank
6819        // in phase 2). `query_vector_bin` is the (possibly centered) f32
6820        // fed to `vec_quantize_binary` in phase 1. The centering decision
6821        // mirrors the write path: identity must be MC-required AND a
6822        // mean_vec must be pinned. NoopEmbedder collapses to
6823        // `query_vector_bin == query_vector` until EU-5b.
6824        let raw_query_vector =
6825            self.runtime_embedder.as_ref().and_then(|embedder| embedder.embed(query).ok());
6826        let query_vector_bin = match raw_query_vector.as_ref() {
6827            Some(vector) if identity_requires_mean_centering(&self.runtime_embedder_identity) => {
6828                let pinned = {
6829                    let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
6830                    let connection = connection.as_ref().ok_or(EngineError::Closing)?;
6831                    read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)?
6832                };
6833                match pinned {
6834                    Some(mean) => serde_json::to_string(&subtract_mean(vector, &mean)).ok(),
6835                    None => serde_json::to_string(vector).ok(),
6836                }
6837            }
6838            Some(vector) => serde_json::to_string(vector).ok(),
6839            None => None,
6840        };
6841        let query_vector = raw_query_vector.and_then(|vector| serde_json::to_string(&vector).ok());
6842        // 0.7.2 PR-2bc S1 fix-1 — phase-2 rerank LIMIT. Production default is
6843        // `SEARCH_RERANK_LIMIT` (10); the test seam may RAISE it, clamped to
6844        // the production floor so a test can never shrink search semantics.
6845        let search_limit = self
6846            .projection_runtime
6847            .shared
6848            .search_limit_override
6849            .load(Ordering::SeqCst)
6850            .max(SEARCH_RERANK_LIMIT);
6851        let recency_enabled =
6852            self.projection_runtime.shared.recency_reweight_enabled.load(Ordering::SeqCst);
6853        let importance_enabled =
6854            self.projection_runtime.shared.importance_reweight_enabled.load(Ordering::SeqCst);
6855        let vector_stage_only =
6856            self.projection_runtime.shared.vector_stage_only_for_test.load(Ordering::SeqCst);
6857        let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
6858        let request = ReaderRequest::Search {
6859            compiled,
6860            query_vector,
6861            query_vector_bin,
6862            search_limit,
6863            filter: filter.map(Box::new),
6864            recency_enabled,
6865            importance_enabled,
6866            vector_stage_only,
6867            raw_query: Box::from(query), // FIX-4: Box<str> (16B) not String (24B)
6868            rerank_depth,
6869            use_graph_arm,
6870            alpha,
6871            pool_n,
6872            explain,
6873            view,
6874            respond: response_tx,
6875        };
6876        if self.reader_pool.dispatch(request).is_err() {
6877            return Err(EngineError::Closing);
6878        }
6879        let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
6880        let (cursor, soft_fallback, results, graph_stats, explanation) = match search_result {
6881            Ok(result) => result,
6882            // fix-3 (codex §9 [P2]) — an undeclared `filterable` attribute is caught
6883            // on the reader's OWN snapshot (validate + vec0 query = one transaction),
6884            // so it surfaces as the EXISTING typed `InvalidFilter` and can never be
6885            // the opaque `no such column` `Storage` error the TOCTOU race produced.
6886            Err(SearchReaderError::InvalidFilter(reason)) => {
6887                return Err(EngineError::InvalidFilter { reason });
6888            }
6889            Err(SearchReaderError::Sqlite(err)) => {
6890                self.emit_sqlite_internal_error(&err);
6891                return Err(EngineError::Storage);
6892            }
6893        };
6894
6895        // The worker (`read_search_in_tx`) has no embedder identity; fill the
6896        // trace's `embedder_id` here, where `self.runtime_embedder_identity` is in
6897        // scope. Only on the explain path (`explanation` is `Some`).
6898        let explanation = explanation.map(|mut exp| {
6899            let id = &self.runtime_embedder_identity;
6900            exp.trace.embedder_id = format!("{}@{} (dim={})", id.name, id.revision, id.dimension);
6901            exp
6902        });
6903
6904        Ok((
6905            SearchResult { projection_cursor: cursor, soft_fallback, results, explanation },
6906            graph_stats,
6907        ))
6908    }
6909
6910    /// G0 Phase-2 (BLOCK-1) test seam — runs the graph-arm retrieval path and
6911    /// returns the frontier meter (`GraphFrontierStats`) for `query`. Mirrors the
6912    /// sanctioned `set_vector_stage_only_for_test` / `_configure_vector_kind_for_test`
6913    /// pattern: kept OFF the governed surface (test/eval-only), so the meter never
6914    /// appears on `SearchResult`. Used by the recall harness to prove the
6915    /// doc-seeded frontier is empty (`resolved_seed_rate == 0.0`) and, post-C1, the
6916    /// 0→>0 flip.
6917    pub fn _graph_frontier_stats_for_test(
6918        &self,
6919        query: &str,
6920    ) -> Result<GraphFrontierStats, EngineError> {
6921        self.search_inner_with_stats(query, None, 0, true, 0.3, 0, false, ReadView::default())
6922            .map(|(_result, stats)| stats)
6923    }
6924
6925    /// Slice 30 (G2) — `read.get`: active-only point lookup by `logical_id`.
6926    /// Delegates to [`Engine::read_get_many`]; returns the single slot. A
6927    /// missing/superseded id is `None` (a normal absence, not an error). Reads
6928    /// ride the ReaderWorkerPool DEFERRED-tx path (never the writer lock).
6929    pub fn read_get(
6930        &self,
6931        logical_id: &str,
6932        view: &ReadView,
6933    ) -> Result<Option<NodeRecord>, EngineError> {
6934        let ids = [logical_id.to_string()];
6935        let rows = self.read_get_many(&ids, view)?;
6936        Ok(rows.into_iter().next().flatten())
6937    }
6938
6939    /// Slice 30 (G2) — `read.get_many`: active-only point lookup over many
6940    /// `logical_id`s. Returns one slot per requested id in REQUEST ORDER, `None`
6941    /// where no active row carries that id (partial, never all-or-nothing).
6942    pub fn read_get_many(
6943        &self,
6944        logical_ids: &[String],
6945        view: &ReadView,
6946    ) -> Result<Vec<Option<NodeRecord>>, EngineError> {
6947        self.ensure_open()?;
6948        if logical_ids.is_empty() {
6949            return Ok(Vec::new());
6950        }
6951        let (response_tx, response_rx) = mpsc::sync_channel(1);
6952        let request = ReaderRequest::GetById {
6953            logical_ids: logical_ids.to_vec(),
6954            view: *view,
6955            respond: response_tx,
6956        };
6957        if self.reader_pool.dispatch(request).is_err() {
6958            return Err(EngineError::Closing);
6959        }
6960        match response_rx.recv().map_err(|_| EngineError::Storage)? {
6961            Ok(rows) => Ok(rows),
6962            Err(err) => {
6963                self.emit_sqlite_internal_error(&err);
6964                Err(EngineError::Storage)
6965            }
6966        }
6967    }
6968
6969    /// Slice 20 (G5) — `read.neighbors`: bounded BFS from `root_logical_id`
6970    /// over `canonical_edges`. Returns nodes reachable within `depth` hops
6971    /// (`1..=3`) in the given `direction`, excluding the root itself.
6972    ///
6973    /// Hard cap: 50 results (engine-enforced `LIMIT 50`).
6974    /// Traversal filter: `superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now)`.
6975    ///
6976    /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
6977    /// Returns `Ok(vec![])` for an unknown/superseded root.
6978    /// Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
6979    pub fn graph_neighbors(
6980        &self,
6981        root_logical_id: &str,
6982        depth: u32,
6983        direction: TraversalDirection,
6984        view: &ReadView,
6985    ) -> Result<Vec<NodeRecord>, EngineError> {
6986        self.ensure_open()?;
6987        if depth == 0 || depth > 3 {
6988            return Err(EngineError::InvalidArgument {
6989                msg: format!("traversal depth {depth} is out of range; must be 1, 2, or 3"),
6990            });
6991        }
6992        let (response_tx, response_rx) = mpsc::sync_channel(1);
6993        let request = ReaderRequest::GraphNeighbors {
6994            root_logical_id: root_logical_id.to_string(),
6995            depth,
6996            direction,
6997            view: *view,
6998            respond: response_tx,
6999        };
7000        if self.reader_pool.dispatch(request).is_err() {
7001            return Err(EngineError::Closing);
7002        }
7003        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7004            Ok(nodes) => Ok(nodes),
7005            Err(err) => {
7006                self.emit_sqlite_internal_error(&err);
7007                Err(EngineError::Storage)
7008            }
7009        }
7010    }
7011
7012    /// Slice 20 (G6) — `search_expand`: hybrid search (`G1+G9`) followed by
7013    /// bounded BFS expansion (`G5`) of each search hit. Returns the original
7014    /// search hits (with RRF scores) plus nodes reachable from any hit via
7015    /// up to `depth` hops that are NOT already in the search hit set.
7016    ///
7017    /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
7018    /// A `depth = 0` call returns search hits with their logical_ids resolved
7019    /// but no BFS expansion. Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
7020    ///
7021    /// **Snapshot note:** the search phase (`search_inner`) and the expansion
7022    /// phase (`SearchExpand` reader request) run in separate DEFERRED reader
7023    /// transactions; a write that lands between them is visible to expansion
7024    /// but not search (or vice-versa). In practice the window is negligible for
7025    /// single-process embedded use. The expansion phase mitigates drift by
7026    /// filtering `search_hits` to only include hits whose `write_cursor` is
7027    /// still active in the expansion snapshot (superseded hits are dropped from
7028    /// the result rather than surfaced with stale data).
7029    pub fn search_expand(
7030        &self,
7031        query: &str,
7032        filter: Option<SearchFilter>,
7033        depth: u32,
7034    ) -> Result<SearchExpandResult, EngineError> {
7035        self.ensure_open()?;
7036        if depth > 3 {
7037            return Err(EngineError::InvalidArgument {
7038                msg: format!("traversal depth {depth} exceeds the SDK ceiling of 3"),
7039            });
7040        }
7041        // Step 1: run the hybrid search to get initial hits (no CE reranking in expand).
7042        // 0.8.5: depth=0 → no rerank, so α/pool_n (0.3, 0) are inert here.
7043        let search_result =
7044            self.search_inner(query, filter, 0, false, 0.3, 0, false, ReadView::default())?;
7045        if search_result.results.is_empty() {
7046            return Ok(SearchExpandResult {
7047                search_hits: Vec::new(),
7048                expanded: Vec::new(),
7049                all_logical_ids: Vec::new(),
7050            });
7051        }
7052        // Step 2: dispatch to the reader pool to resolve logical_ids and run BFS.
7053        // depth=0 is forwarded to the reader so it can populate all_logical_ids
7054        // (the union of search-hit logical_ids), even with no expansion.
7055        let (response_tx, response_rx) = mpsc::sync_channel(1);
7056        let request = ReaderRequest::SearchExpand {
7057            search_hits: search_result.results,
7058            depth,
7059            respond: response_tx,
7060        };
7061        if self.reader_pool.dispatch(request).is_err() {
7062            return Err(EngineError::Closing);
7063        }
7064        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7065            Ok(result) => Ok(result),
7066            Err(err) => {
7067                self.emit_sqlite_internal_error(&err);
7068                Err(EngineError::Storage)
7069            }
7070        }
7071    }
7072
7073    /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and
7074    /// return the plan detail lines. Used by `explain_plan_uses_indexes`.
7075    #[doc(hidden)]
7076    pub fn explain_graph_neighbors_for_test(
7077        &self,
7078        root_logical_id: &str,
7079        depth: u32,
7080        direction: TraversalDirection,
7081    ) -> Result<Vec<String>, EngineError> {
7082        self.ensure_open()?;
7083        let (response_tx, response_rx) = mpsc::sync_channel(1);
7084        let request = ReaderRequest::ExplainGraphNeighbors {
7085            root_logical_id: root_logical_id.to_string(),
7086            depth,
7087            direction,
7088            respond: response_tx,
7089        };
7090        if self.reader_pool.dispatch(request).is_err() {
7091            return Err(EngineError::Closing);
7092        }
7093        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7094            Ok(plan) => Ok(plan),
7095            Err(err) => {
7096                self.emit_sqlite_internal_error(&err);
7097                Err(EngineError::Storage)
7098            }
7099        }
7100    }
7101
7102    /// Slice 30 (G3) — `read.collection`: paginated op-store read-back over
7103    /// `operational_mutations` for `collection`, `ORDER BY id`. `limit` is
7104    /// MANDATORY (clamped to the ~1M cap); `after_id` is the exclusive cursor.
7105    /// Reads ride the ReaderWorkerPool DEFERRED-tx path.
7106    pub fn read_collection(
7107        &self,
7108        collection: &str,
7109        after_id: Option<i64>,
7110        limit: usize,
7111    ) -> Result<Vec<OpStoreRow>, EngineError> {
7112        self.read_collection_dispatch(collection, after_id, limit)
7113    }
7114
7115    /// Slice 30 (G3) — `read.mutations`: the mutation-log-oriented alias surface
7116    /// over the SAME op-store read-back as [`Engine::read_collection`].
7117    pub fn read_mutations(
7118        &self,
7119        collection: &str,
7120        after_id: Option<i64>,
7121        limit: usize,
7122    ) -> Result<Vec<OpStoreRow>, EngineError> {
7123        self.read_collection_dispatch(collection, after_id, limit)
7124    }
7125
7126    fn read_collection_dispatch(
7127        &self,
7128        collection: &str,
7129        after_id: Option<i64>,
7130        limit: usize,
7131    ) -> Result<Vec<OpStoreRow>, EngineError> {
7132        self.ensure_open()?;
7133        let (response_tx, response_rx) = mpsc::sync_channel(1);
7134        let request = ReaderRequest::ReadCollection {
7135            collection: collection.to_string(),
7136            after_id,
7137            limit,
7138            respond: response_tx,
7139        };
7140        if self.reader_pool.dispatch(request).is_err() {
7141            return Err(EngineError::Closing);
7142        }
7143        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7144            Ok(rows) => Ok(rows),
7145            Err(err) => {
7146                self.emit_sqlite_internal_error(&err);
7147                Err(EngineError::Storage)
7148            }
7149        }
7150    }
7151
7152    /// Slice 35 (G4) — `read.list`: list active `canonical_nodes` of a given
7153    /// `kind`, optionally filtered by a closed [`Predicate`] set, up to `limit`
7154    /// rows. Returns `Vec<NodeRecord>` (active only; `superseded_at IS NULL`).
7155    ///
7156    /// Multiple predicates are combined as AND (D-F5). An empty predicate slice
7157    /// returns all active nodes of the given kind up to `limit` (unfiltered path).
7158    /// Compilation target: `json_extract(body, '$.field') <op> ?` with bound
7159    /// parameters (injection-safe per D-F4). See `dev/adr/ADR-0.8.0-filter-grammar.md`.
7160    ///
7161    /// Path validation happens at [`Predicate`] construction time; `read_list`
7162    /// revalidates as defense-in-depth (enum variants are `pub`, so direct
7163    /// struct-literal construction could bypass the constructors).
7164    pub fn read_list(
7165        &self,
7166        kind: &str,
7167        predicates: &[Predicate],
7168        limit: usize,
7169        view: &ReadView,
7170    ) -> Result<Vec<NodeRecord>, EngineError> {
7171        self.ensure_open()?;
7172        // Defense-in-depth: revalidate paths even if the caller bypassed the
7173        // validated constructors by constructing enum variants directly.
7174        for pred in predicates {
7175            let path = pred.path();
7176            if !PREDICATE_PATH_ALLOWLIST.contains(&path) {
7177                return Err(EngineError::InvalidFilter {
7178                    reason: format!("path '{path}' is not in the predicate path allowlist"),
7179                });
7180            }
7181        }
7182        let (response_tx, response_rx) = mpsc::sync_channel(1);
7183        let request = ReaderRequest::ReadList {
7184            kind: kind.to_string(),
7185            predicates: predicates.to_vec(),
7186            limit,
7187            view: *view,
7188            respond: response_tx,
7189        };
7190        if self.reader_pool.dispatch(request).is_err() {
7191            return Err(EngineError::Closing);
7192        }
7193        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7194            Ok(rows) => Ok(rows),
7195            Err(err) => {
7196                self.emit_sqlite_internal_error(&err);
7197                Err(EngineError::Storage)
7198            }
7199        }
7200    }
7201
7202    /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the
7203    /// canonical_nodes `read.list` backend. Accepts the **full** [`FilterTerm`]
7204    /// set (D3): `Json` runs the shipped allowlisted `json_extract` path;
7205    /// `Status`/`CreatedAfter` lower to allowlisted json-paths; `Kind`/`SourceType`
7206    /// **constant-fold** against the partition `kind` (a guaranteed-empty fold
7207    /// returns an empty `Vec` without touching SQL). Dispatches to the same
7208    /// [`Engine::read_list`] machinery the shipped `Predicate` surface uses, so
7209    /// every inherited invariant (`superseded_at IS NULL`, `json_valid(body)`,
7210    /// the `canonical_nodes(kind)` index, parameterized binds) is preserved.
7211    pub fn read_list_filter(
7212        &self,
7213        kind: &str,
7214        filter: &Filter,
7215        limit: usize,
7216        view: &ReadView,
7217    ) -> Result<Vec<NodeRecord>, EngineError> {
7218        self.ensure_open()?;
7219        match filter.lower_for_read_list(kind)? {
7220            None => Ok(Vec::new()),
7221            Some(preds) => self.read_list(kind, &preds, limit, view),
7222        }
7223    }
7224
7225    /// 0.8.20 Slice 10b (R-20-NV) — the **validity-boundary hook**: which nodes
7226    /// crossed a `[valid_from, valid_until)` boundary in the half-open interval
7227    /// `(since, as_of]`?
7228    ///
7229    /// `since` and the resolved upper bound are INTEGER epoch SECONDS. The upper
7230    /// bound is the view's own instant (`view.valid_as_of`, defaulting to now),
7231    /// so one instant governs both the boundary interval and the view — and, as
7232    /// everywhere else on this path, it is BOUND, never a `datetime('now')`
7233    /// literal, so the answer is deterministic for a fixed `(since, as_of)`.
7234    ///
7235    /// A node appears once, carrying whichever of the two boundaries it crossed;
7236    /// a window that both opened AND closed inside the interval reports both.
7237    /// Rows with an unbounded window on a side cannot cross that side, so a
7238    /// NULL/NULL row (every row predating schema step 22) never appears.
7239    ///
7240    /// The view's EXISTENCE flags still apply (so by default only current,
7241    /// active rows are considered), but its validity predicate does NOT: the
7242    /// question is about boundary crossings, not about being valid right now.
7243    ///
7244    /// When the view relaxes validity entirely (`include_out_of_window`), the
7245    /// interval is unbounded above.
7246    ///
7247    /// This is world-time only. There is deliberately no transaction-time
7248    /// (`history_as_of`) counterpart.
7249    pub fn crossed_boundary_since(
7250        &self,
7251        since: i64,
7252        view: &ReadView,
7253    ) -> Result<Vec<BoundaryCrossing>, EngineError> {
7254        self.ensure_open()?;
7255        let (response_tx, response_rx) = mpsc::sync_channel(1);
7256        let request =
7257            ReaderRequest::CrossedBoundarySince { since, view: *view, respond: response_tx };
7258        if self.reader_pool.dispatch(request).is_err() {
7259            return Err(EngineError::Closing);
7260        }
7261        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7262            Ok(rows) => Ok(rows),
7263            Err(err) => {
7264                self.emit_sqlite_internal_error(&err);
7265                Err(EngineError::Storage)
7266            }
7267        }
7268    }
7269
7270    pub fn close(&self) -> Result<(), EngineError> {
7271        self.closed.store(true, Ordering::SeqCst);
7272        self.projection_runtime.stop();
7273        // Uninstall profile callbacks before dropping the connections so
7274        // SQLite cannot fire one last callback against a profile context
7275        // whose Box is about to free. Per `dev/design/engine.md` § Close
7276        // path step 6, readers drain before the writer connection so
7277        // SQLite's last-handle checkpointer runs on the writer. Each
7278        // reader worker uninstalls its own callback inside
7279        // `reader_worker_loop` before dropping its connection, then
7280        // exits — `shutdown` joins those threads here.
7281        self.reader_pool.shutdown();
7282        if let Ok(mut connection) = self.connection.lock() {
7283            if let Some(conn) = connection.as_ref() {
7284                uninstall_profile_callback(conn);
7285            }
7286            connection.take();
7287        }
7288        if let Ok(mut contexts) = self.profile_contexts.lock() {
7289            contexts.clear();
7290        }
7291        if let Ok(mut lock) = self.lock.lock() {
7292            lock.take();
7293        }
7294        Ok(())
7295    }
7296
7297    /// Block until in-flight writes drain or `timeout_ms` elapses.
7298    ///
7299    /// Surface owned by `dev/interfaces/rust.md` § Engine-attached
7300    /// instrumentation; semantics are owned by `dev/design/lifecycle.md`.
7301    pub fn drain(&self, timeout_ms: u64) -> Result<(), EngineError> {
7302        self.ensure_open()?;
7303        if self.projection_runtime.wait_for_idle(timeout_ms) {
7304            Ok(())
7305        } else {
7306            Err(EngineError::Scheduler)
7307        }
7308    }
7309
7310    /// Snapshot of engine-internal counters.
7311    ///
7312    /// Field set owned by `dev/design/lifecycle.md`.
7313    #[must_use]
7314    pub fn counters(&self) -> CounterSnapshot {
7315        self.counters.snapshot()
7316    }
7317
7318    /// Toggle response-cycle profiling.
7319    ///
7320    /// Per `dev/design/lifecycle.md` § Per-statement profiling, profiling
7321    /// is an opt-in surface that is independently toggleable on a running
7322    /// engine without restart. AC-005a locks runtime toggleability.
7323    pub fn set_profiling(&self, enabled: bool) -> Result<(), EngineError> {
7324        self.profiling_enabled.store(enabled, Ordering::Relaxed);
7325        Ok(())
7326    }
7327
7328    /// Set the threshold above which an operation is reported as slow.
7329    ///
7330    /// Per `dev/design/lifecycle.md` § Slow and heartbeat policy, the
7331    /// threshold is runtime-configurable; mutating it changes detection
7332    /// behavior on subsequent statements without restart (AC-007b).
7333    pub fn set_slow_threshold_ms(&self, value: u64) -> Result<(), EngineError> {
7334        self.slow_threshold_ms.store(value, Ordering::Relaxed);
7335        Ok(())
7336    }
7337
7338    /// Attach a host subscriber to engine events.
7339    ///
7340    /// Dropping the returned [`Subscription`] detaches the subscriber.
7341    /// Payload shape owned by `dev/design/lifecycle.md` and
7342    /// `dev/design/migrations.md`.
7343    #[must_use]
7344    pub fn subscribe(&self, subscriber: Arc<dyn lifecycle::Subscriber>) -> Subscription {
7345        self.subscribers.attach(subscriber)
7346    }
7347
7348    #[cfg(debug_assertions)]
7349    #[doc(hidden)]
7350    pub fn reader_worker_count_for_test(&self) -> usize {
7351        self.reader_pool.worker_count()
7352    }
7353
7354    #[cfg(debug_assertions)]
7355    #[doc(hidden)]
7356    pub fn live_reader_worker_count_for_test(&self) -> usize {
7357        self.reader_pool.live_count()
7358    }
7359
7360    /// Pack 6.G G.1 — return the `sqlite3_db_config(LOOKASIDE)` rc
7361    /// captured for each reader worker at open time, in worker index
7362    /// order. SQLITE_OK (= 0) means the lookaside was configured
7363    /// before any allocation happened on the connection.
7364    #[cfg(debug_assertions)]
7365    #[doc(hidden)]
7366    pub fn reader_lookaside_config_rcs_for_test(&self) -> Vec<i32> {
7367        self.reader_lookaside_rcs.clone()
7368    }
7369
7370    /// Pack 6.G G.1 — query each reader worker's
7371    /// `SQLITE_DBSTATUS_LOOKASIDE_USED` counter. A value > 0 means at
7372    /// least one allocation was satisfied from the per-connection
7373    /// lookaside arena (proof the configuration was honored before the
7374    /// first prepare).
7375    #[cfg(debug_assertions)]
7376    #[doc(hidden)]
7377    pub fn reader_lookaside_used_per_worker_for_test(&self) -> Vec<i32> {
7378        self.reader_pool.lookaside_used_per_worker()
7379    }
7380
7381    /// Pack 6.G G.3.5 — broadcast a debug-only `CacheStatus` request to
7382    /// every reader worker and collect per-worker
7383    /// `SQLITE_DBSTATUS_CACHE_HIT` / `_CACHE_MISS` / `_CACHE_USED`
7384    /// values. Counters are monotonic (reset flag = 0); callers compute
7385    /// pre/post deltas explicitly.
7386    #[cfg(debug_assertions)]
7387    #[doc(hidden)]
7388    pub fn cache_status_per_worker_for_test(&self, label: &str) -> Vec<CacheStatusReply> {
7389        self.reader_pool.cache_status_per_worker(label)
7390    }
7391
7392    #[cfg(debug_assertions)]
7393    #[doc(hidden)]
7394    pub fn force_next_commit_failure_for_test(&self) {
7395        self.force_next_commit_failure.store(true, Ordering::SeqCst);
7396    }
7397
7398    /// Force the next background projection terminal commit to fail with a
7399    /// synthetic SQLite busy error. Test-only seam for TC-91 rollback and
7400    /// redispatch coverage; it does not affect the caller's write transaction.
7401    #[cfg(debug_assertions)]
7402    #[doc(hidden)]
7403    pub fn force_next_projection_commit_failure_for_test(&self) {
7404        self.projection_runtime.force_next_projection_commit_failure_for_test();
7405    }
7406
7407    /// Force the next background projection terminal commit to fail with a
7408    /// rusqlite-layer storage error. Test-only TC-91 diagnostic classifier seam.
7409    #[cfg(debug_assertions)]
7410    #[doc(hidden)]
7411    pub fn force_next_projection_storage_failure_for_test(&self) {
7412        self.projection_runtime.force_next_projection_storage_failure_for_test();
7413    }
7414
7415    /// Pause a worker after a forced projection-commit error was reported and
7416    /// before its state cleanup. TC-91 test-only shutdown/reopen rendezvous.
7417    #[cfg(debug_assertions)]
7418    #[doc(hidden)]
7419    pub fn pause_projection_commit_failure_cleanup_for_test(
7420        &self,
7421        reported: Arc<Barrier>,
7422        release: Arc<Barrier>,
7423    ) {
7424        self.projection_runtime.pause_projection_commit_failure_cleanup_for_test(reported, release);
7425    }
7426
7427    /// Acknowledge after `Engine::close` marks the projection runtime stopping
7428    /// and before it joins workers. TC-91 test-only shutdown rendezvous.
7429    #[cfg(debug_assertions)]
7430    #[doc(hidden)]
7431    pub fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
7432        self.projection_runtime.acknowledge_projection_stop_for_test(acknowledged);
7433    }
7434
7435    /// Execute an arbitrary SQL statement on the writer connection through
7436    /// the same wall-clock + slow-detect path as `write` / `search`.
7437    ///
7438    /// Test-only helper for the deterministic-slow-cte fixture used by
7439    /// AC-007a / AC-007b. Not part of the public 0.6.0 surface; gated on
7440    /// `debug_assertions` so release builds do not expose it.
7441    #[cfg(debug_assertions)]
7442    #[doc(hidden)]
7443    pub fn execute_for_test(&self, sql: &str) -> Result<(), EngineError> {
7444        self.ensure_open()?;
7445        let started = Instant::now();
7446        {
7447            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7448            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7449            connection.execute_batch(sql).map_err(|_| EngineError::Storage)?;
7450        }
7451        self.detect_slow(started, lifecycle::EventCategory::Search);
7452        Ok(())
7453    }
7454
7455    /// One-thread-poison robustness fixture (AC-009).
7456    ///
7457    /// Spawns four reader threads + one writer thread that all make
7458    /// forward progress (single canonical write + repeated searches),
7459    /// plus one designated poison thread that runs an empty-batch write
7460    /// — a deterministic `EngineError::WriteValidation`. The captured
7461    /// poison failure is dispatched as a `StressFailureContext` whose
7462    /// `last_error_chain` is `[EngineError::stable_code(),
7463    /// engine_error.to_string()]` per the lifecycle § Stress-failure
7464    /// context payload contract.
7465    #[doc(hidden)]
7466    #[cfg(debug_assertions)]
7467    pub fn run_one_thread_poison_for_test(&self) -> Result<(), EngineError> {
7468        self.ensure_open()?;
7469
7470        // Forward-progress writer seeds a row so readers + the poison
7471        // thread share a non-trivial canonical state.
7472        self.write(&[PreparedWrite::Node {
7473            kind: "doc".to_string(),
7474            body: "poison-fixture-seed".to_string(),
7475            source_id: SourceId::engine_derived("poison-fixture"),
7476            logical_id: None,
7477            state: InitialState::Active,
7478            reason: None,
7479            valid_from: None,
7480            valid_until: None,
7481        }])?;
7482
7483        let poison_outcome: Mutex<Option<EngineError>> = Mutex::new(None);
7484        let poison_thread_id: AtomicU64 = AtomicU64::new(0);
7485
7486        thread::scope(|scope| {
7487            // N=4 reader threads make forward progress.
7488            for _ in 0..4 {
7489                scope.spawn(|| {
7490                    for _ in 0..4 {
7491                        let _ = self.search("poison-fixture-seed");
7492                    }
7493                });
7494            }
7495            // One forward-progress writer thread.
7496            scope.spawn(|| {
7497                let _ = self.write(&[PreparedWrite::Node {
7498                    kind: "doc".to_string(),
7499                    body: "writer-progress".to_string(),
7500                    source_id: SourceId::engine_derived("poison-fixture"),
7501                    logical_id: None,
7502                    state: InitialState::Active,
7503                    reason: None,
7504                    valid_from: None,
7505                    valid_until: None,
7506                }]);
7507            });
7508            // One poison thread — empty batch is a deterministic
7509            // WriteValidation failure.
7510            scope.spawn(|| {
7511                // Use a non-zero, deterministic group id so subscribers
7512                // see a stable identifier across runs of the fixture.
7513                poison_thread_id.store(1, Ordering::SeqCst);
7514                if let Err(err) = self.write(&[]) {
7515                    *poison_outcome.lock().expect("poison_outcome lock") = Some(err);
7516                }
7517            });
7518        });
7519
7520        let err = poison_outcome
7521            .into_inner()
7522            .expect("poison_outcome lock")
7523            .expect("poison thread must produce a deterministic error");
7524
7525        let projection_state = match self.projection_status_for_test("doc") {
7526            Ok(lifecycle::ProjectionStatus::Pending) => "Pending",
7527            Ok(lifecycle::ProjectionStatus::Failed) => "Failed",
7528            Ok(lifecycle::ProjectionStatus::UpToDate) => "UpToDate",
7529            // Default to UpToDate when projection status is unobservable
7530            // (e.g. embedder not configured for the seed kind). The
7531            // value is still one of the documented enum stringifications
7532            // per AC-010.
7533            Err(_) => "UpToDate",
7534        };
7535
7536        let context = lifecycle::StressFailureContext {
7537            thread_group_id: poison_thread_id.load(Ordering::SeqCst),
7538            op_kind: "write".to_string(),
7539            last_error_chain: vec![err.stable_code().to_string(), err.to_string()],
7540            projection_state: projection_state.to_string(),
7541        };
7542        self.subscribers.dispatch_stress_failure(&context);
7543        Ok(())
7544    }
7545
7546    #[doc(hidden)]
7547    pub fn set_projection_scheduler_frozen_for_test(&self, frozen: bool) {
7548        self.projection_runtime.set_frozen(frozen);
7549    }
7550
7551    #[doc(hidden)]
7552    pub fn set_projection_retry_delays_for_test(&self, delays_ms: &[u64]) {
7553        self.projection_runtime.set_retry_delays_for_test(delays_ms);
7554    }
7555
7556    /// PR-9 — lower the ADR-0.6.0 Invariant 5 per-`embed()` watchdog deadline
7557    /// for tests (production default is `DEFAULT_EMBED_TIMEOUT_MS` = 30s).
7558    #[doc(hidden)]
7559    pub fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
7560        self.projection_runtime.set_embed_timeout_ms_for_test(timeout_ms);
7561    }
7562
7563    /// PR-9 — lower the embed circuit-breaker threshold for tests (production
7564    /// default `DEFAULT_EMBED_CIRCUIT_THRESHOLD`); 0 disables the breaker.
7565    #[doc(hidden)]
7566    pub fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
7567        self.projection_runtime.set_embed_circuit_threshold_for_test(threshold);
7568    }
7569
7570    /// PR-9 — whether the embed circuit breaker has latched open.
7571    #[doc(hidden)]
7572    pub fn embed_circuit_open_for_test(&self) -> bool {
7573        self.projection_runtime.embed_circuit_open_for_test()
7574    }
7575
7576    #[doc(hidden)]
7577    pub fn projection_status_for_test(
7578        &self,
7579        kind: &str,
7580    ) -> Result<lifecycle::ProjectionStatus, EngineError> {
7581        self.ensure_open()?;
7582        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7583        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7584        projection_status(connection, kind)
7585    }
7586
7587    #[doc(hidden)]
7588    pub fn has_vector_for_cursor_for_test(&self, cursor: u64) -> Result<bool, EngineError> {
7589        self.ensure_open()?;
7590        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7591        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7592        terminal_state_for_cursor(connection, cursor)
7593            .map(|state| matches!(state.as_deref(), Some("up_to_date")))
7594            .map_err(|_| EngineError::Storage)
7595    }
7596
7597    #[doc(hidden)]
7598    pub fn projection_failure_count_for_test(&self, cursor: u64) -> Result<u64, EngineError> {
7599        self.ensure_open()?;
7600        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7601        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7602        connection
7603            .query_row(
7604                "SELECT COUNT(*) FROM operational_mutations
7605                 WHERE collection_name = 'projection_failures'
7606                   AND record_key = ?1",
7607                [cursor.to_string()],
7608                |row| row.get::<_, u64>(0),
7609            )
7610            .map_err(|_| EngineError::Storage)
7611    }
7612
7613    #[doc(hidden)]
7614    pub fn set_provenance_row_cap_for_test(&self, cap: Option<u64>) {
7615        self.provenance_row_cap.store(cap.unwrap_or(0), Ordering::Relaxed);
7616    }
7617
7618    #[doc(hidden)]
7619    pub fn provenance_row_count_for_test(&self) -> Result<u64, EngineError> {
7620        self.ensure_open()?;
7621        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7622        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7623        connection
7624            .query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get::<_, u64>(0))
7625            .map_err(|_| EngineError::Storage)
7626    }
7627
7628    #[doc(hidden)]
7629    pub fn oldest_provenance_record_key_for_test(
7630        &self,
7631        collection: &str,
7632    ) -> Result<Option<String>, EngineError> {
7633        self.ensure_open()?;
7634        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7635        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7636        connection
7637            .query_row(
7638                "SELECT record_key FROM operational_mutations
7639                 WHERE collection_name = ?1
7640                 ORDER BY id
7641                 LIMIT 1",
7642                [collection],
7643                |row| row.get::<_, String>(0),
7644            )
7645            .map(Some)
7646            .or_else(|err| match err {
7647                rusqlite::Error::QueryReturnedNoRows => Ok(None),
7648                _ => Err(EngineError::Storage),
7649            })
7650    }
7651
7652    #[doc(hidden)]
7653    pub fn configure_vector_kind_for_test(&self, kind: &str) -> Result<(), EngineError> {
7654        self.ensure_open()?;
7655        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7656        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7657        connection
7658            .execute(
7659                "INSERT OR REPLACE INTO _fathomdb_vector_kinds(kind, profile, created_at)
7660                 VALUES(?1, ?2, 0)",
7661                params![kind, DEFAULT_VECTOR_PROFILE],
7662            )
7663            .map_err(|_| EngineError::Storage)?;
7664        Ok(())
7665    }
7666
7667    /// OPP-12 Phase-1 (0.8.19 Slice 10) — read the writer connection's
7668    /// `PRAGMA secure_delete` (design §3 gap-4). `true` iff the standing
7669    /// connection-open PRAGMA is in effect, so `purge` freelist erasure is
7670    /// complete without a per-purge `VACUUM`.
7671    #[doc(hidden)]
7672    pub fn secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
7673        self.ensure_open()?;
7674        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7675        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7676        let value: i64 = connection
7677            .query_row("PRAGMA secure_delete", [], |r| r.get(0))
7678            .map_err(|_| EngineError::Storage)?;
7679        Ok(value != 0)
7680    }
7681
7682    /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff EVERY
7683    /// reader-pool connection reports `PRAGMA secure_delete = ON`. Broadcasts a
7684    /// per-worker probe; proves the standing flag is set on the non-writer
7685    /// connections (which perform projection/vector-rewrite DELETEs), closing
7686    /// the GDPR-erasure leak codex flagged.
7687    #[cfg(debug_assertions)]
7688    #[doc(hidden)]
7689    pub fn reader_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
7690        self.ensure_open()?;
7691        let per_worker = self.reader_pool.secure_delete_per_worker();
7692        if per_worker.is_empty() {
7693            return Err(EngineError::Storage);
7694        }
7695        Ok(per_worker.iter().all(|&v| v == 1))
7696    }
7697
7698    /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff a freshly
7699    /// opened projection/runtime connection (`open_runtime_connection`) reports
7700    /// `PRAGMA secure_delete = ON`. The runtime connection performs the
7701    /// vector-rewrite/projection DELETEs, so its freed pages must be scrubbed too.
7702    #[doc(hidden)]
7703    pub fn runtime_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
7704        self.ensure_open()?;
7705        let connection = open_runtime_connection(&self.path).map_err(|_| EngineError::Storage)?;
7706        let value: i64 = connection
7707            .query_row("PRAGMA secure_delete", [], |r| r.get(0))
7708            .map_err(|_| EngineError::Storage)?;
7709        Ok(value != 0)
7710    }
7711
7712    /// EXP-S (0.8.14 Slice 5, D1) — write one canonical node row carrying an
7713    /// explicit structural `row_kind` (leaf/coverage/graph), routing the index
7714    /// projection through the SAME `row_kind -> index-target` dispatch seam
7715    /// (`project_canonical_node_row`) as the production `leaf` write path.
7716    ///
7717    /// This is the internal-only writer for `coverage`/`graph` rows (there is no
7718    /// public SDK surface for `row_kind` in 0.8.14). Cursor assignment preserves
7719    /// the `rowid == write_cursor == cursor` determinism identity. When the row
7720    /// projects into an async vector index, the worker pool is notified so the
7721    /// embed is scheduled exactly as for a normal write.
7722    #[doc(hidden)]
7723    pub fn write_canonical_row_with_kind_for_test(
7724        &self,
7725        kind: &str,
7726        body: &str,
7727        row_kind: RowKind,
7728    ) -> Result<WriteReceipt, EngineError> {
7729        self.ensure_open()?;
7730        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7731        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7732
7733        // R-20-E3 / design §4 item 6 — this writer BYPASSES `PreparedWrite`, so
7734        // the `SourceId` newtype cannot reach it; before 0.8.20 it inserted a
7735        // literal NULL `source_id` and produced a row that no `excise_source`
7736        // call could reach. Engine-derived rows instead take a reserved
7737        // `_engine:*` provenance, keyed by the structural role that produced
7738        // them, so they are both erasable and distinguishable from caller data.
7739        let engine_provenance = SourceId::engine_derived(row_kind.as_str());
7740
7741        // 0.8.20 Slice 20c — same late enrolment the governed write path takes
7742        // (`Engine::enrol_batch_vector_kinds`), so this internal writer does not
7743        // silently diverge into the false-ready barrier for `coverage` rows. The
7744        // live-embedder precondition is checked here, as that caller does; the
7745        // `row_kind` gate keeps `graph` rows out of the vector registry.
7746        //
7747        // fix-2 (codex §9 [P2]) — including the un-stranding half, so this door
7748        // cannot diverge from the other one either. fix-5 (codex §9 round 4 [P2])
7749        // — and both halves commit as ONE transaction, via the same shared
7750        // `enrol_and_unstrand`.
7751        let unstranded = if self.runtime_embedder.is_some()
7752            && self.vector_kind_needs_enrolment(connection, kind, row_kind)?
7753        {
7754            self.enrol_and_unstrand(connection, &[kind])?
7755        } else {
7756            false
7757        };
7758
7759        let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
7760        let enqueued = {
7761            let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
7762            // 0.8.20 Slice 15b (TC-34) — this writer takes NO validity window, and
7763            // that is deliberate rather than an oversight. It is a `#[doc(hidden)]`
7764            // test-only writer for the internal `coverage`/`graph` row kinds, which
7765            // have no public SDK surface at all (see the doc comment above); the
7766            // caller-facing authoring path is `PreparedWrite::Node`, handled in
7767            // `commit_batch`. Omitting the columns binds NULL — the migration
7768            // step-22 default and the UNBOUNDED reading — so engine-derived rows
7769            // stay valid at every instant, which is the only correct answer for a
7770            // structural row that no caller can address a window to.
7771            tx.execute(
7772                "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind)
7773                 VALUES(?1, ?2, ?3, ?4, NULL, ?5)",
7774                params![cursor, kind, body, engine_provenance.as_str(), row_kind.as_str()],
7775            )
7776            .map_err(|_| EngineError::Storage)?;
7777            let enqueued = project_canonical_node_row(
7778                &tx,
7779                cursor,
7780                kind,
7781                body,
7782                row_kind,
7783                ProjectionPass::Write,
7784                // This #[doc(hidden)] writer inserts with the column DEFAULT
7785                // `state = 'active'` (no state column in its INSERT), so the row
7786                // is always active and its attributes project.
7787                true,
7788            )
7789            .map_err(|_| EngineError::Storage)?;
7790            advance_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
7791            tx.commit().map_err(|_| EngineError::Storage)?;
7792            enqueued
7793        };
7794        self.next_cursor.store(cursor, Ordering::SeqCst);
7795        if enqueued || unstranded {
7796            self.projection_runtime.notify_new_work();
7797        }
7798        Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
7799    }
7800
7801    /// EXP-S (0.8.14 Slice 5, D1) — select the active canonical rows carrying a
7802    /// given `row_kind`, returning their `write_cursor`s in cursor order. Proves
7803    /// the engine can query/select rows by the structural `row_kind` axis.
7804    #[doc(hidden)]
7805    pub fn canonical_rows_with_row_kind_for_test(
7806        &self,
7807        row_kind: RowKind,
7808    ) -> Result<Vec<u64>, EngineError> {
7809        self.ensure_open()?;
7810        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7811        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7812        let mut stmt = connection
7813            .prepare(
7814                "SELECT write_cursor FROM canonical_nodes
7815                 WHERE row_kind = ?1 AND superseded_at IS NULL
7816                 ORDER BY write_cursor",
7817            )
7818            .map_err(|_| EngineError::Storage)?;
7819        let cursors = stmt
7820            .query_map(params![row_kind.as_str()], |row| row.get::<_, u64>(0))
7821            .map_err(|_| EngineError::Storage)?
7822            .collect::<rusqlite::Result<Vec<u64>>>()
7823            .map_err(|_| EngineError::Storage)?;
7824        Ok(cursors)
7825    }
7826
7827    /// F5 (0.8.14 Slice 10) — the fielded BM25F lexical arm over
7828    /// `search_index_v2`. Recalls candidate rows through the FTS5 index
7829    /// (`search_index_v2 MATCH`) and scores them with a textbook BM25F using the
7830    /// plan's tunable per-field `weights` and tunable `b`/`k1`, returning
7831    /// `(write_cursor, score)` in descending score order (write_cursor asc as the
7832    /// deterministic tiebreak). Superseded node versions are excluded (join to
7833    /// `canonical_nodes WHERE superseded_at IS NULL`).
7834    ///
7835    /// This is the engine-internal `BM25fQueryPlan` compiler path (`ADR-0.8.1`
7836    /// §3.2); there is no public Py/TS SDK surface this release. The score is
7837    /// computed in-engine (not via SQLite's `bm25()`, which cannot express a
7838    /// tunable `b`); the FTS5 index remains load-bearing for candidate recall.
7839    #[doc(hidden)]
7840    pub fn bm25f_search(
7841        &self,
7842        query: &str,
7843        plan: &Bm25fQueryPlan,
7844    ) -> Result<Vec<(u64, f64)>, EngineError> {
7845        self.ensure_open()?;
7846        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7847        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7848        bm25f_search_inner(connection, query, plan).map_err(|_| EngineError::Storage)
7849    }
7850
7851    /// Embed arbitrary text with the engine's configured runtime embedder,
7852    /// returning the raw (un-centered) vector.
7853    ///
7854    /// This is the read-path embed primitive: it mirrors the search
7855    /// query-embedding path — a single, direct [`Embedder::embed`] call. The
7856    /// per-`embed()` watchdog/circuit-breaker guards only the bulk
7857    /// projection/write path (many embeds, fault isolation), not single
7858    /// read-side embeds, so a direct call is consistent with how a query is
7859    /// embedded. Callers get vectors under the engine's *pinned* embedder
7860    /// identity (`fathomdb-bge-small-en-v1.5` by default) rather than a
7861    /// parallel, possibly-divergent embedder.
7862    ///
7863    /// Returns [`EngineError::EmbedderNotConfigured`] if the engine was opened
7864    /// without an embedder (`use_default_embedder = false`).
7865    pub fn embed_text(&self, text: &str) -> Result<Vec<f32>, EngineError> {
7866        self.ensure_open()?;
7867        let embedder =
7868            self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
7869        embedder.embed(text).map_err(map_runtime_embedder_error)
7870    }
7871
7872    #[doc(hidden)]
7873    pub fn write_vector_for_test(
7874        &self,
7875        kind: &str,
7876        text: &str,
7877    ) -> Result<WriteReceipt, EngineError> {
7878        self.ensure_open()?;
7879        let embedder =
7880            self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
7881
7882        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7883        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7884        if !kind_is_vector_indexed(connection, kind)? {
7885            return Err(EngineError::KindNotVectorIndexed);
7886        }
7887
7888        let expected = default_profile_dimension(connection)?;
7889        ensure_vector_partition(connection, expected).map_err(|_| EngineError::Storage)?;
7890        let vector = embedder.embed(text).map_err(map_runtime_embedder_error)?;
7891        let actual = u32::try_from(vector.len()).unwrap_or(u32::MAX);
7892        if actual != expected {
7893            return Err(EngineError::EmbedderDimensionMismatch { expected, actual });
7894        }
7895
7896        let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
7897        // EU-5a2 mean-centering apply path (write side). f32 BLOB stored
7898        // is ALWAYS un-centered; the sign-quant input is the centered
7899        // vector iff the identity is MC-required AND a `mean_vec` is
7900        // pinned. NoopEmbedder identity (the only EU-5a2 live one) is
7901        // NOT MC-required, so this is a no-op until EU-5b's flip.
7902        let blob = encode_vector_blob(&vector);
7903        let bin_blob = if identity_requires_mean_centering(&self.runtime_embedder_identity) {
7904            match read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)? {
7905                Some(mean) => encode_vector_blob(&subtract_mean(&vector, &mean)),
7906                None => blob.clone(),
7907            }
7908        } else {
7909            blob.clone()
7910        };
7911        let source_type = resolve_source_type(kind)?;
7912        let now_unix =
7913            SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
7914
7915        // EU-5b — feed the streaming mean accumulator (if live) and detect
7916        // a threshold-crossing pin. The mean materialization, pre-pin
7917        // re-quantize, and `MeanVecPinned` event emission all happen in
7918        // the SAME SQLite transaction as the row INSERT.
7919        let pin_event = {
7920            let runtime = &self.projection_runtime.shared;
7921            let mut accumulator =
7922                runtime.mean_accumulator.lock().map_err(|_| EngineError::Storage)?;
7923            if let Some(acc) = accumulator.as_mut() {
7924                acc.add(&vector);
7925                if acc.count() >= MEAN_VEC_PIN_THRESHOLD {
7926                    let mean = acc.materialize();
7927                    *accumulator = None;
7928                    Some(mean)
7929                } else {
7930                    None
7931                }
7932            } else {
7933                None
7934            }
7935        };
7936
7937        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
7938        tx.execute(
7939            "INSERT INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
7940            params![cursor, kind, cursor],
7941        )
7942        .map_err(|_| EngineError::Storage)?;
7943        // Slice 10 / G10 — `status` ships an empty-string sentinel only: vec0 TEXT
7944        // metadata columns are NOT NULL-able ("Expected text for TEXT metadata
7945        // column"), so the "no real population yet" state is `''`, not NULL.
7946        //
7947        // 0.8.20 Slice 15e — this test helper carries no JSON body, so every live
7948        // `filterable` `attr_<hex>` column binds the `''` sentinel (an empty body
7949        // extracts nothing). When the table has no attr columns the statement is
7950        // byte-identical to the shipped form.
7951        let (cols_sql, ph_sql, attr_vals) =
7952            vector_attr_insert_fragments(&tx, "", 7).map_err(|_| EngineError::Storage)?;
7953        let sql = format!(
7954            "INSERT INTO vector_default(
7955                rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
7956             ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
7957        );
7958        let mut pv: Vec<rusqlite::types::Value> = vec![
7959            rusqlite::types::Value::Integer(cursor as i64),
7960            rusqlite::types::Value::Blob(blob.clone()),
7961            rusqlite::types::Value::Blob(bin_blob.clone()),
7962            rusqlite::types::Value::Text(source_type.to_string()),
7963            rusqlite::types::Value::Text(kind.to_string()),
7964            rusqlite::types::Value::Integer(now_unix),
7965        ];
7966        pv.extend(attr_vals);
7967        tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
7968            .map_err(|_| EngineError::Storage)?;
7969
7970        let mut emitted_event: Option<EmbedderEvent> = None;
7971        if let Some(mean_vec) = pin_event {
7972            let mean_bytes = encode_vector_blob(&mean_vec);
7973            tx.execute(
7974                "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
7975                params![mean_bytes],
7976            )
7977            .map_err(|_| EngineError::Storage)?;
7978            // Read all pre-pin (rowid, embedding) and re-quantize within
7979            // the same tx. The just-inserted row above is also covered.
7980            let rows: Vec<(i64, Vec<u8>)> = {
7981                let mut statement = tx
7982                    .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
7983                    .map_err(|_| EngineError::Storage)?;
7984                let mapped = statement
7985                    .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
7986                    .map_err(|_| EngineError::Storage)?;
7987                let mut out = Vec::new();
7988                for r in mapped {
7989                    out.push(r.map_err(|_| EngineError::Storage)?);
7990                }
7991                out
7992            };
7993            let (doc_count, _) = run_pin_and_requantize_pass(&tx, &rows, &mean_vec)?;
7994            emitted_event = Some(EmbedderEvent::MeanVecPinned {
7995                dim: u32::try_from(mean_vec.len()).unwrap_or(u32::MAX),
7996                doc_count,
7997            });
7998        }
7999
8000        tx.commit().map_err(|_| EngineError::Storage)?;
8001
8002        if let Some(ev) = emitted_event {
8003            if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
8004                events.push(ev);
8005            }
8006        }
8007
8008        self.next_cursor.store(cursor, Ordering::SeqCst);
8009        // G8 — this path (embedder-profile pin) commits no canonical edges, so
8010        // no endpoint can dangle.
8011        Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
8012    }
8013
8014    /// EU-5b test seam — drain MeanVecPinned events queued by the
8015    /// projection-commit pin transaction since the last drain. Production
8016    /// callers consume these via `OpenReport.embedder_events`; this seam
8017    /// exists so the EU-5b RED test can observe the live emission.
8018    #[doc(hidden)]
8019    pub fn drain_mean_centering_events_for_test(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
8020        self.ensure_open()?;
8021        let mut events = self
8022            .projection_runtime
8023            .shared
8024            .pending_events
8025            .lock()
8026            .map_err(|_| EngineError::Storage)?;
8027        let out = std::mem::take(&mut *events);
8028        Ok(out)
8029    }
8030
8031    /// 0.7.2 PR-2b — NON-test observation seam. Drains and returns every
8032    /// `EmbedderEvent` queued since the last drain (mean pin, manual mean
8033    /// recompute). Production callers use
8034    /// this to observe the synchronous recompute work; events are queued
8035    /// only AFTER the recompute transaction is durable, so a rolled-back
8036    /// recompute never surfaces. Mirrors the at-open
8037    /// `OpenReport.embedder_events` channel for the steady-state path.
8038    pub fn drain_embedder_events(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
8039        self.ensure_open()?;
8040        let mut events = self
8041            .projection_runtime
8042            .shared
8043            .pending_events
8044            .lock()
8045            .map_err(|_| EngineError::Storage)?;
8046        Ok(std::mem::take(&mut *events))
8047    }
8048
8049    /// 0.7.2 PR-2b — explicit `doctor recompute-mean` path. Re-derives the
8050    /// pinned corpus mean from the current `vector_default` rows and
8051    /// re-quantizes every row, SYNCHRONOUSLY in one transaction. ALWAYS
8052    /// allowed at any corpus size — this is the ONLY mean-refresh path as of
8053    /// 0.7.2 (the automatic in-ingest drift detector was carved out / deferred
8054    /// to 0.8.x; see `dev/design/embedder.md` §0.3).
8055    ///
8056    /// Serializes against the projection workers via `commit_gate` so the
8057    /// re-quantize sees a totally-ordered history, exactly like the at-pin
8058    /// commit. Publishes a `MeanVecRecomputed { trigger: Manual }` event
8059    /// only after the transaction is durable. No-op-safe on a non-MC
8060    /// identity (returns `EmbedderNotConfigured` rather than corrupting an
8061    /// un-centered workspace).
8062    #[cfg(feature = "operator")]
8063    pub fn recompute_mean(&self) -> Result<MeanRecomputeReport, EngineError> {
8064        self.ensure_open()?;
8065        let identity = self.runtime_embedder_identity.clone();
8066        if !identity_requires_mean_centering(&identity) {
8067            return Err(EngineError::EmbedderNotConfigured);
8068        }
8069        let report = {
8070            // Hold the commit gate for the whole recompute so no projection
8071            // worker commit interleaves with the re-quantize.
8072            let _gate = self
8073                .projection_runtime
8074                .shared
8075                .commit_gate
8076                .lock()
8077                .unwrap_or_else(|p| p.into_inner());
8078            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8079            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8080            let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8081            #[cfg(debug_assertions)]
8082            let fail = self
8083                .projection_runtime
8084                .shared
8085                .force_recompute_failure
8086                .swap(false, Ordering::SeqCst);
8087            #[cfg(not(debug_assertions))]
8088            let fail = false;
8089            let report = recompute_mean_in_tx_inner(&tx, &identity, fail)?;
8090            tx.commit().map_err(|_| EngineError::Storage)?;
8091            report
8092        };
8093        // Post-durable-commit publish.
8094        if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
8095            events.push(EmbedderEvent::MeanVecRecomputed {
8096                dim: report.dim,
8097                doc_count: report.doc_count_requantized,
8098                trigger: MeanRecomputeTrigger::Manual,
8099            });
8100        }
8101        Ok(report)
8102    }
8103
8104    /// 0.7.2 PR-2bc S1 fix-1 test seam — RAISE the phase-2 rerank `LIMIT`
8105    /// above the production `SEARCH_RERANK_LIMIT` (10) so the recall harness
8106    /// can pull top-(10+slack) and exclude the self-retrieving query-source
8107    /// doc before truncating to 10. The search path clamps the stored value
8108    /// to the production floor, so a test can never shrink search fanout
8109    /// below production semantics. Production reads the same atomic and never
8110    /// consults any env var.
8111    #[doc(hidden)]
8112    pub fn set_search_limit_for_test(&self, limit: usize) {
8113        self.projection_runtime.shared.search_limit_override.store(limit, Ordering::SeqCst);
8114    }
8115
8116    /// Slice 10 / G12-recency test seam — flip the dedicated recency-reweight
8117    /// flag (off by default). The reweight runs AFTER bit-KNN on the fused hits;
8118    /// it is never a vec0 predicate and is NOT `fusion_mode`.
8119    #[doc(hidden)]
8120    pub fn set_recency_reweight_enabled_for_test(&self, enabled: bool) {
8121        self.projection_runtime.shared.recency_reweight_enabled.store(enabled, Ordering::SeqCst);
8122    }
8123
8124    /// 0.8.16 Slice 5 / F9 test seam — flip the dedicated importance/confidence
8125    /// reweight flag (off by default). The reweight runs AFTER bit-KNN + RRF on
8126    /// the fused hits (multiplicative-on-fused, `NULL ⇒ neutral`); it is never a
8127    /// vec0 predicate and is NOT `fusion_mode`. Mirrors
8128    /// `set_recency_reweight_enabled_for_test`.
8129    #[doc(hidden)]
8130    pub fn set_importance_reweight_enabled_for_test(&self, enabled: bool) {
8131        self.projection_runtime.shared.importance_reweight_enabled.store(enabled, Ordering::SeqCst);
8132    }
8133
8134    /// 0.8.16 Slice 5 / F9 (R-F9-1) — set the caller-supplied `importance` ranking
8135    /// scalar on the `canonical_nodes` row identified by `write_cursor` (the
8136    /// interim id `SearchHit.id` carries). Validates `importance ∈ [0.0, 1.0]`,
8137    /// mirroring the existing `canonical_edges.confidence` write-path check —
8138    /// an out-of-range value is a deterministic [`EngineError::WriteValidation`].
8139    ///
8140    /// The 3-way sentinel: NOT calling this leaves the column `NULL` (never
8141    /// assigned = graceful-absent, ranks NEUTRAL); `0.0` is the explicit floor;
8142    /// `(0.0, 1.0]` is an explicit importance. Importance is a caller-supplied
8143    /// scalar — the engine does NOT compute graph-centrality importance (ADR §4
8144    /// non-goal). Engine-internal minimal surface for this keystone; SDK (Py/TS)
8145    /// exposure is a Slice-40 concern.
8146    pub fn write_node_importance(
8147        &self,
8148        write_cursor: u64,
8149        importance: f64,
8150    ) -> Result<(), EngineError> {
8151        if !importance.is_finite() || !(0.0..=1.0).contains(&importance) {
8152            return Err(EngineError::WriteValidation);
8153        }
8154        self.ensure_open()?;
8155        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8156        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8157        connection
8158            .execute(
8159                "UPDATE canonical_nodes SET importance = ?1 WHERE write_cursor = ?2",
8160                params![importance, write_cursor],
8161            )
8162            .map_err(|_| EngineError::Storage)?;
8163        Ok(())
8164    }
8165
8166    /// 0.8.16 Slice 5 / F9 (R-F9-1) — read back the `importance` scalar for the
8167    /// `canonical_nodes` row identified by `write_cursor`. `None` = SQL `NULL` =
8168    /// never assigned (graceful-absent). The reciprocal read for
8169    /// [`Engine::write_node_importance`].
8170    pub fn node_importance(&self, write_cursor: u64) -> Result<Option<f64>, EngineError> {
8171        self.ensure_open()?;
8172        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8173        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8174        connection
8175            .query_row(
8176                "SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
8177                params![write_cursor],
8178                |r| r.get::<_, Option<f64>>(0),
8179            )
8180            .map_err(|_| EngineError::Storage)
8181    }
8182
8183    /// GA-2 / Slice-40 (◆ B-1) measurement seam — make `search()` return the
8184    /// pre-fusion VECTOR-branch ranking (the ANN+ bit-KNN K=192 + f32 rerank
8185    /// signal) instead of the unconditional RRF-fused result, so the eu7 recall
8186    /// gate (AC-075) can measure ANN-quantization FIDELITY — vector top-10 vs
8187    /// the exact-f32 VECTOR top-10 ground truth — in isolation. Off by default;
8188    /// never set on any production path. This is NOT a `fusion_mode` knob:
8189    /// production RRF fusion stays unconditional and `fuse_rrf`/`rerank_fused`/
8190    /// recency are unchanged. Mirrors `set_recency_reweight_enabled_for_test`
8191    /// (release-available, since eu7 runs in `--release`).
8192    #[doc(hidden)]
8193    pub fn set_vector_stage_only_for_test(&self, enabled: bool) {
8194        self.projection_runtime.shared.vector_stage_only_for_test.store(enabled, Ordering::SeqCst);
8195    }
8196
8197    /// 0.7.2 PR-2b test seam — arm a one-shot fault inside the NEXT
8198    /// `recompute_mean` so it errors after the `mean_vec` UPDATE but before
8199    /// the re-quantize completes. Proves the recompute tx rolls back whole.
8200    #[doc(hidden)]
8201    #[cfg(debug_assertions)]
8202    pub fn force_next_recompute_failure_for_test(&self) {
8203        self.projection_runtime.shared.force_recompute_failure.store(true, Ordering::SeqCst);
8204    }
8205
8206    #[doc(hidden)]
8207    pub fn vector_row_count_for_test(&self) -> Result<u64, EngineError> {
8208        self.ensure_open()?;
8209        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8210        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8211        connection
8212            .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get::<_, u64>(0))
8213            .map_err(|_| EngineError::Storage)
8214    }
8215
8216    #[doc(hidden)]
8217    pub fn read_vector_blob_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8218        self.ensure_open()?;
8219        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8220        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8221        connection
8222            .query_row("SELECT embedding FROM vector_default WHERE rowid = ?1", [rowid], |row| {
8223                row.get::<_, Vec<u8>>(0)
8224            })
8225            .map_err(|_| EngineError::Storage)
8226    }
8227
8228    /// 0.8.20 Slice 15e — read a row's raw `embedding_bin` blob bytes (the
8229    /// sign-quantized vector). Used to prove the non-destructive reshape copies the
8230    /// bits VERBATIM (condition #4): the pre-reshape and post-reshape bytes must be
8231    /// byte-identical.
8232    #[doc(hidden)]
8233    pub fn read_vector_bin_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8234        self.ensure_open()?;
8235        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8236        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8237        connection
8238            .query_row(
8239                "SELECT embedding_bin FROM vector_default WHERE rowid = ?1",
8240                [rowid],
8241                |row| row.get::<_, Vec<u8>>(0),
8242            )
8243            .map_err(|_| EngineError::Storage)
8244    }
8245
8246    /// 0.8.20 Slice 15e — run an arbitrary read-only SELECT on the ENGINE
8247    /// connection (which has the vec0 extension loaded, unlike a bare
8248    /// `Connection::open`) and collect column 0 as `i64`. Lets a test run a
8249    /// phase-1-style KNN `MATCH ... {attr clause}` and observe which `rowid`s
8250    /// survive the pre-KNN filter.
8251    #[doc(hidden)]
8252    pub fn query_i64_col_for_test(&self, sql: &str) -> Result<Vec<i64>, EngineError> {
8253        self.ensure_open()?;
8254        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8255        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8256        let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8257        let rows =
8258            stmt.query_map([], |row| row.get::<_, i64>(0)).map_err(|_| EngineError::Storage)?;
8259        rows.collect::<rusqlite::Result<Vec<i64>>>().map_err(|_| EngineError::Storage)
8260    }
8261
8262    /// 0.8.20 Slice 15e — as [`query_i64_col_for_test`] but collects column 0 as
8263    /// `String` (e.g. an `attr_<hex>` metadata column's stored value).
8264    #[doc(hidden)]
8265    pub fn query_text_col_for_test(&self, sql: &str) -> Result<Vec<String>, EngineError> {
8266        self.ensure_open()?;
8267        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8268        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8269        let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8270        let rows =
8271            stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
8272        rows.collect::<rusqlite::Result<Vec<String>>>().map_err(|_| EngineError::Storage)
8273    }
8274
8275    #[doc(hidden)]
8276    pub fn default_embedder_profile_for_test(&self) -> Result<EmbedderIdentity, EngineError> {
8277        self.ensure_open()?;
8278        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8279        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8280        load_default_profile(connection).map_err(|_| EngineError::Storage)
8281    }
8282
8283    /// Doctor read-only integrity report. Three-section output per
8284    /// AC-043a/b. `opts.full` adds `PRAGMA integrity_check`. `quick` and
8285    /// `round_trip` are accepted but treated as default for 0.6.0.
8286    #[cfg(feature = "operator")]
8287    pub fn check_integrity(
8288        &self,
8289        opts: CheckIntegrityOpts,
8290    ) -> Result<IntegrityReport, EngineError> {
8291        self.ensure_open()?;
8292        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8293        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8294        Ok(IntegrityReport {
8295            physical: physical_section(connection, opts.full),
8296            logical: logical_section(connection),
8297            semantic: semantic_section(connection),
8298        })
8299    }
8300
8301    /// Doctor bit-preserving export. Runs `VACUUM INTO` to produce a
8302    /// self-contained SQLite file at `out`, computes SHA-256 of the
8303    /// resulting bytes, and writes a JSON manifest at `manifest`. Per
8304    /// AC-039a/b.
8305    #[cfg(feature = "operator")]
8306    pub fn safe_export(
8307        &self,
8308        out: &Path,
8309        manifest: &Path,
8310    ) -> Result<SafeExportArtifact, EngineError> {
8311        self.ensure_open()?;
8312        {
8313            let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8314            let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8315            let target = out.to_string_lossy().to_string();
8316            connection
8317                .execute("VACUUM INTO ?1", params![target])
8318                .map_err(|_| EngineError::Storage)?;
8319        }
8320        let bytes = std::fs::read(out).map_err(|_| EngineError::Storage)?;
8321        let digest = sha2::Sha256::digest(&bytes);
8322        let sha256_hex = hex_encode(digest.as_slice());
8323        let export_abs = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());
8324        let manifest_json = serde_json::json!({
8325            "export_path": export_abs.to_string_lossy(),
8326            "sha256": sha256_hex,
8327            "byte_count": bytes.len() as u64,
8328        });
8329        let manifest_bytes =
8330            serde_json::to_vec_pretty(&manifest_json).map_err(|_| EngineError::Storage)?;
8331        std::fs::write(manifest, &manifest_bytes).map_err(|_| EngineError::Storage)?;
8332        Ok(SafeExportArtifact {
8333            export_path: out.to_path_buf(),
8334            manifest_path: manifest.to_path_buf(),
8335            manifest_sha256: sha256_hex,
8336        })
8337    }
8338
8339    /// Operator regenerate workflow per `dev/design/projections.md`
8340    /// § Regenerate workflow. Drains in-flight projection work, then
8341    /// truncates FTS5 + vec0 shadow rows, resets the projection cursor,
8342    /// and lets the scheduler re-enqueue every canonical row. Durable
8343    /// `projection_failures` audit rows are preserved per design. AC-044
8344    /// + AC-063c.
8345    #[cfg(feature = "operator")]
8346    pub fn rebuild_projections(&self) -> Result<RebuildReport, EngineError> {
8347        self.ensure_open()?;
8348        self.run_rebuild(true, RebuildKind::Projections)
8349    }
8350
8351    /// Vec0-only variant of [`Engine::rebuild_projections`]. Leaves
8352    /// FTS5 shadow content untouched; per recovery design,
8353    /// `recover --rebuild-vec0` is the surface for vec0-only repair.
8354    #[cfg(feature = "operator")]
8355    pub fn rebuild_vec0(&self) -> Result<RebuildReport, EngineError> {
8356        self.ensure_open()?;
8357        self.run_rebuild(false, RebuildKind::Vec0)
8358    }
8359
8360    /// Phase 9 Pack B / AC-042 source trace. Returns the canonical-row
8361    /// id set produced by `source_id`, ordered by `write_cursor`. Empty
8362    /// string is not a valid `source_id`; rows with NULL `source_id`
8363    /// are excluded from every result.
8364    #[cfg(feature = "operator")]
8365    pub fn trace_source_ref(&self, source_id: &str) -> Result<TraceReport, EngineError> {
8366        self.ensure_open()?;
8367        if source_id.is_empty() {
8368            return Err(EngineError::WriteValidation);
8369        }
8370        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8371        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8372
8373        let mut events: Vec<TraceEvent> = Vec::new();
8374        let mut nodes = connection
8375            .prepare(
8376                "SELECT write_cursor, kind FROM canonical_nodes WHERE source_id = ?1
8377                 ORDER BY write_cursor",
8378            )
8379            .map_err(|_| EngineError::Storage)?;
8380        let node_rows = nodes
8381            .query_map([source_id], |row| {
8382                Ok(TraceEvent {
8383                    write_cursor: row.get::<_, i64>(0)? as u64,
8384                    kind: row.get::<_, String>(1)?,
8385                    table: "canonical_nodes",
8386                })
8387            })
8388            .map_err(|_| EngineError::Storage)?;
8389        for row in node_rows {
8390            events.push(row.map_err(|_| EngineError::Storage)?);
8391        }
8392
8393        let mut edges = connection
8394            .prepare(
8395                "SELECT write_cursor, kind FROM canonical_edges WHERE source_id = ?1
8396                 ORDER BY write_cursor",
8397            )
8398            .map_err(|_| EngineError::Storage)?;
8399        let edge_rows = edges
8400            .query_map([source_id], |row| {
8401                Ok(TraceEvent {
8402                    write_cursor: row.get::<_, i64>(0)? as u64,
8403                    kind: row.get::<_, String>(1)?,
8404                    table: "canonical_edges",
8405                })
8406            })
8407            .map_err(|_| EngineError::Storage)?;
8408        for row in edge_rows {
8409            events.push(row.map_err(|_| EngineError::Storage)?);
8410        }
8411
8412        events.sort_by_key(|e| e.write_cursor);
8413        Ok(TraceReport { source_ref: source_id.to_string(), events })
8414    }
8415
8416    /// OPP-12 Phase-1 (0.8.19 Slice 10) — resolve a lifecycle-verb id argument to
8417    /// the BARE `logical_id` it addresses, enforcing `Logical`(`l:`)-only
8418    /// addressability (design §3). An untagged string is taken as a bare
8419    /// `logical_id` (the `l:` form); an explicit `l:`-prefixed string is stripped
8420    /// to its value; a `Content`(`h:`) or `Passage`(`p:`) id is a typed
8421    /// [`EngineError::NotLifecycleAddressable`] refusal (never a panic / no-op).
8422    fn resolve_lifecycle_target(id: &str) -> Result<String, EngineError> {
8423        match IdSpace::parse(id) {
8424            Some(parsed) => match parsed.space {
8425                IdSpaceKind::Logical => Ok(parsed.value),
8426                other => Err(EngineError::NotLifecycleAddressable { id_space: other }),
8427            },
8428            // Untagged — no id-space prefix; treat as a bare logical_id (l: space).
8429            None => Ok(id.to_string()),
8430        }
8431    }
8432
8433    /// OPP-12 Phase-1 (0.8.19 Slice 10, R-TR-1/2) — move a governed node between
8434    /// existence states per the engine-enforced legal-transition table (design
8435    /// §2): promote `pending→active`, reject `pending→deleted`, soft-delete
8436    /// `active→deleted`, undelete `deleted→active`. `to_state` is a full
8437    /// [`LifecycleState`], but `Pending` (create-time only) and `Purged`
8438    /// (`purge`-only) are never legal `transition` targets, nor are self-loops or
8439    /// any move from a non-existent/`purged` row — each returns a typed
8440    /// [`EngineError::IllegalTransition`] enumerating the legal targets.
8441    ///
8442    /// `reason` semantics (design §3 gap-6): promote/undelete CLEAR `reason` to
8443    /// `NULL` (the row is admitted; no standing cause); reject/soft-delete SET
8444    /// `reason` to the supplied value (`NULL` allowed but the delete-family
8445    /// expects it). `reason` is advisory — the engine never interprets it.
8446    ///
8447    /// Keys on the BARE `logical_id` (`l:` space only); a `Content`(`h:`) or
8448    /// `Passage`(`p:`) id raises [`EngineError::NotLifecycleAddressable`].
8449    /// The state flip mutates the single active (`superseded_at IS NULL`) row; a
8450    /// `deleted` row STAYS node-FTS / vector indexed (gap-5) — only the
8451    /// `state='active'` default filter excludes those shadows, so an undelete
8452    /// needs no re-projection there.
8453    ///
8454    /// 0.8.20 Slice 15d fix-2 [P2] — the row-owned ATTRIBUTE projection
8455    /// (`canonical_attributes` / `property_search_index`) is the exception: it has
8456    /// NO read-side lifecycle filter (the property-FTS5 table cannot carry one), so
8457    /// it is maintained AT REST to track the backfill's set
8458    /// (projected ⟺ active ∧ non-superseded). Promote/undelete PROJECT the declared
8459    /// attributes; soft-delete PURGES them; reject is a no-op.
8460    pub fn transition(
8461        &self,
8462        logical_id: &str,
8463        to_state: LifecycleState,
8464        reason: Option<String>,
8465    ) -> Result<(), EngineError> {
8466        self.ensure_open()?;
8467        let lid = Self::resolve_lifecycle_target(logical_id)?;
8468
8469        // Settle in-flight projection work first: the async projection worker
8470        // commits vector/FTS shadows on its OWN connection, so a state flip issued
8471        // while a worker holds the write lock would SQLITE_BUSY. Draining
8472        // (unfrozen so any unprojected row completes) leaves the worker idle; a
8473        // bare state flip enqueues no new projection work.
8474        //
8475        // Slice 40 B3 aligns the worker with `commit_batch`:
8476        // `commit_projection_outcomes` acquires `BEGIN IMMEDIATE` before its reads.
8477        // This drain remains load-bearing because the worker owns a separate
8478        // connection while this state flip still reads before its own write; it
8479        // keeps that deferred transaction out of the worker's write window.
8480        self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8481
8482        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8483        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8484        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8485
8486        // The lifecycle state lives on the single active (superseded_at IS NULL)
8487        // version; a `deleted` row is still that active version, just flagged.
8488        // fix-2 [P2] — also read its `write_cursor` + `body` so the row-owned
8489        // attribute projection can be maintained after the state flip.
8490        let current: Option<(String, i64, String)> = tx
8491            .query_row(
8492                "SELECT state, write_cursor, body FROM canonical_nodes \
8493                 WHERE logical_id = ?1 AND superseded_at IS NULL",
8494                params![lid],
8495                |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
8496            )
8497            .optional()
8498            .map_err(|_| EngineError::Storage)?;
8499
8500        // A missing active row is an absent/purged node — the terminal `Purged`
8501        // state for legality purposes (nothing is a legal target from there).
8502        let from_state = match &current {
8503            Some((s, _, _)) => LifecycleState::from_str_opt(s).ok_or(EngineError::Storage)?,
8504            None => LifecycleState::Purged,
8505        };
8506
8507        if !is_legal_transition_move(from_state, to_state) {
8508            return Err(EngineError::IllegalTransition {
8509                from_state,
8510                to_state,
8511                legal: from_state.legal_next_states(),
8512            });
8513        }
8514
8515        if matches!(to_state, LifecycleState::Active) {
8516            if let Some((_, _, body)) = &current {
8517                validate_nested_projection_sources_for_body(&tx, body)?;
8518            }
8519        }
8520
8521        // Admit (promote/undelete) → clear reason; exclude (reject/soft-delete) →
8522        // set the supplied reason. `to_state` is Active or Deleted here.
8523        let new_reason: Option<String> = match to_state {
8524            LifecycleState::Active => None,
8525            _ => reason,
8526        };
8527        tx.execute(
8528            "UPDATE canonical_nodes SET state = ?1, reason = ?2 \
8529             WHERE logical_id = ?3 AND superseded_at IS NULL",
8530            params![to_state.as_str(), new_reason, lid],
8531        )
8532        .map_err(|_| EngineError::Storage)?;
8533
8534        // fix-2 [P2] — maintain the row-owned attribute projection so it keeps
8535        // tracking the backfill's set (projected ⟺ active ∧ non-superseded). The
8536        // transitioned row is the single non-superseded version, so the invariant
8537        // reduces to `projected ⟺ to_state == Active`. We PURGE unconditionally
8538        // (idempotent — a no-op on the never-projected pending / already-purged
8539        // deleted arms) then RE-PROJECT when landing `Active`. This covers every
8540        // legal move: promote (pending→active) projects the withheld attributes;
8541        // soft-delete (active→deleted) purges; undelete (deleted→active)
8542        // re-projects; reject (pending→deleted) is a no-op. The property tables
8543        // (`canonical_attributes` / `property_search_index`) carry NO read-side
8544        // lifecycle filter — unlike node-FTS / vector shadows, which the canonical
8545        // read path already excludes when non-active — so they MUST be maintained
8546        // at rest, the same rationale as fix-1's purge-on-supersede. Node-FTS /
8547        // vector shadows are deliberately left intact (gap-5: a deleted row STAYS
8548        // indexed; only the `state='active'` default read filter hides it).
8549        if let Some((cursor, body)) = current.as_ref().map(|(_, c, b)| (*c, b.as_str())) {
8550            purge_row_projections_for_cursor_in(
8551                &tx,
8552                cursor,
8553                &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
8554            )
8555            .map_err(|_| EngineError::Storage)?;
8556            if matches!(to_state, LifecycleState::Active) {
8557                project_node_attributes(&tx, cursor, body).map_err(|_| EngineError::Storage)?;
8558                refresh_vector_attr_values_for_row(&tx, cursor, body)
8559                    .map_err(|_| EngineError::Storage)?;
8560            }
8561        }
8562        tx.commit().map_err(|_| EngineError::Storage)?;
8563        self.counters.record_admin();
8564        Ok(())
8565    }
8566
8567    /// 0.8.20 Slice 15d (R-20-PR / C-1) — the projection registry as a
8568    /// DECLARATIVE, IDEMPOTENT apply. The engine is the SOLE projection authority
8569    /// (Q3): it diffs the supplied `specs` against the durable registry and
8570    /// backfills the difference in ONE transaction. Cheap projections
8571    /// (`filterable`, `searchable→FTS`) are built same-transaction; `rankable`
8572    /// and the `searchable→vector` sub-target are PERSISTED but deferred (F9 /
8573    /// Slice 20) — declaring them never errors (graceful-absent, Q6a).
8574    ///
8575    /// 0.8.20 Slice 23 (`R-20-SV`) — **SPEC VALIDATION.** A spec that carries an
8576    /// `fts` or `vector` sub-object WITHOUT [`ProjectionRole::Searchable`] is an
8577    /// INVALID SPEC and is refused with [`EngineError::WriteValidation`] (HITL
8578    /// 2026-07-24; see [`apply_projection_config`] for the full rationale). A
8579    /// rejected request is a TOTAL no-op. `read_projections` is unaffected — it
8580    /// is a pure read — so a LEGACY row in that shape still reports verbatim but
8581    /// can no longer be re-applied.
8582    ///
8583    /// `drop` is EXPLICIT (C3, `api-surface.md:27`): omission of a live
8584    /// projection from `specs` does NOT drop it; removal requires naming it in
8585    /// `drop`. An incompatible/destructive change to a live projection that is
8586    /// NOT in `drop` is refused with [`EngineError::ProjectionDestructive`], the
8587    /// destructive delta surfaced — never silent data loss. Re-applying an
8588    /// unchanged spec diffs to a no-op ([`ProjectionDelta::unchanged`]).
8589    ///
8590    /// Pair with [`Engine::read_projections`] to see current state before
8591    /// applying.
8592    pub fn configure_projections(
8593        &self,
8594        specs: &[ProjectionSpec],
8595        drop: &[String],
8596    ) -> Result<ProjectionDelta, EngineError> {
8597        self.ensure_open()?;
8598        // Settle in-flight async projection work first. The worker commits on its
8599        // own connection with `BEGIN IMMEDIATE`; a backfill issued in that write
8600        // window would SQLITE_BUSY.
8601        self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8602
8603        // 0.8.20 Slice 20c (R-20-DR remainder) — the backfill is gated on a LIVE
8604        // embedder. With `EmbedderChoice::None` there is no dense arm, so the
8605        // declaration persists and DEFERS (Q6a graceful-absent, exactly like
8606        // `rankable`) rather than queueing embeds that could only retry to a
8607        // `failed` terminal. Re-applying the same spec in a session that HAS an
8608        // embedder grafts the backfill on — the shipped graceful-graft contract.
8609        let dense_arm_live = self.runtime_embedder.is_some();
8610        let (delta, enqueued_backfill) = {
8611            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8612            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8613            let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8614            let applied = apply_projection_config(&tx, specs, drop, dense_arm_live)?;
8615            tx.commit().map_err(|_| EngineError::Storage)?;
8616            applied
8617        };
8618        // 0.8.20 Slice 20c (R-20-DR remainder) — the C4 rider's second half. The
8619        // enrolment + terminal-clear committed above; now WAKE the dispatcher, or
8620        // it sleeps on `pending_scan == false` and the very next `drain` burns its
8621        // whole timeout waiting for work nobody scheduled. `drain` itself stays
8622        // PASSIVE (a barrier, never a trigger) — the notify belongs here, on the
8623        // enqueue side. Deliberately after the connection guard is dropped: the
8624        // dispatcher immediately opens its own connection to scan.
8625        if enqueued_backfill {
8626            self.projection_runtime.notify_new_work();
8627        }
8628        self.counters.record_admin();
8629        Ok(delta)
8630    }
8631
8632    /// 0.8.20 Slice 15d (R-20-PR) — read the current projection registry (C5
8633    /// introspection: `read.projections`). Returns every declared
8634    /// [`ProjectionSpec`] sorted by name, so a caller can inspect current state
8635    /// (and the destructive delta a change would cause) BEFORE applying. Pure
8636    /// read; never mutates.
8637    ///
8638    /// 0.8.20 Slice 20 (R-20-DR) — this is ALSO the surface that populates the
8639    /// engine-set [`ProjectionVector::dense_readiness`] READ METADATA. It is
8640    /// derived here, on the way out (see [`derive_dense_readiness`]); the durable
8641    /// registry stores no readiness. Only a spec that declares the
8642    /// `searchable→vector` sub-object carries one — `filterable` and
8643    /// `searchable→FTS` are same-transaction and have no readiness axis.
8644    pub fn read_projections(&self) -> Result<Vec<ProjectionSpec>, EngineError> {
8645        self.ensure_open()?;
8646        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8647        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8648        let registry = load_projection_registry(connection).map_err(|_| EngineError::Storage)?;
8649        // Derived ONCE per call, so every vector projection in one read reports a
8650        // consistent readiness (they are all served by the one vector pipeline).
8651        // Skipped entirely when no vector projection is declared, keeping the
8652        // no-vector default path free of the extra probe.
8653        let mut readiness: Option<DenseReadiness> = None;
8654        let mut specs: Vec<ProjectionSpec> =
8655            registry.iter().map(|(name, stored)| stored.to_spec(name)).collect();
8656        for spec in &mut specs {
8657            if let Some(vector) = spec.vector.as_mut() {
8658                let value = match readiness {
8659                    Some(value) => value,
8660                    None => {
8661                        let value = derive_dense_readiness(connection)?;
8662                        readiness = Some(value);
8663                        value
8664                    }
8665                };
8666                vector.dense_readiness = Some(value);
8667            }
8668        }
8669        Ok(specs)
8670    }
8671
8672    /// OPP-12 Phase-1 (0.8.19 Slice 10, R-PG-1/2) — irreversibly hard-erase a
8673    /// governed node. A SEPARATE verb from [`Engine::transition`] (NOT on the
8674    /// `recovery_denylist`). Precondition: DELETED-FIRST — legal only from
8675    /// `deleted` (else a typed [`EngineError::IllegalTransition`] to `purged`);
8676    /// IDEMPOTENT — purging an already-absent/already-purged id is a no-op
8677    /// success. Keys on the bare `logical_id` (`l:` only); `h:`/`p:` →
8678    /// [`EngineError::NotLifecycleAddressable`].
8679    ///
8680    /// In ONE transaction, physically erases every ROW-OWNED target for the node
8681    /// (design §3 / gap-3): all `canonical_nodes` versions; its `search_index`,
8682    /// `search_index_edges`, `search_index_v2` FTS rows; its `vector_default`
8683    /// (vec0) + `_fathomdb_vector_rows` vectors; its `_fathomdb_projection_terminal`
8684    /// bookkeeping; and — CASCADE-REMOVE, no content-free stubs — every
8685    /// `canonical_edges` row touching it (`from_id`/`to_id`) plus those edges'
8686    /// projection shadows. The global/kind-level registries
8687    /// `_fathomdb_projection_state` and `_fathomdb_vector_kinds` are NOT keyed to
8688    /// a node id and are DELIBERATELY untouched.
8689    ///
8690    /// Erasure completeness relies on the standing `PRAGMA secure_delete=ON`
8691    /// (design §3 gap-4) which zeroes every freed page — so no per-purge `VACUUM`.
8692    /// (Freelist content written on a pre-20 DB before `secure_delete` was on is a
8693    /// documented residual; there is no forced migration-time `VACUUM`.)
8694    pub fn purge(&self, logical_id: &str) -> Result<(), EngineError> {
8695        self.ensure_open()?;
8696        let lid = Self::resolve_lifecycle_target(logical_id)?;
8697
8698        // Drain in-flight projection work before the erase, exactly as
8699        // `excise_source` does: SQLite-WAL would otherwise let a worker that
8700        // already dequeued a job for a purged cursor commit its vec0 /
8701        // `_fathomdb_vector_rows` INSERT after our DELETE releases the writer
8702        // lock, leaving residue that defeats the erasure sweep.
8703        // Settle every pending projection FIRST (unfrozen) so no unprojected row
8704        // is left behind that a subsequent freeze would wedge `drain` on, and so
8705        // the async worker is idle. THEN freeze the scanner (no new work is queued
8706        // while we erase), confirm idle, and erase in one writer transaction.
8707        // Freezing before the first drain would stall projection of any
8708        // just-written row → `database_has_pending_projection_work` never clears →
8709        // `drain` times out into `Scheduler`.
8710        self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8711        self.projection_runtime.set_frozen(true);
8712        let outcome = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS).and_then(|()| self.purge_inner(&lid));
8713        self.projection_runtime.set_frozen(false);
8714        // 0.8.20 Slice 5b (R-20-E5/E6) — the rows are gone from the tables; now
8715        // finish the erasure AT REST (telemetry sink + `-wal` bytes) before
8716        // reporting success. Runs after the connection guard inside
8717        // `purge_inner` has been dropped: `complete_erasure_at_rest` re-acquires
8718        // it for the checkpoint.
8719        outcome?;
8720        self.complete_erasure_at_rest("purge")
8721    }
8722
8723    /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
8724    /// returned: they are enqueued for redaction inside this transaction (see
8725    /// [`enqueue_pending_redaction`]), because a caller-held vector is lost on the
8726    /// retry path that codex §9 P2 found.
8727    fn purge_inner(&self, lid: &str) -> Result<(), EngineError> {
8728        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8729        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8730        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8731
8732        // Precondition on the active row's state. Absent (never-created or
8733        // already-purged) → idempotent no-op success.
8734        let current: Option<String> = tx
8735            .query_row(
8736                "SELECT state FROM canonical_nodes \
8737                 WHERE logical_id = ?1 AND superseded_at IS NULL",
8738                params![lid],
8739                |r| r.get::<_, String>(0),
8740            )
8741            .optional()
8742            .map_err(|_| EngineError::Storage)?;
8743        let from_state = match current {
8744            None => {
8745                // Idempotent: nothing to erase.
8746                tx.commit().map_err(|_| EngineError::Storage)?;
8747                return Ok(());
8748            }
8749            Some(s) => LifecycleState::from_str_opt(&s).ok_or(EngineError::Storage)?,
8750        };
8751        if from_state != LifecycleState::Deleted {
8752            // Deleted-first precondition. Dropping `tx` rolls back (no-op read).
8753            return Err(EngineError::IllegalTransition {
8754                from_state,
8755                to_state: LifecycleState::Purged,
8756                legal: from_state.legal_next_states(),
8757            });
8758        }
8759
8760        // Collect every version cursor for the node, plus every cursor of an edge
8761        // that touches it (either endpoint), across ALL versions — the projection
8762        // shadow tables are keyed by these per-row `write_cursor`s.
8763        let node_cursors: Vec<i64> = {
8764            let mut stmt = tx
8765                .prepare("SELECT write_cursor FROM canonical_nodes WHERE logical_id = ?1")
8766                .map_err(|_| EngineError::Storage)?;
8767            let rows = stmt
8768                .query_map(params![lid], |row| row.get::<_, i64>(0))
8769                .map_err(|_| EngineError::Storage)?;
8770            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
8771        };
8772        let edge_cursors: Vec<i64> = {
8773            let mut stmt = tx
8774                .prepare(
8775                    "SELECT write_cursor FROM canonical_edges \
8776                     WHERE from_id = ?1 OR to_id = ?1",
8777                )
8778                .map_err(|_| EngineError::Storage)?;
8779            let rows = stmt
8780                .query_map(params![lid], |row| row.get::<_, i64>(0))
8781                .map_err(|_| EngineError::Storage)?;
8782            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
8783        };
8784
8785        // 0.8.20 Slice 5b (R-20-E6) — the stable ids the telemetry sink may have
8786        // persisted for these rows, collected BEFORE the DELETEs.
8787        let erased_stable_ids = collect_erased_stable_ids(
8788            &tx,
8789            "SELECT logical_id, body FROM canonical_nodes WHERE logical_id = ?1",
8790            "SELECT logical_id, body FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1",
8791            lid,
8792        )?;
8793
8794        // Erase the row-owned projection shadows for every collected cursor.
8795        // 0.8.20 Slice 5a (R-20-E1): registry-driven — the hand-rolled delete
8796        // list is gone, so a newly registered projection table is erased here
8797        // without touching this site. vec0 rowid == the canonical row's
8798        // write_cursor (see `_fathomdb_vector_rows`).
8799        for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
8800            erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?;
8801        }
8802
8803        // Erase the canonical rows: all node versions + all touching edges
8804        // (gap-3 CASCADE-REMOVE — no content-free stubs in Phase-1).
8805        tx.execute("DELETE FROM canonical_nodes WHERE logical_id = ?1", params![lid])
8806            .map_err(|_| EngineError::Storage)?;
8807        tx.execute("DELETE FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1", params![lid])
8808            .map_err(|_| EngineError::Storage)?;
8809
8810        // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
8811        // erasure now owes, atomically with the deletes above. Only when a sink
8812        // is attached: with telemetry never enabled there is no file the ids
8813        // could have leaked into, so there is nothing to owe.
8814        let pending_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
8815        let enqueued =
8816            self.telemetry_enabled.load(Ordering::Acquire) && !erased_stable_ids.is_empty();
8817        if enqueued {
8818            enqueue_pending_redaction(&tx, "purge", &erased_stable_ids, pending_cursor)?;
8819        }
8820
8821        tx.commit().map_err(|_| EngineError::Storage)?;
8822        if enqueued {
8823            self.next_cursor.store(pending_cursor, Ordering::SeqCst);
8824        }
8825        self.counters.record_admin();
8826        Ok(())
8827    }
8828
8829    /// 0.8.20 Slice 5d (R-20-E4, design §4 item 9b) — the **governed SDK
8830    /// erasure verb**. Deletes every canonical row attributable to `source_id`,
8831    /// plus its row-owned projections, and finishes the erasure at rest.
8832    ///
8833    /// This is NOT `operator`-gated: erasing content a consumer wrote is an
8834    /// application obligation, not a recovery workflow. Before this slice the
8835    /// only erasure path was [`Engine::excise_source`], which lives behind the
8836    /// operator feature (i.e. the CLI), so an SDK-only consumer holding a
8837    /// deletion obligation over ANONYMOUS content — content with no
8838    /// `logical_id`, therefore not reachable by [`Engine::purge`] — had no way
8839    /// to discharge it at all. That gap is what R-20-E4 closes.
8840    ///
8841    /// **One engine path.** `erase_source` and `excise_source` are the SAME
8842    /// operation: both delegate to [`Engine::erase_source_shared`]. They are
8843    /// not competing implementations, and no behaviour is duplicated.
8844    ///
8845    /// **Validation differs, deliberately.** `erase_source` admits only ids
8846    /// [`SourceId::new`] would admit, so a caller cannot aim the governed verb
8847    /// at the engine's reserved `_`-prefixed namespace (`_engine:*` substrate,
8848    /// or the `_legacy:pre-0.8.20` cohort migration step 21 back-filled — a
8849    /// single call against which would erase every pre-0.8.20 anonymous row).
8850    /// `excise_source` stays permissive precisely BECAUSE it is the recovery
8851    /// seam: R-20-E8 requires an operator to be able to excise `_legacy:`.
8852    ///
8853    /// **Not a recovery verb.** `erase_source` carries no REQ-054
8854    /// recovery-denylist name (`{recover, restore, repair, fix, rebuild}`); it
8855    /// is a lifecycle verb alongside `transition`/`purge`. AC-041 is unaffected.
8856    ///
8857    /// # Errors
8858    ///
8859    /// [`EngineError::WriteValidation`] for an empty, whitespace-only or
8860    /// reserved `source_id`; [`EngineError::ErasureIncomplete`] if the erasure
8861    /// could not be completed at rest (see [`Engine::complete_erasure_at_rest`]).
8862    pub fn erase_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
8863        // Construct-to-validate: reuse the newtype's rule rather than restating
8864        // it, so the erasure boundary and the write boundary cannot drift.
8865        let _validated = SourceId::new(source_id)?;
8866        self.erase_source_shared("erase_source", source_id)
8867    }
8868
8869    /// Phase 9 Pack B / AC-028a/b/c source excise — the **operator/recovery**
8870    /// spelling of [`Engine::erase_source`], sharing one engine path with it.
8871    ///
8872    /// Kept `operator`-gated and kept permissive about reserved ids: this is
8873    /// the seam an operator uses to excise `_legacy:pre-0.8.20` (R-20-E8) or
8874    /// `_engine:*` substrate, which the governed SDK verb refuses.
8875    #[cfg(feature = "operator")]
8876    pub fn excise_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
8877        if source_id.is_empty() {
8878            self.ensure_open()?;
8879            return Err(EngineError::WriteValidation);
8880        }
8881        self.erase_source_shared("excise_source", source_id)
8882    }
8883
8884    /// The single erasure implementation behind [`Engine::erase_source`] and
8885    /// [`Engine::excise_source`]. `verb` names the caller for the telemetry
8886    /// redaction record only; the deletion semantics are identical.
8887    ///
8888    /// Non-perturbation: rows from other sources (and rows with NULL
8889    /// `source_id`) are untouched; the projection cursor is NOT reset
8890    /// and no blanket projection rebuild is issued.
8891    fn erase_source_shared(
8892        &self,
8893        verb: &'static str,
8894        source_id: &str,
8895    ) -> Result<ExciseReport, EngineError> {
8896        self.ensure_open()?;
8897
8898        // Drain MUST succeed before the excise transaction. SQLite-WAL
8899        // would otherwise allow a worker that already dequeued a job
8900        // for an excised cursor to commit its INSERT into vec0 /
8901        // _fathomdb_vector_rows after our DELETE releases the writer
8902        // lock, leaving residue and breaking AC-028b. Surface the
8903        // timeout instead of swallowing it (Pack A pattern).
8904        //
8905        // ORDER IS LOAD-BEARING, exactly as in `purge`: settle every pending
8906        // projection FIRST (UNFROZEN), and only THEN freeze the scanner and
8907        // confirm idle. Freezing first parks the dispatcher, so a row written
8908        // moments ago can never be scanned and enqueued — while `drain` ->
8909        // `wait_for_idle` keeps seeing it via
8910        // `database_has_pending_projection_work`, which reads the DATABASE and
8911        // not the queue. The result is that the ordinary sequence "write a
8912        // vector-indexed row, then erase it" stalls for the whole
8913        // LIFECYCLE_DRAIN_TIMEOUT_MS and fails with `Scheduler`.
8914        // (codex §9 [P2]; `erase_source_drains_before_freezing`.)
8915        self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8916        self.projection_runtime.set_frozen(true);
8917        let drain_result = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS);
8918        let outcome = drain_result.and_then(|()| self.excise_source_inner(verb, source_id));
8919        self.projection_runtime.set_frozen(false);
8920        // 0.8.20 Slice 5b (R-20-E5/E6) — finish the erasure AT REST before
8921        // reporting success: redact the erased stable ids out of the telemetry
8922        // sink, then truncate the `-wal` so the erased bytes are not still
8923        // readable on disk. On persistent checkpoint BUSY this returns
8924        // `ErasureIncomplete` rather than an `ExciseReport`.
8925        let report = outcome?;
8926        self.complete_erasure_at_rest(verb)?;
8927        Ok(report)
8928    }
8929
8930    /// Doctor `verify-embedder` seam (AC-040a). Compares the
8931    /// `_fathomdb_embedder_profiles` row to the operator-supplied
8932    /// `name:revision` identity + dimension; never raises on mismatch.
8933    #[cfg(feature = "operator")]
8934    pub fn verify_embedder(
8935        &self,
8936        supplied_identity: &str,
8937        supplied_dimension: u32,
8938    ) -> Result<VerifyEmbedderReport, EngineError> {
8939        self.ensure_open()?;
8940        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8941        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8942        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
8943        let stored_identity = format!("{}:{}", stored.name, stored.revision);
8944        let identity_match = stored_identity == supplied_identity;
8945        let dimension_match = stored.dimension == supplied_dimension;
8946        let status = match (identity_match, dimension_match) {
8947            (true, true) => VerifyEmbedderStatus::Match,
8948            (false, true) => VerifyEmbedderStatus::IdentityMismatch,
8949            (true, false) => VerifyEmbedderStatus::DimensionMismatch,
8950            (false, false) => VerifyEmbedderStatus::BothMismatch,
8951        };
8952        Ok(VerifyEmbedderReport {
8953            stored_identity,
8954            stored_dimension: stored.dimension,
8955            supplied_identity: supplied_identity.to_string(),
8956            supplied_dimension,
8957            status,
8958        })
8959    }
8960
8961    /// Doctor `dump-schema` seam (AC-040a). Returns the
8962    /// `PRAGMA user_version` sentinel plus the table + index inventory
8963    /// from `sqlite_schema`, excluding `sqlite_*` internal rows.
8964    /// Canonical tables appear first per [`CANONICAL_TABLES`].
8965    #[cfg(feature = "operator")]
8966    pub fn dump_schema(&self) -> Result<DumpSchemaReport, EngineError> {
8967        self.ensure_open()?;
8968        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8969        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8970        let user_version: u32 = connection
8971            .query_row("PRAGMA user_version", [], |row| row.get(0))
8972            .map_err(|_| EngineError::Storage)?;
8973        let tables = read_schema_objects(connection, "table")?;
8974        let indexes = read_schema_objects(connection, "index")?;
8975        Ok(DumpSchemaReport { user_version, tables: order_canonical_first(tables), indexes })
8976    }
8977
8978    /// Doctor `dump-row-counts` seam (AC-040a). Emits canonical-table
8979    /// counts only; projection / FTS / vec0 shadow tables are excluded.
8980    #[cfg(feature = "operator")]
8981    pub fn dump_row_counts(&self) -> Result<DumpRowCountsReport, EngineError> {
8982        self.ensure_open()?;
8983        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8984        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8985        let mut counts = Vec::with_capacity(CANONICAL_TABLES.len());
8986        for name in CANONICAL_TABLES {
8987            let rows: u64 = connection
8988                .query_row(&format!("SELECT COUNT(*) FROM {name}"), [], |row| row.get(0))
8989                .map_err(|_| EngineError::Storage)?;
8990            counts.push(TableRowCount { name: (*name).to_string(), rows });
8991        }
8992        Ok(DumpRowCountsReport { counts })
8993    }
8994
8995    /// 0.8.20 Slice 5d (R-20-E8, design §4 item 11) — doctor
8996    /// `orphan-provenance` seam: a **read-only** per-`source_id` census over
8997    /// `canonical_nodes` + `canonical_edges`.
8998    ///
8999    /// Answers the operator question the erasure work made askable: *"for this
9000    /// database, is every row actually reachable by some erasure verb?"* A row
9001    /// is reachable by `erase_source` / `excise_source` via `source_id`, or —
9002    /// **if it is a NODE** — by `purge` via `logical_id`. A row with neither is
9003    /// un-erasable, and is counted into
9004    /// [`OrphanProvenanceReport::unerasable_rows`].
9005    ///
9006    /// The node/edge asymmetry is load-bearing and mirrors migration step 21:
9007    /// an EDGE's `logical_id` is a supersession identity only and confers no
9008    /// purge-addressability, so a NULL-`source_id` edge is un-erasable however
9009    /// governed it looks. See the query comment below.
9010    ///
9011    /// CLI-only (no SDK parity), matching the `dump-*` diagnostic family.
9012    ///
9013    /// Read-only by construction: this method issues SELECTs exclusively and
9014    /// opens no transaction.
9015    #[cfg(feature = "operator")]
9016    pub fn orphan_provenance(&self) -> Result<OrphanProvenanceReport, EngineError> {
9017        self.ensure_open()?;
9018        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9019        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9020
9021        // One UNION ALL over both canonical tables so a source that spans nodes
9022        // AND edges reports as a single bucket.
9023        //
9024        // TWO DIFFERENT SUMS, and the difference is the whole point:
9025        //
9026        // * `governed` counts `logical_id` carriers — a reporting figure;
9027        // * `purge_addressable` counts rows that `purge` can actually reach,
9028        //   and it is NODE-ONLY (the edge arm contributes a literal 0).
9029        //
9030        // This is the same node/edge asymmetry migration step 21 carries, for
9031        // the same reason, and the two must stay in step: `purge_inner`
9032        // resolves its target exclusively through `canonical_nodes` (`SELECT
9033        // state FROM canonical_nodes WHERE logical_id = ?1`) and then erases
9034        // edges by ENDPOINT (`from_id`/`to_id`). It NEVER resolves an edge by
9035        // edge `logical_id` — an edge `logical_id` is only a SUPERSESSION
9036        // identity and confers no purge-addressability whatsoever.
9037        //
9038        // Crediting an edge's `logical_id` here made the diagnostic subtract
9039        // exactly the rows it exists to find: a NULL-`source_id` edge is
9040        // reachable by no erasure verb at all, yet `orphan-provenance` would
9041        // exit CLEAN on precisely the legacy/corrupt shape step 21 closes.
9042        // False assurance from a governance verb is worse than no verb.
9043        // (codex §9 [P2]; `null_source_governed_edge_counts_as_unerasable`.)
9044        let mut stmt = connection
9045            .prepare(
9046                "SELECT source_id,
9047                        COUNT(*) AS rows_total,
9048                        SUM(CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END) AS governed,
9049                        SUM(purge_addressable) AS purge_addressable
9050                   FROM (SELECT source_id,
9051                                logical_id,
9052                                CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END
9053                                    AS purge_addressable
9054                           FROM canonical_nodes
9055                         UNION ALL
9056                         SELECT source_id, logical_id, 0 AS purge_addressable
9057                           FROM canonical_edges)
9058                  GROUP BY source_id
9059                  ORDER BY rows_total DESC, source_id",
9060            )
9061            .map_err(|_| EngineError::Storage)?;
9062
9063        let rows = stmt
9064            .query_map([], |row| {
9065                let source_id: Option<String> = row.get(0)?;
9066                let rows: i64 = row.get(1)?;
9067                let governed: i64 = row.get(2)?;
9068                let purge_addressable: i64 = row.get(3)?;
9069                Ok((source_id, rows, governed, purge_addressable))
9070            })
9071            .map_err(|_| EngineError::Storage)?;
9072
9073        let mut sources = Vec::new();
9074        let mut total_rows: u64 = 0;
9075        let mut unerasable_rows: u64 = 0;
9076        for row in rows {
9077            let (source_id, rows, governed, purge_addressable) =
9078                row.map_err(|_| EngineError::Storage)?;
9079            let rows = u64::try_from(rows).unwrap_or(0);
9080            let governed_rows = u64::try_from(governed).unwrap_or(0);
9081            let purge_addressable = u64::try_from(purge_addressable).unwrap_or(0);
9082            total_rows = total_rows.saturating_add(rows);
9083            if source_id.is_none() {
9084                // No provenance: only the PURGE-ADDRESSABLE subset (governed
9085                // NODES) is reachable. The remainder — including every governed
9086                // EDGE, whose `logical_id` reaches nothing — is reachable by no
9087                // erasure verb at all.
9088                unerasable_rows =
9089                    unerasable_rows.saturating_add(rows - purge_addressable.min(rows));
9090            }
9091            let reserved = source_id.as_deref().is_some_and(|s| s.starts_with('_'));
9092            sources.push(OrphanProvenanceSource { source_id, rows, governed_rows, reserved });
9093        }
9094
9095        Ok(OrphanProvenanceReport { sources, total_rows, unerasable_rows })
9096    }
9097
9098    /// Doctor `dump-profile` seam (AC-040a). Returns the stored
9099    /// embedder identity + dimension plus the registered vectorized
9100    /// kinds from `_fathomdb_vector_kinds`.
9101    #[cfg(feature = "operator")]
9102    pub fn dump_profile(&self) -> Result<DumpProfileReport, EngineError> {
9103        self.ensure_open()?;
9104        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9105        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9106        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
9107        let mut stmt = connection
9108            .prepare("SELECT kind FROM _fathomdb_vector_kinds ORDER BY kind")
9109            .map_err(|_| EngineError::Storage)?;
9110        let rows =
9111            stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
9112        let mut vectorized_kinds = Vec::new();
9113        for row in rows {
9114            vectorized_kinds.push(row.map_err(|_| EngineError::Storage)?);
9115        }
9116        Ok(DumpProfileReport {
9117            embedder_identity: format!("{}:{}", stored.name, stored.revision),
9118            embedder_dimension: stored.dimension,
9119            vectorized_kinds,
9120        })
9121    }
9122
9123    /// Recover `--truncate-wal` seam. Runs
9124    /// `PRAGMA wal_checkpoint(TRUNCATE)` and returns the three counters
9125    /// SQLite reports. `status = Busy` when SQLite signalled a blocked
9126    /// checkpoint (`busy != 0`); the WAL may still be partially
9127    /// checkpointed in that case.
9128    #[cfg(feature = "operator")]
9129    pub fn truncate_wal(&self) -> Result<TruncateWalReport, EngineError> {
9130        self.ensure_open()?;
9131        // The operator verb keeps SQLite's own busy handler: `recover
9132        // --truncate-wal` is an explicit, foreground operator act, so waiting out
9133        // a transient reader is the helpful behaviour.
9134        self.wal_checkpoint_truncate_once(true)
9135    }
9136
9137    /// One `PRAGMA wal_checkpoint(TRUNCATE)` on the writer connection.
9138    ///
9139    /// NOT operator-gated: the erasure verbs (`purge` is a default-feature verb)
9140    /// need it too, and a `#[cfg(feature = "operator")]` helper would break the
9141    /// default build. Acquires the connection mutex, so callers must NOT already
9142    /// hold it — every erasure verb calls this AFTER its transaction has
9143    /// committed and the guard has been dropped.
9144    ///
9145    /// `honor_busy_timeout = false` suppresses SQLite's busy handler for the
9146    /// duration of the checkpoint. rusqlite installs a **5 s** default
9147    /// `busy_timeout`, so a blocked checkpoint sits for 5 s before reporting
9148    /// `busy` — under the erasure verbs' bounded retry that compounds to a ~25 s
9149    /// stall on a verb that is supposed to fail fast. The erasure path therefore
9150    /// takes the immediate `busy` answer and runs its OWN short backoff; the
9151    /// prior value is restored before returning, on every path.
9152    fn wal_checkpoint_truncate_once(
9153        &self,
9154        honor_busy_timeout: bool,
9155    ) -> Result<TruncateWalReport, EngineError> {
9156        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9157        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9158
9159        let restore_timeout_ms: Option<i64> = if honor_busy_timeout {
9160            None
9161        } else {
9162            let previous: i64 = connection
9163                .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
9164                .map_err(|_| EngineError::Storage)?;
9165            connection.busy_timeout(Duration::ZERO).map_err(|_| EngineError::Storage)?;
9166            Some(previous)
9167        };
9168
9169        let checkpoint: rusqlite::Result<(i64, i64, i64)> =
9170            connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
9171                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
9172            });
9173
9174        if let Some(previous) = restore_timeout_ms {
9175            let previous = u64::try_from(previous.max(0)).unwrap_or(0);
9176            connection
9177                .busy_timeout(Duration::from_millis(previous))
9178                .map_err(|_| EngineError::Storage)?;
9179        }
9180
9181        let (busy, log_frames, checkpointed_frames) =
9182            checkpoint.map_err(|_| EngineError::Storage)?;
9183        let status = if busy == 0 { TruncateWalStatus::Done } else { TruncateWalStatus::Busy };
9184        Ok(TruncateWalReport {
9185            status,
9186            busy: busy.max(0) as u32,
9187            log_frames: log_frames.max(0) as u32,
9188            checkpointed_frames: checkpointed_frames.max(0) as u32,
9189        })
9190    }
9191
9192    /// 0.8.20 Slice 5b (R-20-E5) — complete an erasure **at rest** after the
9193    /// erasing transaction has committed. Two obligations, in order:
9194    ///
9195    /// 1. **Telemetry redaction** — drop the erased stable ids out of the opt-in
9196    ///    telemetry sink. Driven from the DURABLE pending queue
9197    ///    ([`Engine::discharge_pending_redactions`]), NOT from the ids the caller
9198    ///    happens to be holding, so a retry after a failed redaction still knows
9199    ///    what it owes.
9200    /// 2. **WAL truncation** — `wal_checkpoint(TRUNCATE)` with a BOUNDED retry.
9201    ///    `PRAGMA secure_delete=ON` zeroes pages freed inside the database file,
9202    ///    but the erased content also lives in the write-ahead log as committed
9203    ///    frames from the ORIGINAL insert; the erasure DELETE appends new frames
9204    ///    rather than rewriting old ones, so without a truncating checkpoint the
9205    ///    erased body stays `grep`-able in `<db>-wal`.
9206    ///
9207    /// A concurrent reader pinning a WAL snapshot makes the checkpoint report
9208    /// `busy`. After [`ERASURE_WAL_TRUNCATE_ATTEMPTS`] tries the verb raises
9209    /// [`EngineError::ErasureIncomplete`] — **an erasure verb must never report
9210    /// success on an incomplete erasure.** The retry budget is deliberately small
9211    /// (~100 ms total): the caller retries the verb, the verb does not block.
9212    fn complete_erasure_at_rest(&self, verb: &'static str) -> Result<(), EngineError> {
9213        // The ids are NOT passed in: they were persisted inside the erasing
9214        // transaction, and this drains that queue. The WAL truncation below then
9215        // runs AFTER the pending rows have been deleted, so the freed pages
9216        // holding them (zeroed by `secure_delete=ON`) are checkpointed out too.
9217        self.discharge_pending_redactions(verb)?;
9218
9219        let mut last: Option<TruncateWalReport> = None;
9220        for attempt in 0..ERASURE_WAL_TRUNCATE_ATTEMPTS {
9221            let report = self.wal_checkpoint_truncate_once(false)?;
9222            if report.status == TruncateWalStatus::Done {
9223                return Ok(());
9224            }
9225            last = Some(report);
9226            if attempt + 1 < ERASURE_WAL_TRUNCATE_ATTEMPTS {
9227                std::thread::sleep(Duration::from_millis(ERASURE_WAL_TRUNCATE_BACKOFF_MS));
9228            }
9229        }
9230        let frames = last.map_or(0, |r| r.log_frames);
9231        Err(EngineError::ErasureIncomplete {
9232            stage: "wal_checkpoint".to_string(),
9233            detail: format!(
9234                "`{verb}` deleted its rows, but `wal_checkpoint(TRUNCATE)` reported BUSY on all \
9235                 {ERASURE_WAL_TRUNCATE_ATTEMPTS} attempts ({frames} frames still in the log) — a \
9236                 concurrent reader is pinning a WAL snapshot, so the erased bytes remain readable \
9237                 in the `-wal` file. Retry once the reader has finished."
9238            ),
9239        })
9240    }
9241
9242    /// 0.8.20 Slice 5 fix-1 (codex §9 P2) — perform every telemetry redaction the
9243    /// engine still OWES, from the durable pending queue.
9244    ///
9245    /// **The defect this closes.** Redaction necessarily runs after the erasing
9246    /// transaction commits (the sink is a file, not a table, so it cannot join
9247    /// the transaction). When it failed, the verb correctly raised
9248    /// `ErasureIncomplete { stage: "telemetry_redaction" }` and told the operator
9249    /// to retry — but the retry recomputed the id set by querying the canonical
9250    /// tables, whose rows the FIRST call had already deleted. It therefore got an
9251    /// EMPTY set, hit the empty-id fast path in
9252    /// [`Engine::redact_telemetry_stable_ids`], and returned success while the
9253    /// leaked `l:`/`h:` ids were still sitting in the sink. An erasure verb
9254    /// reporting success on an incomplete erasure is precisely what R-20-E5
9255    /// forbids, and it is the worst failure mode available to this slice: silent,
9256    /// and indistinguishable from a real erasure.
9257    ///
9258    /// **The mechanism — an intent log.** The ids are captured BEFORE the deletes
9259    /// (they are derived from `logical_id`/`body`, which the deletes destroy) and
9260    /// written into [`ERASURE_PENDING_REDACTION_COLLECTION`] INSIDE the same
9261    /// transaction, so "the rows are gone" and "a redaction is owed for them"
9262    /// commit atomically. There is no window in which the rows are deleted and
9263    /// the obligation is unrecorded. A pending row is deleted only once its
9264    /// redaction has actually been performed, so the obligation survives process
9265    /// death, and the empty-id fast path is unreachable while one is outstanding:
9266    /// this drains the QUEUE, never the caller's id vector.
9267    ///
9268    /// The queue is drained by EVERY erasure verb, not just a retry of the one
9269    /// that failed — an outstanding obligation is the engine's, not one call's.
9270    ///
9271    /// **Honest refusal.** If a redaction is owed but no telemetry sink is
9272    /// attached to this `Engine` (only reachable if the process restarted between
9273    /// the failure and the retry without re-enabling telemetry), the ids really
9274    /// are still in the sink file and this returns `ErasureIncomplete` rather
9275    /// than guessing. Re-enable telemetry on the same sink and retry.
9276    ///
9277    /// **Exposure tradeoff, stated plainly.** A pending row holds the stable ids
9278    /// in the database for the window between the delete and the redaction. That
9279    /// is a strict improvement: those ids are, during exactly that window,
9280    /// already readable in the telemetry sink — which is the leak being closed —
9281    /// and the pending row is deleted the moment the sink is clean, on pages
9282    /// `secure_delete=ON` zeroes and the subsequent `TRUNCATE` checkpoint clears
9283    /// from the log.
9284    fn discharge_pending_redactions(&self, verb: &'static str) -> Result<(), EngineError> {
9285        let pending = self.load_pending_redactions()?;
9286        if pending.is_empty() {
9287            return Ok(());
9288        }
9289
9290        let mut ids: Vec<String> =
9291            pending.iter().flat_map(|(_, ids)| ids.iter().cloned()).collect();
9292        ids.sort_unstable();
9293        ids.dedup();
9294
9295        // A queue entry exists ⇒ a sink was attached when the rows were deleted ⇒
9296        // the ids are in that file. Never clear the queue without redacting.
9297        if !self.telemetry_enabled.load(Ordering::Acquire) {
9298            return Err(EngineError::ErasureIncomplete {
9299                stage: "telemetry_redaction".to_string(),
9300                detail: format!(
9301                    "`{verb}` has {} outstanding telemetry redaction(s) covering {} erased \
9302                     stable id(s), but no telemetry sink is attached to this engine — the ids \
9303                     cannot be removed from the sink file. Re-enable telemetry on the same sink \
9304                     path and retry.",
9305                    pending.len(),
9306                    ids.len()
9307                ),
9308            });
9309        }
9310
9311        // On failure the queue rows stay put and the error propagates: the verb
9312        // does not report success, and the next call retries the same obligation.
9313        self.redact_telemetry_stable_ids(verb, &ids)?;
9314
9315        let row_ids: Vec<i64> = pending.iter().map(|(row_id, _)| *row_id).collect();
9316        self.clear_pending_redactions(&row_ids)
9317    }
9318
9319    /// Read the outstanding redaction queue: `(operational_mutations.id, ids)`.
9320    fn load_pending_redactions(&self) -> Result<Vec<(i64, Vec<String>)>, EngineError> {
9321        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9322        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9323        let mut stmt = connection
9324            .prepare(
9325                "SELECT id, payload_json FROM operational_mutations \
9326                 WHERE collection_name = ?1 ORDER BY id",
9327            )
9328            .map_err(|_| EngineError::Storage)?;
9329        let rows = stmt
9330            .query_map([ERASURE_PENDING_REDACTION_COLLECTION], |row| {
9331                Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
9332            })
9333            .map_err(|_| EngineError::Storage)?;
9334        let mut pending = Vec::new();
9335        for row in rows {
9336            let (row_id, payload) = row.map_err(|_| EngineError::Storage)?;
9337            // A payload we cannot parse is an obligation we cannot discharge;
9338            // keeping it (empty) is safe — it never unblocks a false success,
9339            // and `discharge_pending_redactions` still refuses.
9340            let ids = serde_json::from_str::<serde_json::Value>(&payload)
9341                .ok()
9342                .and_then(|v| v.get("erased_stable_ids").cloned())
9343                .and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
9344                .unwrap_or_default();
9345            pending.push((row_id, ids));
9346        }
9347        Ok(pending)
9348    }
9349
9350    /// Retire queue entries whose redaction has been PERFORMED. Committed before
9351    /// the caller's WAL truncation so the freed pages are checkpointed out.
9352    fn clear_pending_redactions(&self, row_ids: &[i64]) -> Result<(), EngineError> {
9353        if row_ids.is_empty() {
9354            return Ok(());
9355        }
9356        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9357        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9358        let mut stmt = connection
9359            .prepare("DELETE FROM operational_mutations WHERE id = ?1")
9360            .map_err(|_| EngineError::Storage)?;
9361        for row_id in row_ids {
9362            stmt.execute([row_id]).map_err(|_| EngineError::Storage)?;
9363        }
9364        Ok(())
9365    }
9366
9367    /// 0.8.20 Slice 5b (R-20-E6) — SELECTIVE redaction of erased stable ids from
9368    /// the opt-in telemetry sink.
9369    ///
9370    /// `capture_telemetry` persists `result_stable_ids` — `l:`/`h:` prefixed ids
9371    /// — into a JSONL file that outlives the erased rows, and nothing in the
9372    /// engine could previously remove them. A retained `l:` id is not inert:
9373    /// [`derive_logical_id`] is `SHA256(lowercase(kind) + ":" + lowercase(name))`,
9374    /// and the case-folding of BOTH inputs shrinks the preimage space, so a
9375    /// surviving id is dictionary-attackable back to the natural key it was
9376    /// derived from. An `h:` id is a plain `SHA256(body)`, confirmable against a
9377    /// guessed body.
9378    ///
9379    /// **This MUST NOT truncate the sink.** `sink_path` is CALLER-SUPPLIED and
9380    /// may hold unrelated operator eval history that the erasure obligation never
9381    /// covered; the v3 truncation approach was rejected as unsafe. Only the
9382    /// matching `result_stable_ids` ELEMENTS are replaced with
9383    /// [`REDACTED_STABLE_ID`], preserving record count, record order and
9384    /// positional alignment with the parallel `result_ids` array. Lines that are
9385    /// not engine-authored JSON events are copied through verbatim.
9386    ///
9387    /// **Crash safety.** The rewrite is write-temp-then-`rename`: a sibling
9388    /// `.redact.tmp` is written and fsynced, then atomically renamed over the
9389    /// sink, so a crash leaves either the old file or the new one — never a
9390    /// half-rewritten sink. The telemetry mutex is held across the whole rewrite,
9391    /// so no in-process `capture_telemetry` can append into the window; an
9392    /// out-of-process appender is handled by re-reading and folding in the tail
9393    /// delta before the rename (bounded retry).
9394    ///
9395    /// The privacy contract is unchanged: query TEXT and `source_id` are never
9396    /// captured (ADR-0.8.8 §C), so there is nothing else in the sink to redact.
9397    /// The fast-OFF atomic guard is preserved — when telemetry was never enabled
9398    /// this is a single relaxed-ordering load and no mutex acquisition.
9399    fn redact_telemetry_stable_ids(
9400        &self,
9401        verb: &'static str,
9402        erased_stable_ids: &[String],
9403    ) -> Result<(), EngineError> {
9404        // Fast OFF path — mirrors `capture_telemetry`. No mutex, no I/O.
9405        if erased_stable_ids.is_empty() || !self.telemetry_enabled.load(Ordering::Acquire) {
9406            return Ok(());
9407        }
9408        let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
9409        let Some(sink) = guard.as_ref() else { return Ok(()) };
9410        let erased: std::collections::HashSet<&str> =
9411            erased_stable_ids.iter().map(String::as_str).collect();
9412
9413        match redact_jsonl_stable_ids(&sink.path, &erased) {
9414            Ok(()) => Ok(()),
9415            // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P2) — `NotFound` is NOT a
9416            // discharge. It previously returned `Ok(())` ("the sink is gone,
9417            // nothing to redact"), which cleared the durable pending queue and
9418            // let the verb report success. That inference does not hold: a path
9419            // cannot distinguish `rm` from `mv`, and log rotation of a
9420            // caller-supplied sink is an ordinary operational event that leaves
9421            // the erased `l:`/`h:` ids fully readable under the rotated name.
9422            //
9423            // The burden of proof is on DISCHARGING the obligation, and the
9424            // engine cannot meet it here: `TelemetrySink` holds a PATH, not an
9425            // open handle, so there is no `nlink == 0` witness that the inode was
9426            // actually unlinked — and even that would not cover a copy taken
9427            // before the deletion. So there is no narrow provable case to carve
9428            // out, and `NotFound` fails closed.
9429            //
9430            // This cannot fire spuriously for a sink that never existed:
9431            // `enable_telemetry` CREATES the file before arming capture, so for
9432            // any engine with telemetry enabled the sink demonstrably existed and
9433            // `NotFound` means it existed and then vanished.
9434            Err(err) => Err(EngineError::ErasureIncomplete {
9435                stage: "telemetry_redaction".to_string(),
9436                detail: if err.kind() == std::io::ErrorKind::NotFound {
9437                    format!(
9438                        "`{verb}` deleted its rows, but the telemetry sink {} no longer exists, \
9439                         so the erased stable ids could not be redacted from it. A missing path \
9440                         does NOT prove the sink was deleted — if it was rotated or moved aside, \
9441                         the erased ids are still readable under its new name. The pending \
9442                         redaction is durable: restore the sink at this path and retry (if the \
9443                         sink really was destroyed, an empty file at this path discharges the \
9444                         obligation).",
9445                        sink.path.display()
9446                    )
9447                } else {
9448                    format!(
9449                        "`{verb}` deleted its rows, but the erased stable ids could not be \
9450                         redacted from the telemetry sink {}: {err}",
9451                        sink.path.display()
9452                    )
9453                },
9454            }),
9455        }
9456    }
9457
9458    /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
9459    /// returned to the caller for redaction (R-20-E6). They are enqueued INSIDE
9460    /// this transaction via [`enqueue_pending_redaction`]: a caller-held vector
9461    /// is lost on the retry path, which is exactly the false-success codex §9 P2
9462    /// found. Only the report comes back.
9463    ///
9464    /// 0.8.20 Slice 5d (R-20-E4): no longer `operator`-gated — it is the shared
9465    /// body behind BOTH `erase_source` (governed SDK) and `excise_source`
9466    /// (operator seam). Still private; the gate that matters is on the two
9467    /// public spellings.
9468    fn excise_source_inner(
9469        &self,
9470        verb: &'static str,
9471        source_id: &str,
9472    ) -> Result<ExciseReport, EngineError> {
9473        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9474        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9475        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9476
9477        // Collect the cursor sets up-front so we can targeted-delete
9478        // shadow rows AND emit an accurate audit row in one txn.
9479        let node_cursors: Vec<i64> = {
9480            let mut stmt = tx
9481                .prepare("SELECT write_cursor FROM canonical_nodes WHERE source_id = ?1")
9482                .map_err(|_| EngineError::Storage)?;
9483            let rows = stmt
9484                .query_map([source_id], |row| row.get::<_, i64>(0))
9485                .map_err(|_| EngineError::Storage)?;
9486            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9487        };
9488        let edge_cursors: Vec<i64> = {
9489            let mut stmt = tx
9490                .prepare("SELECT write_cursor FROM canonical_edges WHERE source_id = ?1")
9491                .map_err(|_| EngineError::Storage)?;
9492            let rows = stmt
9493                .query_map([source_id], |row| row.get::<_, i64>(0))
9494                .map_err(|_| EngineError::Storage)?;
9495            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9496        };
9497
9498        // 0.8.20 Slice 5b (R-20-E6) — stable ids the telemetry sink may hold for
9499        // these rows, collected BEFORE the DELETEs.
9500        let erased_stable_ids = collect_erased_stable_ids(
9501            &tx,
9502            "SELECT logical_id, body FROM canonical_nodes WHERE source_id = ?1",
9503            "SELECT logical_id, body FROM canonical_edges WHERE source_id = ?1",
9504            source_id,
9505        )?;
9506
9507        // 0.8.20 Slice 5a (R-20-E1) — registry-driven erasure. The previous
9508        // hand-rolled list here OMITTED `search_index_v2`, a CONTENT-STORING
9509        // FTS5 table (no `content=''`) that keeps the document body verbatim:
9510        // after `excise_source` the erased body was still on disk, invisible to
9511        // every functional test because both v2 read paths discard candidates
9512        // lacking a live `canonical_nodes` row. `erase_row_projections` covers
9513        // every registered projection, so the omission cannot recur.
9514        let mut shadow_invalidated: u64 = 0;
9515        for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
9516            shadow_invalidated = shadow_invalidated.saturating_add(
9517                erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?,
9518            );
9519        }
9520
9521        let nodes_excised = tx
9522            .execute("DELETE FROM canonical_nodes WHERE source_id = ?1", [source_id])
9523            .map_err(|_| EngineError::Storage)? as u64;
9524        let edges_excised = tx
9525            .execute("DELETE FROM canonical_edges WHERE source_id = ?1", [source_id])
9526            .map_err(|_| EngineError::Storage)? as u64;
9527
9528        // AC-028a audit row: a single append on the
9529        // `excise_source_audit` collection naming the excised source.
9530        //
9531        // DURABILITY (0.8.20 Slice 5b, design v5 §2 defect D-A; HITL-ruled
9532        // 2026-07-19: *"there must be an auditable record of deletion event."*).
9533        // This row lands in `operational_mutations`, the same table the retention
9534        // sweep drains — and it is written BEFORE the workload that follows it,
9535        // so an oldest-`id`-first sweep evicted it FIRST. It is now protected:
9536        // `excise_source_audit` is in `ERASURE_AUDIT_COLLECTIONS`, which
9537        // `enforce_provenance_retention` excludes. The proof of erasure is no
9538        // longer destructible by ordinary retention pressure.
9539        //
9540        // NON-PII `source_id` (rationale corrected in this slice). v4 §3.6
9541        // justified the "`source_id` must not be PII" rule by claiming the audit
9542        // row retains it *permanently, by design*. That premise was FALSE — the
9543        // row was sweepable. The rule stands on a different and simpler footing:
9544        // this row persists the caller's raw `source_id` verbatim, and an
9545        // `excise_source` that erased the payload while keeping an identifying
9546        // source label would not be an erasure. The exemption above makes the
9547        // retention now genuinely indefinite, which makes the rule MORE
9548        // load-bearing, not less.
9549        //
9550        // `next_cursor` after a prior write holds the LAST committed cursor;
9551        // mirror the vec writer pattern (load + 1, then store post-commit)
9552        // so the audit row's `write_cursor` is strictly greater than every
9553        // canonical row that preceded it.
9554        let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
9555        let payload = serde_json::json!({
9556            "source_id": source_id,
9557            "excised_at": excised_at,
9558            "nodes_excised": nodes_excised,
9559            "edges_excised": edges_excised,
9560            "projections_invalidated": shadow_invalidated,
9561        })
9562        .to_string();
9563        let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9564        tx.execute(
9565            "INSERT INTO operational_mutations(
9566                collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
9567             ) VALUES('excise_source_audit', ?1, 'append', ?2, NULL, ?3)",
9568            params![source_id, payload, audit_cursor],
9569        )
9570        .map_err(|_| EngineError::Storage)?;
9571
9572        // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
9573        // erasure owes, atomically with the deletes. Shares `audit_cursor`: one
9574        // erasure event, and this row is retired as soon as the sink is clean.
9575        if self.telemetry_enabled.load(Ordering::Acquire) {
9576            enqueue_pending_redaction(&tx, verb, &erased_stable_ids, audit_cursor)?;
9577        }
9578
9579        tx.commit().map_err(|_| EngineError::Storage)?;
9580        self.next_cursor.store(audit_cursor, Ordering::SeqCst);
9581        Ok(ExciseReport {
9582            source_ref: source_id.to_string(),
9583            nodes_excised,
9584            edges_excised,
9585            projections_invalidated: shadow_invalidated,
9586        })
9587    }
9588
9589    /// 0.8.20 Slice 5b (R-20-E7) — erase ONE op-store record, by collection and
9590    /// record key, from both op-store shapes: every `operational_mutations`
9591    /// version of the key (append-only-log collections) and its
9592    /// `operational_state` row (latest-state collections).
9593    ///
9594    /// **Refuses the engine's own erasure bookkeeping** (0.8.20 Slice 5 fix-3,
9595    /// codex §9 round-3 P1): a `collection` for which
9596    /// [`is_erasure_bookkeeping_collection`] holds raises
9597    /// [`EngineError::InvalidArgument`] before anything is deleted. Aimed at the
9598    /// pending-redaction queue this verb otherwise destroys an outstanding
9599    /// erasure obligation, after which the next erasure verb reports success with
9600    /// the erased ids still in the telemetry sink; aimed at the audit trail it
9601    /// destroys the auditable record of the deletion event.
9602    ///
9603    /// Before this slice the op-store had NO record-level delete at all:
9604    /// [`enforce_provenance_retention`] is a cap sweep, not an erasure verb, so a
9605    /// caller holding an erasure obligation over an op-store record had no way to
9606    /// discharge it. Idempotent — erasing an absent key is a zero-count success.
9607    ///
9608    /// Like the other erasure verbs this finishes at rest (telemetry is not
9609    /// involved — op-store record keys never reach the telemetry sink — but the
9610    /// `-wal` is), so it can return [`EngineError::ErasureIncomplete`].
9611    ///
9612    /// AUDIT (D-A). Appends a row to the retention-exempt `excise_record_audit`
9613    /// collection. Unlike `source_id`, a `record_key` carries NO non-PII rule:
9614    /// it is arbitrary caller-supplied text and may itself be the identifier
9615    /// being erased. The audit therefore records a SHA-256 digest of
9616    /// `collection` + `record_key`, never the key — enough to prove *that* a
9617    /// specific record was erased to anyone who already knows the key, and
9618    /// useless to anyone who does not.
9619    #[cfg(feature = "operator")]
9620    pub fn excise_collection_record(
9621        &self,
9622        collection: &str,
9623        record_key: &str,
9624    ) -> Result<ExciseRecordReport, EngineError> {
9625        self.ensure_open()?;
9626        if collection.is_empty() || record_key.is_empty() {
9627            return Err(EngineError::WriteValidation);
9628        }
9629        // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P1) — the engine's own erasure
9630        // bookkeeping is not caller data and is not excisable. See
9631        // `is_erasure_bookkeeping_collection` for why each member is protected.
9632        // Checked BEFORE any deletion so the refusal is total, not partial.
9633        if is_erasure_bookkeeping_collection(collection) {
9634            return Err(EngineError::InvalidArgument {
9635                msg: format!(
9636                    "`{collection}` is engine-internal erasure bookkeeping and cannot be excised \
9637                     by `excise_collection_record`. The pending-redaction queue records an \
9638                     erasure the engine still owes (deleting it would let a later verb report \
9639                     success on an incomplete erasure, R-20-E5), and the erasure-audit \
9640                     collections are the auditable record of the deletion event. Pending \
9641                     redactions retire themselves once performed; retry the erasure verb instead."
9642                ),
9643            });
9644        }
9645        let report = self.excise_collection_record_inner(collection, record_key)?;
9646        self.complete_erasure_at_rest("excise_collection_record")?;
9647        Ok(report)
9648    }
9649
9650    #[cfg(feature = "operator")]
9651    fn excise_collection_record_inner(
9652        &self,
9653        collection: &str,
9654        record_key: &str,
9655    ) -> Result<ExciseRecordReport, EngineError> {
9656        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9657        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9658        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9659
9660        let records_excised = tx
9661            .execute(
9662                "DELETE FROM operational_mutations
9663                 WHERE collection_name = ?1 AND record_key = ?2",
9664                params![collection, record_key],
9665            )
9666            .map_err(|_| EngineError::Storage)? as u64;
9667        let state_rows_excised = tx
9668            .execute(
9669                "DELETE FROM operational_state
9670                 WHERE collection_name = ?1 AND record_key = ?2",
9671                params![collection, record_key],
9672            )
9673            .map_err(|_| EngineError::Storage)? as u64;
9674
9675        let record_digest = digest_record_identity(collection, record_key);
9676        let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
9677        let payload = serde_json::json!({
9678            "collection": collection,
9679            "record_digest": record_digest,
9680            "excised_at": excised_at,
9681            "records_excised": records_excised,
9682            "state_rows_excised": state_rows_excised,
9683        })
9684        .to_string();
9685        let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9686        tx.execute(
9687            "INSERT INTO operational_mutations(
9688                collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
9689             ) VALUES('excise_record_audit', ?1, 'append', ?2, NULL, ?3)",
9690            params![record_digest, payload, audit_cursor],
9691        )
9692        .map_err(|_| EngineError::Storage)?;
9693
9694        tx.commit().map_err(|_| EngineError::Storage)?;
9695        self.next_cursor.store(audit_cursor, Ordering::SeqCst);
9696        self.counters.record_admin();
9697        Ok(ExciseRecordReport {
9698            collection: collection.to_string(),
9699            record_digest,
9700            records_excised,
9701            state_rows_excised,
9702        })
9703    }
9704
9705    #[cfg(feature = "operator")]
9706    fn run_rebuild(
9707        &self,
9708        include_fts: bool,
9709        kind: RebuildKind,
9710    ) -> Result<RebuildReport, EngineError> {
9711        self.projection_runtime.set_frozen(true);
9712        // Drain MUST succeed: rebuild_shadow_state truncates shadow rows,
9713        // and SQLite-WAL allows a worker that already dequeued a job to
9714        // commit its `INSERT OR IGNORE INTO _fathomdb_vector_rows / vec0`
9715        // after our truncate releases the writer lock, leaving stale
9716        // rows. Surfacing the timeout (instead of swallowing it) lets the
9717        // operator retry rather than silently corrupt the rebuild.
9718        let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
9719        let result = drain_result.and_then(|()| self.rebuild_shadow_state(include_fts, kind));
9720        self.projection_runtime.set_frozen(false);
9721        result
9722    }
9723
9724    #[cfg(feature = "operator")]
9725    fn rebuild_shadow_state(
9726        &self,
9727        include_fts: bool,
9728        kind: RebuildKind,
9729    ) -> Result<RebuildReport, EngineError> {
9730        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9731        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9732        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9733        // 0.8.20 Slice 5a (R-20-E1) — registry-driven invalidation. A full
9734        // rebuild truncates EVERY row-owned projection (the previous hand-rolled
9735        // list omitted `search_index_v2`, so a rebuild neither dropped stale v2
9736        // rows nor repopulated the table); a vec0-only rebuild truncates the
9737        // vector + readiness classes exactly as before. Kind-owned watermark
9738        // state (`_fathomdb_projection_state`) is deliberately NOT truncated —
9739        // readiness is reset by rewinding the projection cursor below.
9740        let rows_invalidated = if include_fts {
9741            truncate_all_row_projections(&tx).map_err(|_| EngineError::Storage)?
9742        } else {
9743            truncate_row_projections_in(&tx, &[ProjectionClass::Vector, ProjectionClass::Readiness])
9744                .map_err(|_| EngineError::Storage)?
9745        };
9746        store_projection_cursor(&tx, 0).map_err(|_| EngineError::Storage)?;
9747        // 0.8.20 Slice 5a (R-20-E1, work item 1) — the replay runs through the
9748        // SAME two projectors the write path uses, so the rebuilt projections
9749        // are identical to what a re-write would have produced. `include_fts`
9750        // selects the pass: a vec0-only rebuild must not write FTS rows.
9751        let pass = if include_fts { ProjectionPass::Write } else { ProjectionPass::VectorOnly };
9752        let mut rows_rebuilt: u64 = 0;
9753        for row in canonical_node_rows(&tx).map_err(|_| EngineError::Storage)? {
9754            project_canonical_node_row(
9755                &tx,
9756                row.cursor,
9757                &row.kind,
9758                &row.body,
9759                row.row_kind,
9760                pass,
9761                // fix-2 [P2]: the attribute half of the replay tracks the backfill's
9762                // active-and-non-superseded row set; FTS / vector shadows still
9763                // rebuild for every row (read-side lifecycle filter, unchanged).
9764                row.attr_projected,
9765            )
9766            .map_err(|_| EngineError::Storage)?;
9767            if include_fts {
9768                rows_rebuilt = rows_rebuilt.saturating_add(1);
9769            }
9770        }
9771        // fix-26 [P2]: rebuild the edge shadows from active canonical_edges
9772        // (G11 search_index_edges).
9773        // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex
9774        // §9 [P2]): mirror the graph-traversal recency filter
9775        // (`edge_validity_sql`) here too, so a full rebuild does not re-surface
9776        // an edge that recency consolidation already invalidated.
9777        // 0.8.20 Slice 5a: body-less structural edges are now included in the
9778        // replay. They project no FTS/vector row, but the write path DOES record
9779        // their readiness terminal — which this rebuild truncated and (before
9780        // this slice) never restored, stalling `advance_projection_cursor`.
9781        // TC-33: the filter is generated by `edge_validity_sql` and `:now` is
9782        // bound (?1) rather than inlined as `datetime('now')`.
9783        let edge_rows: Vec<(i64, String, Option<String>)> = {
9784            let edge_sql = format!(
9785                "SELECT write_cursor, kind, body FROM canonical_edges \
9786                 WHERE superseded_at IS NULL{}",
9787                edge_validity_sql("canonical_edges", 1)
9788            );
9789            let mut edge_stmt = tx.prepare(&edge_sql).map_err(|_| EngineError::Storage)?;
9790            let rows = edge_stmt
9791                .query_map(params![current_epoch_seconds()], |row| {
9792                    Ok((
9793                        row.get::<_, i64>(0)?,
9794                        row.get::<_, String>(1)?,
9795                        row.get::<_, Option<String>>(2)?,
9796                    ))
9797                })
9798                .map_err(|_| EngineError::Storage)?
9799                .collect::<rusqlite::Result<_>>()
9800                .map_err(|_| EngineError::Storage)?;
9801            rows
9802        };
9803        for (cursor, kind, body) in edge_rows {
9804            let has_body = body.is_some();
9805            project_canonical_edge_row(&tx, cursor as u64, &kind, body.as_deref(), pass)
9806                .map_err(|_| EngineError::Storage)?;
9807            if include_fts && has_body {
9808                rows_rebuilt = rows_rebuilt.saturating_add(1);
9809            }
9810        }
9811        let projection_cursor_after =
9812            load_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
9813        tx.commit().map_err(|_| EngineError::Storage)?;
9814        Ok(RebuildReport { kind, rows_invalidated, rows_rebuilt, projection_cursor_after })
9815    }
9816
9817    fn ensure_open(&self) -> Result<(), EngineError> {
9818        if self.closed.load(Ordering::SeqCst) {
9819            return Err(EngineError::Closing);
9820        }
9821
9822        Ok(())
9823    }
9824}
9825
9826fn batch_is_admin(batch: &[PreparedWrite]) -> bool {
9827    !batch.is_empty() && batch.iter().all(|w| matches!(w, PreparedWrite::AdminSchema { .. }))
9828}
9829
9830// 0.7.0 Pack 2 (ADR-0.7.0-vector-binary-quant § 2; handoff § 2.2):
9831// bit-KNN candidate-set size for the two-phase read path. Tuned with
9832// the recall@10 floor in tests/perf_gates.rs::ac_013b_recall_at_10_floor.
9833//
9834// Bumped from 64 → 192 in EU-5a2 per the HITL 2026-05-29 fine-grained
9835// K-sweep result (dev/notes/0.7.1-default-embedder-research.md §5.4):
9836// K=192 sits above the recall-plateau knee for the default embedder.
9837// Public-visible so the EU-5a2 machinery test can assert the value.
9838pub const TOP_K_BIT_CANDIDATES: usize = 192;
9839
9840/// EU-5a2 — number of documents required before the workspace's
9841/// `_fathomdb_embedder_profiles.mean_vec` is pinned for the default
9842/// profile. Per `dev/design/embedder.md` §0.3 (compute-once-on-first-
9843/// ingest lifecycle). Public-visible so the EU-5a2 machinery test can
9844/// assert the value.
9845pub const MEAN_VEC_PIN_THRESHOLD: u64 = 256;
9846
9847/// 0.7.2 PR-2bc S1 fix-1 — production phase-2 rerank `LIMIT` for engine
9848/// search. This is the original hardcoded `LIMIT 10`; it is the default and
9849/// the floor for `search_limit_override` (a test seam may RAISE it but never
9850/// shrink it below this). There is NO env-var override on the hot path.
9851pub const SEARCH_RERANK_LIMIT: usize = 10;
9852
9853/// EU-5a2 — streaming f64 accumulator for the mean-centering pipeline,
9854/// per `dev/design/embedder.md` §0.3 (f64 chosen to bound numerical
9855/// drift across `MEAN_VEC_PIN_THRESHOLD` adds). Owned by the projection
9856/// worker; materialized into the schema column at the threshold cross.
9857#[derive(Clone, Debug)]
9858struct MeanAccumulator {
9859    sum: Vec<f64>,
9860    count: u64,
9861}
9862
9863impl MeanAccumulator {
9864    fn new(dim: usize) -> Self {
9865        Self { sum: vec![0.0; dim], count: 0 }
9866    }
9867
9868    fn add(&mut self, v: &[f32]) {
9869        debug_assert_eq!(v.len(), self.sum.len(), "accumulator dim mismatch");
9870        for (slot, value) in self.sum.iter_mut().zip(v.iter()) {
9871            *slot += f64::from(*value);
9872        }
9873        self.count = self.count.saturating_add(1);
9874    }
9875
9876    fn materialize(&self) -> Vec<f32> {
9877        if self.count == 0 {
9878            return vec![0.0; self.sum.len()];
9879        }
9880        let denom = self.count as f64;
9881        self.sum.iter().map(|s| (s / denom) as f32).collect()
9882    }
9883
9884    fn count(&self) -> u64 {
9885        self.count
9886    }
9887}
9888
9889/// 0.7.2 PR-2b — cosine similarity between two equal-length vectors.
9890/// Returns 1.0 for a pair with a zero-norm operand (treated as "no drift
9891/// signal"), so the detector never fires on a degenerate all-zero mean.
9892fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
9893    if a.len() != b.len() {
9894        return 1.0;
9895    }
9896    let mut dot = 0.0f64;
9897    let mut na = 0.0f64;
9898    let mut nb = 0.0f64;
9899    for (x, y) in a.iter().zip(b.iter()) {
9900        dot += f64::from(*x) * f64::from(*y);
9901        na += f64::from(*x) * f64::from(*x);
9902        nb += f64::from(*y) * f64::from(*y);
9903    }
9904    if na == 0.0 || nb == 0.0 {
9905        return 1.0;
9906    }
9907    (dot / (na.sqrt() * nb.sqrt())) as f32
9908}
9909
9910/// EU-5b — at-pin pin-and-requantize pass per `dev/design/embedder.md`
9911/// §0.5. Runs INSIDE the caller's SQLite transaction so the mean_vec
9912/// INSERT/UPDATE + the per-row sign-bit UPDATEs commit atomically.
9913///
9914/// For each pre-pin row, recomputes `bits' = sign_quantize(f32 - mean)`
9915/// via the SQL extension's `vec_quantize_binary`, then UPDATEs the
9916/// row's `embedding_bin` column.
9917fn run_pin_and_requantize_pass(
9918    tx: &rusqlite::Transaction<'_>,
9919    rows: &[(i64, Vec<u8>)],
9920    mean: &[f32],
9921) -> Result<(u64, Vec<EmbedderEvent>), EngineError> {
9922    let mut updated: u64 = 0;
9923    let dim = mean.len();
9924    // sqlite-vec's vec0 xUpdate path discards SQL-function result subtypes
9925    // (see sqlite-vec.c §vec0Update_UpdateVectorColumn — "subtypes don't
9926    // appear to survive xColumn -> xUpdate, it's always 0"), so a direct
9927    // `UPDATE ... SET embedding_bin = vec_quantize_binary(?)` reads the
9928    // bound value as a float32-tagged vector and trips the column-type
9929    // check. We work around by DELETE+INSERT inside the same transaction:
9930    // INSERT preserves the BIT subtype on `vec_quantize_binary`. The
9931    // surrounding pin-commit tx keeps the rewrite atomic.
9932    for (rowid, blob) in rows {
9933        if blob.len() != dim * 4 {
9934            return Err(EngineError::Storage);
9935        }
9936        let un_centered = decode_vector_blob(blob);
9937        let centered = subtract_mean(&un_centered, mean);
9938        let centered_blob = encode_vector_blob(&centered);
9939
9940        let (source_type, kind, created_at): (String, String, i64) = tx
9941            .query_row(
9942                "SELECT source_type, kind, created_at FROM vector_default WHERE rowid = ?1",
9943                params![rowid],
9944                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
9945            )
9946            .map_err(|_| EngineError::Storage)?;
9947
9948        // 0.8.20 Slice 15e — this DELETE+INSERT re-quantize (its BIT-subtype
9949        // workaround, condition #4's SEPARATE same-shape op) must PRESERVE every
9950        // `filterable` `attr_<hex>` value, not just re-`''` them: a projection may
9951        // have been declared before the pin. Read the live attr columns + this
9952        // row's values BEFORE the DELETE, then re-bind them. Empty ⇒ the INSERT is
9953        // byte-identical to the shipped statement.
9954        let attr_cols = actual_vector_attr_columns(tx).map_err(|_| EngineError::Storage)?;
9955        let attr_vals: Vec<String> = if attr_cols.is_empty() {
9956            Vec::new()
9957        } else {
9958            let select = attr_cols.join(", ");
9959            tx.query_row(
9960                &format!("SELECT {select} FROM vector_default WHERE rowid = ?1"),
9961                params![rowid],
9962                |row| {
9963                    let mut vals = Vec::with_capacity(attr_cols.len());
9964                    for i in 0..attr_cols.len() {
9965                        vals.push(row.get::<_, String>(i)?);
9966                    }
9967                    Ok(vals)
9968                },
9969            )
9970            .map_err(|_| EngineError::Storage)?
9971        };
9972
9973        // TC-76: the attr VALUES were read above, so neutralizing them inside
9974        // [`delete_vector_partition_row`] cannot lose them; the re-INSERT below
9975        // re-binds `attr_vals` verbatim.
9976        delete_vector_partition_row(tx, *rowid).map_err(|_| EngineError::Storage)?;
9977
9978        // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0 TEXT
9979        // metadata is NOT NULL-able).
9980        let mut cols_sql = String::new();
9981        let mut ph_sql = String::new();
9982        for (i, col) in attr_cols.iter().enumerate() {
9983            cols_sql.push_str(&format!(", {col}"));
9984            ph_sql.push_str(&format!(", ?{}", 7 + i));
9985        }
9986        let sql = format!(
9987            "INSERT INTO vector_default(
9988                rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
9989             ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
9990        );
9991        let mut pv: Vec<rusqlite::types::Value> = vec![
9992            rusqlite::types::Value::Integer(*rowid),
9993            rusqlite::types::Value::Blob(blob.clone()),
9994            rusqlite::types::Value::Blob(centered_blob),
9995            rusqlite::types::Value::Text(source_type),
9996            rusqlite::types::Value::Text(kind),
9997            rusqlite::types::Value::Integer(created_at),
9998        ];
9999        for v in attr_vals {
10000            pv.push(rusqlite::types::Value::Text(v));
10001        }
10002        tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
10003            .map_err(|_| EngineError::Storage)?;
10004
10005        updated = updated.saturating_add(1);
10006    }
10007    let events = vec![EmbedderEvent::MeanVecPinned {
10008        dim: u32::try_from(dim).unwrap_or(u32::MAX),
10009        doc_count: updated,
10010    }];
10011    Ok((updated, events))
10012}
10013
10014/// EU-5a2 — back-compat test-only count+emit helper. Preserved so the
10015/// EU-5a2 machinery test stays green; the EU-5b production path uses
10016/// `run_pin_and_requantize_pass`.
10017fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
10018    let mut updated: u64 = 0;
10019    let dim = mean.len();
10020    for (_rowid, blob) in rows {
10021        if blob.len() != dim * 4 {
10022            continue;
10023        }
10024        updated = updated.saturating_add(1);
10025    }
10026    let events = vec![EmbedderEvent::MeanVecPinned {
10027        dim: u32::try_from(dim).unwrap_or(u32::MAX),
10028        doc_count: updated,
10029    }];
10030    (updated, events)
10031}
10032
10033/// EU-5a2 — test-visible re-exports of the mean-centering internals.
10034/// Per the handoff RED tests; the production accumulator and re-quantize
10035/// pass are otherwise crate-private.
10036#[doc(hidden)]
10037pub mod mean_centering_internals_for_test {
10038    use super::{EmbedderEvent, MeanAccumulator};
10039
10040    pub struct AccumulatorHandle(MeanAccumulator);
10041
10042    #[must_use]
10043    pub fn new_mean_accumulator(dim: usize) -> AccumulatorHandle {
10044        AccumulatorHandle(MeanAccumulator::new(dim))
10045    }
10046
10047    pub fn accumulator_add(handle: &mut AccumulatorHandle, v: &[f32]) {
10048        handle.0.add(v);
10049    }
10050
10051    #[must_use]
10052    pub fn accumulator_materialize(handle: &AccumulatorHandle) -> Vec<f32> {
10053        handle.0.materialize()
10054    }
10055
10056    #[must_use]
10057    pub fn accumulator_count(handle: &AccumulatorHandle) -> u64 {
10058        handle.0.count()
10059    }
10060
10061    #[must_use]
10062    pub fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
10063        super::run_requantize_pass(rows, mean)
10064    }
10065}
10066
10067/// G9 — Reciprocal Rank Fusion constant. IR-C (2026-06-10b,
10068/// `performance-output-and-compare.md`) found the standard `k≈60` slightly too
10069/// high: the recall gain is concentrated at the top of the list, where a lower
10070/// `k` sharpens rank-1/2 contributions. `k=30` is the validated operating point
10071/// (`k10 > k30 > k60 > k100` on the sweep, `30` the conservative middle).
10072/// Fusion is on **rank**, never raw score.
10073pub const RRF_K: f64 = 30.0;
10074
10075/// G9 / IR-C — per-branch RRF weights. The sweep's optimum is strongly
10076/// **text-dominant** (`text:vector ≈ 3:1`): the lexical (BM25) arm carries
10077/// exact-fact recall and the dense arm, over-weighted, is a net drag on
10078/// exploratory recall (`performance-output-and-compare.md`, 2026-06-10b/e). A
10079/// branch contributes `weight / (RRF_K + rank)`.
10080pub const RRF_WEIGHT_VECTOR: f64 = 1.0;
10081pub const RRF_WEIGHT_TEXT: f64 = 3.0;
10082/// R3 (Slice 30) — graph arm RRF weight. Conservative starting value (equal to
10083/// `RRF_WEIGHT_VECTOR`). Without R2 per-class delta data the graph arm weight
10084/// cannot be calibrated; 1.0 is the minimum non-zero contribution. The graph
10085/// arm surfaces newly-reachable nodes from BFS traversal; it is not meant to
10086/// override the primary text/vector signals. Revisable after R2 data arrives.
10087/// See `dev/design/slice-30-design.md` §Q2.
10088pub const RRF_WEIGHT_GRAPH: f64 = 1.0;
10089
10090/// G12-recency — additive recency weight. Must satisfy two constraints:
10091/// 1. Small enough to never override a clear RRF signal: a gap of > RECENCY_WEIGHT
10092///    between two hits' RRF scores means the stronger RRF hit always wins.
10093/// 2. Large enough to break exact ties: any hit with a higher `write_cursor` (more
10094///    recent) gets RECENCY_WEIGHT × 1.0 > 0 nudge and wins a tied comparison.
10095///
10096/// Value 0.002 satisfies the near-tie-nudge contract with respect to the
10097/// committed test (`recency_does_not_override_a_clear_rrf_signal`):
10098/// the test's RRF gap is 0.01, which is larger than 0.002, so recency
10099/// never overrides it. Note: this value is larger than the minimum
10100/// vector-only rank-step at deep ranks (~0.00101 for adjacent ranks near
10101/// the bottom), so recency can flip a single-rank vector difference at
10102/// deep ranks — by design, recency is a near-tie nudge, and "near-tie"
10103/// is scoped to the test gap (0.01), not to every possible rank step.
10104///
10105/// 0.8.1 Slice 10 fix: the previous value `0.5/RRF_K ≈ 0.01667` violated
10106/// the test gap constraint (it exceeded 0.01). Lowered to 0.002.
10107pub const RECENCY_WEIGHT: f64 = 0.002;
10108
10109/// 0.8.8 Slice 15 — the lowercase wire string for a retrieval arm (telemetry +
10110/// the same spelling `SearchHit.branch` crosses every binding).
10111fn branch_str(branch: SoftFallbackBranch) -> &'static str {
10112    match branch {
10113        SoftFallbackBranch::Vector => "vector",
10114        SoftFallbackBranch::Text => "text",
10115        SoftFallbackBranch::TextEdge => "text_edge",
10116        SoftFallbackBranch::GraphArm => "graph_arm",
10117    }
10118}
10119
10120/// 0.8.8 Slice 15 — append one JSON value as a line to the telemetry sink
10121/// (append-only, local file; no network). Best-effort caller handles the error.
10122fn append_jsonl(path: &Path, value: &serde_json::Value) -> std::io::Result<()> {
10123    let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
10124    writeln!(file, "{value}")?;
10125    Ok(())
10126}
10127
10128/// 0.8.20 Slice 5b (R-20-E6) — rewrite a telemetry JSONL sink with every
10129/// `result_stable_ids` element in `erased` replaced by [`REDACTED_STABLE_ID`].
10130///
10131/// **Selective, never truncating.** Every line is carried across: records that
10132/// reference no erased id are byte-identical, records that do keep their shape
10133/// and only lose the matching id VALUES, and a line that is not an
10134/// engine-authored JSON event (an operator note, a hand-appended record) is
10135/// copied through verbatim. The sink is a caller-supplied path that may hold
10136/// unrelated eval history; destroying it is not part of any erasure obligation.
10137///
10138/// **Crash safety.** Write-temp-then-`rename`: the redacted content goes to a
10139/// sibling `<sink>.redact.tmp`, is `sync_all`ed, and is then atomically renamed
10140/// over the sink. A crash at any point leaves either the intact old file or the
10141/// complete new one — never a half-rewritten sink. `rename` is atomic because
10142/// the temp file is a sibling (same directory ⇒ same filesystem).
10143///
10144/// **Concurrent appends.** The caller holds the telemetry mutex, so no
10145/// in-process `capture_telemetry` can append into the window. An out-of-process
10146/// appender is still possible (the sink is just a file), so before renaming we
10147/// re-check the source length: if it grew, the tail delta is read, redacted and
10148/// appended, and the check repeats — bounded, so a pathologically hot external
10149/// writer surfaces as an error rather than an unbounded loop.
10150fn redact_jsonl_stable_ids(
10151    path: &Path,
10152    erased: &std::collections::HashSet<&str>,
10153) -> std::io::Result<()> {
10154    /// Bound on the re-check loop for an out-of-process appender.
10155    const MAX_TAIL_FOLDS: usize = 8;
10156
10157    let mut source = std::fs::read(path)?;
10158    let mut redacted = redact_jsonl_bytes(&source, erased);
10159
10160    for _ in 0..MAX_TAIL_FOLDS {
10161        let current = std::fs::read(path)?;
10162        if current.len() == source.len() {
10163            let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
10164            tmp_name.push(".redact.tmp");
10165            let tmp = path.with_file_name(tmp_name);
10166            {
10167                let mut file = std::fs::File::create(&tmp)?;
10168                file.write_all(&redacted)?;
10169                file.sync_all()?;
10170            }
10171            std::fs::rename(&tmp, path)?;
10172            return Ok(());
10173        }
10174        // Someone appended while we were building the replacement: fold the
10175        // delta in (redacted) rather than dropping it, then re-check.
10176        if current.len() > source.len() && current.starts_with(&source) {
10177            redacted.extend_from_slice(&redact_jsonl_bytes(&current[source.len()..], erased));
10178        } else {
10179            // The file was rewritten under us, not appended to. Start over.
10180            redacted = redact_jsonl_bytes(&current, erased);
10181        }
10182        source = current;
10183    }
10184    Err(std::io::Error::other(format!(
10185        "telemetry sink {} is being appended to faster than it can be redacted",
10186        path.display()
10187    )))
10188}
10189
10190/// Line-wise redaction of a JSONL byte buffer. Non-JSON and non-event lines are
10191/// passed through unchanged, as is a trailing partial line (no terminating
10192/// newline) — the sink is append-only, so a partial tail is a torn write, not
10193/// ours to normalize.
10194fn redact_jsonl_bytes(bytes: &[u8], erased: &std::collections::HashSet<&str>) -> Vec<u8> {
10195    let mut out = Vec::with_capacity(bytes.len());
10196    let mut rest = bytes;
10197    while !rest.is_empty() {
10198        let (line, tail) = match rest.iter().position(|b| *b == b'\n') {
10199            Some(idx) => (&rest[..idx], &rest[idx + 1..]),
10200            // No trailing newline: a torn/partial final line. Pass it through.
10201            None => {
10202                out.extend_from_slice(rest);
10203                break;
10204            }
10205        };
10206        match redact_jsonl_line(line, erased) {
10207            Some(replacement) => out.extend_from_slice(replacement.as_bytes()),
10208            None => out.extend_from_slice(line),
10209        }
10210        out.push(b'\n');
10211        rest = tail;
10212    }
10213    out
10214}
10215
10216/// `Some(replacement)` when the line is an engine-authored telemetry event whose
10217/// `result_stable_ids` referenced an erased id; `None` to pass it through.
10218fn redact_jsonl_line(line: &[u8], erased: &std::collections::HashSet<&str>) -> Option<String> {
10219    let text = std::str::from_utf8(line).ok()?;
10220    let mut value: serde_json::Value = serde_json::from_str(text).ok()?;
10221    let ids = value.get_mut("result_stable_ids")?.as_array_mut()?;
10222    let mut touched = false;
10223    for id in ids.iter_mut() {
10224        if id.as_str().is_some_and(|s| erased.contains(s)) {
10225            *id = serde_json::Value::from(REDACTED_STABLE_ID);
10226            touched = true;
10227        }
10228    }
10229    touched.then(|| value.to_string())
10230}
10231
10232/// G9 — fuse the vector and text branches with Reciprocal Rank Fusion.
10233///
10234/// Delegates to [`fuse_three_arms`] with an empty graph arm. The two-arm
10235/// contract is preserved: `fuse_rrf(v, t)` == `fuse_three_arms(v, t, vec![])`.
10236/// All existing callers are unaffected.
10237///
10238/// See [`fuse_three_arms`] for the full RRF formula documentation.
10239#[doc(hidden)]
10240#[must_use]
10241pub fn fuse_rrf(vector_hits: Vec<SearchHit>, text_hits: Vec<SearchHit>) -> Vec<SearchHit> {
10242    fuse_three_arms(vector_hits, text_hits, vec![])
10243}
10244
10245/// R3 (Slice 30) — fuse vector, text, and graph arms with Reciprocal Rank Fusion.
10246///
10247/// Each branch contributes `weight / (RRF_K + rank)` (1-based rank within that
10248/// branch; `weight` = [`RRF_WEIGHT_VECTOR`] / [`RRF_WEIGHT_TEXT`] /
10249/// [`RRF_WEIGHT_GRAPH`], text-dominant per IR-C), accumulated **keyed on
10250/// `SearchHit.body`**, so a body surfaced by multiple branches accumulates all
10251/// terms (agreement boosts it). The fused value is written into `SearchHit.score`.
10252/// A body in multiple branches surfaces **once** with the **vector** branch's
10253/// identity (vector-first), then graph arm identity for non-vector hits, then
10254/// text. Output is sorted by score descending, then vector-first, then insertion
10255/// order — a pure, deterministic function of the three input lists.
10256///
10257/// With an empty `graph_hits` (`vec![]`), the output is byte-identical to the
10258/// pre-Slice-30 two-arm `fuse_rrf`. This is the backward-compatibility contract.
10259///
10260/// This is the **unconditional** new ranking (HITL Q3 — no `fusion_mode` knob,
10261/// no legacy path). Graph arm is opt-in via `use_graph_arm=true`.
10262#[doc(hidden)]
10263#[must_use]
10264pub fn fuse_three_arms(
10265    vector_hits: Vec<SearchHit>,
10266    text_hits: Vec<SearchHit>,
10267    graph_hits: Vec<SearchHit>,
10268) -> Vec<SearchHit> {
10269    struct Entry {
10270        hit: SearchHit,
10271        score: f64,
10272        in_vector: bool,
10273        order: usize,
10274    }
10275    let mut entries: Vec<Entry> = Vec::new();
10276    let mut accumulate = |hit: SearchHit, rank0: usize, in_vector: bool, weight: f64| {
10277        let contrib = weight / (RRF_K + (rank0 as f64 + 1.0));
10278        if let Some(existing) = entries.iter_mut().find(|e| e.hit.body == hit.body) {
10279            // Dedup on body; the representative hit (vector-first) is retained.
10280            existing.score += contrib;
10281        } else {
10282            let order = entries.len();
10283            entries.push(Entry { hit, score: contrib, in_vector, order });
10284        }
10285    };
10286    for (rank0, hit) in vector_hits.into_iter().enumerate() {
10287        accumulate(hit, rank0, true, RRF_WEIGHT_VECTOR);
10288    }
10289    for (rank0, hit) in text_hits.into_iter().enumerate() {
10290        accumulate(hit, rank0, false, RRF_WEIGHT_TEXT);
10291    }
10292    for (rank0, hit) in graph_hits.into_iter().enumerate() {
10293        // Graph arm: vector-first=false (never overrides an existing vector hit's
10294        // representative identity; only new bodies from the graph arm get GraphArm
10295        // as their branch identity). The in_vector=false ensures graph arm hits
10296        // never sort ahead of vector hits on exact score ties.
10297        accumulate(hit, rank0, false, RRF_WEIGHT_GRAPH);
10298    }
10299    entries.sort_by(|a, b| {
10300        b.score
10301            .partial_cmp(&a.score)
10302            .unwrap_or(std::cmp::Ordering::Equal)
10303            // vector-first on equal score (true sorts before false).
10304            .then_with(|| b.in_vector.cmp(&a.in_vector))
10305            .then_with(|| a.order.cmp(&b.order))
10306    });
10307    entries
10308        .into_iter()
10309        .map(|mut e| {
10310            e.hit.score = e.score;
10311            e.hit
10312        })
10313        .collect()
10314}
10315
10316/// G12-recency — reweight fused hits toward the more recent (higher
10317/// `write_cursor`/`id`) AFTER bit-KNN (never a vec0 predicate). Gated by the
10318/// caller's dedicated recency flag; `enabled=false` is a no-op (pure RRF).
10319#[doc(hidden)]
10320#[must_use]
10321pub fn apply_recency_reweight(hits: Vec<SearchHit>, enabled: bool) -> Vec<SearchHit> {
10322    if !enabled || hits.len() < 2 {
10323        return hits;
10324    }
10325    let min_id = hits.iter().map(|h| h.write_cursor).min().unwrap_or(0);
10326    let max_id = hits.iter().map(|h| h.write_cursor).max().unwrap_or(0);
10327    if max_id == min_id {
10328        return hits;
10329    }
10330    let span = (max_id - min_id) as f64;
10331    let mut reweighted: Vec<SearchHit> = hits
10332        .into_iter()
10333        .map(|mut h| {
10334            let norm = (h.write_cursor - min_id) as f64 / span;
10335            h.score += RECENCY_WEIGHT * norm;
10336            h
10337        })
10338        .collect();
10339    // Stable sort preserves the fused order on exact ties.
10340    reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10341    reweighted
10342}
10343
10344/// 0.8.16 Slice 5 / F9 — OFF-by-default importance/confidence reweight, applied
10345/// to the fused hits AFTER bit-KNN + RRF (mirrors [`apply_recency_reweight`]).
10346///
10347/// Multiplicative-on-fused (ADR-0.8.16 §2.2, HITL-SIGNED 2026-07-08): a hit's
10348/// score is scaled by node `importance` (`importance_by_id`) × edge `confidence`
10349/// (`confidence_by_id`), each keyed by the hit's interim id (`write_cursor`). A
10350/// missing key = `NULL` = never assigned = graceful-absent ⇒ neutral (1.0), the
10351/// OPP-12 Q6a graceful-absent state. Node hits carry `importance`; graph/edge hits
10352/// carry `confidence`; the two id-spaces never collide (cursors are globally
10353/// unique), so each hit gets exactly one non-neutral factor.
10354///
10355/// **R-F9-4 graceful-neutral identity:** when `enabled` but *no* hit has a
10356/// non-neutral factor (every importance/confidence absent), the input is returned
10357/// **unchanged** — byte-identical to the `enabled == false` result (no re-sort),
10358/// so declaring the mechanism never perturbs an all-absent corpus.
10359#[must_use]
10360pub fn apply_importance_reweight(
10361    hits: Vec<SearchHit>,
10362    importance_by_id: &HashMap<u64, f64>,
10363    confidence_by_id: &HashMap<u64, f64>,
10364    enabled: bool,
10365) -> Vec<SearchHit> {
10366    if !enabled {
10367        return hits;
10368    }
10369    // Graceful-neutral fast path (R-F9-4): if nothing is weighted, do not touch
10370    // order or scores — identical to the reweight-OFF result.
10371    let any_weighted = hits.iter().any(|h| {
10372        importance_by_id.contains_key(&h.write_cursor)
10373            || confidence_by_id.contains_key(&h.write_cursor)
10374    });
10375    if !any_weighted {
10376        return hits;
10377    }
10378    let mut reweighted: Vec<SearchHit> = hits
10379        .into_iter()
10380        .map(|mut h| {
10381            let importance = importance_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10382            let confidence = confidence_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10383            h.score *= importance * confidence;
10384            h
10385        })
10386        .collect();
10387    // Stable sort preserves the fused order on exact ties.
10388    reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10389    reweighted
10390}
10391
10392/// 0.8.16 Slice 5 / F9 — build the per-hit importance/confidence weight maps for
10393/// the candidate `hits` from the durable columns (`canonical_nodes.importance`,
10394/// `canonical_edges.confidence`). Only NON-NULL values are inserted; an absent
10395/// value stays out of the map (graceful-absent ⇒ neutral in
10396/// [`apply_importance_reweight`]). Prepared statements are guarded so a pre-step-18
10397/// / pre-step-14 schema (no column) yields empty maps rather than an error.
10398fn build_importance_confidence_maps(
10399    tx: &rusqlite::Connection,
10400    hits: &[SearchHit],
10401) -> rusqlite::Result<(HashMap<u64, f64>, HashMap<u64, f64>)> {
10402    let mut importance_by_id: HashMap<u64, f64> = HashMap::new();
10403    let mut confidence_by_id: HashMap<u64, f64> = HashMap::new();
10404    if let Ok(mut stmt) =
10405        tx.prepare("SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1")
10406    {
10407        for h in hits {
10408            if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10409                importance_by_id.insert(h.write_cursor, v);
10410            }
10411        }
10412    }
10413    if let Ok(mut stmt) = tx.prepare(
10414        "SELECT confidence FROM canonical_edges \
10415         WHERE write_cursor = ?1 AND superseded_at IS NULL LIMIT 1",
10416    ) {
10417        for h in hits {
10418            if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10419                confidence_by_id.insert(h.write_cursor, v);
10420            }
10421        }
10422    }
10423    Ok((importance_by_id, confidence_by_id))
10424}
10425
10426/// 0.8.1 Slice 10 (R1) — CE rerank seam.
10427///
10428/// `rerank_depth = 0` (or model absent / `default-reranker` feature off): returns
10429/// `hits` **unchanged** — byte-identical to the old identity stub. This is the
10430/// soft-fallback contract.
10431///
10432/// `rerank_depth > 0` with the `default-reranker` feature on and the model
10433/// loaded: scores the top-`rerank_depth` (query, passage) pairs with the
10434/// TinyBERT-L-2 cross-encoder, blends CE score with the RRF score using the
10435/// formula from the design memo (Decision 5), re-sorts the top-N, and appends
10436/// the remainder in their original RRF order.
10437///
10438/// Score-blend (Decision 5): `α × sigmoid(ce_logit) + (1−α) × rrf_score_normalized`
10439/// where both CE and RRF scores are normalized to [0,1] over the reranked pool.
10440///
10441/// 0.8.5 (EXP-0): `alpha` (clamped to `[0,1]`) and `pool_n` (the reranked-pool
10442/// size, clamped to `hits.len()`) are caller-supplied. The defaults
10443/// `alpha = 0.3, pool_n = rerank_depth` reproduce the pre-slice blend exactly.
10444/// `rerank_depth == 0` remains the identity gate regardless of `pool_n`.
10445///
10446/// This is the rerank hook, **not** the dropped `fusion_mode` knob.
10447#[doc(hidden)]
10448#[must_use]
10449pub fn rerank_fused(
10450    _query: &str,
10451    hits: Vec<SearchHit>,
10452    rerank_depth: usize,
10453    alpha: f64,
10454    pool_n: usize,
10455) -> Vec<SearchHit> {
10456    // Soft-fallback: depth=0 → identity (byte-identical to old stub). NOTE this
10457    // early gate is independent of `pool_n`: `rerank_depth == 0, pool_n = 10`
10458    // does NOT rerank (0.8.5 D4).
10459    if rerank_depth == 0 {
10460        return hits;
10461    }
10462
10463    // Feature-gated CE inference. In the default build (no feature) this block
10464    // compiles away and `hits` is returned unchanged regardless of `rerank_depth`.
10465    // FIX-1: pass `&hits` (borrow) so `hits` remains owned for the soft-fallback path.
10466    #[cfg(feature = "default-reranker")]
10467    {
10468        if let Some(reranked) = ce_rerank(_query, &hits, rerank_depth, alpha, pool_n) {
10469            return reranked;
10470        }
10471    }
10472
10473    // 0.8.5: the bindings/default callers pass `alpha = 0.3, pool_n = rerank_depth`;
10474    // referenced here so the no-feature build does not warn on unused params.
10475    #[cfg(not(feature = "default-reranker"))]
10476    let _ = (alpha, pool_n);
10477
10478    // Model absent (feature off, weights not loaded, or CE returned None) →
10479    // soft-fallback: return input unchanged.
10480    hits
10481}
10482
10483/// 0.8.2 Slice E2 — standalone CE rerank of a caller-supplied passage list.
10484///
10485/// The pure, testable core that the `fathomdb.rerank` pyo3 binding is a thin
10486/// wrapper over. Slice 5's `fused_rerank` comparator must CE-rerank its OWN
10487/// in-harness fused(bm25+dense) pool — a pool the engine's `search()` never
10488/// constructs — so the CE has to be reachable over an arbitrary passage list,
10489/// not just the engine's capped text-only pool. This adapts `(id, body, score)`
10490/// passages into `SearchHit`s (`kind = "passage"`, `branch = Vector`,
10491/// `source_id = None`; only `body` and `score` feed the blend), runs them
10492/// through [`rerank_fused`], and projects back to `(id, score, ce_score)` in the reranked
10493/// order.
10494///
10495/// Contract (inherited verbatim from `rerank_fused`): `rerank_depth == 0` OR an
10496/// empty list returns the input order WITH the input scores, byte-identical — no
10497/// model load, no network. With `--features default-reranker` and
10498/// `rerank_depth > 0` the CE blends the top-`depth` and may reorder; with the
10499/// feature off the CE path compiles away and this is always identity.
10500///
10501/// 0.8.2 Slice E2 fix-1 [P2]: returns `Err` when any passage carries a non-finite
10502/// score (NaN / ±inf), mirroring the malformed-passage loud-fail contract.
10503/// Callers (pyo3 `rerank` binding, tests) must handle `Result`.
10504/// (`#[must_use]` removed: `Result` is already `#[must_use]`.)
10505pub fn rerank_passages(
10506    query: &str,
10507    passages: Vec<(u64, String, f64)>,
10508    rerank_depth: usize,
10509    alpha: f64,
10510    pool_n: usize,
10511) -> Result<Vec<(u64, f64, Option<f64>)>, String> {
10512    // [P2] guard: reject non-finite scores before they reach normalization/sort.
10513    // A NaN or ±inf score would produce NaN blended scores and an unstable sort
10514    // order — surface the error early as the typed WriteValidationError at the
10515    // pyo3 boundary (mirroring the malformed-passage loud-fail contract).
10516    for (id, _, score) in &passages {
10517        if !score.is_finite() {
10518            return Err(format!(
10519                "rerank: non-finite score for passage id={id}: {score} \
10520                 (NaN/\u{00b1}inf must not reach the normalization/sort step)"
10521            ));
10522        }
10523    }
10524    let hits: Vec<SearchHit> = passages
10525        .into_iter()
10526        .map(|(id, body, score)| SearchHit {
10527            // C-2: synthetic passages carry no canonical identity — mint the
10528            // `Passage` (`p:`) id from the caller-supplied ordinal. The ordinal
10529            // is ALSO kept as the engine-internal positional cursor so the
10530            // projection below returns it byte-unchanged.
10531            id: IdSpace::passage(id.to_string()),
10532            write_cursor: id,
10533            kind: "passage".to_string(),
10534            body,
10535            score,
10536            branch: SoftFallbackBranch::Vector,
10537            source_id: None,
10538            ce_score: None,
10539        })
10540        .collect();
10541    // 0.8.5 — project `(id, score, ce_score)` so the binding can surface the CE
10542    // score per candidate; `ce_score` is `None` for the identity / out-of-pool path.
10543    // The projected id is the caller's ordinal (the engine-internal `write_cursor`).
10544    Ok(rerank_fused(query, hits, rerank_depth, alpha, pool_n)
10545        .into_iter()
10546        .map(|h| (h.write_cursor, h.score, h.ce_score))
10547        .collect())
10548}
10549
10550/// 0.8.1 Slice 10 — score-blend reranking when CE model is loaded.
10551///
10552/// Returns `Some(reranked)` if the model is available, `None` otherwise
10553/// (caller then applies the soft-fallback).
10554///
10555/// Design memo Decision 5:
10556/// - CE normalized = sigmoid(raw_logit) ∈ [0,1]
10557/// - RRF normalized = min-max of `hit.score` over the top-K pool
10558/// - `final_score = 0.3 × ce_norm + 0.7 × rrf_norm`
10559/// - Hits beyond `rerank_depth` keep their original RRF scores and order.
10560#[cfg(feature = "default-reranker")]
10561fn ce_rerank(
10562    _query: &str,
10563    hits: &[SearchHit], // FIX-1: borrow, not move — caller retains ownership for soft-fallback
10564    _rerank_depth: usize, // 0.8.5: pool sizing moved to `pool_n`; depth gate stays in `rerank_fused`.
10565    alpha: f64,
10566    pool_n: usize,
10567) -> Option<Vec<SearchHit>> {
10568    // 0.8.5 (D3) — clamp α to [0,1] silently here so EVERY path (engine search,
10569    // `rerank_passages`, the bindings) is covered by one clamp, matching the
10570    // existing `pool_n.min(len)` clamp idiom.
10571    // codex §9 P2-1: `f64::clamp(NaN)` returns NaN (clamp does NOT map NaN into
10572    // range) — a non-finite α would then make every blended score NaN and destroy
10573    // the ranking. The high-level SDKs reject non-finite α, but the low-level
10574    // `rerank()` / direct-Rust callers don't, so fall back to the documented
10575    // default α=0.3 here for any non-finite input.
10576    let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 0.3 };
10577    // fix-1 [P2]: short-circuit before touching the singleton when there is
10578    // nothing to rerank — avoids loading/downloading the ~17 MB model for an
10579    // empty result set and prevents memoizing a transient load failure.
10580    if hits.is_empty() {
10581        return Some(vec![]);
10582    }
10583
10584    // Try to get the loaded model. Returns None when weights are absent.
10585    let model = CandleCrossEncoder::try_get_loaded()?;
10586
10587    // 0.8.5 (D4) — the reranked pool is the top `pool_n` (caller resolves the
10588    // `unwrap_or(rerank_depth)` default at the binding), clamped to the hit count.
10589    let n = pool_n.min(hits.len());
10590    let top = &hits[..n]; // no split_at_mut needed; borrow slices directly
10591    let rest = &hits[n..];
10592
10593    // --- RRF min-max normalization over the top-N pool ---
10594    let rrf_min = top.iter().map(|h| h.score).fold(f64::INFINITY, f64::min);
10595    let rrf_max = top.iter().map(|h| h.score).fold(f64::NEG_INFINITY, f64::max);
10596    let rrf_span = rrf_max - rrf_min;
10597
10598    // Batched CE scoring: ONE forward over the whole top-N pool instead of N
10599    // per-pair forwards. The ranking math below (RRF min-max norm, sigmoid,
10600    // ALPHA blend, sort) is byte-unchanged — only the scoring is batched.
10601    let bodies: Vec<&str> = top.iter().map(|h| h.body.as_str()).collect();
10602    let raw_logits = model.score_batch(_query, &bodies);
10603
10604    let mut scored: Vec<(f64, SearchHit)> = top
10605        .iter()
10606        .zip(raw_logits)
10607        .map(|(h, raw_logit)| {
10608            let rrf_norm = if rrf_span > 0.0 { (h.score - rrf_min) / rrf_span } else { 1.0 };
10609            // Sigmoid for CE normalization: 1/(1+exp(-x)).
10610            let ce_norm = 1.0 / (1.0 + (-raw_logit).exp());
10611            // 0.8.5 — α is the caller-supplied (clamped) blend weight; default 0.3
10612            // reproduces the pre-slice `const ALPHA = 0.3` blend exactly.
10613            let blended = alpha * ce_norm + (1.0 - alpha) * rrf_norm;
10614            // 0.8.5 (D1) — expose the per-candidate CE score on in-pool hits.
10615            let mut hit = h.clone();
10616            hit.ce_score = Some(ce_norm);
10617            (blended, hit)
10618        })
10619        .collect();
10620
10621    // Sort top-N by blended score descending (stable within ties by original order).
10622    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
10623
10624    let mut result: Vec<SearchHit> = scored
10625        .into_iter()
10626        .map(|(score, mut h)| {
10627            h.score = score;
10628            h
10629        })
10630        .collect();
10631
10632    // Append hits beyond rerank_depth in their original RRF order.
10633    result.extend_from_slice(rest);
10634    Some(result)
10635}
10636
10637/// 0.8.1 Slice 10 (R1) / 0.8.2 Slice E1 — CPU TinyBERT-L-2 cross-encoder.
10638///
10639/// Thin engine-side handle over the embedder crate's `CandleTinyBertReranker`
10640/// (Candle BERT stack + `tokenizers`, pinned `cross-encoder/ms-marco-TinyBERT-
10641/// L2-v2`). The model is loaded once, process-wide, the first time
10642/// `rerank_depth > 0` reaches the CE path (lazy init via the `OnceLock` below);
10643/// on cache miss that first load fetches the ~17 MB weights over the network
10644/// (sha256-verified). When the weights are absent and the network is
10645/// unavailable, the load fails and `try_get_loaded()` returns `None` so the
10646/// caller soft-falls-back to RRF order — it never panics.
10647///
10648/// Footprint: this whole type compiles ONLY under `default-reranker`. With the
10649/// feature off the CE path compiles away and `rerank_fused` is always identity.
10650/// With the feature on, `rerank_depth == 0` short-circuits in `rerank_fused`
10651/// BEFORE this is ever touched, so depth-0 stays byte-identical and no-network.
10652/// Similarly, an empty hit set short-circuits in `ce_rerank` before the singleton
10653/// is consulted (fix-1 [P2]).
10654#[cfg(feature = "default-reranker")]
10655struct CandleCrossEncoder {
10656    inner: &'static fathomdb_embedder::CandleTinyBertReranker,
10657}
10658
10659/// Process-wide lazily-initialized reranker. `None` once initialization has
10660/// been attempted and failed (no weights + no network) — memoized so a failed
10661/// load is not retried on every query.
10662#[cfg(feature = "default-reranker")]
10663fn reranker_singleton() -> Option<&'static fathomdb_embedder::CandleTinyBertReranker> {
10664    static CELL: std::sync::OnceLock<Option<fathomdb_embedder::CandleTinyBertReranker>> =
10665        std::sync::OnceLock::new();
10666    CELL.get_or_init(|| fathomdb_embedder::CandleTinyBertReranker::try_load().ok()).as_ref()
10667}
10668
10669#[cfg(feature = "default-reranker")]
10670impl CandleCrossEncoder {
10671    /// Returns a model handle if the reranker is (or can be) loaded, `None`
10672    /// otherwise. The first call drives the lazy load (cache probe → gated
10673    /// download); subsequent calls reuse the memoized result.
10674    fn try_get_loaded() -> Option<Self> {
10675        Some(Self { inner: reranker_singleton()? })
10676    }
10677
10678    /// Score a (query, passage) pair. Returns the raw cross-encoder logit, or
10679    /// `0.0` (a neutral logit → sigmoid 0.5) if the forward pass errors, so a
10680    /// single bad pair degrades to a neutral CE contribution rather than
10681    /// panicking in the reader thread.
10682    fn score(&self, query: &str, passage: &str) -> f64 {
10683        self.inner.score(query, passage).map(f64::from).unwrap_or(0.0)
10684    }
10685
10686    /// Batched [`score`](Self::score): score every `(query, passage_i)` pair in a
10687    /// single forward pass. Returns one logit per passage in input order, each
10688    /// honoring the same neutral-`0.0`-on-error contract as [`score`](Self::score).
10689    ///
10690    /// Fallback: if the batched forward errors as a whole (e.g. an OOM or a
10691    /// tokenize failure on one pair surfaces as a batch `Err`), we DO NOT
10692    /// neutralize the entire pool — we fall back to per-pair [`score`](Self::score),
10693    /// so a single bad pair degrades only its own element to a neutral logit while
10694    /// the rest keep their real scores. Empty input → empty output (no forward).
10695    fn score_batch(&self, query: &str, passages: &[&str]) -> Vec<f64> {
10696        match self.inner.score_batch(query, passages) {
10697            Ok(logits) => logits.into_iter().map(f64::from).collect(),
10698            Err(_) => passages.iter().map(|p| self.score(query, p)).collect(),
10699        }
10700    }
10701}
10702
10703/// 0.8.20 Slice 15e fix-2 finding 1 [P2] + keystone closeout fix-3 (codex §9 [P2],
10704/// TOCTOU) — reject a search filter that names an attribute with NO declared
10705/// `filterable` projection, ON THE READER'S OWN SNAPSHOT, before the vec0 SQL is
10706/// built.
10707///
10708/// The vector arm lowers each `filter.attributes` term to `AND attr_<hex>=?`
10709/// against a vec0 metadata column that exists ONLY for a declared `filterable`
10710/// projection (the reshape in [`reconcile_vector_attr_columns`] tracks exactly the
10711/// registry's `filterable` set; see [`desired_vector_attr_columns`]). A name that
10712/// is not a declared `filterable` projection therefore has no column: the vec0 KNN
10713/// would fail with `no such column` (surfacing as an opaque `Storage` error),
10714/// while the FTS arm ([`hit_attributes_pass_filter`]) would silently no-match. That
10715/// divergence violates ADR-0.8.11 D3 (every filter term has a DEFINED, arm-uniform
10716/// outcome).
10717///
10718/// fix-3 snapshot contract: this is called from [`read_search_in_tx`] INSIDE the
10719/// reader's `DEFERRED` transaction, so the `_fathomdb_projection_registry` it reads
10720/// and the `vector_default` columns [`vector_filter_clause`] compiles against are
10721/// the SAME WAL snapshot. A concurrent `configure_projections` DROP either commits
10722/// before this snapshot is pinned (then BOTH the registry and the vec0 columns show
10723/// the attribute gone ⇒ a consistent `InvalidFilter`) or after (then it is invisible
10724/// to this transaction and BOTH still show it declared ⇒ the query runs against the
10725/// column it validated). The registry's `filterable` set is the arm-INDEPENDENT
10726/// authority (correct even with no embedder / no `vector_default`, where a
10727/// declared-`filterable` term still filters legitimately via the row-owned
10728/// `canonical_attributes` EAV store). The caller re-raises
10729/// [`SearchReaderError::InvalidFilter`] as the EXISTING typed
10730/// [`EngineError::InvalidFilter`], so both arms see the SAME rejection because it is
10731/// raised before either runs.
10732fn validate_filter_attributes_on_snapshot(
10733    conn: &Connection,
10734    filter: &SearchFilter,
10735) -> Result<(), SearchReaderError> {
10736    if filter.attributes.is_empty() {
10737        return Ok(());
10738    }
10739    // `?` maps a registry-read failure to `SearchReaderError::Sqlite` (unchanged
10740    // `Storage` semantics for a genuine backend fault) via the `From` impl.
10741    let registry = load_projection_registry(conn)?;
10742    for (name, _value) in &filter.attributes {
10743        let declared_filterable =
10744            registry.get(name).is_some_and(|s| s.roles.contains(&ProjectionRole::Filterable));
10745        if !declared_filterable {
10746            return Err(SearchReaderError::InvalidFilter(format!(
10747                "filter attribute {name:?} is not a declared `filterable` projection; \
10748                 declare it via configure_projections before filtering on it"
10749            )));
10750        }
10751    }
10752    Ok(())
10753}
10754
10755/// G10 — the `AND col=?n` predicate fragment appended to the phase-1 candidates
10756/// `WHERE` for the present filter fields. Placeholders are numbered from `?3`
10757/// (`?1` = sign-quant query, `?2` = f32 rerank query). Field order is canonical
10758/// (`source_type`, `kind`, `created_after`, `status`), THEN the Slice-15e
10759/// `filterable`-attribute predicates (`attr_<hex>=?n`) in `attributes` order, and
10760/// is mirrored exactly by [`vector_filter_values`]. Empty for `None`/all-`None`
10761/// (byte-identity path).
10762fn vector_filter_clause(filter: Option<&SearchFilter>) -> String {
10763    let Some(filter) = filter else {
10764        return String::new();
10765    };
10766    if filter.is_unfiltered() {
10767        return String::new();
10768    }
10769    // 0.8.20 Slice 15e — the attribute predicates encode the (arbitrary,
10770    // possibly space/unicode-bearing) registry name into the byte-safe
10771    // `attr_<hex>` column vec0 accepts (vec0 rejects quoted identifiers). Owned
10772    // strings so they live past the closure; the shipped metadata columns are
10773    // static `&str`.
10774    let mut cols: Vec<(String, &str)> = Vec::new();
10775    if filter.source_type.is_some() {
10776        cols.push(("source_type".to_string(), "="));
10777    }
10778    if filter.kind.is_some() {
10779        cols.push(("kind".to_string(), "="));
10780    }
10781    if filter.created_after.is_some() {
10782        cols.push(("created_at".to_string(), ">="));
10783    }
10784    if filter.status.is_some() {
10785        cols.push(("status".to_string(), "="));
10786    }
10787    for (name, _value) in &filter.attributes {
10788        cols.push((attr_vec0_column(name), "="));
10789    }
10790    let mut clause = String::new();
10791    for (i, (col, op)) in cols.iter().enumerate() {
10792        clause.push_str(&format!(" AND {col}{op}?{}", i + 3));
10793    }
10794    clause
10795}
10796
10797/// G10 — the bound values for the present filter fields, in the SAME canonical
10798/// order as [`vector_filter_clause`] so placeholder `?{n}` lines up with value
10799/// `n-3`.
10800fn vector_filter_values(filter: Option<&SearchFilter>) -> Vec<rusqlite::types::Value> {
10801    use rusqlite::types::Value;
10802    let mut out = Vec::new();
10803    let Some(filter) = filter else {
10804        return out;
10805    };
10806    if filter.is_unfiltered() {
10807        return out;
10808    }
10809    if let Some(s) = &filter.source_type {
10810        out.push(Value::Text(s.clone()));
10811    }
10812    if let Some(s) = &filter.kind {
10813        out.push(Value::Text(s.clone()));
10814    }
10815    if let Some(c) = filter.created_after {
10816        out.push(Value::Integer(c));
10817    }
10818    if let Some(s) = &filter.status {
10819        out.push(Value::Text(s.clone()));
10820    }
10821    // 0.8.20 Slice 15e — attribute values, in the SAME order the clause appended
10822    // the `attr_<hex>` columns (after the four metadata fields). fix-3 [P2] — the
10823    // filter value is encoded `\x01 || V` to match the encoded PRESENT column
10824    // value, so `attr_<hex> = enc("")` matches present-empty but NEVER the
10825    // `''`-absent rows.
10826    for (_name, value) in &filter.attributes {
10827        out.push(Value::Text(encode_attr_vec0_present(value)));
10828    }
10829    out
10830}
10831
10832/// G10 — build the single phase-1 candidates statement. With `filter=None` (or
10833/// all-`None`) the `{filter_clause}` is empty and the SQL is **byte-identical to
10834/// 0.7.2** (the documented behavior-compat invariant; pinned by
10835/// `pr_g10_filtered_knn.rs`). The KNN form (`ORDER BY distance LIMIT top_k`, no
10836/// `k=`) is preserved.
10837fn build_vector_phase1_sql(filter: Option<&SearchFilter>, final_limit: usize) -> String {
10838    let filter_clause = vector_filter_clause(filter);
10839    format!(
10840        "WITH candidates AS (
10841                     SELECT rowid
10842                     FROM vector_default
10843                     WHERE embedding_bin MATCH vec_quantize_binary(vec_f32(?1)){filter_clause}
10844                     ORDER BY distance
10845                     LIMIT {top_k}
10846                 )
10847                 SELECT c.rowid, vec_distance_l2(v.embedding, vec_f32(?2)) AS l2
10848                 FROM candidates c
10849                 JOIN vector_default v ON v.rowid = c.rowid
10850                 ORDER BY l2
10851                 LIMIT {final_limit}",
10852        top_k = TOP_K_BIT_CANDIDATES,
10853    )
10854}
10855
10856/// Test seam — exposes [`build_vector_phase1_sql`] at the production
10857/// `SEARCH_RERANK_LIMIT` so `pr_g10_filtered_knn.rs` can pin the `filter=None`
10858/// byte-identity and the appended predicates.
10859#[doc(hidden)]
10860#[must_use]
10861pub fn vector_phase1_sql_for_test(filter: Option<&SearchFilter>) -> String {
10862    build_vector_phase1_sql(filter, SEARCH_RERANK_LIMIT)
10863}
10864
10865/// G10 — does a text-branch hit satisfy the filter? The vector branch is
10866/// pruned in-SQL; the text branch is constrained here against the same metadata:
10867/// `kind` directly, `source_type` via [`resolve_source_type`], and
10868/// `created_after`/`status` from `vector_default` by `rowid == write_cursor`. A
10869/// text-only row absent from the vector partition cannot satisfy a
10870/// `created_after`/`status` predicate, so it is excluded — filtered semantic
10871/// search is a vector-metadata capability.
10872fn text_hit_passes_filter(
10873    tx: &rusqlite::Transaction<'_>,
10874    id: u64,
10875    kind: &str,
10876    filter: Option<&SearchFilter>,
10877) -> rusqlite::Result<bool> {
10878    let Some(filter) = filter else {
10879        return Ok(true);
10880    };
10881    if filter.is_unfiltered() {
10882        return Ok(true);
10883    }
10884    if let Some(k) = &filter.kind {
10885        if kind != k {
10886            return Ok(false);
10887        }
10888    }
10889    if let Some(st) = &filter.source_type {
10890        match resolve_source_type(kind) {
10891            Ok(resolved) if resolved == st.as_str() => {}
10892            _ => return Ok(false),
10893        }
10894    }
10895    if filter.created_after.is_some() || filter.status.is_some() {
10896        let meta: Option<(i64, Option<String>)> = tx
10897            .query_row(
10898                "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
10899                [id as i64],
10900                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
10901            )
10902            .optional()?;
10903        let Some((created_at, status)) = meta else {
10904            // No vector-partition row: cannot satisfy a vec-metadata predicate.
10905            return Ok(false);
10906        };
10907        if let Some(bound) = filter.created_after {
10908            if created_at < bound {
10909                return Ok(false);
10910            }
10911        }
10912        if let Some(want) = &filter.status {
10913            if status.as_deref() != Some(want.as_str()) {
10914                return Ok(false);
10915            }
10916        }
10917    }
10918    // 0.8.20 Slice 15e fix-1 finding 1 [P2] — enforce the declared-`filterable`
10919    // attribute-equality predicates on the TEXT/FTS arm too (D3 total dispatch).
10920    if !hit_attributes_pass_filter(tx, id, filter)? {
10921        return Ok(false);
10922    }
10923    Ok(true)
10924}
10925
10926/// 0.8.20 Slice 15e fix-1 finding 1 [P2] — do a TEXT/FTS hit's `filterable`
10927/// attributes satisfy `filter.attributes`?
10928///
10929/// The vector arm enforces each `(attr_name, value)` pre-KNN as `attr_<hex>=?`
10930/// against the vec0 metadata column. The FTS/text arm must enforce the SAME
10931/// equality so a hybrid RRF fusion is coherent (ADR-0.8.11 D3 — every filter term
10932/// has a defined outcome on EVERY surface; no arm silently ignores it). Without
10933/// this, a doc returned by the FTS arm that FAILS the attribute filter still
10934/// surfaces in a hybrid search — a false positive.
10935///
10936/// The value is read from the row-owned `canonical_attributes` EAV table (keyed
10937/// by the hit's `write_cursor` + `attr_name`), which Slice 15d keeps active-only
10938/// and populates via the SAME [`extract_scalar_attribute`] that fills the vec0
10939/// `attr_<hex>` column — so the two arms see IDENTICAL values by construction.
10940///
10941/// # fix-3 [P2]: ABSENT vs PRESENT-EMPTY
10942///
10943/// A real empty-string value (`{"status":""}`) is DISTINCT from an absent
10944/// attribute. On this (FTS/text) arm the distinction is ROW EXISTENCE: a present
10945/// attribute (even value `''`) has a `canonical_attributes` row; an absent one has
10946/// NONE. So a filter `("status","")` matches present-empty (row exists, RAW value
10947/// `''`) but NOT absent (no row), and `("status","open")` matches only the
10948/// `open` row. The vec0 arm reaches the SAME verdict via its `\x01`-marker
10949/// encoding (see [`ATTR_VEC0_PRESENT_MARKER`]): present-empty is the bare marker,
10950/// absent is `''`, so `attr_<hex> = enc("")` matches present-empty but never
10951/// absent. `canonical_attributes.attr_value` and `property_search_index` stay RAW.
10952///
10953/// # 0.8.20 semantics (Finding 1 → HITL ruling (A)): attribute filters are NODE-scoped
10954///
10955/// Attribute projection is `PreparedWrite::Node`-gated (see `collect_projection_jobs`
10956/// / [`project_one_attribute`]): an EDGE is never projected into
10957/// `canonical_attributes`, and its `vector_default` row (kind `edge_fact`) carries
10958/// the `''` sentinel in every `attr_<hex>` column (the async worker reads the body
10959/// from `canonical_nodes`, which has no row for an edge cursor). Therefore an
10960/// attribute filter **excludes every edge hit** — on BOTH the edge-FTS arm (this
10961/// helper, keyed by the edge's write_cursor, reads `''`) and the edge-vector arm
10962/// (the pre-KNN `attr_<hex>='…'` predicate prunes the `''`-sentinel edge row) —
10963/// even when the edge body itself names the attribute. This is the intended
10964/// 0.8.20 behaviour, pinned by `attribute_filter_excludes_edge_hits_on_both_arms`.
10965///
10966/// The reserved widening is **(D) endpoint-node filtering** (an edge passes iff its
10967/// endpoint node(s) satisfy the attribute predicate): **(A) is (D) with an empty
10968/// endpoint rule.** (B) edges-pass-through and (C) project-edge-attributes are the
10969/// other reserved options. None are implemented in 0.8.20 — do not add a per-query
10970/// flag; a widening is a deliberate, separately-governed later slice.
10971fn hit_attributes_pass_filter(
10972    tx: &rusqlite::Transaction<'_>,
10973    id: u64,
10974    filter: &SearchFilter,
10975) -> rusqlite::Result<bool> {
10976    for (name, want) in &filter.attributes {
10977        // fix-3 [P2] — distinguish ABSENT from PRESENT-EMPTY by ROW EXISTENCE: a
10978        // present attribute (including one whose value is a real empty string `''`)
10979        // has a `canonical_attributes` row; an absent one has NONE. The outer
10980        // `Option` is row existence; the RAW `attr_value` is compared verbatim.
10981        // This mirrors the vec0 arm exactly (present-empty matches `("k","")`;
10982        // absent matches nothing, including `""`), so a fused hybrid search is
10983        // coherent. `canonical_attributes.attr_value` stays RAW (unencoded).
10984        let stored: Option<Option<String>> = tx
10985            .query_row(
10986                "SELECT attr_value FROM canonical_attributes \
10987                 WHERE write_cursor = ?1 AND attr_name = ?2 LIMIT 1",
10988                params![id as i64, name],
10989                |row| row.get::<_, Option<String>>(0),
10990            )
10991            .optional()?;
10992        match stored {
10993            // Present (row exists) and the RAW value equals the filter value.
10994            Some(Some(v)) if v.as_str() == want.as_str() => {}
10995            // Absent (no row), present-but-NULL, or present-but-different ⇒ fail.
10996            _ => return Ok(false),
10997        }
10998    }
10999    Ok(true)
11000}
11001
11002/// G11 (Slice 15) — does an edge FTS hit satisfy the filter?
11003///
11004/// Edge FTS hits always have `source_type = "edge_fact"` (the partition
11005/// discriminant). Their `row.kind` is the **relation** kind (e.g. `"owns"`,
11006/// `"works_for"`), not a node kind, so [`text_hit_passes_filter`] MUST NOT be
11007/// used for edge hits: `resolve_source_type(relation_kind)` returns `Err` for
11008/// unknown kinds, causing every edge hit to be silently rejected when a
11009/// `source_type` filter is set — the exact inverse of correct behaviour.
11010///
11011/// Edge bodies ARE projected into `vector_default` (rowid = `write_cursor`),
11012/// so `created_after` / `status` are satisfied by querying `vector_default`
11013/// exactly as [`text_hit_passes_filter`] does for node hits.
11014///
11015/// Rules:
11016/// - `source_type`: pass iff `None` **or** `== "edge_fact"`.
11017/// - `kind`: filter on the relation kind (`row.kind`) if specified.
11018/// - `created_after` / `status`: query `vector_default WHERE rowid = write_cursor`;
11019///   if absent from the vector partition the hit cannot satisfy a vec-metadata
11020///   predicate and is excluded.
11021fn edge_fts_hit_passes_filter(
11022    tx: &rusqlite::Transaction<'_>,
11023    write_cursor: u64,
11024    row_kind: &str,
11025    filter: Option<&SearchFilter>,
11026) -> rusqlite::Result<bool> {
11027    let Some(filter) = filter else {
11028        return Ok(true);
11029    };
11030    if filter.is_unfiltered() {
11031        return Ok(true);
11032    }
11033    if let Some(ref st) = filter.source_type {
11034        if st != "edge_fact" {
11035            return Ok(false); // filter targets a specific non-edge source_type
11036        }
11037    }
11038    if let Some(ref k) = filter.kind {
11039        if k != row_kind {
11040            return Ok(false); // kind filter applies to the relation kind
11041        }
11042    }
11043    // Edge bodies are projected into vector_default; check created_after/status
11044    // there, the same way text_hit_passes_filter does for node hits.
11045    if filter.created_after.is_some() || filter.status.is_some() {
11046        let meta: Option<(i64, Option<String>)> = tx
11047            .query_row(
11048                "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
11049                [write_cursor as i64],
11050                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
11051            )
11052            .optional()?;
11053        let Some((created_at, status)) = meta else {
11054            // No vector-partition row: cannot satisfy a vec-metadata predicate.
11055            return Ok(false);
11056        };
11057        if let Some(bound) = filter.created_after {
11058            if created_at < bound {
11059                return Ok(false);
11060            }
11061        }
11062        if let Some(want) = &filter.status {
11063            if status.as_deref() != Some(want.as_str()) {
11064                return Ok(false);
11065            }
11066        }
11067    }
11068    // 0.8.20 Slice 15e fix-1 finding 1 [P2] — an edge body is projected into
11069    // vector_default (rowid = write_cursor) with the same `attr_<hex>` pre-KNN
11070    // columns as a node, and Slice 15d projects an edge's `filterable` attributes
11071    // into `canonical_attributes` keyed by its write_cursor. Enforce the same
11072    // attribute equality here so the edge-FTS arm matches the vector arm (D3 total
11073    // dispatch). An edge that carries no such attribute reads the `''` sentinel and
11074    // fails a non-empty equality, exactly as on the vector arm.
11075    if !hit_attributes_pass_filter(tx, write_cursor, filter)? {
11076        return Ok(false);
11077    }
11078    Ok(true)
11079}
11080
11081/// Apply every edge filter except declared projection attributes. This isolates
11082/// the explanatory count from edge candidates rejected for an independent
11083/// source-type, relation-kind, or vec-metadata predicate.
11084fn edge_fts_hit_passes_non_attribute_filter(
11085    tx: &rusqlite::Transaction<'_>,
11086    write_cursor: u64,
11087    row_kind: &str,
11088    filter: Option<&SearchFilter>,
11089) -> rusqlite::Result<bool> {
11090    let Some(filter) = filter else {
11091        return Ok(true);
11092    };
11093    let mut non_attribute_filter = filter.clone();
11094    non_attribute_filter.attributes.clear();
11095    edge_fts_hit_passes_filter(tx, write_cursor, row_kind, Some(&non_attribute_filter))
11096}
11097
11098/// Read projection cursor and matching body rows inside one read tx.
11099fn read_projected_text_in_tx(
11100    reader: &mut Connection,
11101    query: &str,
11102    name: &str,
11103    filter: Option<&SearchFilter>,
11104    limit: usize,
11105    view: ReadView,
11106) -> ProjectedTextReaderResponse {
11107    let compiled = compile_text_query(query);
11108    let frozen = view.freeze();
11109    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11110    let registry = load_projection_registry(&tx)?;
11111    let declared = registry.get(name).ok_or_else(|| {
11112        SearchReaderError::InvalidFilter(format!("projected text field {name:?} is not declared"))
11113    })?;
11114    if !declared.wants_property_fts() {
11115        return Err(SearchReaderError::InvalidFilter(format!(
11116            "projected text field {name:?} is not a declared `searchable` projection with property FTS"
11117        )));
11118    }
11119    if let Some(filter) = filter {
11120        validate_filter_attributes_on_snapshot(&tx, filter)?;
11121    }
11122    let validity = frozen.validity_sql("n", 3);
11123    let sql = format!(
11124        "SELECT p.write_cursor, bm25(property_search_index), n.kind, n.body, n.logical_id, n.source_id
11125         FROM property_search_index p JOIN canonical_nodes n ON n.write_cursor = p.write_cursor
11126         WHERE p.attr_name = ?1 AND property_search_index MATCH ?2
11127           AND n.superseded_at IS NULL AND n.state = 'active'{validity}
11128         ORDER BY bm25(property_search_index) ASC, p.write_cursor ASC"
11129    );
11130    let mut params = vec![
11131        rusqlite::types::Value::Text(name.to_string()),
11132        rusqlite::types::Value::Text(compiled.match_expression),
11133    ];
11134    if let Some(now) = frozen.now_param() {
11135        params.push(rusqlite::types::Value::Integer(now));
11136    }
11137    let mut stmt = tx.prepare(&sql)?;
11138    let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
11139        Ok((
11140            row.get::<_, i64>(0)?,
11141            row.get::<_, f64>(1)?,
11142            row.get::<_, String>(2)?,
11143            row.get::<_, String>(3)?,
11144            row.get::<_, Option<String>>(4)?,
11145            row.get::<_, Option<String>>(5)?,
11146        ))
11147    })?;
11148    let mut results = Vec::new();
11149    for row in rows {
11150        let (cursor, bm25, kind, body, logical_id, source_id) = row?;
11151        if !text_hit_passes_filter(&tx, cursor as u64, &kind, filter)? {
11152            continue;
11153        }
11154        results.push(SearchHit {
11155            id: derive_stable_id(logical_id.as_deref(), &body),
11156            write_cursor: cursor as u64,
11157            kind,
11158            body,
11159            score: -bm25,
11160            branch: SoftFallbackBranch::Text,
11161            source_id,
11162            ce_score: None,
11163        });
11164        if results.len() >= limit {
11165            break;
11166        }
11167    }
11168    let projection_cursor = load_projection_cursor(&tx)?;
11169    Ok(SearchResult { projection_cursor, soft_fallback: None, results, explanation: None })
11170}
11171
11172// The 8th parameter (`vector_stage_only`) is the additive GA-2 / ◆ B-1
11173// measurement seam; the reader-worker call site threads each field through
11174// explicitly (mirroring the existing `recency_enabled` plumbing), so a wrapper
11175// struct would only obscure that 1:1 mapping for a test-only flag.
11176#[allow(clippy::too_many_arguments)]
11177fn read_search_in_tx(
11178    reader: &mut Connection,
11179    compiled: &fathomdb_query::CompiledQuery,
11180    query_vector: Option<&str>,
11181    query_vector_bin: Option<&str>,
11182    final_limit: usize,
11183    filter: Option<&SearchFilter>,
11184    recency_enabled: bool,
11185    importance_enabled: bool,
11186    vector_stage_only: bool,
11187    raw_query: &str,
11188    rerank_depth: usize,
11189    use_graph_arm: bool,
11190    alpha: f64,
11191    pool_n: usize,
11192    explain: bool,
11193    view: ReadView,
11194) -> ReaderResponse {
11195    // 0.8.20 Slice 15b fix-2 (R-20-NV) — the `:now` instant is read HERE, in
11196    // Rust, ONCE per query, and bound positionally into every node-hydration
11197    // SELECT. Never `datetime('now')` / `strftime('%s','now')`: an inline clock
11198    // would make the query non-deterministic, untestable, and re-evaluated per
11199    // row. `None` ⇒ the view relaxes validity ⇒ no conjunct is emitted and
11200    // nothing is bound (`validity_sql` returns the empty string).
11201    //
11202    // fix-3 (F2): FREEZE the view here, at the single point every arm flows
11203    // through. `freeze()` is the only place on this path that reads the clock;
11204    // downstream arms hold a `FrozenView` and have no way to resolve a second,
11205    // different instant. Previously the graph arm re-derived it from the raw
11206    // `ReadView`, so a boundary-straddling query could have its arms disagree.
11207    let view = view.freeze();
11208    let now_param = view.now_param();
11209    // fix-3 (codex §9 [P2], TOCTOU) — test-only rendezvous: parks the worker here,
11210    // BEFORE the deferred snapshot is pinned, so a test can commit a concurrent
11211    // `configure_projections` DROP in the exact race window. Disarmed (no-op) in
11212    // production and on every non-race test.
11213    reader_search_hook::fire();
11214    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11215    let cursor = load_projection_cursor(&tx)?;
11216    // fix-3 (codex §9 [P2], TOCTOU) — validate every filter attribute name on THIS
11217    // reader transaction's snapshot, before `build_vector_phase1_sql` emits
11218    // `AND attr_<hex>=?` and before the FTS arm probes `canonical_attributes`. The
11219    // registry read and the vec0 query now share ONE snapshot (see
11220    // `validate_filter_attributes_on_snapshot`), so a `configure_projections` DROP
11221    // racing this search yields a consistent typed `InvalidFilter` — never the
11222    // opaque `no such column` `Storage` error fix-2's writer-connection check could
11223    // still leak. Skipped when the filter carries no attribute terms (common path).
11224    if let Some(filter) = filter {
11225        validate_filter_attributes_on_snapshot(&tx, filter)?;
11226    }
11227    let vector_results = if let Some(query_vector) = query_vector {
11228        let mut rowids = Vec::new();
11229        let bin_vector = query_vector_bin.unwrap_or(query_vector);
11230        {
11231            // Phase 1: bit-KNN over `embedding_bin` to a top-K candidate
11232            // set; Phase 2: f32 rerank on the candidate set via
11233            // vec_distance_l2 against the retained `embedding` column.
11234            // EU-5a2: ?1 is the (possibly centered) sign-quant input,
11235            // ?2 is the un-centered f32 for vec_distance_l2 — both sides
11236            // of the f32 cosine use un-centered vectors.
11237            // PR-2bc S1 fix-1: the phase-2 rerank LIMIT is `SEARCH_RERANK_LIMIT`
11238            // (10) in production. `final_limit` is supplied by the caller from
11239            // `ProjectionRuntimeShared::search_limit_override` (default 10,
11240            // clamped >=10) — there is NO env-var read on this hot path. A test
11241            // seam (`set_search_limit_for_test`) may RAISE it so the recall
11242            // harness can pull top-(10+slack) and exclude the self-retrieving
11243            // query-source doc BEFORE truncating to 10 (standard ANN-recall
11244            // practice); it can never shrink below production semantics.
11245            // G10: the metadata filter is appended to this single phase-1
11246            // statement (`AND col=?n` from ?3); `filter=None` keeps the SQL
11247            // byte-identical to 0.7.2. `?1`/`?2` are the sign-quant + f32 query
11248            // vectors; filter values bind at ?3.. in `vector_filter_clause`
11249            // order.
11250            // fix-3 (F1, codex §9 [P2]) — OVERFETCH the phase-2 rerank so the
11251            // validity/existence filter applied at hydration cannot starve the
11252            // result set.
11253            //
11254            // The defect: hydration drops rows that are expired, superseded or
11255            // inactive, but it ran on candidates ALREADY truncated to
11256            // `final_limit`. If the nearest `final_limit` neighbours were all
11257            // out-of-window they consumed every slot and were then dropped, so
11258            // valid rows just below the cutoff were never considered — a
11259            // default search silently returned too few hits, or none.
11260            //
11261            // Why overfetch rather than filtering in SQL: the natural fix is to
11262            // join `canonical_nodes` into the candidate query, but (i) phase 1
11263            // is a `vec0` KNN and ADR-0.8.11 D3 forbids demoting it with
11264            // non-metadata predicates, and (ii) there is NO index on
11265            // `canonical_nodes(write_cursor)`, so an `EXISTS` per candidate
11266            // would be a full scan × the whole pool on EVERY query — a
11267            // guaranteed cost to fix a degenerate case.
11268            //
11269            // Overfetching is free by comparison: phase 2 already computes
11270            // `vec_distance_l2` for all `TOP_K_BIT_CANDIDATES` in order to sort
11271            // them, so raising the LIMIT only returns more of a result set that
11272            // was already materialized. No extra vec0 work, no schema change,
11273            // no new index, no second query. The hydration loop below then
11274            // stops at `final_limit` SURVIVING hits, so the common case does
11275            // exactly as many hydration probes as before.
11276            //
11277            // `max` (not a bare constant) because `set_search_limit_for_test`
11278            // may raise `final_limit` above the pool for the recall harness;
11279            // this must never request FEWER candidates than the caller wants.
11280            let candidate_limit = final_limit.max(TOP_K_BIT_CANDIDATES);
11281            let sql = build_vector_phase1_sql(filter, candidate_limit);
11282            let mut params: Vec<rusqlite::types::Value> = vec![
11283                rusqlite::types::Value::Text(bin_vector.to_string()),
11284                rusqlite::types::Value::Text(query_vector.to_string()),
11285            ];
11286            params.extend(vector_filter_values(filter));
11287            let mut statement = tx.prepare(&sql)?;
11288            let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
11289                Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
11290            })?;
11291            for row in rows.flatten() {
11292                rowids.push(row);
11293            }
11294        }
11295        // G1: carry the canonical row's `write_cursor` (interim id), `kind`,
11296        // `body`, and the `vec_distance_l2` rerank score per hit. The
11297        // `_fathomdb_vector_rows.rowid` equals the canonical `write_cursor`,
11298        // so the candidate rowid IS the hit id.
11299        //
11300        // G11 (Slice 15) fix: edge bodies are projected into vector_default under
11301        // kind = "edge_fact"; their write_cursor is in canonical_edges, not
11302        // canonical_nodes. Try canonical_nodes first; fall back to canonical_edges
11303        // for edge-fact hits so they are not silently dropped.
11304        let mut results = Vec::new();
11305        // Cause-A: the two node/edge SELECTs additively fetch `logical_id` so the
11306        // hit can carry a stable cross-session id (derive_stable_id). Read-only
11307        // additive column — ordering/scores are untouched.
11308        // fix-1 (codex §9): co-locate BOTH existence guards. Node supersession
11309        // is tombstone-then-insert (`commit_batch`) — the prior `canonical_nodes`
11310        // row is kept (same `write_cursor`, `state = 'active'`, `superseded_at`
11311        // set) and, unlike the edge path (fix-30), its stale `vector_default` row
11312        // is NOT pruned, so the phase-1 bit-KNN can still surface the OLD cursor.
11313        // Without `superseded_at IS NULL` here that superseded version would
11314        // hydrate and leak stale content through vector search. This matches the
11315        // edge branch below and every other retrieval site (design §2: enforce
11316        // the exclusion at EVERY retrieval site). It only drops already-superseded
11317        // rows → a no-op on the all-active / non-superseded corpus.
11318        // TC-31 (0.8.20 Slice 10a): both hydration SELECTs additively fetch the
11319        // canonical row's OWN `source_id` so a vector hit carries the provenance
11320        // `erase_source` consumes. These statements already read the canonical
11321        // row by `write_cursor`, so this is one extra COLUMN on an existing
11322        // lookup — NOT an extra query. (A per-hit `WHERE write_cursor = ?`
11323        // probe would be a full scan: there is no index on
11324        // `canonical_nodes(write_cursor)`. This site already pays that cost by
11325        // construction; TC-31 must not add a second one.) Read-only additive
11326        // column — row-set, ordering and scores are untouched.
11327        // fix-2 (codex §9 [P2]): the validity conjunct comes from
11328        // `ReadView::validity_sql` — the SAME generator the five read verbs use.
11329        // It is NOT hand-rolled here: Slice 10's whole design is that the
11330        // predicate exists in exactly ONE place, so no retrieval site can drift
11331        // from another. `?1` is the candidate rowid, so `:now` binds at `?2`.
11332        // On a corpus that never authored a window every row is NULL/NULL and
11333        // the conjunct matches everything ⇒ default behaviour is unchanged.
11334        let node_validity = view.validity_sql("canonical_nodes", 2);
11335        let mut node_stmt = tx.prepare(&format!(
11336            "SELECT kind, body, logical_id, source_id FROM canonical_nodes \
11337             WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'\
11338             {node_validity} LIMIT 1"
11339        ))?;
11340        // fix-2 (codex §9 [P2]): an edge body projected into `vector_default`
11341        // (kind = "edge_fact") is hydrated HERE by write_cursor. Gating on
11342        // `superseded_at` alone let an EXPIRED edge (`t_invalid <= :now`) surface
11343        // its body through the VECTOR arm — the same "validity enforced on
11344        // traversal, not on search" gap Slice 15b closed for nodes, now on the
11345        // edge-vector read path. Apply the shared `edge_validity_sql` predicate
11346        // (the ONE generator every edge read site uses) so no arm can drift.
11347        // `?1` is the rowid, so the edge `:now` binds at `?2`; the instant is the
11348        // frozen `view.edge_now()` — a bound value, never `datetime('now')`
11349        // (the :9161 no-inline-clock rule). edge_now is ALWAYS present, so unlike
11350        // node validity this conjunct is unconditional (an edge invalidated in the
11351        // past stays excluded even when node existence is relaxed).
11352        let edge_validity = edge_validity_sql("canonical_edges", 2);
11353        let mut edge_stmt = tx.prepare(&format!(
11354            "SELECT body, logical_id, source_id FROM canonical_edges \
11355             WHERE write_cursor = ?1 AND superseded_at IS NULL AND body IS NOT NULL\
11356             {edge_validity} LIMIT 1"
11357        ))?;
11358        // The bound parameter list for the node lookup: the candidate rowid,
11359        // plus `:now` when (and only when) the view emitted a validity conjunct.
11360        // One instant for the whole query — resolved once, above, not per row.
11361        let node_params = |rowid: i64| -> Vec<rusqlite::types::Value> {
11362            let mut p = vec![rusqlite::types::Value::Integer(rowid)];
11363            if let Some(now) = now_param {
11364                p.push(rusqlite::types::Value::Integer(now));
11365            }
11366            p
11367        };
11368        for (rowid, score) in rowids {
11369            // fix-3 (F1): the candidate list is now the OVERFETCHED pool in
11370            // exact-L2 order, so the caller's cutoff is applied HERE — after
11371            // the validity/existence filter, not before it. Bounded worst case:
11372            // at most `TOP_K_BIT_CANDIDATES` hydration probes when nearly every
11373            // candidate is filtered out; exactly `final_limit` (i.e. unchanged)
11374            // when nothing is. Ordering is unchanged — the surviving rows are
11375            // still emitted nearest-first — so on a corpus with no windows this
11376            // loop yields byte-identical results to the pre-fix code.
11377            if results.len() >= final_limit {
11378                break;
11379            }
11380            if let Ok((kind, body, logical_id, source_id)) =
11381                node_stmt.query_row(rusqlite::params_from_iter(node_params(rowid)), |row| {
11382                    Ok((
11383                        row.get::<_, String>(0)?,
11384                        row.get::<_, String>(1)?,
11385                        row.get::<_, Option<String>>(2)?,
11386                        row.get::<_, Option<String>>(3)?,
11387                    ))
11388                })
11389            {
11390                let id = derive_stable_id(logical_id.as_deref(), &body);
11391                results.push(SearchHit {
11392                    id,
11393                    write_cursor: rowid as u64,
11394                    kind,
11395                    body,
11396                    score,
11397                    branch: SoftFallbackBranch::Vector,
11398                    // TC-31: the NODE's own provenance (a node hit is erased by
11399                    // the document it was written from).
11400                    source_id,
11401                    ce_score: None,
11402                });
11403            } else if let Ok((body, logical_id, source_id)) =
11404                edge_stmt.query_row(rusqlite::params![rowid, view.edge_now()], |row| {
11405                    Ok((
11406                        row.get::<_, String>(0)?,
11407                        row.get::<_, Option<String>>(1)?,
11408                        row.get::<_, Option<String>>(2)?,
11409                    ))
11410                })
11411            {
11412                let id = derive_stable_id(logical_id.as_deref(), &body);
11413                results.push(SearchHit {
11414                    id,
11415                    write_cursor: rowid as u64,
11416                    kind: "edge_fact".to_string(),
11417                    body,
11418                    score,
11419                    branch: SoftFallbackBranch::TextEdge,
11420                    // TC-31: the EDGE's own provenance — consistent with the
11421                    // graph arm's existing edge-source semantics.
11422                    source_id,
11423                    ce_score: None,
11424                });
11425            }
11426        }
11427        results
11428    } else {
11429        Vec::new()
11430    };
11431    let vector_rows_visible = !vector_results.is_empty();
11432    let soft_fallback = if query_vector.is_some() && !vector_rows_visible {
11433        tx.query_row(
11434            "SELECT 1
11435             FROM search_index
11436             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = search_index.kind
11437             LEFT JOIN _fathomdb_projection_terminal
11438               ON _fathomdb_projection_terminal.write_cursor = search_index.write_cursor
11439             WHERE search_index MATCH ?1
11440              AND _fathomdb_projection_terminal.write_cursor IS NULL
11441             LIMIT 1",
11442            [compiled.match_expression.as_str()],
11443            |_row| Ok(SoftFallback { branch: SoftFallbackBranch::Vector }),
11444        )
11445        .ok()
11446    } else {
11447        None
11448    };
11449    // Collect the text branch (ranked by `write_cursor`, as 0.7.2), then
11450    // post-filter it against the same metadata the vector branch was pruned by
11451    // in SQL (the vector branch is filtered in phase 1; the text branch has no
11452    // metadata columns of its own).
11453    let text_candidates: Vec<SearchHit> = {
11454        // 0.7.0 perf-experiments: optional FTS5 LIMIT cap. Gated on
11455        // FATHOMDB_PERF_EXPERIMENTS=1; opt-in via
11456        // FATHOMDB_PERF_SEARCH_LIMIT=<k>. No-op by default — preserves
11457        // 0.6.x unbounded result-set semantics. Removed (or made the
11458        // hardcoded default) at Wave 5 landing per
11459        // dev/plans/0.7.0-perf-experiments.md.
11460        let perf_limit: Option<usize> = if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_some() {
11461            std::env::var("FATHOMDB_PERF_SEARCH_LIMIT").ok().and_then(|s| s.parse().ok())
11462        } else {
11463            None
11464        };
11465        // G1: SELECT body + kind + write_cursor (interim id) and the
11466        // `bm25()` text-relevance score. IR-C (2026-06-10,
11467        // `performance-output-and-compare.md`): the per-branch rank RRF fuses on
11468        // must be **`bm25()` relevance**, not `write_cursor` (insertion order) —
11469        // the prior `ORDER BY write_cursor` meant the lexical arm never ranked by
11470        // relevance, the single biggest fusion bug. `bm25()` is more-negative ⇒
11471        // better, so ascending puts best matches first; `write_cursor` is the
11472        // deterministic tiebreak. The filter is applied as a Rust post-filter so
11473        // the unfiltered path is untouched.
11474        let limit_clause = perf_limit.map(|k| format!(" LIMIT {k}")).unwrap_or_default();
11475        // Cause-A: PREFER a logical_id-bearing query — LEFT JOIN canonical_nodes so
11476        // node hits carry the `l:`-tagged stable id. The join is 1:1 on
11477        // `write_cursor` (search_index holds node bodies only; edge bodies live in
11478        // search_index_edges), so the row-set and the `bm25(search_index),
11479        // write_cursor` ordering are byte-unchanged — only `cn.logical_id` is added.
11480        // FALL BACK to the original (logical_id-free) query on pre-step-12 schemas
11481        // (v10) whose `canonical_nodes` lacks `logical_id`: those hits key by the
11482        // `h:` content-hash. This keeps old-schema search byte-identical to the
11483        // pre-Cause-A behaviour (the prepare of the plain SQL is the exact prior
11484        // statement). Columns are qualified because both tables expose `write_cursor`.
11485        // CORRECTNESS (0.8.11.2 pico): `AND cn.superseded_at IS NULL` drops
11486        // superseded node versions. Node supersession is tombstone-then-insert
11487        // (`commit_batch`): the prior `canonical_nodes` row is UPDATEd to set
11488        // `superseded_at` (row kept, same write_cursor) and a NEW `search_index`
11489        // row is inserted for the new cursor — the OLD `search_index` row is
11490        // never deleted, so without this filter both versions stay live in FTS
11491        // and the stale one is returned. The other arms already filter this way
11492        // (edge branch, graph-arm node seed, point-recall `read_get_by_id`);
11493        // only this default node-text branch was missing it. The `LEFT JOIN` is
11494        // KEPT (not switched to inner): an active row joins to its `cn` with
11495        // `superseded_at = NULL` (kept); a superseded row joins to its tombstoned
11496        // `cn` with `superseded_at` NOT NULL (dropped); a legacy/orphan
11497        // `search_index` row with no `cn` gets `superseded_at = NULL` via the
11498        // LEFT JOIN (KEPT — preserves prior behaviour for ownerless rows).
11499        // TC-31 (0.8.20 Slice 10a): `cn.source_id` is selected off the SAME
11500        // already-present 1:1 LEFT JOIN that supplies `cn.logical_id` — one extra
11501        // column, no extra query, no row-set or ordering change.
11502        // fix-2 (codex §9 [P2]): the node-body FTS branch takes the SAME
11503        // validity conjunct, generated by `ReadView::validity_sql` rather than
11504        // hand-rolled — the predicate lives in exactly one place (Slice 10).
11505        // `?1` is the MATCH expression, so `:now` binds at `?2`.
11506        //
11507        // The generated conjunct is NULL-PERMISSIVE by construction
11508        // (`valid_from IS NULL OR ...`), which is exactly what this LEFT JOIN
11509        // needs: an ownerless `search_index` row with no `cn` reads NULL on both
11510        // columns and is KEPT, preserving the deliberate keep-ownerless
11511        // behaviour the `superseded_at` / `state` conjuncts above encode with
11512        // their explicit `OR ... IS NULL`. No extra `OR IS NULL` is needed here,
11513        // and none may be added: that would be a second, drifting copy of the
11514        // predicate.
11515        //
11516        // NO-REGRESSION: on a corpus that never authored a window every
11517        // `cn.valid_from` / `cn.valid_until` is NULL (step 22 back-filled NULL
11518        // with no DEFAULT), so both disjuncts are TRUE for every row and the
11519        // row-set, the `bm25(search_index), write_cursor` ordering and the
11520        // scores are all byte-unchanged.
11521        let text_validity = view.validity_sql("cn", 2);
11522        let join_sql = format!(
11523            "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11524             bm25(search_index), cn.logical_id, cn.source_id FROM search_index \
11525             LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11526             WHERE search_index MATCH ?1 \
11527               AND cn.superseded_at IS NULL \
11528               AND (cn.state = 'active' OR cn.state IS NULL)\
11529               {text_validity} \
11530             ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11531        );
11532        // `:now` rides at ?2 only when the view emitted a conjunct; the relaxed
11533        // view produces the byte-identical single-parameter statement.
11534        let mut text_params: Vec<rusqlite::types::Value> =
11535            vec![rusqlite::types::Value::Text(compiled.match_expression.clone())];
11536        if let Some(now) = now_param {
11537            text_params.push(rusqlite::types::Value::Integer(now));
11538        }
11539        if let Ok(mut statement) = tx.prepare(&join_sql) {
11540            let rows =
11541                statement.query_map(rusqlite::params_from_iter(text_params.iter()), |row| {
11542                    let body = row.get::<_, String>(0)?;
11543                    let logical_id = row.get::<_, Option<String>>(4)?;
11544                    Ok(SearchHit {
11545                        id: derive_stable_id(logical_id.as_deref(), &body),
11546                        body,
11547                        kind: row.get::<_, String>(1)?,
11548                        write_cursor: row.get::<_, i64>(2)? as u64,
11549                        score: row.get::<_, f64>(3)?,
11550                        branch: SoftFallbackBranch::Text,
11551                        // TC-31: the NODE's own provenance. NULL for a legacy /
11552                        // TC-11-spared governed row, and NULL for an ownerless
11553                        // `search_index` row the LEFT JOIN keeps with no `cn`.
11554                        source_id: row.get::<_, Option<String>>(5)?,
11555                        ce_score: None,
11556                    })
11557                })?;
11558            rows.flatten().collect()
11559        } else {
11560            // No `superseded_at IS NULL` filter here (and none is possible): this
11561            // fallback fires only on pre-step-12 schemas whose `canonical_nodes`
11562            // lacks `logical_id` — and step-12 adds `logical_id` and
11563            // `superseded_at` in the SAME migration, so this schema has neither
11564            // column. Supersession (`commit_batch`) is a no-op without
11565            // `logical_id`, so no superseded node rows can exist on this path.
11566            //
11567            // TC-31 (0.8.20 Slice 10a): `source_id` arrived in step 8, `logical_id`
11568            // in step 12, so a schema that lands HERE (no `logical_id`) may still
11569            // HAVE `source_id` — steps 8..11. Try a provenance-bearing variant
11570            // first, adding only `cn.source_id` over the SAME 1:1 LEFT JOIN shape
11571            // used above (row-set and ordering unchanged; a missing `cn` row keeps
11572            // NULL as before). Fall back to the historical, byte-identical
11573            // provenance-free statement on a pre-step-8 schema, where the column
11574            // genuinely does not exist and `None` is the only truthful answer.
11575            let source_sql = format!(
11576                "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11577                 bm25(search_index), cn.source_id FROM search_index \
11578                 LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11579                 WHERE search_index MATCH ?1 \
11580                 ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11581            );
11582            if let Ok(mut statement) = tx.prepare(&source_sql) {
11583                let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
11584                    let body = row.get::<_, String>(0)?;
11585                    Ok(SearchHit {
11586                        // No logical_id column on this schema → content-hash id.
11587                        id: derive_stable_id(None, &body),
11588                        body,
11589                        kind: row.get::<_, String>(1)?,
11590                        write_cursor: row.get::<_, i64>(2)? as u64,
11591                        score: row.get::<_, f64>(3)?,
11592                        branch: SoftFallbackBranch::Text,
11593                        source_id: row.get::<_, Option<String>>(4)?,
11594                        ce_score: None,
11595                    })
11596                })?;
11597                rows.flatten().collect()
11598            } else {
11599                // Pre-step-8: no `source_id` column anywhere. Byte-identical to
11600                // the historical statement.
11601                let plain_sql = format!(
11602                    "SELECT body, kind, write_cursor, bm25(search_index) FROM search_index \
11603                     WHERE search_index MATCH ?1 \
11604                     ORDER BY bm25(search_index), write_cursor{limit_clause}"
11605                );
11606                let mut statement = tx.prepare(&plain_sql)?;
11607                let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
11608                    let body = row.get::<_, String>(0)?;
11609                    Ok(SearchHit {
11610                        // No logical_id column on this schema → content-hash id.
11611                        id: derive_stable_id(None, &body),
11612                        body,
11613                        kind: row.get::<_, String>(1)?,
11614                        write_cursor: row.get::<_, i64>(2)? as u64,
11615                        score: row.get::<_, f64>(3)?,
11616                        branch: SoftFallbackBranch::Text,
11617                        source_id: None,
11618                        ce_score: None,
11619                    })
11620                })?;
11621                rows.flatten().collect()
11622            }
11623        }
11624    };
11625    let mut text_results: Vec<SearchHit> = Vec::with_capacity(text_candidates.len());
11626    for hit in text_candidates {
11627        if text_hit_passes_filter(&tx, hit.write_cursor, &hit.kind, filter)? {
11628            text_results.push(hit);
11629        }
11630    }
11631
11632    // G11 (Slice 15) — edge-body FTS branch from `search_index_edges`.
11633    // Appended to text_results; tagged with SoftFallbackBranch::TextEdge so
11634    // callers can distinguish edge hits from node hits.
11635    //
11636    // fix-1 [P2]: JOIN canonical_edges to exclude superseded edge rows
11637    // (invalidate-not-accumulate can leave a superseded body in the FTS index).
11638    // fix-2 [P2]: use edge_fts_hit_passes_filter (NOT text_hit_passes_filter).
11639    // Edge hits always have source_type="edge_fact"; text_hit_passes_filter
11640    // calls resolve_source_type(relation_kind) which returns Err for unknown
11641    // relation kinds, silently rejecting every edge hit when a source_type
11642    // filter is set — the exact inverse of correct behaviour.
11643    // fix-3 [P2]: edge_fts_hit_passes_filter now queries vector_default for
11644    // created_after/status (mirroring text_hit_passes_filter). Collect edge
11645    // candidates into a Vec first (drops stmt borrow on tx) so we can pass
11646    // &tx to edge_fts_hit_passes_filter without a borrow conflict.
11647    let edge_candidates: Vec<SearchHit> = {
11648        // Cause-A: the JOIN to canonical_edges already exists; additively select
11649        // `ce.logical_id` (edges always carry one) for the stable hit-id. No
11650        // ordering/row-set change.
11651        // TC-31 (0.8.20 Slice 10a): `ce.source_id` rides the SAME existing inner
11652        // JOIN as `ce.logical_id` — one extra column, no extra query, no
11653        // row-set/ordering change. An edge hit carries the EDGE's own provenance,
11654        // matching the graph arm's edge-source semantics.
11655        // fix-2 (codex §9 [P2]): the JOIN already dropped superseded edge rows,
11656        // but a body-bearing edge written with `t_invalid <= :now` (expired /
11657        // invalidated) still MATCHed and surfaced its body through ordinary
11658        // search — edge temporal validity was enforced on the graph-traversal and
11659        // projection paths but NOT on this FTS read path. Apply the shared
11660        // `edge_validity_sql` conjunct (the ONE generator every edge read site
11661        // uses, so no path can drift). `?1` is the MATCH expression, so the edge
11662        // `:now` binds at `?2`; the instant is the frozen `view.edge_now()` — a
11663        // bound value, never `datetime('now')` (the :9161 no-inline-clock rule),
11664        // and always present (edge invalidation is not relaxed by node existence
11665        // relaxation).
11666        let edge_validity = edge_validity_sql("ce", 2);
11667        let edge_sql = format!(
11668            "SELECT sei.body, sei.kind, sei.write_cursor, bm25(search_index_edges), \
11669             ce.logical_id, ce.source_id \
11670             FROM search_index_edges sei \
11671             JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
11672             WHERE search_index_edges MATCH ?1 \
11673               AND ce.superseded_at IS NULL{edge_validity} \
11674             ORDER BY bm25(search_index_edges), sei.write_cursor"
11675        );
11676        // search_index_edges may not exist on very old DBs not yet at step-14;
11677        // ignore the error gracefully (returns empty slice).
11678        if let Ok(mut stmt) = tx.prepare(&edge_sql) {
11679            if let Ok(rows) = stmt.query_map(
11680                rusqlite::params![compiled.match_expression.as_str(), view.edge_now()],
11681                |row| {
11682                    let body = row.get::<_, String>(0)?;
11683                    let logical_id = row.get::<_, Option<String>>(4)?;
11684                    Ok(SearchHit {
11685                        id: derive_stable_id(logical_id.as_deref(), &body),
11686                        body,
11687                        kind: row.get::<_, String>(1)?,
11688                        write_cursor: row.get::<_, i64>(2)? as u64,
11689                        score: row.get::<_, f64>(3)?,
11690                        branch: SoftFallbackBranch::TextEdge,
11691                        // TC-31: the EDGE's own provenance.
11692                        source_id: row.get::<_, Option<String>>(5)?,
11693                        ce_score: None,
11694                    })
11695                },
11696            ) {
11697                rows.flatten().collect()
11698            } else {
11699                Vec::new()
11700            }
11701        } else {
11702            Vec::new()
11703        }
11704    };
11705    // Attribute predicates intentionally apply only to node projections. Count
11706    // edge-FTS candidates that would otherwise pass when the caller requested
11707    // the opt-in explanation, without adding work to the default search path.
11708    let mut dropped_edge_hits = 0_u32;
11709    for row in edge_candidates {
11710        if edge_fts_hit_passes_filter(&tx, row.write_cursor, &row.kind, filter)? {
11711            text_results.push(row);
11712        } else if explain
11713            && filter.is_some_and(|active_filter| !active_filter.attributes.is_empty())
11714            && edge_fts_hit_passes_non_attribute_filter(&tx, row.write_cursor, &row.kind, filter)?
11715        {
11716            dropped_edge_hits = dropped_edge_hits.saturating_add(1);
11717        }
11718    }
11719    tx.commit()?;
11720
11721    // GA-2 / Slice-40 (◆ B-1) measurement seam: when `vector_stage_only` is set
11722    // (only ever by the eu7 recall harness via `set_vector_stage_only_for_test`,
11723    // off for every production caller), return the pre-fusion VECTOR-branch
11724    // ranking (bit-KNN K=192 + f32 rerank) verbatim, skipping `fuse_rrf` /
11725    // recency / `rerank_fused`. This exposes the ANN-quantization FIDELITY
11726    // signal — vector top-N vs the exact-f32 VECTOR top-10 ground truth — that
11727    // the AC-075 0.90 floor is defined to measure. It is NOT a `fusion_mode`
11728    // knob: the production branch below is byte-unchanged and RRF stays
11729    // unconditional.
11730    // G0 Phase-2 (BLOCK-1) side-channel meter — default (all-zero, rate 0.0) on
11731    // the non-graph-arm paths; populated by the BFS seed phase when graph-arm runs.
11732    let mut graph_stats = GraphFrontierStats::default();
11733
11734    // 0.8.8 EXP-OBS (Slice 5) — capture per-arm rank maps + counts BEFORE the arms
11735    // are consumed by fusion. All reads; only when `explain` (else zero work).
11736    // `body_rank_map` keeps the FIRST occurrence (== the rank `fuse_three_arms`
11737    // uses, which dedups keeping the first). `*_fused_scores` is captured from the
11738    // post-recency / pre-CE intermediate so `fused_score` is faithful to what
11739    // `ce_rerank` normalizes.
11740    let body_rank_map = |hits: &[SearchHit]| -> HashMap<String, u32> {
11741        let mut m: HashMap<String, u32> = HashMap::new();
11742        for (i, h) in hits.iter().enumerate() {
11743            m.entry(h.body.clone()).or_insert(i as u32);
11744        }
11745        m
11746    };
11747    let body_score_map = |hits: &[SearchHit]| -> HashMap<String, f64> {
11748        hits.iter().map(|h| (h.body.clone(), h.score)).collect()
11749    };
11750
11751    let (exp_vector_ranks, exp_text_ranks, exp_vector_n, exp_text_n) = if explain {
11752        (
11753            Some(body_rank_map(&vector_results)),
11754            Some(body_rank_map(&text_results)),
11755            vector_results.len() as u32,
11756            text_results.len() as u32,
11757        )
11758    } else {
11759        (None, None, 0, 0)
11760    };
11761    let mut exp_graph_ranks: Option<HashMap<String, u32>> = None;
11762    let mut exp_fused_scores: Option<HashMap<String, f64>> = None;
11763    let mut exp_graph_n: u32 = 0;
11764    // F9 (0.8.16 Slice 5) — per-hit importance/confidence contribution maps
11765    // (keyed by hit id == write_cursor), captured for the explain sidecar.
11766    let mut exp_importance: Option<HashMap<u64, f64>> = None;
11767    let mut exp_confidence: Option<HashMap<u64, f64>> = None;
11768
11769    let results = if vector_stage_only {
11770        vector_results
11771    } else if use_graph_arm {
11772        // R3 (Slice 30) — graph arm: BFS over temporal fact-edges seeded from
11773        // the top-10 two-arm fused candidates, depth ≤ 3, cap 50.
11774        // Temporal filter: superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now).
11775        // Synthesized-node penalty: kind = 'unknown' → score *= 0.3.
11776        //
11777        // Approach: compute the two-arm fused result first (for BFS seeding),
11778        // then fuse three arms: the two-arm result (as "vector" arm), an empty
11779        // text arm, and the graph candidates. The two-arm result preserves all
11780        // existing ranking semantics; the graph arm contributes new candidates.
11781        let two_arm_fused = fuse_rrf(vector_results, text_results);
11782        // C1: seed the graph arm from the query's FTS match expression (entities /
11783        // edge-facts), not the doc-node fused hits. `fused_hits` is still passed for
11784        // the seed-body exclusion set.
11785        let (graph_candidates, stats, graph_edge_confidence) = bfs_graph_arm_candidates(
11786            reader,
11787            &two_arm_fused,
11788            compiled.match_expression.as_str(),
11789            3,
11790            50,
11791            view,
11792        )?;
11793        graph_stats = stats;
11794        if explain {
11795            exp_graph_ranks = Some(body_rank_map(&graph_candidates));
11796            exp_graph_n = graph_candidates.len() as u32;
11797        }
11798        // Named intermediate (byte-identical to the prior nested call) so explain
11799        // can read the pre-CE fused scores without perturbing the ranking.
11800        let fused = apply_recency_reweight(
11801            fuse_three_arms(two_arm_fused, vec![], graph_candidates),
11802            recency_enabled,
11803        );
11804        // F9 — importance (node) / confidence (edge) reweight, OFF by default.
11805        // Order: AFTER recency (consistent placement), BEFORE the CE rerank seam.
11806        let (imp_map, mut conf_map) = if importance_enabled || explain {
11807            build_importance_confidence_maps(reader, &fused).unwrap_or_default()
11808        } else {
11809            (HashMap::new(), HashMap::new())
11810        };
11811        // F9 FIX-1: `build_importance_confidence_maps` keys edge confidence on the
11812        // EDGE `write_cursor`, which never matches a graph-arm NODE hit's cursor —
11813        // so it alone leaves graph-arm hits with no edge confidence. Merge the
11814        // BFS-collected per-node traversing-edge confidence (node cursor ⇒ conf).
11815        // Node/edge cursors are globally unique, so there is never a key collision
11816        // with the edge-fact confidence above; `or_insert` documents that intent.
11817        if importance_enabled || explain {
11818            for (cursor, conf) in &graph_edge_confidence {
11819                conf_map.entry(*cursor).or_insert(*conf);
11820            }
11821        }
11822        let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
11823        if explain {
11824            exp_importance = Some(imp_map);
11825            exp_confidence = Some(conf_map);
11826            exp_fused_scores = Some(body_score_map(&fused));
11827        }
11828        rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
11829    } else {
11830        // G9 + G12: RRF-fuse the two ranked branches (keyed on body, vector-first
11831        // tiebreak) into the unconditional new ranking, recency-reweight (gated,
11832        // off by default), then pass through the identity rerank seam. The
11833        // vector-empty `soft_fallback` signal was computed above, BEFORE this
11834        // branch-collapse.
11835        let fused = apply_recency_reweight(fuse_rrf(vector_results, text_results), recency_enabled);
11836        // F9 — importance (node) / confidence (edge) reweight, OFF by default.
11837        // Same placement as the graph-arm branch: after recency, before CE rerank.
11838        let (imp_map, conf_map) = if importance_enabled || explain {
11839            build_importance_confidence_maps(reader, &fused).unwrap_or_default()
11840        } else {
11841            (HashMap::new(), HashMap::new())
11842        };
11843        let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
11844        if explain {
11845            exp_importance = Some(imp_map);
11846            exp_confidence = Some(conf_map);
11847            exp_fused_scores = Some(body_score_map(&fused));
11848        }
11849        rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
11850    };
11851
11852    // 0.8.8 EXP-OBS — assemble the sidecar `Explanation` from the captured maps +
11853    // the final `results`. `embedder_id` is left empty here (the worker has no
11854    // identity) and filled by `search_inner_with_stats`.
11855    let explanation = if explain {
11856        let fused_scores = exp_fused_scores.unwrap_or_default();
11857        let per_hit: Vec<PerHitExplain> = results
11858            .iter()
11859            .map(|h| PerHitExplain {
11860                // `PerHitExplain.id` carries the engine-internal positional
11861                // `write_cursor` (the pre-C-2 `SearchHit.id`), matching the
11862                // telemetry `result_ids` / importance-map key space; the typed
11863                // `SearchHit.id` is the separate caller-facing identity.
11864                id: h.write_cursor,
11865                arm: h.branch,
11866                vector_rank: exp_vector_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
11867                text_rank: exp_text_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
11868                graph_rank: exp_graph_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
11869                fused_score: fused_scores.get(&h.body).copied().unwrap_or(h.score),
11870                ce_score: h.ce_score,
11871                blended: h.score,
11872                importance: exp_importance.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
11873                confidence: exp_confidence.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
11874            })
11875            .collect();
11876        let ce_active = rerank_depth > 0 && per_hit.iter().any(|p| p.ce_score.is_some());
11877        Some(Explanation {
11878            trace: QueryTrace {
11879                query_chars: raw_query.chars().count() as u32,
11880                k: final_limit as u32,
11881                rerank_depth: rerank_depth as u32,
11882                pool_n: pool_n as u32,
11883                alpha,
11884                use_graph_arm,
11885                recency: recency_enabled,
11886                embedder_id: String::new(),
11887                ce_active,
11888                vector_hits: exp_vector_n,
11889                text_hits: exp_text_n,
11890                graph_hits: exp_graph_n,
11891                dropped_edge_hits,
11892            },
11893            per_hit,
11894        })
11895    } else {
11896        None
11897    };
11898
11899    Ok((cursor, soft_fallback, results, graph_stats, explanation))
11900}
11901
11902/// R3 (Slice 30) + C1 (0.8.1 graph-arm seeding) — graph-arm BFS candidate generation.
11903///
11904/// **C1 seeding (the BLOCK-1 fix):** the frontier is seeded from the graph's OWN
11905/// query-matched text surfaces — NOT from doc-node hits (doc nodes carry
11906/// `logical_id = NULL`, so the old doc-seeding produced an empty frontier). Two
11907/// seed sources are unioned on `match_expression` (the compiled FTS query):
11908///   A. **edge-fact FTS** (`search_index_edges`) — both endpoints (`from_id`,
11909///      `to_id`) of matched, temporally-live, non-fallback edges;
11910///   B. **entity-node FTS** (`search_index` ⋈ `canonical_nodes`) — matched nodes
11911///      with `logical_id IS NOT NULL` (excludes doc nodes — the bug surface).
11912/// Each distinct candidate `logical_id` is counted in `seeds_considered`; those
11913/// confirmed active in `canonical_nodes` are `seeds_resolved` and pushed onto the
11914/// frontier (dangling edge endpoints count considered-but-unresolved).
11915///
11916/// Phase 2 is unchanged: BFS over `canonical_edges` with the temporal filter,
11917/// carrying each traversed edge's `source_id` (G0 BLOCK-2) onto the emitted hit.
11918/// Collects reachable node bodies (up to `cap`) as [`SearchHit`]s tagged
11919/// `SoftFallbackBranch::GraphArm`. Score = `1.0 / (1.0 + hop_count)` with a
11920/// synthesized-node penalty (`kind = 'unknown'` → score *= 0.3). Bodies already
11921/// present in `fused_hits` are excluded (already covered by the two-arm result).
11922///
11923/// **F9 (0.8.16 Slice 5) confidence carry:** the third tuple element maps each
11924/// emitted graph-arm hit's `write_cursor` (its `SearchHit.id`, a NODE cursor) to
11925/// the `confidence` of the EDGE traversed to reach that node — the input the F9
11926/// reweight (`graph_rrf_score(edge) = confidence × 1/(K+bfs_rank)`) consumes.
11927/// `build_importance_confidence_maps` keys edge confidence on the EDGE
11928/// `write_cursor`, which never equals a reached node's cursor, so without this
11929/// carry edge confidence never reaches a graph-arm hit. **Determinism rule (matches
11930/// the BLOCK-2 provenance carry):** when several edges reach the same node, the
11931/// FIRST edge to claim the node in the `visited` dedup wins — i.e. the edge that
11932/// produced the node's winning `bfs_rank` (seeds are considered before Phase-2
11933/// neighbors; within a phase, `ORDER BY write_cursor` makes the earliest-written
11934/// edge win). A NULL edge confidence is simply not inserted ⇒ neutral (1.0).
11935fn bfs_graph_arm_candidates(
11936    reader: &mut Connection,
11937    fused_hits: &[SearchHit],
11938    match_expression: &str,
11939    max_depth: u32,
11940    cap: usize,
11941    view: FrozenView,
11942) -> rusqlite::Result<(Vec<SearchHit>, GraphFrontierStats, HashMap<u64, f64>)> {
11943    // fix-2 (codex §9 [P2]): the opt-in graph arm hydrates NODES too, so it takes
11944    // the same validity conjunct as the vector and FTS branches — otherwise
11945    // `search_reranked(.., use_graph_arm = true)` would keep the exact leak the
11946    // other two branches just closed. Same generator, same bound `:now`.
11947    //
11948    // fix-3 (F2): the instant arrives ALREADY RESOLVED in the `FrozenView` — it
11949    // is the identical value the vector and FTS arms bound. This arm cannot
11950    // re-read the clock: a `FrozenView` carries no route to one.
11951    let now_param = view.now_param();
11952    // C1 — seed-FTS fan-out cap per source (A: edge endpoints, B: entity nodes).
11953    const SEED_FTS_N: usize = 10;
11954    const SYNTHESIZED_PENALTY: f64 = 0.3;
11955
11956    // Bodies already in the fused result — exclude these from graph arm output.
11957    let seed_bodies: std::collections::HashSet<&str> =
11958        fused_hits.iter().map(|h| h.body.as_str()).collect();
11959
11960    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11961
11962    let mut frontier: VecDeque<(String, u32)> = VecDeque::new(); // (logical_id, depth)
11963    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
11964    let mut candidates: Vec<SearchHit> = Vec::new();
11965    // F9 (0.8.16 Slice 5) — per-hit traversing-edge confidence, keyed by the
11966    // emitted hit's NODE `write_cursor`. First edge to reach a node wins (visited
11967    // dedup); NULL confidence is never inserted (⇒ neutral in the reweight).
11968    let mut edge_confidence_by_cursor: HashMap<u64, f64> = HashMap::new();
11969    // G0 Phase-2 (BLOCK-1) frontier meter — distinct seed candidates considered vs
11970    // resolved-active; `resolved_seed_rate` flips 0→>0 once entities/edge-facts seed.
11971    let mut stats = GraphFrontierStats::default();
11972    {
11973        // C1 seeding — gather distinct candidate (logical_id, provenance source_id)
11974        // pairs from the graph's OWN query-matched FTS surfaces (NOT doc-node hits).
11975        // Order-preserving dedup (first provenance wins) so `seeds_considered` counts
11976        // each candidate once. `source_id` is the session the seed traces back to: the
11977        // matched edge's `source_id` (source A) or the entity node's own (source B).
11978        // F9: each seed carries the confidence of the edge that surfaced it
11979        // (`None` for entity-FTS seeds, which have no traversing edge).
11980        let mut candidate_seeds: Vec<(String, Option<String>, Option<f64>)> = Vec::new();
11981        let mut seen_candidates: std::collections::HashSet<String> =
11982            std::collections::HashSet::new();
11983        let push_candidate =
11984            |lid: String,
11985             source_id: Option<String>,
11986             confidence: Option<f64>,
11987             seen: &mut std::collections::HashSet<String>,
11988             out: &mut Vec<(String, Option<String>, Option<f64>)>| {
11989                if seen.insert(lid.clone()) {
11990                    out.push((lid, source_id, confidence));
11991                }
11992            };
11993
11994        // Seed source A — edge-fact endpoints (primary). Both endpoints of each
11995        // matched, temporally-live, non-fallback edge are candidate seeds, tagged with
11996        // the edge's `source_id` provenance and (F9) `confidence`. `search_index_edges`
11997        // may be absent on very old DBs (< step-14) — degrade to no edge seeds rather
11998        // than error.
11999        // TC-33: `?1` MATCH, `?2` LIMIT ⇒ the edge `:now` binds at `?3`.
12000        if let Ok(mut edge_seed_stmt) = tx.prepare(&format!(
12001            "SELECT ce.from_id, ce.to_id, ce.source_id, ce.confidence \
12002             FROM search_index_edges sei \
12003             JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
12004             WHERE search_index_edges MATCH ?1 \
12005               AND ce.superseded_at IS NULL{} \
12006               AND (ce.temporal_fallback IS NULL OR ce.temporal_fallback = 0) \
12007             ORDER BY bm25(search_index_edges), sei.write_cursor \
12008             LIMIT ?2",
12009            edge_validity_sql("ce", 3)
12010        )) {
12011            let rows = edge_seed_stmt.query_map(
12012                rusqlite::params![match_expression, SEED_FTS_N as i64, view.edge_now()],
12013                |row| {
12014                    Ok((
12015                        row.get::<_, String>(0)?,
12016                        row.get::<_, String>(1)?,
12017                        row.get::<_, Option<String>>(2)?,
12018                        row.get::<_, Option<f64>>(3)?,
12019                    ))
12020                },
12021            )?;
12022            for quad in rows {
12023                let (from_id, to_id, source_id, confidence) = quad?;
12024                push_candidate(
12025                    from_id,
12026                    source_id.clone(),
12027                    confidence,
12028                    &mut seen_candidates,
12029                    &mut candidate_seeds,
12030                );
12031                push_candidate(
12032                    to_id,
12033                    source_id,
12034                    confidence,
12035                    &mut seen_candidates,
12036                    &mut candidate_seeds,
12037                );
12038            }
12039        }
12040
12041        // Seed source B — entity-node FTS (isolated / strongly-named entities).
12042        // `logical_id IS NOT NULL` structurally excludes doc nodes (the bug surface).
12043        // Provenance = the node's own `source_id` (the session it was extracted from).
12044        {
12045            // `?1` MATCH, `?2` LIMIT ⇒ `:now` binds at `?3`.
12046            let seed_validity = view.validity_sql("cn", 3);
12047            let mut node_seed_stmt = tx.prepare(&format!(
12048                "SELECT cn.logical_id, cn.source_id \
12049                 FROM search_index si \
12050                 JOIN canonical_nodes cn ON cn.write_cursor = si.write_cursor \
12051                 WHERE search_index MATCH ?1 \
12052                   AND cn.superseded_at IS NULL \
12053                   AND cn.state = 'active' \
12054                   AND cn.logical_id IS NOT NULL\
12055                   {seed_validity} \
12056                 ORDER BY bm25(search_index), si.write_cursor \
12057                 LIMIT ?2"
12058            ))?;
12059            let mut seed_params: Vec<rusqlite::types::Value> = vec![
12060                rusqlite::types::Value::Text(match_expression.to_string()),
12061                rusqlite::types::Value::Integer(SEED_FTS_N as i64),
12062            ];
12063            if let Some(now) = now_param {
12064                seed_params.push(rusqlite::types::Value::Integer(now));
12065            }
12066            let rows = node_seed_stmt
12067                .query_map(rusqlite::params_from_iter(seed_params.iter()), |row| {
12068                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12069                })?;
12070            for pair in rows {
12071                let (lid, source_id) = pair?;
12072                // Entity-FTS seed: no traversing edge ⇒ no edge confidence (neutral).
12073                push_candidate(lid, source_id, None, &mut seen_candidates, &mut candidate_seeds);
12074            }
12075        }
12076
12077        // Resolve + emit: a seed is `resolved` only if an ACTIVE canonical_node carries
12078        // that logical_id (dangling edge endpoints count considered-not-resolved). A
12079        // resolved seed is BOTH a BFS root AND emitted as a graph-arm candidate (depth
12080        // 0, hop_score 1.0) — so an edge-only query match surfaces the connected ENTITY
12081        // nodes, not just the fact body (codex §9 [P2]). Seeds whose body is already in
12082        // the two-arm result are skipped; the cap is respected.
12083        let active_validity = view.validity_sql("canonical_nodes", 2);
12084        let mut active_stmt = tx.prepare(&format!(
12085            "SELECT kind, body, write_cursor FROM canonical_nodes \
12086             WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
12087             {active_validity} LIMIT 1"
12088        ))?;
12089        for (lid, source_id, seed_confidence) in candidate_seeds {
12090            stats.seeds_considered += 1;
12091            let mut active_params: Vec<rusqlite::types::Value> =
12092                vec![rusqlite::types::Value::Text(lid.clone())];
12093            if let Some(now) = now_param {
12094                active_params.push(rusqlite::types::Value::Integer(now));
12095            }
12096            let row: Option<(String, String, i64)> = active_stmt
12097                .query_row(rusqlite::params_from_iter(active_params.iter()), |r| {
12098                    Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
12099                })
12100                .optional()?;
12101            if let Some((kind, body, write_cursor)) = row {
12102                stats.seeds_resolved += 1;
12103                if visited.insert(lid.clone()) {
12104                    // Cause-A: the seed's `logical_id` is in hand (`lid`) — derive the
12105                    // stable id before `lid` is moved onto the frontier (zero extra query).
12106                    let id = derive_stable_id(Some(&lid), &body);
12107                    frontier.push_back((lid, 0));
12108                    if !seed_bodies.contains(body.as_str()) && candidates.len() < cap {
12109                        // depth-0 hop_score = 1.0/(1.0+0) = 1.0; synthesized penalty for
12110                        // 'unknown' kind (mirrors the Phase-2 neighbor scoring).
12111                        let score = if kind == "unknown" { SYNTHESIZED_PENALTY } else { 1.0 };
12112                        // F9: an edge-seeded endpoint carries its seeding edge's
12113                        // confidence (source A); entity-FTS seeds carry None.
12114                        if let Some(c) = seed_confidence {
12115                            edge_confidence_by_cursor.insert(write_cursor as u64, c);
12116                        }
12117                        candidates.push(SearchHit {
12118                            id,
12119                            write_cursor: write_cursor as u64,
12120                            kind,
12121                            body,
12122                            score,
12123                            branch: SoftFallbackBranch::GraphArm,
12124                            source_id,
12125                            ce_score: None,
12126                        });
12127                    }
12128                }
12129            }
12130        }
12131    }
12132    stats.frontier_nonempty = !frontier.is_empty();
12133
12134    // Phase 2: BFS over canonical_edges (temporal filter). `candidates` already
12135    // holds the depth-0 emitted seeds; BFS appends the reachable neighbors.
12136    // Both statements are prepared ONCE outside the loops — re-preparing inside
12137    // would issue O(frontier_size × neighbors) sqlite3_prepare_v2 calls.
12138    let mut edge_stmt = tx.prepare(
12139        // G0 Phase-2 (BLOCK-2): carry the traversed edge's `source_id` so a
12140        // graph-reached neighbor can resolve back to the session it was extracted
12141        // from. `ORDER BY e.write_cursor` makes the traversal deterministic: when
12142        // several active edges connect this node to the SAME neighbor with
12143        // different `source_id`s, the earliest-written edge wins the `visited`
12144        // dedup, so the carried provenance is stable (not SQLite-order-dependent).
12145        // (codex §9 [P2]; the design §B already rejected the memo's arbitrary
12146        // `LIMIT 1` lookup for the same reason.)
12147        // F9: also carry the traversed edge's `confidence` — the reweight input for
12148        // the reached node (keyed downstream by the node's `write_cursor`). Same
12149        // determinism as `source_id`: the earliest-written edge wins the `visited`
12150        // dedup, so the reached node's confidence is the winning-`bfs_rank` edge's.
12151        // TC-33: `?1` is the anchor logical_id ⇒ the edge `:now` binds at `?2`.
12152        &format!(
12153            "SELECT e.from_id, e.to_id, e.source_id, e.confidence \
12154             FROM canonical_edges e \
12155             WHERE (e.from_id = ?1 OR e.to_id = ?1) \
12156               AND e.superseded_at IS NULL{} \
12157               AND (e.temporal_fallback IS NULL OR e.temporal_fallback = 0) \
12158             ORDER BY e.write_cursor \
12159             LIMIT 64",
12160            edge_validity_sql("e", 2)
12161        ),
12162    )?;
12163    // Fetch write_cursor alongside kind+body so graph-arm hits carry a real id
12164    // for apply_recency_reweight (id=0 would force min_id=0 and distort span).
12165    let body_validity = view.validity_sql("canonical_nodes", 2);
12166    let mut body_stmt = tx.prepare(&format!(
12167        "SELECT kind, body, write_cursor FROM canonical_nodes \
12168         WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
12169         {body_validity} \
12170         LIMIT 1"
12171    ))?;
12172
12173    while let Some((lid, depth)) = frontier.pop_front() {
12174        if candidates.len() >= cap {
12175            break;
12176        }
12177        if depth >= max_depth {
12178            continue;
12179        }
12180
12181        // Fetch temporal-live neighbors via edges, each paired with the
12182        // traversing edge's `source_id` (BLOCK-2 provenance carry) and (F9)
12183        // `confidence` (the reweight input for the reached node).
12184        let neighbors: Vec<(String, Option<String>, Option<f64>)> = {
12185            let rows = edge_stmt.query_map(params![&lid, view.edge_now()], |row| {
12186                Ok((
12187                    row.get::<_, String>(0)?,
12188                    row.get::<_, String>(1)?,
12189                    row.get::<_, Option<String>>(2)?,
12190                    row.get::<_, Option<f64>>(3)?,
12191                ))
12192            })?;
12193            rows.flatten()
12194                .map(|(from_id, to_id, source_id, confidence)| {
12195                    let neighbor = if from_id == lid { to_id } else { from_id };
12196                    (neighbor, source_id, confidence)
12197                })
12198                .collect()
12199        };
12200
12201        for (neighbor, edge_source_id, edge_confidence) in neighbors {
12202            if visited.contains(&neighbor) {
12203                continue;
12204            }
12205            visited.insert(neighbor.clone());
12206
12207            // Fetch neighbor body + write_cursor from canonical_nodes.
12208            let mut body_params: Vec<rusqlite::types::Value> =
12209                vec![rusqlite::types::Value::Text(neighbor.clone())];
12210            if let Some(now) = now_param {
12211                body_params.push(rusqlite::types::Value::Integer(now));
12212            }
12213            let row: Option<(String, String, i64)> = body_stmt
12214                .query_row(rusqlite::params_from_iter(body_params.iter()), |row| {
12215                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
12216                })
12217                .optional()?;
12218
12219            if let Some((kind, body, write_cursor)) = row {
12220                // Skip bodies already covered by the two-arm result.
12221                if !seed_bodies.contains(body.as_str()) {
12222                    let hop_score = 1.0 / (1.0 + (depth + 1) as f64);
12223                    let score =
12224                        if kind == "unknown" { hop_score * SYNTHESIZED_PENALTY } else { hop_score };
12225                    // Cause-A: the neighbor's `logical_id` is `neighbor` (still in
12226                    // scope here; only moved onto the frontier below) — derive the
12227                    // stable id with no extra query.
12228                    let id = derive_stable_id(Some(&neighbor), &body);
12229                    // F9: record the traversing edge's confidence for this node
12230                    // (first edge wins — this is the winning-`bfs_rank` edge).
12231                    if let Some(c) = edge_confidence {
12232                        edge_confidence_by_cursor.insert(write_cursor as u64, c);
12233                    }
12234                    candidates.push(SearchHit {
12235                        id,
12236                        write_cursor: write_cursor as u64,
12237                        kind,
12238                        body,
12239                        score,
12240                        branch: SoftFallbackBranch::GraphArm,
12241                        // BLOCK-2: the session this fact-edge was extracted from.
12242                        source_id: edge_source_id.clone(),
12243                        ce_score: None,
12244                    });
12245                    if candidates.len() >= cap {
12246                        break;
12247                    }
12248                }
12249                // Always push neighbor to frontier for further BFS expansion.
12250                frontier.push_back((neighbor, depth + 1));
12251            }
12252        }
12253    }
12254
12255    drop(edge_stmt);
12256    drop(body_stmt);
12257    tx.commit()?;
12258    stats.graph_candidates_emitted = candidates.len() as u32;
12259    Ok((candidates, stats, edge_confidence_by_cursor))
12260}
12261
12262/// Slice 30 (G3) — the ~1M cap on a single op-store read-back page. The public
12263/// `read.collection` / `read.mutations` LIMIT is `min(caller_limit, this)`, so
12264/// no API path can issue an unbounded SELECT. Cursor/limit hardening under a
12265/// genuine ~1M-row append-only log is reserved-gap Slice 32.
12266const READ_COLLECTION_MAX_LIMIT: usize = 1_000_000;
12267
12268/// Slice 30 (G2) — active-only point lookup by `logical_id` on the DEFERRED
12269/// reader tx (mirrors `read_search_in_tx`'s snapshot-stable BEGIN DEFERRED). One
12270/// returned slot per requested id, in REQUEST ORDER; `None` where no ACTIVE row
12271/// (`superseded_at IS NULL`) carries that id. Mirrors the `:4170` canonical
12272/// projection columns + `logical_id`; superseded versions are never returned.
12273fn read_get_by_id_in_tx(
12274    reader: &mut Connection,
12275    logical_ids: &[String],
12276    view: &ReadView,
12277) -> rusqlite::Result<Vec<Option<NodeRecord>>> {
12278    if logical_ids.is_empty() {
12279        return Ok(Vec::new());
12280    }
12281    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12282    // De-duplicate the requested ids for the IN(...) probe, then re-expand into
12283    // request order (a repeated id echoes the same active row).
12284    let mut found: HashMap<String, NodeRecord> = HashMap::new();
12285    {
12286        let unique: Vec<&String> = {
12287            let mut seen = std::collections::HashSet::new();
12288            logical_ids.iter().filter(|id| seen.insert((*id).clone())).collect()
12289        };
12290        let placeholders = std::iter::repeat_n("?", unique.len()).collect::<Vec<_>>().join(", ");
12291        // The `?` placeholders above auto-number 1..=unique.len(), so the
12292        // validity instant takes the next positional slot.
12293        let now_idx = unique.len() + 1;
12294        let node_sql = view.node_sql("canonical_nodes", now_idx);
12295        // R-20-RV: with `include_superseded` a logical_id can match several
12296        // rows. `ORDER BY write_cursor` + last-write-wins into `found` resolves
12297        // the slot DETERMINISTICALLY to the most recent version, rather than
12298        // leaving it at the mercy of scan order.
12299        let sql = format!(
12300            "SELECT logical_id, kind, body, write_cursor
12301             FROM canonical_nodes
12302             WHERE logical_id IN ({placeholders}){node_sql}
12303             ORDER BY write_cursor"
12304        );
12305        let mut statement = tx.prepare(&sql)?;
12306        let mut binds: Vec<rusqlite::types::Value> =
12307            unique.iter().map(|s| rusqlite::types::Value::Text((*s).clone())).collect();
12308        if let Some(now) = view.now_param() {
12309            binds.push(rusqlite::types::Value::Integer(now));
12310        }
12311        let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
12312            let logical_id: String = row.get(0)?;
12313            Ok(NodeRecord {
12314                logical_id,
12315                kind: row.get(1)?,
12316                body: row.get(2)?,
12317                write_cursor: row.get::<_, i64>(3)? as u64,
12318            })
12319        })?;
12320        for row in rows {
12321            let record = row?;
12322            found.insert(record.logical_id.clone(), record);
12323        }
12324    }
12325    // tx is read-only; dropping it rolls back the (empty) transaction.
12326    let out = logical_ids.iter().map(|id| found.get(id).cloned()).collect();
12327    Ok(out)
12328}
12329
12330/// Slice 30 (G3) — paginated op-store read-back over `operational_mutations` for
12331/// one `collection`, `ORDER BY id`, on the DEFERRED reader tx. The effective SQL
12332/// LIMIT is `min(limit, READ_COLLECTION_MAX_LIMIT)`; a caller `limit == 0`
12333/// returns an empty `Vec` without a SELECT. The after-id cursor (`id > ?`,
12334/// default 0) excludes the boundary row. The `_for_test` SELECTs
12335/// (`lib.rs` op-store probes) are a shape oracle only — this is a new statement.
12336///
12337/// Slice 33 (G3 / F4-READ) — hardened under a genuine large multi-collection log:
12338/// the SELECT rides the step-13 `operational_mutations(collection_name, id)`
12339/// index (`SEARCH … USING INDEX …(collection_name=? AND id>?)`), so the per-page
12340/// cost is O(page) — the leading `collection_name` equality fixes the prefix and
12341/// the trailing `id` serves both the cursor range and `ORDER BY id` with no temp
12342/// B-tree. The cursor is normalized with `.max(0)` so a negative `after_id` is
12343/// explicitly clamped to the start of the log (ids are ≥ 1) and is never confused
12344/// with a row id; `after_id` past the end and unknown collections yield empty
12345/// pages.
12346fn read_collection_in_tx(
12347    reader: &mut Connection,
12348    collection: &str,
12349    after_id: Option<i64>,
12350    limit: usize,
12351) -> rusqlite::Result<Vec<OpStoreRow>> {
12352    if limit == 0 {
12353        return Ok(Vec::new());
12354    }
12355    let clamped = limit.min(READ_COLLECTION_MAX_LIMIT) as i64;
12356    // Normalize the cursor: a negative after_id is clamped to the start of the
12357    // log. `operational_mutations.id` is autoincrement (≥ 1), so `id > 0` is the
12358    // full log; clamping removes the "is a negative cursor a sentinel or a row
12359    // id?" ambiguity without changing happy-path semantics.
12360    let after = after_id.unwrap_or(0).max(0);
12361    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12362    let mut statement = tx.prepare(
12363        "SELECT id, collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
12364         FROM operational_mutations
12365         WHERE collection_name = ?1 AND id > ?2
12366         ORDER BY id
12367         LIMIT ?3",
12368    )?;
12369    let rows = statement.query_map(params![collection, after, clamped], |row| {
12370        Ok(OpStoreRow {
12371            id: row.get(0)?,
12372            collection: row.get(1)?,
12373            record_key: row.get(2)?,
12374            op_kind: row.get(3)?,
12375            payload: row.get(4)?,
12376            schema_id: row.get(5)?,
12377            write_cursor: row.get::<_, i64>(6)? as u64,
12378        })
12379    })?;
12380    let mut out = Vec::new();
12381    for row in rows {
12382        out.push(row?);
12383    }
12384    Ok(out)
12385}
12386
12387/// Slice 35 (G4) — execute `read.list` inside a DEFERRED reader transaction.
12388///
12389/// Builds parameterized SQL: `kind = ?1 AND superseded_at IS NULL [AND
12390/// json_extract(body, '$.field') <op> ?N ...]` — injection-safe because:
12391///   (a) `kind` is `?1` (bound parameter);
12392///   (b) each predicate value is a bound `?N` parameter;
12393///   (c) the json_extract path is the ALLOWLIST ENTRY (a server-side constant
12394///       validated at `Predicate` construction time), never the raw caller string;
12395///   (d) `ComparisonOp` compiles to a server-side literal operator string from a
12396///       closed enum, not a caller-supplied string.
12397fn read_list_in_tx(
12398    reader: &mut Connection,
12399    kind: &str,
12400    predicates: &[Predicate],
12401    limit: usize,
12402    view: &ReadView,
12403) -> rusqlite::Result<Vec<NodeRecord>> {
12404    if limit == 0 {
12405        return Ok(Vec::new());
12406    }
12407    // Build the SQL WHERE clauses for each predicate.
12408    // Parameters: ?1 = kind; ?2..?N = predicate values; limit is inlined.
12409    // `logical_id IS NOT NULL` is a SQL-level predicate so that LIMIT counts
12410    // only rows that can be represented as NodeRecord (which requires a non-null
12411    // String logical_id). Anonymous nodes (PreparedWrite::Node { logical_id: None })
12412    // cannot be included in NodeRecord results and are excluded before LIMIT.
12413    // When predicates are present we add `json_valid(body)` so rows with
12414    // non-JSON bodies are skipped rather than causing a `malformed JSON` error.
12415    let json_valid_guard = if predicates.is_empty() { "" } else { " AND json_valid(body)" };
12416    // R-20-RV/R-20-NV: the view's predicates replace the previously hard-coded
12417    // existence pair. The validity instant takes the positional slot AFTER the
12418    // predicate binds (?1 = kind, ?2..=?(1+n) = predicate values), so it is
12419    // `?{predicates.len() + 2}`. Positional `?N` is order-independent in SQLite,
12420    // so emitting it here — textually before the predicate clauses appended
12421    // below — is safe and unambiguous.
12422    let now_idx = predicates.len() + 2;
12423    let node_sql = view.node_sql("canonical_nodes", now_idx);
12424    let mut sql = format!(
12425        "SELECT logical_id, kind, body, write_cursor \
12426         FROM canonical_nodes \
12427         WHERE kind = ?1{node_sql} \
12428         AND logical_id IS NOT NULL{json_valid_guard}"
12429    );
12430
12431    // Predicate params start at ?2.
12432    for (i, pred) in predicates.iter().enumerate() {
12433        let param_idx = i + 2; // ?1 is kind
12434        sql.push_str(" AND ");
12435        sql.push_str(&pred.to_sql_clause(param_idx));
12436    }
12437    sql.push_str(&format!(" LIMIT {limit}"));
12438
12439    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12440    let mut statement = tx.prepare(&sql)?;
12441
12442    // Bind all parameters: [kind, predicate_values...]
12443    let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(2 + predicates.len());
12444    params.push(rusqlite::types::Value::Text(kind.to_string()));
12445    for pred in predicates {
12446        params.push(pred.bind_value());
12447    }
12448    // Lands at index `now_idx` (= predicates.len() + 2), matching `?{now_idx}`
12449    // emitted by `ReadView::validity_sql`. Omitted entirely when the view
12450    // relaxes validity, in which case no `?{now_idx}` was emitted either.
12451    if let Some(now) = view.now_param() {
12452        params.push(rusqlite::types::Value::Integer(now));
12453    }
12454
12455    let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
12456        Ok(NodeRecord {
12457            logical_id: row.get(0)?,
12458            kind: row.get(1)?,
12459            body: row.get(2)?,
12460            write_cursor: row.get::<_, i64>(3)? as u64,
12461        })
12462    })?;
12463
12464    let mut out = Vec::new();
12465    for row in rows {
12466        out.push(row?);
12467    }
12468    Ok(out)
12469}
12470
12471// ---------------------------------------------------------------------------
12472// Slice 20 (G5/G6) — BFS graph-traversal helpers
12473// ---------------------------------------------------------------------------
12474
12475/// Hard cap on the number of nodes returned by a single `graph_neighbors` call.
12476/// Ported from v0.5.6 `MAX_TRAVERSAL_DEPTH` (applied as a LIMIT on the CTE and
12477/// the final SELECT). Defense-in-depth against unbounded traversal.
12478const GRAPH_NEIGHBORS_HARD_CAP: usize = 50;
12479
12480/// Build the BFS CTE SQL for the given `direction`, under `view`.
12481///
12482/// Parameters (positional):
12483///   `?1` — root `logical_id`
12484///   `?2` — max_depth (`u32`, SDK-facing depth ceiling ≤ 3)
12485///   `?3` — R-20-NV node-validity instant (`:now` seam), emitted at EVERY node
12486///          position; omitted entirely when the view relaxes validity.
12487///
12488/// `LIMIT {GRAPH_NEIGHBORS_HARD_CAP}` appears on both the CTE and the final SELECT.
12489///
12490/// # Why one template instead of three
12491///
12492/// The three directions previously carried three hand-maintained copies of the
12493/// CTE, each repeating the node predicate at THREE positions (anchor, recursive
12494/// join, final projection) — nine hand-written copies in total. R-20-RV requires
12495/// a relax flag to apply at every one of them, and nine copies is exactly the
12496/// shape in which "it works on `Outgoing` but silently not on `Both`" hides. The
12497/// directions are folded into ONE template parameterised by the two things that
12498/// actually differ (the edge join condition and the traversed-to expression), so
12499/// `view.node_sql(...)` is written once per position and applying to all three
12500/// directions is structural rather than a thing to remember.
12501///
12502/// **TC-33: the `canonical_edges` temporal filter is now parameterised too.** It
12503/// was `datetime(e.t_invalid) > datetime('now')` inline, deliberately left alone
12504/// while edge validity was ISO-8601 TEXT. Edge timestamps are INTEGER epoch
12505/// seconds now, so the predicate is generated by [`edge_validity_sql`] and binds
12506/// the frozen instant at `?4` — no inline clock remains in this template.
12507fn build_bfs_sql(direction: TraversalDirection, view: &ReadView) -> String {
12508    let cap = GRAPH_NEIGHBORS_HARD_CAP;
12509    // cte_cap: the SQLite CTE LIMIT counts path-rows, not distinct nodes. In a
12510    // multigraph (multiple parallel edges between the same pair of nodes), the CTE
12511    // can contain duplicate-target rows before the final SELECT DISTINCT. A cap of
12512    // cap+1 would be exhausted by ~50 parallel edges to the same node, preventing
12513    // other neighbors from being discovered. Use cap*cap as a generous safety
12514    // ceiling that still bounds CTE growth for any realistic graph while allowing
12515    // the final SELECT LIMIT cap to be the authoritative distinct-node cap.
12516    let cte_cap = cap * cap;
12517    // Cycle guard uses char(30) (ASCII Record Separator, 0x1E) as delimiter instead
12518    // of comma, so logical_ids containing commas are handled correctly. char(30) is
12519    // a non-printable control character that callers cannot place in logical_id values
12520    // via normal text input.
12521    //
12522    // `?3` is the node-validity instant. Positional (not named), so the repeated
12523    // occurrences across the three node positions all bind the SAME value once.
12524    const NOW_IDX: usize = 3;
12525    // TC-33: `?4` is the EDGE-validity instant, bound separately because the node
12526    // instant is `Option` (relaxed by `include_out_of_window`) while edge recency
12527    // is always applied.
12528    const EDGE_NOW_IDX: usize = 4;
12529
12530    // The ONLY two things that differ between directions.
12531    let (edge_join, target_expr) = match direction {
12532        TraversalDirection::Outgoing => ("e.from_id = t.logical_id", "e.to_id"),
12533        TraversalDirection::Incoming => ("e.to_id = t.logical_id", "e.from_id"),
12534        TraversalDirection::Both => (
12535            "(e.from_id = t.logical_id OR e.to_id = t.logical_id)",
12536            "CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END",
12537        ),
12538    };
12539
12540    // Position 1 (anchor), position 2 (recursive join), position 3 (final
12541    // projection) — the view is applied at all three, for every direction.
12542    let anchor_node = view.node_sql("n", NOW_IDX);
12543    let next_node = view.node_sql("next_n", NOW_IDX);
12544    let projection_node = view.node_sql("n", NOW_IDX);
12545    let edge_valid = edge_validity_sql("e", EDGE_NOW_IDX);
12546
12547    format!(
12548        "WITH RECURSIVE
12549  traversal(logical_id, depth, visited) AS (
12550    SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12551    FROM canonical_nodes n
12552    WHERE n.logical_id = ?1{anchor_node}
12553    UNION ALL
12554    SELECT {target_expr}, t.depth + 1, t.visited || {target_expr} || char(30)
12555    FROM traversal t
12556    JOIN canonical_edges e ON {edge_join}
12557    JOIN canonical_nodes next_n ON next_n.logical_id = {target_expr}{next_node}
12558    WHERE t.depth < ?2
12559      AND e.superseded_at IS NULL{edge_valid}
12560      AND instr(t.visited, char(30) || {target_expr} || char(30)) = 0
12561    LIMIT {cte_cap}
12562  )
12563SELECT DISTINCT n.logical_id, n.kind, n.body, n.write_cursor
12564FROM traversal tr
12565JOIN canonical_nodes n ON n.logical_id = tr.logical_id
12566WHERE tr.logical_id != ?1{projection_node}
12567LIMIT {cap}"
12568    )
12569}
12570
12571/// Build the BFS CTE SQL for `search_expand` — identical to `build_bfs_sql`
12572/// but the final SELECT uses `GROUP BY` + `MIN(tr.depth)` so that each
12573/// expanded node carries its actual BFS distance from the root.
12574///
12575/// Returns 5 columns: logical_id, kind, body, write_cursor, min_depth.
12576fn build_bfs_with_depth_sql() -> String {
12577    let cap = GRAPH_NEIGHBORS_HARD_CAP;
12578    let cte_cap = cap * cap; // same multigraph-safe headroom as build_bfs_sql
12579                             // TC-33: `?1` anchor, `?2` depth ⇒ the edge `:now` binds at `?3`. This is a
12580                             // SECOND, separate BFS template — the edge-validity predicate has to be
12581                             // re-grounded here too or `search_expand` silently keeps the old semantics.
12582    let edge_valid = edge_validity_sql("e", 3);
12583    format!(
12584        "WITH RECURSIVE
12585  traversal(logical_id, depth, visited) AS (
12586    SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12587    FROM canonical_nodes n
12588    WHERE n.logical_id = ?1 AND n.superseded_at IS NULL AND n.state = 'active'
12589    UNION ALL
12590    SELECT
12591      CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END,
12592      t.depth + 1,
12593      t.visited || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)
12594    FROM traversal t
12595    JOIN canonical_edges e ON (e.from_id = t.logical_id OR e.to_id = t.logical_id)
12596    JOIN canonical_nodes next_n
12597      ON next_n.logical_id = CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END
12598      AND next_n.superseded_at IS NULL AND next_n.state = 'active'
12599    WHERE t.depth < ?2
12600      AND e.superseded_at IS NULL{edge_valid}
12601      AND instr(t.visited,
12602            char(30) || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)) = 0
12603    LIMIT {cte_cap}
12604  )
12605SELECT n.logical_id, n.kind, n.body, n.write_cursor, MIN(tr.depth) AS min_depth
12606FROM traversal tr
12607JOIN canonical_nodes n ON n.logical_id = tr.logical_id
12608WHERE n.superseded_at IS NULL AND n.state = 'active'
12609  AND tr.logical_id != ?1
12610GROUP BY n.logical_id
12611LIMIT {cap}"
12612    )
12613}
12614
12615/// 0.8.20 Slice 10b (R-20-NV) — the validity-boundary hook, on the DEFERRED
12616/// reader transaction.
12617///
12618/// Reports nodes whose `valid_from` and/or `valid_until` falls in the half-open
12619/// interval `(since, upper]`. Both bounds are BOUND parameters (`?1`, `?2`) —
12620/// the node-validity path never inlines `datetime('now')`.
12621///
12622/// The view's EXISTENCE conjunct applies (default: current + active rows only);
12623/// its VALIDITY conjunct deliberately does not, because the question is "did
12624/// this window cross a boundary", not "is this row valid now".
12625fn crossed_boundary_since_in_tx(
12626    reader: &mut Connection,
12627    since: i64,
12628    view: &ReadView,
12629) -> rusqlite::Result<Vec<BoundaryCrossing>> {
12630    // `now_param()` is None exactly when the view relaxes validity, which here
12631    // means "no upper bound on the interval".
12632    let upper = view.now_param().unwrap_or(i64::MAX);
12633    let existence = view.existence_sql("canonical_nodes");
12634    // `1 = 1` keeps the leading ` AND ` of `existence_sql` well-formed even when
12635    // every existence flag is relaxed and the conjunct is empty.
12636    let sql = format!(
12637        "SELECT logical_id, kind, body, write_cursor, valid_from, valid_until \
12638         FROM canonical_nodes \
12639         WHERE 1 = 1{existence} \
12640           AND logical_id IS NOT NULL \
12641           AND ( (valid_from IS NOT NULL AND valid_from > ?1 AND valid_from <= ?2) \
12642              OR (valid_until IS NOT NULL AND valid_until > ?1 AND valid_until <= ?2) ) \
12643         ORDER BY write_cursor"
12644    );
12645    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12646    let mut statement = tx.prepare(&sql)?;
12647    let rows = statement.query_map(params![since, upper], |row| {
12648        let valid_from: Option<i64> = row.get(4)?;
12649        let valid_until: Option<i64> = row.get(5)?;
12650        Ok(BoundaryCrossing {
12651            node: NodeRecord {
12652                logical_id: row.get(0)?,
12653                kind: row.get(1)?,
12654                body: row.get(2)?,
12655                write_cursor: row.get::<_, i64>(3)? as u64,
12656            },
12657            became_valid_at: valid_from.filter(|t| *t > since && *t <= upper),
12658            became_invalid_at: valid_until.filter(|t| *t > since && *t <= upper),
12659        })
12660    })?;
12661    let mut out = Vec::new();
12662    for row in rows {
12663        out.push(row?);
12664    }
12665    Ok(out)
12666}
12667
12668/// Slice 20 (G5) — execute a bounded BFS on the DEFERRED reader transaction.
12669/// Called inside the reader worker loop.
12670fn graph_neighbors_in_tx(
12671    reader: &mut Connection,
12672    root_logical_id: &str,
12673    depth: u32,
12674    direction: TraversalDirection,
12675    view: &ReadView,
12676) -> rusqlite::Result<Vec<NodeRecord>> {
12677    let sql = build_bfs_sql(direction, view);
12678    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12679    let depth_i64 = depth as i64;
12680    let mut statement = tx.prepare(&sql)?;
12681    // ?1 root, ?2 depth, ?3 = the NODE validity instant, ?4 = the EDGE validity
12682    // instant (TC-33).
12683    //
12684    // ?3 is bound UNCONDITIONALLY even when the view relaxes node validity and
12685    // `build_bfs_sql` emitted no `?3`: the template still references ?4, so
12686    // SQLite's parameter count is 4 and the positions must not shift. Binding an
12687    // index the SQL never reads is harmless; letting ?4's value slide into ?3
12688    // would silently compare edge times against a placeholder.
12689    let frozen = (*view).freeze();
12690    let binds: Vec<rusqlite::types::Value> = vec![
12691        rusqlite::types::Value::Text(root_logical_id.to_string()),
12692        rusqlite::types::Value::Integer(depth_i64),
12693        rusqlite::types::Value::Integer(frozen.now_param().unwrap_or_default()),
12694        rusqlite::types::Value::Integer(frozen.edge_now()),
12695    ];
12696    let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
12697        Ok(NodeRecord {
12698            logical_id: row.get(0)?,
12699            kind: row.get(1)?,
12700            body: row.get(2)?,
12701            write_cursor: row.get::<_, i64>(3)? as u64,
12702        })
12703    })?;
12704    let mut out = Vec::new();
12705    for row in rows {
12706        out.push(row?);
12707    }
12708    Ok(out)
12709}
12710
12711/// Slice 20 (G6) — resolve search hit `write_cursor`s to `logical_id`s, run
12712/// BFS for each root, and merge into a [`SearchExpandResult`]. Called inside
12713/// the reader worker loop on the DEFERRED reader transaction.
12714fn search_expand_in_tx(
12715    reader: &mut Connection,
12716    search_hits: &[SearchHit],
12717    depth: u32,
12718) -> rusqlite::Result<SearchExpandResult> {
12719    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12720
12721    // Step 1: resolve write_cursor → logical_id for each search hit.
12722    // Possible outcomes per hit:
12723    //   - None: no matching write_cursor in canonical_nodes (superseded) → drop.
12724    //   - Some(""):  row exists but logical_id IS NULL (anonymous node) or TextEdge hit
12725    //                → keep as valid search result, skip BFS expansion (empty sentinel).
12726    //   - Some(lid): active named node → keep; use as BFS root.
12727    let mut hit_logical_ids: Vec<Option<String>> = Vec::with_capacity(search_hits.len());
12728    {
12729        let mut node_stmt = tx.prepare(
12730            "SELECT logical_id FROM canonical_nodes
12731             WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'
12732             LIMIT 1",
12733        )?;
12734        let mut edge_stmt = tx.prepare(
12735            "SELECT 1 FROM canonical_edges
12736             WHERE write_cursor = ?1 AND superseded_at IS NULL
12737             LIMIT 1",
12738        )?;
12739        for hit in search_hits {
12740            if hit.branch == SoftFallbackBranch::TextEdge {
12741                // Edge-body hit: verify the edge row is still active in THIS snapshot.
12742                // Stale edge hits (superseded between search and expansion) are dropped.
12743                let cursor_i64 = hit.write_cursor as i64;
12744                let active: Option<i32> =
12745                    edge_stmt.query_row([cursor_i64], |row| row.get(0)).optional()?;
12746                if active.is_some() {
12747                    hit_logical_ids.push(Some(String::new())); // sentinel: keep hit, skip BFS
12748                } else {
12749                    hit_logical_ids.push(None); // superseded edge: drop
12750                }
12751            } else {
12752                let cursor_i64 = hit.write_cursor as i64;
12753                // Returns Option<Option<String>>:
12754                //   None         → no row → superseded
12755                //   Some(None)   → row with NULL logical_id → anonymous node
12756                //   Some(Some(s)) → active named node
12757                let resolved = node_stmt
12758                    .query_row([cursor_i64], |row| row.get::<_, Option<String>>(0))
12759                    .optional()?;
12760                match resolved {
12761                    None => hit_logical_ids.push(None), // superseded: drop
12762                    Some(None) => hit_logical_ids.push(Some(String::new())), // anon: keep, skip BFS
12763                    Some(Some(lid)) => hit_logical_ids.push(Some(lid)), // named: keep + BFS root
12764                }
12765            }
12766        }
12767    }
12768
12769    // Build a set of logical_ids present in the search hits (for deduplication).
12770    // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
12771    let hit_id_set: std::collections::HashSet<String> =
12772        hit_logical_ids.iter().filter_map(|id| id.clone()).filter(|s| !s.is_empty()).collect();
12773
12774    // Step 2: for each root logical_id, run the BFS and collect expanded nodes.
12775    // A node already in `hit_id_set` is NOT added to `expanded`.
12776    // Use the depth-aware variant so each node reports its actual BFS distance.
12777    let bfs_sql = build_bfs_with_depth_sql();
12778    let depth_i64 = depth as i64;
12779    // nearest_hop: for each expanded logical_id track the minimum hop count
12780    // seen across ALL search-hit roots. A node reachable from multiple roots
12781    // at different depths must report the shortest distance (nearest root).
12782    let mut nearest_hop: std::collections::HashMap<String, (NodeRecord, u32)> =
12783        std::collections::HashMap::new();
12784
12785    if depth > 0 {
12786        let mut bfs_stmt = tx.prepare(&bfs_sql)?;
12787        // TC-33: `?3` is the edge-validity instant. `search_expand` has no
12788        // `ReadView` in scope, so it uses the default (strict) semantics —
12789        // resolved ONCE here, not per root, so every root in one call agrees.
12790        let edge_now = current_epoch_seconds();
12791        for root_id in hit_logical_ids.iter().flatten().filter(|s| !s.is_empty()) {
12792            let neighbor_rows =
12793                bfs_stmt.query_map(params![root_id, depth_i64, edge_now], |row| {
12794                    let node = NodeRecord {
12795                        logical_id: row.get(0)?,
12796                        kind: row.get(1)?,
12797                        body: row.get(2)?,
12798                        write_cursor: row.get::<_, i64>(3)? as u64,
12799                    };
12800                    let min_depth: i64 = row.get(4)?;
12801                    Ok((node, min_depth as u32))
12802                })?;
12803            for row_result in neighbor_rows {
12804                let (node, hop_count) = row_result?;
12805                if hit_id_set.contains(&node.logical_id) {
12806                    // Already a search hit — skip (search score takes priority).
12807                    continue;
12808                }
12809                nearest_hop
12810                    .entry(node.logical_id.clone())
12811                    .and_modify(|(_, prev_hop)| {
12812                        if hop_count < *prev_hop {
12813                            *prev_hop = hop_count;
12814                        }
12815                    })
12816                    .or_insert((node, hop_count));
12817            }
12818        }
12819    }
12820
12821    // Materialize expanded in insertion order (deterministic for tests).
12822    let mut expanded: Vec<(NodeRecord, u32)> = nearest_hop.into_values().collect();
12823    expanded.sort_by(|(a, _), (b, _)| a.logical_id.cmp(&b.logical_id));
12824
12825    // Filter search_hits to only include those whose write_cursor resolved to an
12826    // active logical_id in THIS snapshot. Hits that were superseded between the
12827    // search phase and the expansion phase (the two-snapshot window) are dropped
12828    // rather than returned with stale data.
12829    let resolved_hits: Vec<SearchHit> = search_hits
12830        .iter()
12831        .zip(hit_logical_ids.iter())
12832        .filter_map(|(hit, lid)| lid.as_ref().map(|_| hit.clone()))
12833        .collect();
12834
12835    // Build `all_logical_ids` = resolved search-hit logical_ids + expanded node ids.
12836    // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
12837    let mut all_logical_ids: Vec<String> =
12838        hit_logical_ids.into_iter().flatten().filter(|s| !s.is_empty()).collect();
12839    for (node, _) in &expanded {
12840        if !all_logical_ids.contains(&node.logical_id) {
12841            all_logical_ids.push(node.logical_id.clone());
12842        }
12843    }
12844
12845    Ok(SearchExpandResult { search_hits: resolved_hits, expanded, all_logical_ids })
12846}
12847
12848/// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and return
12849/// the plan `detail` column (column index 3) for each row. Used by
12850/// `explain_plan_uses_indexes` to assert index usage.
12851fn explain_graph_neighbors_in_tx(
12852    reader: &mut Connection,
12853    root_logical_id: &str,
12854    depth: u32,
12855    direction: TraversalDirection,
12856) -> rusqlite::Result<Vec<String>> {
12857    // The EXPLAIN index-usage gate measures the DEFAULT (strict) read path.
12858    let view = ReadView::default();
12859    let bfs_sql = build_bfs_sql(direction, &view);
12860    let explain_sql = format!("EXPLAIN QUERY PLAN {bfs_sql}");
12861    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12862    let depth_i64 = depth as i64;
12863    let mut statement = tx.prepare(&explain_sql)?;
12864    // EXPLAIN QUERY PLAN returns rows: (id, parent, notused, detail).
12865    // We collect the `detail` column (index 3).
12866    // The strict view emits `?3` (the node-validity instant) at every node
12867    // position; TC-33 adds `?4`, the edge-validity instant.
12868    let frozen = view.freeze();
12869    let now = frozen.now_param().expect("the strict view always binds a validity instant");
12870    let rows = statement
12871        .query_map(params![root_logical_id, depth_i64, now, frozen.edge_now()], |row| {
12872            row.get::<_, String>(3)
12873        })?;
12874    let mut out = Vec::new();
12875    for row in rows {
12876        out.push(row?);
12877    }
12878    Ok(out)
12879}
12880
12881fn projection_dispatcher_loop(shared: Arc<ProjectionRuntimeShared>) {
12882    let connection = match open_runtime_connection(&shared.path) {
12883        Ok(connection) => connection,
12884        Err(_) => return,
12885    };
12886    // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — read ONCE:
12887    // `ProjectionRuntimeShared::embedder` is fixed for the session's lifetime.
12888    let dense_arm_live = shared.embedder.is_some();
12889    loop {
12890        let in_flight = {
12891            let mut state = match shared.state.lock() {
12892                Ok(state) => state,
12893                Err(_) => return,
12894            };
12895            while !state.stopping
12896                && (!state.pending_scan
12897                    || state.frozen
12898                    || state.active_jobs + state.queued_jobs >= PROJECTION_INFLIGHT_LIMIT)
12899            {
12900                state = match shared.state_cvar.wait(state) {
12901                    Ok(state) => state,
12902                    Err(_) => return,
12903                };
12904            }
12905            if state.stopping {
12906                return;
12907            }
12908            state.pending_scan = false;
12909            state.in_flight.clone()
12910        };
12911
12912        // Fetch up to the in-flight budget in one SQL roundtrip and
12913        // enqueue them as a batch — previously this loop fetched ONE job
12914        // per cycle, which capped projection throughput at one row per
12915        // scanner/worker handshake regardless of how much work was queued
12916        // in canonical_nodes.
12917        let budget = {
12918            let state = match shared.state.lock() {
12919                Ok(state) => state,
12920                Err(_) => return,
12921            };
12922            PROJECTION_INFLIGHT_LIMIT.saturating_sub(state.active_jobs + state.queued_jobs)
12923        };
12924        let fetch_cap = budget.clamp(1, PROJECTION_SCAN_FETCH);
12925        // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — with no live embedder a
12926        // NODE job can only come back DEFERRED (`ProjectionOutcome::Deferred`),
12927        // which by design records no terminal, so dispatching one would re-fetch
12928        // the SAME cursor forever. fix-5 (codex §9 round 4 [P1]) moved that
12929        // exclusion INSIDE the scan, so the `LIMIT` applies to the already-filtered
12930        // set and a pending EDGE body behind a full window of node rows is still
12931        // reachable. See `next_pending_projection_jobs`.
12932        let fetched =
12933            next_pending_projection_jobs(&connection, &in_flight, fetch_cap, dense_arm_live);
12934        // Cheap assertion only — it can never DROP a job, which is precisely what
12935        // the fix-4 shape did.
12936        debug_assert!(
12937            fetched
12938                .as_ref()
12939                .map(|jobs| dense_arm_live || jobs.iter().all(|job| job.kind == EDGE_FACT_KIND))
12940                .unwrap_or(true),
12941            "no-embedder scan returned a NODE job: the exclusion must be in the scan's SQL"
12942        );
12943        match fetched {
12944            Ok(jobs) if !jobs.is_empty() => {
12945                if let Ok(mut state) = shared.state.lock() {
12946                    state.queued_jobs = state.queued_jobs.saturating_add(jobs.len());
12947                    for job in &jobs {
12948                        state.in_flight.insert(job.cursor);
12949                    }
12950                    state.pending_scan = true;
12951                    shared.state_cvar.notify_all();
12952                }
12953                if let Ok(mut queue) = shared.queue.lock() {
12954                    for job in jobs {
12955                        queue.push_back(job);
12956                    }
12957                    shared.queue_cvar.notify_all();
12958                }
12959            }
12960            Ok(_) => {}
12961            Err(_) => {
12962                if let Ok(mut state) = shared.state.lock() {
12963                    state.pending_scan = false;
12964                    shared.state_cvar.notify_all();
12965                }
12966            }
12967        }
12968    }
12969}
12970
12971fn projection_worker_loop(shared: Arc<ProjectionRuntimeShared>) {
12972    let mut connection = match open_runtime_connection(&shared.path) {
12973        Ok(connection) => connection,
12974        Err(_) => return,
12975    };
12976    if ensure_vector_partition(&mut connection, shared.embedder_identity.dimension).is_err() {
12977        return;
12978    }
12979    loop {
12980        let jobs = {
12981            let mut queue = match shared.queue.lock() {
12982                Ok(queue) => queue,
12983                Err(_) => return,
12984            };
12985            loop {
12986                let stopping = shared.state.lock().map(|state| state.stopping).unwrap_or(true);
12987                if stopping && queue.is_empty() {
12988                    return;
12989                }
12990                if let Some(job) = queue.pop_front() {
12991                    let mut jobs = vec![job];
12992                    while jobs.len() < PROJECTION_COMMIT_BATCH {
12993                        let Some(job) = queue.pop_front() else {
12994                            break;
12995                        };
12996                        jobs.push(job);
12997                    }
12998                    if let Ok(mut state) = shared.state.lock() {
12999                        state.queued_jobs = state.queued_jobs.saturating_sub(jobs.len());
13000                        state.active_jobs = state.active_jobs.saturating_add(jobs.len());
13001                        shared.state_cvar.notify_all();
13002                    }
13003                    break jobs;
13004                }
13005                queue = match shared.queue_cvar.wait(queue) {
13006                    Ok(queue) => queue,
13007                    Err(_) => return,
13008                };
13009            }
13010        };
13011
13012        // EU-5f — isolate worker faults. A panic inside `embed()` (or the
13013        // commit) must not skip the state cleanup below, or `active_jobs`
13014        // would stay elevated forever and `wait_for_idle` / `drain` would
13015        // wedge into `EngineError::Scheduler` (Finding A). Mirrors the
13016        // reader pool's `LiveGuard` panic-safety. The local commit tx rolls
13017        // back on unwind, leaving the connection clean for reuse.
13018        let commit_result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13019            run_projection_jobs(&shared, &mut connection, &jobs)
13020        })) {
13021            Ok(result) => result,
13022            Err(_) => commit_projection_panic_failures(&shared, &mut connection, &jobs),
13023        };
13024        if let Err(err) = commit_result {
13025            // Host subscribers are arbitrary application code. Their panic must
13026            // not bypass the mandatory state cleanup below, or the durable
13027            // pending row would stay stranded in `in_flight` forever.
13028            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13029                report_projection_commit_failure(&shared, &err);
13030            }));
13031            #[cfg(debug_assertions)]
13032            if let Some((reported, release)) = shared
13033                .projection_commit_failure_pause
13034                .lock()
13035                .unwrap_or_else(|poisoned| poisoned.into_inner())
13036                .take()
13037            {
13038                reported.wait();
13039                release.wait();
13040            }
13041        }
13042
13043        if let Ok(mut state) = shared.state.lock() {
13044            state.active_jobs = state.active_jobs.saturating_sub(jobs.len());
13045            for job in &jobs {
13046                state.in_flight.remove(&job.cursor);
13047            }
13048            if !state.stopping {
13049                state.pending_scan = true;
13050            }
13051            shared.state_cvar.notify_all();
13052        }
13053    }
13054}
13055
13056enum ProjectionOutcome {
13057    /// `blob` is the un-centered f32 BLOB persisted to
13058    /// `vector_default.embedding`. `bin_blob` is the (possibly centered)
13059    /// f32 BLOB fed to `vec_quantize_binary` for the sign-bit column.
13060    /// EU-5a2: `bin_blob == blob` unless the identity is MC-required
13061    /// AND a mean_vec is pinned.
13062    Success {
13063        cursor: u64,
13064        kind: String,
13065        blob: Vec<u8>,
13066        bin_blob: Vec<u8>,
13067    },
13068    Failure {
13069        cursor: u64,
13070        failure_code: &'static str,
13071    },
13072    /// 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — the ENVIRONMENT could
13073    /// not serve this row, as distinct from the embed FAILING. Records nothing
13074    /// at all: no `projection_failures` audit row and, decisively, **no
13075    /// terminal**. The row stays `terminal IS NULL`, i.e. PENDING, so the next
13076    /// session that DOES have an embedder picks it up through the ordinary
13077    /// scheduler — no graft path and no recovery machinery.
13078    ///
13079    /// The only producer is the absent-embedder check at the top of
13080    /// [`run_projection_job`]. That condition cannot change within a session, so
13081    /// this can never become a retry loop for a genuinely-failing row.
13082    ///
13083    /// It carries NO cursor, deliberately: the other two variants carry one
13084    /// because they identify the row they are about to WRITE, and this variant
13085    /// writes nothing at all. A row's deferral is represented on disk by the
13086    /// continued ABSENCE of its `_fathomdb_projection_terminal` row, which is
13087    /// exactly the state it was already in.
13088    Deferred,
13089}
13090
13091fn run_projection_jobs(
13092    shared: &ProjectionRuntimeShared,
13093    connection: &mut Connection,
13094    jobs: &[ProjectionJob],
13095) -> rusqlite::Result<()> {
13096    let outcomes = embed_projection_batch(shared, jobs);
13097    commit_projection_outcomes(connection, &outcomes, shared)
13098}
13099
13100/// Embed a whole commit-batch in ONE `embed_batch` call (amortizes per-call
13101/// overhead; saturates the GPU — minutes -> seconds on a full-corpus embed). The
13102/// batched path is the fast HAPPY path only; on ANY anomaly — no embedder, breaker
13103/// open, single job, batch timeout/failure, row-count or per-row dimension mismatch
13104/// — it falls back to the proven per-job [`run_projection_job`], which carries the
13105/// full retry + circuit-breaker + failure-isolation semantics. So batching can only
13106/// make the common case faster, never change correctness. A panic inside the batch
13107/// embed resume-unwinds exactly like the per-embed watchdog, so the worker's
13108/// batch-level `catch_unwind` records `ProjectionPanic` as before.
13109///
13110/// Batching is **opt-in** via `FATHOMDB_PROJECTION_BATCH=1` (`true`/`on` accepted).
13111/// It reshapes the PR-9 per-embed watchdog/breaker accounting into per-batch, so the
13112/// conservative DEFAULT keeps the proven per-job path — leaving every PR-9 safety
13113/// test (watchdog, serialization, circuit breaker) behaving exactly as before. The
13114/// eval GPU-embed run sets the env to get the batched-forward speedup (minutes ->
13115/// seconds), where the per-job fallback below still backs every error case.
13116fn projection_batch_enabled() -> bool {
13117    matches!(
13118        std::env::var("FATHOMDB_PROJECTION_BATCH").ok().as_deref(),
13119        Some("1") | Some("true") | Some("on")
13120    )
13121}
13122
13123fn embed_projection_batch(
13124    shared: &ProjectionRuntimeShared,
13125    jobs: &[ProjectionJob],
13126) -> Vec<ProjectionOutcome> {
13127    let per_job = || jobs.iter().map(|job| run_projection_job(shared, job)).collect();
13128
13129    let Some(embedder) = shared.embedder.as_ref() else {
13130        return per_job();
13131    };
13132    if jobs.len() < 2
13133        || shared.embed_circuit_open.load(Ordering::Relaxed)
13134        || !projection_batch_enabled()
13135    {
13136        return per_job();
13137    }
13138
13139    let bodies: Vec<String> = jobs.iter().map(|job| job.body.clone()).collect();
13140    let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
13141    // Each row keeps its single-embed budget worst-case (batch <= COMMIT_BATCH=16).
13142    let batch_timeout = embed_timeout.saturating_mul(jobs.len() as u32);
13143
13144    let vectors = {
13145        // PR-9 — serialize the embedder call (ONE batched call at a time) and make
13146        // the breaker decision with the guard held (race-free vs other workers),
13147        // mirroring `run_projection_job`. The batch thread counts as one live embed.
13148        let _embed_permit =
13149            shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13150        let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
13151        if shared.embed_circuit_open.load(Ordering::Relaxed)
13152            || (threshold != 0 && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
13153        {
13154            shared.embed_circuit_open.store(true, Ordering::Relaxed);
13155            return per_job();
13156        }
13157        match embed_batch_with_watchdog(
13158            embedder,
13159            &bodies,
13160            batch_timeout,
13161            &shared.live_embed_threads,
13162        ) {
13163            Ok(vectors) => vectors,
13164            // Timeout / failed / disconnected -> the per-job path retries each row
13165            // and engages the breaker exactly as before.
13166            Err(_) => return per_job(),
13167        }
13168    };
13169
13170    if vectors.len() != jobs.len() {
13171        return per_job();
13172    }
13173    let mut outcomes = Vec::with_capacity(jobs.len());
13174    for (job, vector) in jobs.iter().zip(vectors) {
13175        if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
13176            // A row came back wrong-dim: fall back per-job for the whole batch
13177            // (rare; keeps the dimension-mismatch failure path identical).
13178            return per_job();
13179        }
13180        // Mirror run_projection_job's post-embed step exactly: persisted f32 BLOB is
13181        // un-centered; centering for the binary column is finalized in
13182        // commit_projection_outcomes (so bin_blob == blob here).
13183        let blob = encode_vector_blob(&vector);
13184        let bin_blob = blob.clone();
13185        outcomes.push(ProjectionOutcome::Success {
13186            cursor: job.cursor,
13187            kind: job.kind.clone(),
13188            blob,
13189            bin_blob,
13190        });
13191    }
13192    outcomes
13193}
13194
13195/// EU-5f — record every job in a panicked batch as a terminal projection
13196/// failure so the scheduler does not re-enqueue and re-panic on the same
13197/// cursors. Best-effort; runs after the worker caught a panic.
13198fn commit_projection_panic_failures(
13199    shared: &ProjectionRuntimeShared,
13200    connection: &mut Connection,
13201    jobs: &[ProjectionJob],
13202) -> rusqlite::Result<()> {
13203    let outcomes: Vec<ProjectionOutcome> = jobs
13204        .iter()
13205        .map(|job| ProjectionOutcome::Failure {
13206            cursor: job.cursor,
13207            failure_code: "ProjectionPanic",
13208        })
13209        .collect();
13210    commit_projection_outcomes(connection, &outcomes, shared)
13211}
13212
13213/// Route a background projection-commit failure through the engine's existing
13214/// host subscriber path. A SQLite error retains its stable SQLite code; a
13215/// rusqlite-layer error is an engine storage failure rather than a fabricated
13216/// SQLite diagnostic.
13217fn report_projection_commit_failure(shared: &ProjectionRuntimeShared, err: &rusqlite::Error) {
13218    let event = if let Some(code) = sqlite_extended_code_name(err) {
13219        lifecycle::Event {
13220            phase: lifecycle::Phase::Failed,
13221            source: lifecycle::EventSource::SqliteInternal,
13222            category: lifecycle::EventCategory::Error,
13223            code: Some(code),
13224        }
13225    } else {
13226        lifecycle::Event {
13227            phase: lifecycle::Phase::Failed,
13228            source: lifecycle::EventSource::Engine,
13229            category: lifecycle::EventCategory::Error,
13230            code: Some("StorageError"),
13231        }
13232    };
13233    shared.subscribers.dispatch(&event);
13234}
13235
13236/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5**: run one `embed()`
13237/// under a per-call deadline. A hung (non-panicking) embed would otherwise
13238/// park a projection worker forever — the EU-5f `catch_unwind` only catches
13239/// *panics*. On timeout we return `RuntimeEmbedderError::Timeout`, which the
13240/// caller's existing retry/failure path already handles.
13241///
13242/// Cancellation follows Invariant 5 exactly: the embed runs on a detached
13243/// thread that is allowed to *finish + discard* its result — never aborted
13244/// mid-call (there is no safe thread-cancel API). The caller (the projection
13245/// worker) holds `embed_serialize` across this call, but DROPS it the moment
13246/// this returns — including on timeout — so the abandoned detached thread
13247/// runs lock-free and a hung embed can neither hold the serialization guard
13248/// forever nor deadlock the pool. (The commit happens later, outside this
13249/// call, under the separate `commit_gate`.)
13250///
13251/// Panic-transparent: if `embed()` panics, the panic payload is captured on
13252/// the watchdog thread and resumed on the worker thread, so the existing
13253/// batch-level `catch_unwind` records `ProjectionPanic` exactly as before.
13254///
13255/// `live` counts embed threads currently alive: incremented before the spawn
13256/// and decremented by the thread when it finishes (even if its result was
13257/// abandoned on timeout). The caller reads it to bound the abandoned-thread
13258/// leak via the circuit breaker.
13259fn embed_with_watchdog(
13260    embedder: &Arc<dyn Embedder>,
13261    body: &str,
13262    timeout: Duration,
13263    live: &Arc<AtomicU64>,
13264) -> Result<Vec<f32>, RuntimeEmbedderError> {
13265    let (tx, rx) = mpsc::channel();
13266    let embedder = Arc::clone(embedder);
13267    let body = body.to_string();
13268    // Count this embed thread as live before spawning; the thread decrements
13269    // when it finishes, whether or not its result is still wanted.
13270    live.fetch_add(1, Ordering::Relaxed);
13271    let live_thread = Arc::clone(live);
13272    thread::spawn(move || {
13273        let outcome =
13274            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(&body)));
13275        // The receiver may already be gone (this call timed out): an async
13276        // channel send never blocks, and a send to a dropped receiver is a
13277        // no-op error we deliberately ignore — the result is discarded.
13278        let _ = tx.send(outcome);
13279        live_thread.fetch_sub(1, Ordering::Relaxed);
13280    });
13281    match rx.recv_timeout(timeout) {
13282        Ok(Ok(result)) => result,
13283        Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13284        Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13285        // The watchdog thread dropped its sender without sending — should not
13286        // happen (panics are captured above), but treat as a failed embed so
13287        // the retry/failure path engages rather than silently succeeding.
13288        Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13289            message: "embed watchdog thread dropped its result channel".to_string(),
13290        }),
13291    }
13292}
13293
13294/// Batch sibling of [`embed_with_watchdog`]: run ONE `embed_batch` on a detached,
13295/// timeout-bounded thread. Same Invariant-5 cancellation contract (the thread is
13296/// allowed to finish + discard on timeout, never aborted mid-call), same
13297/// panic-transparency (a panic is resumed on the caller so the worker's batch-level
13298/// `catch_unwind` records `ProjectionPanic`), same `live` accounting (one batch
13299/// thread = one live embed, bounding the abandoned-thread leak via the breaker).
13300fn embed_batch_with_watchdog(
13301    embedder: &Arc<dyn Embedder>,
13302    bodies: &[String],
13303    timeout: Duration,
13304    live: &Arc<AtomicU64>,
13305) -> Result<Vec<Vec<f32>>, RuntimeEmbedderError> {
13306    let (tx, rx) = mpsc::channel();
13307    let embedder = Arc::clone(embedder);
13308    let bodies = bodies.to_vec();
13309    live.fetch_add(1, Ordering::Relaxed);
13310    let live_thread = Arc::clone(live);
13311    thread::spawn(move || {
13312        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13313            let refs: Vec<&str> = bodies.iter().map(String::as_str).collect();
13314            embedder.embed_batch(&refs)
13315        }));
13316        let _ = tx.send(outcome);
13317        live_thread.fetch_sub(1, Ordering::Relaxed);
13318    });
13319    match rx.recv_timeout(timeout) {
13320        Ok(Ok(result)) => result,
13321        Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13322        Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13323        Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13324            message: "embed batch watchdog thread dropped its result channel".to_string(),
13325        }),
13326    }
13327}
13328
13329fn run_projection_job(shared: &ProjectionRuntimeShared, job: &ProjectionJob) -> ProjectionOutcome {
13330    // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — an ABSENT embedder is an
13331    // ENVIRONMENT fact, not an embed failure, and it CANNOT appear mid-job:
13332    // `ProjectionRuntimeShared::embedder` is fixed for the whole session. So for a
13333    // NODE row the whole retry ladder (0 + 1 + 4 + 16 s) can only reach a
13334    // conclusion that was already knowable at entry — answer it NOW, with the
13335    // NON-TERMINAL `Deferred`: no audit row, no terminal, and therefore a write
13336    // the next live-embedder session can still recover.
13337    //
13338    // That is codex's finding. With the kind already ENROLLED, the shipped
13339    // `'failed'` terminal was PERMANENT (nothing reopens one, and nothing should:
13340    // re-enqueueing one would loop a genuinely-failing row forever), so the write
13341    // was lost while `dense_readiness` read `ready`.
13342    //
13343    // `projection_dispatcher_loop` already declines to dispatch node jobs in a
13344    // no-embedder session — it must, or the still-pending row would be re-scanned
13345    // in a hot loop. This check is the LOCAL backstop for the same invariant:
13346    // whatever reaches a worker with no embedder must not be TERMINATED. Keeping
13347    // the invariant beside the code that would otherwise write the terminal is
13348    // what makes it hold if that dispatcher-side filter is ever loosened.
13349    //
13350    // EDGE rows deliberately fall THROUGH to the shipped path, ladder and all.
13351    // `'edge_fact'` is auto-registered by the edge write itself, UN-gated on the
13352    // embedder (`project_canonical_edge_row`, G11 — see the note in
13353    // `enrol_batch_vector_kinds`), so an edge body written with no embedder is
13354    // outstanding the moment it lands. Deferring it would leave `drain` and
13355    // `excise_source` returning `EngineError::Scheduler` on paths with nothing to
13356    // do with the dense arm (MEASURED: 4 shipped tests across
13357    // `tc31_source_id_on_every_hit`, `provenance_mandatory` and
13358    // `multidoc_extractor_provenance`). Making edges recoverable needs their
13359    // enrolment gated the way fix-2 gated node kinds — reported as OOS-13, and
13360    // outside codex's finding, which is the node path.
13361    //
13362    // Their LADDER is left alone for a second, separately MEASURED reason:
13363    // shortening it makes the worker's terminal-commit land while a caller's own
13364    // write is still open, which used to trip the governed write-race. Measured on
13365    // `consolidate_provider` under 6-way concurrency: 0/48 failures with the
13366    // ladder, 8/48 without. Left byte-for-byte as shipped; the ladder length is
13367    // reported as OOS-17 rather than newly exposed by a fix round.
13368    //
13369    // 0.8.20 Slice 21 (TC-57) — this note used to name that race
13370    // `SQLITE_BUSY_SNAPSHOT` and call it PRE-EXISTING. Both are corrected: the
13371    // characterized mechanism is plain `SQLITE_BUSY` (5) on a read→write lock
13372    // PROMOTION, with the busy handler invoked ZERO times (SQLite skips it for
13373    // deadlock avoidance), so no `busy_timeout` could absorb it;
13374    // `SQLITE_BUSY_SNAPSHOT` (517) is only a second, narrower exit of the same
13375    // shape. And the race is FIXED — `commit_batch` now takes `BEGIN IMMEDIATE`
13376    // (see the note there), so the governed path never promotes. The 0/48-vs-8/48
13377    // measurement above stands as the reason not to shorten the ladder, but it is
13378    // no longer load-bearing for correctness of the governed write path.
13379    if shared.embedder.is_none() && job.kind != EDGE_FACT_KIND {
13380        return ProjectionOutcome::Deferred;
13381    }
13382    // PR-9 — embed circuit breaker (see `embed_circuit_open`). Once abandoned
13383    // (timed-out) embed threads have piled up to the threshold the embedder is
13384    // treated as broken; fail subsequent jobs fast WITHOUT attempting an embed,
13385    // so a wedged embedder cannot keep leaking abandoned watchdog threads. This
13386    // entry check is the fast path; the latch decision itself is made under the
13387    // embed guard below (race-free against other workers).
13388    if shared.embed_circuit_open.load(Ordering::Relaxed) {
13389        return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: "EmbedderError" };
13390    }
13391    let delays = shared.retry_delays_ms.lock().map(|delays| delays.clone()).unwrap_or_default();
13392    let mut last_code = "EmbedderError";
13393    for (attempt, delay_ms) in std::iter::once(0_u64).chain(delays.iter().copied()).enumerate() {
13394        if attempt > 0 {
13395            if shared.state.lock().map(|state| state.stopping).unwrap_or(true) {
13396                return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13397            }
13398            thread::sleep(Duration::from_millis(delay_ms));
13399        }
13400        // PR-9 — re-check the breaker on every attempt, not just at entry:
13401        // another worker (or an earlier attempt of this job) may have latched
13402        // it while we were sleeping between retries. Bail before spawning yet
13403        // another timeout-bound watchdog thread, so the abandoned-thread leak
13404        // stays bounded even on the multi-retry path.
13405        if shared.embed_circuit_open.load(Ordering::Relaxed) {
13406            return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13407        }
13408        // PR-9 / ADR-0.6.0 Invariant 5 — every embed runs under the per-call
13409        // watchdog deadline so a hung embed surfaces Timeout instead of
13410        // parking this worker forever.
13411        let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
13412        let vector = match shared.embedder.as_ref() {
13413            Some(embedder) => {
13414                // PR-9 — serialize the embed call engine-side (see
13415                // `embed_serialize`): the shared embedder is invoked one call
13416                // at a time, for SAFETY with arbitrary caller-supplied
13417                // embedders (throughput is ~neutral on the candle default).
13418                // The guard is held across the watchdog call and released
13419                // here, so commit/IO below stays parallel and a timed-out
13420                // embed frees it. The guard owns no data; a panic-resumed
13421                // embed poisons it, so we recover the inner guard rather than
13422                // wedge the whole pool.
13423                let _embed_permit =
13424                    shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13425                // PR-9 — breaker decision, made WITH the guard held so it is
13426                // race-free against other workers: if abandoned embed threads
13427                // from earlier timeouts have piled up to the threshold, latch
13428                // the breaker and fail fast WITHOUT spawning another one. The
13429                // live count is checked here (also covers a breaker latched by
13430                // another worker while we were queued on the lock), bounding
13431                // the abandoned-thread leak to ~threshold regardless of whether
13432                // the embedder hangs always or only intermittently.
13433                let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
13434                if shared.embed_circuit_open.load(Ordering::Relaxed)
13435                    || (threshold != 0
13436                        && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
13437                {
13438                    shared.embed_circuit_open.store(true, Ordering::Relaxed);
13439                    return ProjectionOutcome::Failure {
13440                        cursor: job.cursor,
13441                        failure_code: last_code,
13442                    };
13443                }
13444                match embed_with_watchdog(
13445                    embedder,
13446                    &job.body,
13447                    embed_timeout,
13448                    &shared.live_embed_threads,
13449                ) {
13450                    Ok(vector) => vector,
13451                    Err(RuntimeEmbedderError::Timeout) => {
13452                        // The embed thread is now abandoned (still counted in
13453                        // live_embed_threads until it returns); the breaker
13454                        // check above caps how many can accumulate.
13455                        last_code = "EmbedderError";
13456                        continue;
13457                    }
13458                    Err(RuntimeEmbedderError::Failed { .. }) => {
13459                        last_code = "EmbedderError";
13460                        continue;
13461                    }
13462                }
13463            }
13464            None => {
13465                last_code = "EmbedderNotConfiguredError";
13466                continue;
13467            }
13468        };
13469
13470        if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
13471            last_code = "EmbedderDimensionMismatchError";
13472            continue;
13473        }
13474
13475        let blob = encode_vector_blob(&vector);
13476        // EU-5a2 mean-centering apply path (projection write side). The
13477        // f32 BLOB persisted is ALWAYS un-centered; `bin_blob` carries
13478        // the (possibly centered) f32 fed to `vec_quantize_binary`. The
13479        // centering decision is finalized in `commit_projection_outcomes`
13480        // where the writer connection is in-hand and the read of
13481        // `_fathomdb_embedder_profiles.mean_vec` is in the same tx as
13482        // the INSERT. NoopEmbedder (EU-5a2's only live identity) is not
13483        // MC-required, so `bin_blob == blob` throughout EU-5a2.
13484        let bin_blob = blob.clone();
13485        return ProjectionOutcome::Success {
13486            cursor: job.cursor,
13487            kind: job.kind.clone(),
13488            blob,
13489            bin_blob,
13490        };
13491    }
13492
13493    ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code }
13494}
13495
13496/// 0.8.20 Slice 20 fix-1 (codex §9 [P2]) — the ONE definition of "a canonical
13497/// EDGE row the vector pipeline still owes an embed for".
13498///
13499/// Two call sites must agree on this predicate and had drifted:
13500///
13501/// - [`next_pending_projection_jobs`] — the SCHEDULER, and therefore the
13502///   authority on what will actually be embedded. It joins
13503///   `_fathomdb_vector_kinds` on `'edge_fact'`, so an edge body is only ever
13504///   scheduled when that kind is registered.
13505/// - [`connection_has_pending_projection_work`] — the PROBE behind
13506///   `drain`/`wait_for_idle` and, since this slice, `dense_readiness`. It
13507///   omitted that join.
13508///
13509/// The consequence of the drift: a live edge body written while `edge_fact` was
13510/// not a registered vector kind (e.g. edges carried forward from before the G11
13511/// edge-vector pipeline, which is what auto-registers the kind) counted as
13512/// outstanding work the scheduler would NEVER take. `dense_readiness` reported
13513/// `embedding` forever and `drain` could never report idle — both the mirror
13514/// image of R-20-DR's property. Building both edge arms from this one fragment
13515/// makes a repeat drift unrepresentable.
13516///
13517/// Emits the `FROM`/`JOIN` clauses plus the shared `WHERE` predicates, with the
13518/// edge table aliased `ce` and the projection terminal aliased `pt`; `now_idx`
13519/// is the 1-based bind index of the `:now` seam that [`edge_validity_sql`]
13520/// consumes. Callers may append further `AND` predicates.
13521///
13522/// **The one predicate deliberately NOT shared** is the scheduler's
13523/// `write_cursor > :cursor` watermark filter, which the scheduler appends and
13524/// the probe must not: per the G11 (Slice 15) fix-1 note on the probe, the
13525/// probe has to see edge bodies left un-projected BELOW the watermark when the
13526/// engine closed mid-flight, or `drain` would report idle with edge vectors
13527/// still missing on reopen. That asymmetry is intentional and load-bearing; the
13528/// row-eligibility predicates above are not, and are shared.
13529fn pending_edge_projection_from_where(now_idx: usize) -> String {
13530    format!(
13531        "FROM canonical_edges ce
13532         JOIN _fathomdb_vector_kinds
13533           ON _fathomdb_vector_kinds.kind = 'edge_fact'
13534         LEFT JOIN _fathomdb_projection_terminal pt
13535           ON pt.write_cursor = ce.write_cursor
13536         WHERE ce.body IS NOT NULL
13537           AND ce.superseded_at IS NULL{}
13538           AND pt.write_cursor IS NULL",
13539        edge_validity_sql("ce", now_idx)
13540    )
13541}
13542
13543/// The SCHEDULER's scan: the next `max_jobs` pending projection jobs in
13544/// `write_cursor` order.
13545///
13546/// `dense_arm_live` is `ProjectionRuntimeShared::embedder.is_some()`, read once
13547/// per dispatcher because it is fixed for the session's lifetime.
13548///
13549/// # fix-5 (codex §9 round 4 [P1]) — why the node exclusion is IN the SQL
13550///
13551/// With no live embedder a NODE job can only come back
13552/// [`ProjectionOutcome::Deferred`], which by design records no terminal (fix-4),
13553/// so dispatching one would re-fetch the SAME cursor forever: a hot loop for the
13554/// whole life of the session. fix-4 suppressed that by filtering the vector this
13555/// function RETURNS — i.e. after the `ORDER BY … LIMIT`, so the `LIMIT` still
13556/// applied to the UNFILTERED set. More than `PROJECTION_SCAN_FETCH` pending node
13557/// rows ordered before a pending EDGE body therefore filled the entire window
13558/// with jobs that were then all dropped, the dispatcher went back to sleep with
13559/// `pending_scan` already consumed, and the edge body was never scheduled at all
13560/// — permanently, since those node rows stay pending for the session's life. The
13561/// exclusion belongs here, where the `LIMIT` applies to the ALREADY-FILTERED set
13562/// and a later edge job is always reachable.
13563///
13564/// Edges are NOT excluded: `'edge_fact'` is auto-registered by the edge write
13565/// itself, un-gated on the embedder (`project_canonical_edge_row`, G11, and the
13566/// note in [`Engine::enrol_batch_vector_kinds`]), so an edge body still
13567/// TERMINATES on an absent embedder exactly as it has shipped since G11. Making
13568/// edges recoverable too needs that enrolment gated first (OOS-13).
13569fn next_pending_projection_jobs(
13570    connection: &Connection,
13571    in_flight: &BTreeSet<u64>,
13572    max_jobs: usize,
13573    dense_arm_live: bool,
13574) -> rusqlite::Result<Vec<ProjectionJob>> {
13575    if max_jobs == 0 {
13576        return Ok(Vec::new());
13577    }
13578    let cursor = load_projection_cursor(connection)?;
13579    // Over-fetch by `in_flight.len()` so the post-filter still returns
13580    // up to `max_jobs` after skipping cursors already in-flight.
13581    let sql_limit = max_jobs.saturating_add(in_flight.len()).min(256);
13582    // G11 (Slice 15) — UNION extends the projection queue to include edge bodies.
13583    // Edge bodies use kind `'edge_fact'` so `resolve_source_type` maps them to
13584    // `source_type = 'edge_fact'` in `vector_default` (partition correctness).
13585    // The UNION is ordered by write_cursor so projection proceeds in
13586    // insertion order across nodes and edges.
13587    //
13588    // fix-5 [P1]: with no dense arm the NODE arm is omitted outright rather than
13589    // predicated false, so the planner never walks it. The edge arm keeps both
13590    // binds (`?1` the cursor, `?2` the `:now` seam), so the bound parameter set
13591    // is identical either way.
13592    let node_arm = if dense_arm_live {
13593        "SELECT canonical_nodes.write_cursor AS write_cursor,
13594                    canonical_nodes.kind AS kind,
13595                    canonical_nodes.body AS body
13596             FROM canonical_nodes
13597             JOIN _fathomdb_vector_kinds
13598               ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
13599             LEFT JOIN _fathomdb_projection_terminal
13600               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
13601             WHERE canonical_nodes.write_cursor > ?1
13602               AND _fathomdb_projection_terminal.write_cursor IS NULL
13603
13604             UNION ALL
13605
13606             "
13607    } else {
13608        ""
13609    };
13610    let sql = format!(
13611        "SELECT write_cursor, kind, body FROM (
13612             {node_arm}SELECT ce.write_cursor AS write_cursor,
13613                    'edge_fact' AS kind,
13614                    ce.body AS body
13615             {edge_arm}
13616               AND ce.write_cursor > ?1
13617         ) ORDER BY write_cursor
13618         LIMIT {sql_limit}",
13619        // fix-1 [P2]: the edge arm's row-eligibility predicates come from the
13620        // shared fragment so this and `connection_has_pending_projection_work`
13621        // cannot disagree about what is outstanding. The `write_cursor > ?1`
13622        // watermark is appended here and ONLY here — see the fragment's doc.
13623        // TC-33: `?1` is the projection cursor ⇒ the edge `:now` binds at `?2`.
13624        edge_arm = pending_edge_projection_from_where(2)
13625    );
13626    let mut statement = connection.prepare_cached(&sql)?;
13627    let rows = statement.query_map(params![cursor, current_epoch_seconds()], |row| {
13628        Ok(ProjectionJob { cursor: row.get(0)?, kind: row.get(1)?, body: row.get(2)? })
13629    })?;
13630    let mut jobs = Vec::with_capacity(max_jobs);
13631    for row in rows {
13632        let job = row?;
13633        if in_flight.contains(&job.cursor) {
13634            continue;
13635        }
13636        jobs.push(job);
13637        if jobs.len() >= max_jobs {
13638            break;
13639        }
13640    }
13641    Ok(jobs)
13642}
13643
13644fn database_has_pending_projection_work(path: &Path) -> rusqlite::Result<bool> {
13645    let connection = open_runtime_connection(path)?;
13646    connection_has_pending_projection_work(&connection)
13647}
13648
13649/// 0.8.20 Slice 20 (R-20-DR) — the body of
13650/// [`database_has_pending_projection_work`], lifted so it can also run on a
13651/// connection the caller ALREADY holds (the engine's own connection, inside
13652/// [`Engine::read_projections`]) instead of opening a runtime connection from a
13653/// path. Both callers run the same two arms and the same predicates — which is
13654/// the point. Readiness and `drain`/`wait_for_idle` must key off ONE definition
13655/// of "outstanding embed", or readiness could report `ready` for work `drain`
13656/// still waits on.
13657///
13658/// fix-1 (codex §9 [P2]) — the edge arm is no longer a hand-copied mirror of
13659/// the scheduler's: both are built from
13660/// [`pending_edge_projection_from_where`]. The copy had lost the
13661/// `_fathomdb_vector_kinds` join, so this probe reported permanent pending work
13662/// for edge bodies the scheduler would never schedule. That was PRE-EXISTING —
13663/// it reached `Engine::drain` through `wait_for_idle` before readiness existed.
13664fn connection_has_pending_projection_work(connection: &Connection) -> rusqlite::Result<bool> {
13665    let cursor = load_projection_cursor(connection)?;
13666    // Check canonical_nodes for un-projected work.
13667    let has_node_work: bool = connection
13668        .query_row(
13669            "SELECT 1
13670             FROM canonical_nodes
13671             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
13672             LEFT JOIN _fathomdb_projection_terminal
13673               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
13674             WHERE canonical_nodes.write_cursor > ?1
13675               AND _fathomdb_projection_terminal.write_cursor IS NULL
13676             LIMIT 1",
13677            [cursor],
13678            |_row| Ok(true),
13679        )
13680        .or_else(|err| match err {
13681            rusqlite::Error::QueryReturnedNoRows => Ok(false),
13682            _ => Err(err),
13683        })?;
13684    if has_node_work {
13685        return Ok(true);
13686    }
13687    // G11 (Slice 15) fix-1 [P2] — also check canonical_edges for edge bodies
13688    // that were not projected before the engine closed. Without this check,
13689    // drain() returns idle while edge vectors remain unembedded on reopen.
13690    // fix-31 [P2]: exclude superseded edges from the pending check so the
13691    // scheduler does not pick up stale tombstoned rows as projection work.
13692    // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex §9
13693    // [P2]) — also exclude t_invalid-excluded (recency-consolidated) edges,
13694    // mirroring `next_pending_projection_jobs`'s edge arm. Required: without
13695    // this mirror, a rebuild-truncated t_invalid edge that
13696    // `next_pending_projection_jobs` now correctly skips would never gain a
13697    // `_fathomdb_projection_terminal` row, so this probe would flag it as
13698    // phantom-pending forever and `drain()`/`wait_for_idle` would hang.
13699    // Slice-20 fix-1 [P2]: the mirror is now STRUCTURAL — the arm is built from
13700    // `pending_edge_projection_from_where`, the same fragment the scheduler
13701    // uses — because the hand-copied mirror had already lost the
13702    // `_fathomdb_vector_kinds` join and produced exactly the phantom-pending
13703    // hang described above for edge bodies under an unregistered `edge_fact`.
13704    connection
13705        .query_row(
13706            // TC-33: no other parameter here ⇒ the edge `:now` binds at `?1`.
13707            // No `write_cursor > cursor` filter — see the fragment's doc for
13708            // why the probe deliberately looks BELOW the watermark too.
13709            &format!("SELECT 1 {} LIMIT 1", pending_edge_projection_from_where(1)),
13710            params![current_epoch_seconds()],
13711            |_row| Ok(true),
13712        )
13713        .or_else(|err| match err {
13714            rusqlite::Error::QueryReturnedNoRows => Ok(false),
13715            _ => Err(err),
13716        })
13717}
13718
13719/// 0.8.20 Slice 20 (R-20-DR) — the `dense_readiness` of the `searchable→vector`
13720/// projection, DERIVED. There is no stored flag and this feature adds no schema
13721/// step or `MIGRATIONS` entry; later unrelated migrations do not affect that
13722/// property.
13723///
13724/// **Why derived is the design, not a shortcut.** §4.1 invariant 1 requires
13725/// `{ vector-insert ∧ dense_readiness := ready }` to be ONE transaction, with a
13726/// torn `ready`-without-vector FORBIDDEN. A stored flag is precisely the thing
13727/// that can tear. Deriving it makes the invariant true **by construction**:
13728/// readiness is a pure function of state that
13729/// [`commit_projection_outcomes`] already writes inside a single transaction —
13730/// the `vector_default` / `_fathomdb_vector_rows` INSERTs, the
13731/// `_fathomdb_projection_terminal` row ([`record_projection_terminal`]) and the
13732/// readiness watermark ([`advance_projection_cursor`], which only ever steps
13733/// over cursors that ALREADY hold a terminal) all commit together or not at all.
13734/// So `ready` cannot be observed before the vector is durable, and the only
13735/// reachable torn state is the tolerated one (`embedding` with the vector
13736/// absent — the dense arm simply reads as partial).
13737///
13738/// It reuses the EXACT predicate `drain`/`wait_for_idle` use
13739/// ([`connection_has_pending_projection_work`]), so "readiness is `ready`" and
13740/// "`drain` reports idle" cannot disagree.
13741///
13742/// **Scope note (honest boundary).** The predicate is corpus-wide, not
13743/// per-attribute, because Slice 15d persists the `searchable→vector` sub-object
13744/// but DEFERS building any per-attribute embedding (`ProjectionDelta::deferred`)
13745/// — every declared vector projection is served by the one engine vector
13746/// pipeline, so per-projection scoping has no distinct meaning yet. A stored
13747/// column would not have been more specific; it would only have been tearable.
13748/// When per-attribute embedding lands, this function is where the scoping goes.
13749///
13750/// **Failure boundary.** A row whose embed FAILED terminally records a `failed`
13751/// terminal (no vector row), so it stops being outstanding and readiness returns
13752/// to `ready`. That is the correct reading of a two-member vocabulary — the row
13753/// will never embed, so reporting `embedding` forever would be a lie — and
13754/// failures stay separately observable through the `projection_failures`
13755/// collection. It is the one case where a `ready` corpus can lack a vector row,
13756/// and it is NOT a torn write: no `up_to_date` terminal exists for it.
13757fn derive_dense_readiness(connection: &Connection) -> Result<DenseReadiness, EngineError> {
13758    if connection_has_pending_projection_work(connection).map_err(|_| EngineError::Storage)? {
13759        Ok(DenseReadiness::Embedding)
13760    } else {
13761        Ok(DenseReadiness::Ready)
13762    }
13763}
13764
13765struct CanonicalNodeRow {
13766    cursor: u64,
13767    kind: String,
13768    body: String,
13769    row_kind: RowKind,
13770    /// fix-2 [P2] — whether this row is in the attribute projection's row set
13771    /// (`state = 'active' AND superseded_at IS NULL`, the exact `backfill_attribute`
13772    /// predicate). A projector-replay rebuild uses this to gate the attribute
13773    /// projection so it does not re-surface a pending / superseded node's values.
13774    /// Node-FTS / vector shadows are rebuilt for every row (their stale versions
13775    /// are excluded by the read-side lifecycle join, unchanged from before).
13776    attr_projected: bool,
13777}
13778
13779/// 0.8.0 Slice 5 (G1) — re-tokenize `search_index` from the canonical source
13780/// rows after the step-11 tokenizer-default upgrade drops + recreates the FTS5
13781/// virtual table. Projection-only: it reads `canonical_nodes` (the source of
13782/// truth, untouched) and rewrites the FTS shadow; it performs **no**
13783/// source-record migration. Every canonical node already carries an FTS row at
13784/// write time (the projection-time INSERT is unconditional), so reinserting
13785/// every node exactly reproduces the prior index content under the new
13786/// tokenizer. Runs in a single transaction on the writer connection before
13787/// readers spawn.
13788///
13789/// Crash-retryable (fix-1): the reindex and its durable completion marker
13790/// (`SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` in `_fathomdb_open_state`)
13791/// commit together in ONE `BEGIN IMMEDIATE…COMMIT`. A crash before the commit
13792/// rolls both back, leaving no marker; the next open re-runs. A crash after
13793/// the commit finds the marker present and skips. Idempotent.
13794fn reproject_search_index_after_tokenizer_upgrade(connection: &Connection) -> rusqlite::Result<()> {
13795    let rows = canonical_node_rows(connection)?;
13796    connection.execute_batch("BEGIN IMMEDIATE")?;
13797    let result = (|| {
13798        // 0.8.20 Slice 5a (R-20-E1) — registry-driven: re-tokenize EVERY
13799        // node-FTS projection, not just `search_index`. `search_index_v2` uses
13800        // the SAME tokenizer (`porter unicode61 remove_diacritics 2`), so it is
13801        // equally invalidated by a tokenizer-default upgrade; before this slice
13802        // it was neither cleared nor re-tokenized here. Edge FTS is out of scope
13803        // for this open-path repair (it postdates the step-11 upgrade and is
13804        // rebuilt by `rebuild_projections`).
13805        truncate_row_projections_in(connection, &[ProjectionClass::NodeFts])?;
13806        for row in &rows {
13807            project_canonical_node_row(
13808                connection,
13809                row.cursor,
13810                &row.kind,
13811                &row.body,
13812                row.row_kind,
13813                ProjectionPass::FtsOnly,
13814                // FtsOnly never touches the attribute store (predates step 24), so
13815                // `node_active` is inert here; forward the row's flag anyway (it is
13816                // the backfill's active-and-non-superseded predicate) so the field
13817                // has a reader in every build configuration.
13818                row.attr_projected,
13819            )?;
13820        }
13821        connection.execute(
13822            "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
13823             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
13824            params![SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY, "1"],
13825        )?;
13826        Ok(())
13827    })();
13828    match result {
13829        Ok(()) => connection.execute_batch("COMMIT"),
13830        Err(err) => {
13831            let _ = connection.execute_batch("ROLLBACK");
13832            Err(err)
13833        }
13834    }
13835}
13836
13837/// 0.8.0 Slice 5 (G1) fix-1 — has the post-tokenizer-upgrade re-tokenization
13838/// committed durably on this DB? Keys off the
13839/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` row written inside the reindex
13840/// transaction; its absence on a v11 DB means the reindex never committed
13841/// (fresh-after-step-11 or crash-in-window) and must (re-)run.
13842///
13843/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
13844/// reproject): that table is created by migration step 1, so its absence means
13845/// the DB never ran our migrations (e.g. a synthetic DB whose `user_version`
13846/// was stamped to 11 by hand, or a legacy/foreign shape). Such DBs are
13847/// rejected by the downstream embedder-identity/integrity probes; the reproject
13848/// must not run — and must not mask those errors — on them. On a genuinely
13849/// migrated DB the table always exists, so the crash-repair path is unaffected.
13850fn search_index_tokenizer_reproject_complete(connection: &Connection) -> rusqlite::Result<bool> {
13851    match connection.query_row(
13852        "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
13853        [SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY],
13854        |row| row.get::<_, String>(0),
13855    ) {
13856        Ok(value) => Ok(value == "1"),
13857        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
13858        Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
13859            if message.contains("no such table") =>
13860        {
13861            Ok(true)
13862        }
13863        Err(err) => Err(err),
13864    }
13865}
13866
13867/// 0.8.20 Slice 15c (TC-33) fix-6 — has the one-time edge-vector prune committed
13868/// durably on this DB? Keys off the [`EDGE_VECTOR_PRUNE_MARKER_KEY`] row written
13869/// inside the prune transaction; its absence means the prune never ran (a DB
13870/// upgraded before this fix shipped, or a crash between the step-23 commit and
13871/// the prune commit) and must (re-)run.
13872///
13873/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
13874/// prune) — that table is created by migration step 1, so its absence means the
13875/// DB never ran our migrations (a synthetic/foreign shape rejected downstream);
13876/// the prune must not run, and must not mask those errors, on it. Mirrors
13877/// [`search_index_tokenizer_reproject_complete`].
13878fn edge_vector_prune_complete(connection: &Connection) -> rusqlite::Result<bool> {
13879    match connection.query_row(
13880        "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
13881        [EDGE_VECTOR_PRUNE_MARKER_KEY],
13882        |row| row.get::<_, String>(0),
13883    ) {
13884        Ok(value) => Ok(value == "1"),
13885        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
13886        Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
13887            if message.contains("no such table") =>
13888        {
13889            Ok(true)
13890        }
13891        Err(err) => Err(err),
13892    }
13893}
13894
13895/// 0.8.20 Slice 15c (TC-33) fix-6 — delete every `vector_default` (vec0) row that
13896/// has NO `_fathomdb_vector_rows` sidecar entry, then record the durable
13897/// completion marker, all in one `BEGIN IMMEDIATE` transaction (crash-retryable:
13898/// a crash before COMMIT leaves no marker and the next open re-runs).
13899///
13900/// A vec0 row and its sidecar row are written and deleted TOGETHER (same
13901/// transaction) on every steady-state path, so a sidecar-less vec0 row is ONLY
13902/// ever produced by the step-23 recreate, which drops the edge rows and their
13903/// sidecar entries but cannot reach the engine-created vec0 table. So this
13904/// targets exactly the dropped edges' orphans and touches NOTHING on a healthy
13905/// corpus. Node vec0 rows keep their sidecar entry, so they are never pruned —
13906/// node recall is unaffected.
13907///
13908/// The orphans are gathered with plain scans (both proven vec0 forms — a full
13909/// `SELECT rowid FROM vector_default` and per-`rowid` `DELETE`) and diffed in
13910/// Rust, rather than relying on a compound `DELETE ... WHERE rowid NOT IN (...)`
13911/// over the virtual table.
13912fn prune_orphaned_edge_vectors(connection: &Connection) -> rusqlite::Result<()> {
13913    connection.execute_batch("BEGIN IMMEDIATE")?;
13914    let result = (|| {
13915        let sidecar: std::collections::HashSet<i64> = {
13916            let mut statement =
13917                connection.prepare("SELECT write_cursor FROM _fathomdb_vector_rows")?;
13918            let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
13919            let mut set = std::collections::HashSet::new();
13920            for r in rows {
13921                set.insert(r?);
13922            }
13923            set
13924        };
13925        let vec_rowids: Vec<i64> = {
13926            let mut statement = connection.prepare("SELECT rowid FROM vector_default")?;
13927            let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
13928            let mut out = Vec::new();
13929            for r in rows {
13930                out.push(r?);
13931            }
13932            out
13933        };
13934        for rowid in vec_rowids {
13935            if !sidecar.contains(&rowid) {
13936                // vec0 rowid IS the canonical write_cursor; delete by rowid (the
13937                // proven vec0 delete form, as `prune_edge_projection_shadows`),
13938                // through the one TC-76-safe vec0-delete primitive.
13939                delete_vector_partition_row(connection, rowid)?;
13940            }
13941        }
13942        connection.execute(
13943            "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
13944             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
13945            params![EDGE_VECTOR_PRUNE_MARKER_KEY, "1"],
13946        )?;
13947        Ok(())
13948    })();
13949    match result {
13950        Ok(()) => connection.execute_batch("COMMIT"),
13951        Err(err) => {
13952            let _ = connection.execute_batch("ROLLBACK");
13953            Err(err)
13954        }
13955    }
13956}
13957
13958fn canonical_node_rows(connection: &Connection) -> rusqlite::Result<Vec<CanonicalNodeRow>> {
13959    // fix-2 [P2] — also read `state` + `superseded_at` so a replay rebuild can gate
13960    // the attribute projection to the backfill's row set. `attr_projected` mirrors
13961    // the exact `backfill_attribute` predicate (`state = 'active' AND
13962    // superseded_at IS NULL`): a NULL/foreign state is NOT 'active' and so is
13963    // excluded, identical to the SQL equality.
13964    let mut statement = connection.prepare(
13965        "SELECT write_cursor, kind, body, row_kind, state, superseded_at \
13966         FROM canonical_nodes ORDER BY write_cursor",
13967    )?;
13968    let rows = statement.query_map([], |row| {
13969        let state: Option<String> = row.get::<_, Option<String>>(4)?;
13970        let superseded_at: Option<i64> = row.get::<_, Option<i64>>(5)?;
13971        Ok(CanonicalNodeRow {
13972            cursor: row.get::<_, u64>(0)?,
13973            kind: row.get::<_, String>(1)?,
13974            body: row.get::<_, String>(2)?,
13975            row_kind: row_kind_from_column(&row.get::<_, String>(3)?),
13976            attr_projected: state.as_deref() == Some("active") && superseded_at.is_none(),
13977        })
13978    })?;
13979    rows.collect()
13980}
13981
13982/// 0.8.20 Slice 5a — inverse of [`RowKind::as_str`] for the stored
13983/// `canonical_nodes.row_kind` column. An unrecognized spelling degrades to
13984/// `Leaf`, the column DEFAULT and the shape every pre-EXP-S row carries; that
13985/// keeps a projector replay behavior-identical to the pre-registry rebuild,
13986/// which ignored `row_kind` entirely.
13987fn row_kind_from_column(value: &str) -> RowKind {
13988    match value {
13989        "coverage" => RowKind::Coverage,
13990        "graph" => RowKind::Graph,
13991        _ => RowKind::Leaf,
13992    }
13993}
13994
13995#[cfg(feature = "operator")]
13996fn hex_encode(bytes: &[u8]) -> String {
13997    let mut out = String::with_capacity(bytes.len() * 2);
13998    for byte in bytes {
13999        out.push(hex_nibble(byte >> 4));
14000        out.push(hex_nibble(byte & 0x0f));
14001    }
14002    out
14003}
14004
14005#[cfg(feature = "operator")]
14006fn hex_nibble(value: u8) -> char {
14007    match value {
14008        0..=9 => (b'0' + value) as char,
14009        10..=15 => (b'a' + value - 10) as char,
14010        _ => unreachable!(),
14011    }
14012}
14013
14014#[cfg(feature = "operator")]
14015fn physical_section(connection: &Connection, full: bool) -> Section {
14016    let mut findings = Vec::new();
14017    if let Err(err) = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0)) {
14018        findings.push(Finding {
14019            code: "E_CORRUPT_HEADER",
14020            stage: "PhysicalProbe",
14021            locator: locator_from_rusqlite_error(&err),
14022            doc_anchor: "design/recovery.md#header-malformed",
14023            detail: format!("page_count probe failed: {err}"),
14024        });
14025    }
14026    if full {
14027        match collect_integrity_check_findings(connection) {
14028            Ok(rows) => findings.extend(rows),
14029            Err(err) => findings.push(Finding {
14030                code: "E_CORRUPT_INTEGRITY_CHECK",
14031                stage: "IntegrityCheck",
14032                locator: locator_from_rusqlite_error(&err),
14033                doc_anchor: "design/recovery.md#integrity-check-full-findings",
14034                detail: format!("PRAGMA integrity_check failed: {err}"),
14035            }),
14036        }
14037    }
14038    if findings.is_empty() {
14039        Section::Clean
14040    } else {
14041        Section::Findings(findings)
14042    }
14043}
14044
14045#[cfg(feature = "operator")]
14046fn logical_section(connection: &Connection) -> Section {
14047    let mut findings = Vec::new();
14048    if let Err(err) = connection.query_row("PRAGMA schema_version", [], |row| row.get::<_, i64>(0))
14049    {
14050        findings.push(Finding {
14051            code: "E_CORRUPT_SCHEMA",
14052            stage: "SchemaProbe",
14053            locator: locator_from_rusqlite_error(&err),
14054            doc_anchor: "design/recovery.md#schema-inconsistent",
14055            detail: format!("schema_version probe failed: {err}"),
14056        });
14057    }
14058    match connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) {
14059        Ok(0) => findings.push(Finding {
14060            code: "E_CORRUPT_SCHEMA",
14061            stage: "SchemaProbe",
14062            locator: CorruptionLocator::MigrationStep { from: 0, to: 0 },
14063            doc_anchor: "design/recovery.md#schema-inconsistent",
14064            detail: "user_version is zero".to_string(),
14065        }),
14066        Ok(_) => {}
14067        Err(err) => findings.push(Finding {
14068            code: "E_CORRUPT_SCHEMA",
14069            stage: "SchemaProbe",
14070            locator: locator_from_rusqlite_error(&err),
14071            doc_anchor: "design/recovery.md#schema-inconsistent",
14072            detail: format!("user_version probe failed: {err}"),
14073        }),
14074    }
14075    if findings.is_empty() {
14076        Section::Clean
14077    } else {
14078        Section::Findings(findings)
14079    }
14080}
14081
14082#[cfg(feature = "operator")]
14083fn semantic_section(connection: &Connection) -> Section {
14084    match load_default_profile(connection) {
14085        Ok(_) => Section::Clean,
14086        Err(rusqlite::Error::QueryReturnedNoRows) => Section::Findings(vec![Finding {
14087            code: "E_CORRUPT_EMBEDDER_IDENTITY",
14088            stage: "EmbedderIdentity",
14089            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
14090            doc_anchor: "design/recovery.md#embedder-identity-drift",
14091            detail: "default embedder profile row is missing".to_string(),
14092        }]),
14093        Err(err) => Section::Findings(vec![Finding {
14094            code: "E_CORRUPT_EMBEDDER_IDENTITY",
14095            stage: "EmbedderIdentity",
14096            locator: locator_from_rusqlite_error(&err),
14097            doc_anchor: "design/recovery.md#embedder-identity-drift",
14098            detail: format!("default embedder profile probe failed: {err}"),
14099        }]),
14100    }
14101}
14102
14103#[cfg(feature = "operator")]
14104fn collect_integrity_check_findings(connection: &Connection) -> rusqlite::Result<Vec<Finding>> {
14105    let mut statement = connection.prepare("PRAGMA integrity_check")?;
14106    let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
14107    let mut findings = Vec::new();
14108    for row in rows {
14109        let message = row?;
14110        if message == "ok" {
14111            continue;
14112        }
14113        findings.push(Finding {
14114            code: "E_CORRUPT_INTEGRITY_CHECK",
14115            stage: "IntegrityCheck",
14116            locator: CorruptionLocator::OpaqueSqliteError {
14117                sqlite_extended_code: rusqlite::ffi::SQLITE_CORRUPT,
14118            },
14119            doc_anchor: "design/recovery.md#integrity-check-full-findings",
14120            detail: message,
14121        });
14122    }
14123    Ok(findings)
14124}
14125
14126#[cfg(feature = "operator")]
14127fn locator_from_rusqlite_error(err: &rusqlite::Error) -> CorruptionLocator {
14128    let extended = err.sqlite_error().map(|inner| inner.extended_code).unwrap_or(0);
14129    CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: extended }
14130}
14131
14132fn open_runtime_connection(path: &Path) -> rusqlite::Result<Connection> {
14133    let connection = Connection::open(path)?;
14134    connection.pragma_update(None, "journal_mode", "WAL")?;
14135    // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON` at
14136    // EVERY open. The projection/vector-rewrite runtime connection performs
14137    // DELETEs (shadow-table rewrites), so its freed pages must be scrubbed too;
14138    // setting the pragma only on the writer left a GDPR-erasure leak here.
14139    connection.pragma_update(None, "secure_delete", "ON")?;
14140    Ok(connection)
14141}
14142
14143fn load_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
14144    connection
14145        .query_row(
14146            "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
14147            [PROJECTION_CURSOR_KEY],
14148            |row| row.get::<_, String>(0),
14149        )
14150        .map(|value| value.parse::<u64>().unwrap_or(0))
14151        .or_else(|err| match err {
14152            rusqlite::Error::QueryReturnedNoRows => Ok(0),
14153            _ => Err(err),
14154        })
14155}
14156
14157fn store_projection_cursor(connection: &Connection, cursor: u64) -> rusqlite::Result<()> {
14158    connection.execute(
14159        "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
14160         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
14161        params![PROJECTION_CURSOR_KEY, cursor.to_string()],
14162    )?;
14163    Ok(())
14164}
14165
14166fn record_projection_terminal(
14167    connection: &Connection,
14168    cursor: u64,
14169    state: &str,
14170) -> rusqlite::Result<()> {
14171    connection.execute(
14172        "INSERT OR IGNORE INTO _fathomdb_projection_terminal(write_cursor, state) VALUES(?1, ?2)",
14173        params![cursor, state],
14174    )?;
14175    Ok(())
14176}
14177
14178fn terminal_state_for_cursor(
14179    connection: &Connection,
14180    cursor: u64,
14181) -> rusqlite::Result<Option<String>> {
14182    connection
14183        .query_row(
14184            "SELECT state FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
14185            [cursor],
14186            |row| row.get::<_, String>(0),
14187        )
14188        .map(Some)
14189        .or_else(|err| match err {
14190            rusqlite::Error::QueryReturnedNoRows => Ok(None),
14191            _ => Err(err),
14192        })
14193}
14194
14195fn advance_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
14196    let mut cursor = load_projection_cursor(connection)?;
14197    loop {
14198        let next = cursor.saturating_add(1);
14199        if terminal_state_for_cursor(connection, next)?.is_some() {
14200            cursor = next;
14201        } else {
14202            break;
14203        }
14204    }
14205    store_projection_cursor(connection, cursor)?;
14206    Ok(cursor)
14207}
14208
14209fn commit_projection_outcomes(
14210    connection: &mut Connection,
14211    outcomes: &[ProjectionOutcome],
14212    shared: &ProjectionRuntimeShared,
14213) -> rusqlite::Result<()> {
14214    let embedder_identity = &shared.embedder_identity;
14215    let mc = identity_requires_mean_centering(embedder_identity);
14216    // EU-5f — serialize the whole commit across workers so the at-pin
14217    // re-quantize sees a totally-ordered history (see `commit_gate`).
14218    let _gate = shared.commit_gate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14219    // Take the WAL write lock before the reads below. A deferred transaction
14220    // would need a read-to-write promotion while a concurrent Engine::write
14221    // holds its own immediate transaction, which SQLite rejects without
14222    // invoking the busy handler and forces the worker to recompute the batch.
14223    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
14224    // The accumulator is mutable process state coupled to this transaction.
14225    // Keep the shared value untouched while building a candidate so rollback
14226    // cannot count a vector or consume the pin threshold prematurely.
14227    let mut shared_accumulator =
14228        shared.mean_accumulator.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14229    let mut candidate_accumulator = shared_accumulator.clone();
14230    // EU-5a2/EU-5f — the live pinned mean. Read once at the top; may pin
14231    // mid-batch (set to `Some` after a threshold-crossing row below).
14232    let mut current_mean: Option<Vec<f32>> = if mc {
14233        tx.query_row(
14234            "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
14235            [],
14236            |row| row.get::<_, Option<Vec<u8>>>(0),
14237        )
14238        .ok()
14239        .flatten()
14240        .map(|bytes| decode_vector_blob(&bytes))
14241    } else {
14242        None
14243    };
14244    let mut staged_events: Vec<EmbedderEvent> = Vec::new();
14245    for outcome in outcomes {
14246        match outcome {
14247            ProjectionOutcome::Success { cursor, kind, blob, bin_blob } => {
14248                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14249                    continue;
14250                }
14251                // Build the threshold decision in the transaction-local
14252                // candidate. The shared accumulator changes only after commit.
14253                let pin_mean: Option<Vec<f32>> = if mc && current_mean.is_none() {
14254                    match candidate_accumulator.as_mut() {
14255                        Some(a) => {
14256                            a.add(&decode_vector_blob(bin_blob));
14257                            if a.count() >= MEAN_VEC_PIN_THRESHOLD {
14258                                let mean = a.materialize();
14259                                candidate_accumulator = None;
14260                                Some(mean)
14261                            } else {
14262                                None
14263                            }
14264                        }
14265                        None => None,
14266                    }
14267                } else {
14268                    None
14269                };
14270
14271                let source_type = resolve_source_type(kind).map_err(|_| {
14272                    rusqlite::Error::SqliteFailure(
14273                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
14274                        Some(format!("unknown kind for source_type mapping: {kind}")),
14275                    )
14276                })?;
14277                let now_unix =
14278                    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
14279                        as i64;
14280                tx.execute(
14281                    "INSERT OR IGNORE INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
14282                    params![cursor, kind, cursor],
14283                )?;
14284                // EU-5a2/EU-5f — sign-quant input is the mean-subtracted
14285                // vector iff a mean is live (`current_mean`); otherwise the
14286                // un-centered `bin_blob`. A row inserted just before the
14287                // crossing is centered retroactively by the re-quantize
14288                // pass below.
14289                let centered_blob: Vec<u8> = match &current_mean {
14290                    Some(mean) if mean.len() * 4 == bin_blob.len() => {
14291                        encode_vector_blob(&subtract_mean(&decode_vector_blob(bin_blob), mean))
14292                    }
14293                    _ => bin_blob.clone(),
14294                };
14295                // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0
14296                // TEXT metadata is NOT NULL-able); no real population source yet
14297                // (reserved-gap candidate 13).
14298                //
14299                // 0.8.20 Slice 15e — when the live `vector_default` carries
14300                // `filterable` `attr_<hex>` columns, EVERY column must be bound
14301                // (vec0 rejects a partial-column INSERT). Bind each from the node
14302                // body's scalar extraction (or the `''` sentinel). The body is not
14303                // carried on the embed job, so it is read from `canonical_nodes` by
14304                // `write_cursor` (== rowid) — but ONLY when attr columns exist, so
14305                // the common no-filterable hot path stays byte-identical and does
14306                // NO extra lookup.
14307                if actual_vector_attr_columns(&tx)?.is_empty() {
14308                    tx.execute(
14309                        "INSERT OR IGNORE INTO vector_default(
14310                            rowid, embedding, embedding_bin, source_type, kind, created_at, status
14311                         ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, '')",
14312                        params![cursor, blob, centered_blob, source_type, kind, now_unix],
14313                    )?;
14314                } else {
14315                    let body: String = tx
14316                        .query_row(
14317                            "SELECT body FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
14318                            [*cursor as i64],
14319                            |row| row.get(0),
14320                        )
14321                        .optional()?
14322                        .unwrap_or_default();
14323                    let (cols_sql, ph_sql, attr_vals) =
14324                        vector_attr_insert_fragments(&tx, &body, 7)?;
14325                    let sql = format!(
14326                        "INSERT OR IGNORE INTO vector_default(
14327                            rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
14328                         ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
14329                    );
14330                    let mut pv: Vec<rusqlite::types::Value> = vec![
14331                        rusqlite::types::Value::Integer(*cursor as i64),
14332                        rusqlite::types::Value::Blob(blob.clone()),
14333                        rusqlite::types::Value::Blob(centered_blob.clone()),
14334                        rusqlite::types::Value::Text(source_type.to_string()),
14335                        rusqlite::types::Value::Text(kind.to_string()),
14336                        rusqlite::types::Value::Integer(now_unix),
14337                    ];
14338                    pv.extend(attr_vals);
14339                    tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))?;
14340                }
14341                record_projection_terminal(&tx, *cursor, "up_to_date")?;
14342
14343                // EU-5f — this row crossed the threshold: pin the mean and
14344                // re-quantize every row written so far (incl. earlier rows
14345                // in this same tx, which are visible to the SELECT) within
14346                // the same transaction so the pin is atomic.
14347                if let Some(mean) = pin_mean {
14348                    tx.execute(
14349                        "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14350                        params![encode_vector_blob(&mean)],
14351                    )?;
14352                    let rows: Vec<(i64, Vec<u8>)> = {
14353                        let mut statement = tx.prepare(
14354                            "SELECT rowid, embedding FROM vector_default ORDER BY rowid",
14355                        )?;
14356                        let mapped = statement.query_map([], |row| {
14357                            Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
14358                        })?;
14359                        let mut out = Vec::new();
14360                        for r in mapped {
14361                            out.push(r?);
14362                        }
14363                        out
14364                    };
14365                    let (doc_count, _) =
14366                        run_pin_and_requantize_pass(&tx, &rows, &mean).map_err(|_| {
14367                            rusqlite::Error::SqliteFailure(
14368                                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
14369                                Some("mean-centering re-quantize pass failed".to_string()),
14370                            )
14371                        })?;
14372                    staged_events.push(EmbedderEvent::MeanVecPinned {
14373                        dim: u32::try_from(mean.len()).unwrap_or(u32::MAX),
14374                        doc_count,
14375                    });
14376                    current_mean = Some(mean);
14377                }
14378            }
14379            ProjectionOutcome::Failure { cursor, failure_code } => {
14380                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14381                    continue;
14382                }
14383                let existing: u64 = tx.query_row(
14384                    "SELECT COUNT(*) FROM operational_mutations
14385                     WHERE collection_name = 'projection_failures'
14386                       AND json_extract(payload_json, '$.write_cursor') = ?1",
14387                    [cursor],
14388                    |row| row.get(0),
14389                )?;
14390                if existing == 0 {
14391                    let payload = format!(
14392                        r#"{{"write_cursor":{cursor},"failure_code":"{failure_code}","recorded_at":0}}"#
14393                    );
14394                    tx.execute(
14395                        "INSERT INTO operational_mutations(
14396                            collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
14397                         ) VALUES('projection_failures', ?1, 'append', ?2, NULL, ?3)",
14398                        params![cursor.to_string(), payload, cursor],
14399                    )?;
14400                }
14401                record_projection_terminal(&tx, *cursor, "failed")?;
14402            }
14403            // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — record NOTHING.
14404            //
14405            // No `projection_failures` audit row (an ABSENT embedder is an
14406            // environment fact, not an embed failure) and, decisively, no
14407            // terminal: the row keeps `terminal IS NULL`, so
14408            // `advance_projection_cursor` below cannot step over it, the shared
14409            // `connection_has_pending_projection_work` predicate still reports it
14410            // outstanding, and `derive_dense_readiness` therefore reads
14411            // `embedding`. That is the ONLY torn state
14412            // `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
14413            // §4.1 invariant 1 tolerates; the alternative — the enqueue-side gate
14414            // — puts an `'up_to_date'` terminal on an ENROLLED row with no
14415            // vector, which is the torn `ready` that invariant calls FORBIDDEN.
14416            //
14417            // Q6a graceful-absent governs ROLE DECLARATION ("you declared a
14418            // projection I cannot build yet" -> defer + graft), i.e. the
14419            // NOT-yet-enrolled case fix-1/fix-2 handle. Once a kind IS enrolled,
14420            // §4.1 invariant 1 governs. (HITL ruling, 0.8.20 Slice 20c fix-4.)
14421            //
14422            // Consumer-visible consequence, accepted deliberately and pinned by
14423            // `slice20c_flush_barrier`: for the REST of that no-embedder session
14424            // `dense_readiness` stays `embedding` and `drain` burns its timeout
14425            // into `EngineError::Scheduler`. Loud and recoverable, rather than
14426            // silent and lost.
14427            ProjectionOutcome::Deferred => {}
14428        }
14429    }
14430    // 0.7.2 PR-2bc S2 — the AUTOMATIC in-ingest drift detector (EWMA recent
14431    // mean + cos-threshold + debounce + 200k cap + `MeanRecomputeDeferred`)
14432    // was CARVED OUT and DEFERRED to 0.8.x; its recall premise was refuted
14433    // (the mean is a non-lever) and the benefit is unmeasured. The mean is
14434    // refreshed only on demand via `Engine::recompute_mean` (the
14435    // `doctor recompute-mean` verb). See `dev/design/embedder.md` §0.3 and
14436    // `dev/plans/prompts/0.8.x-auto-mean-drift-DEFERRED.md`. Nothing here
14437    // mutates `mean_vec` after the initial pin.
14438
14439    advance_projection_cursor(&tx)?;
14440    #[cfg(debug_assertions)]
14441    match shared.force_projection_commit_failure.swap(0, Ordering::SeqCst) {
14442        1 => {
14443            return Err(rusqlite::Error::SqliteFailure(
14444                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
14445                Some("forced projection commit failure".to_string()),
14446            ));
14447        }
14448        2 => return Err(rusqlite::Error::InvalidQuery),
14449        _ => {}
14450    }
14451    tx.commit()?;
14452    // Commit and the accumulator transition become visible together. On every
14453    // earlier error the transaction and the local candidate drop, leaving the
14454    // runtime state exactly as it was before this attempt.
14455    *shared_accumulator = candidate_accumulator;
14456    // EU-5f — publish MeanVecPinned only after the pin tx is durable, so a
14457    // rolled-back pin never emits a spurious event.
14458    if !staged_events.is_empty() {
14459        if let Ok(mut events) = shared.pending_events.lock() {
14460            events.extend(staged_events);
14461        }
14462    }
14463    Ok(())
14464}
14465
14466/// EU-5f — open-time recovery pin (`dev/design/embedder.md` §0.3, Hazard 4).
14467/// Derives the corpus mean from the existing un-centered `vector_default`
14468/// rows, pins it, and re-quantizes every row, all in one transaction on the
14469/// single-threaded open connection (no workers running yet, so no gate is
14470/// needed). Called only when MC is required, no mean is pinned, and the row
14471/// count already meets the threshold.
14472fn recover_mean_vec_pin(
14473    connection: &mut Connection,
14474    identity: &EmbedderIdentity,
14475) -> Result<(), EngineError> {
14476    let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
14477    recompute_mean_in_tx(&tx, identity)?;
14478    tx.commit().map_err(|_| EngineError::Storage)?;
14479    Ok(())
14480}
14481
14482/// 0.7.2 PR-2b — shared mean (re)compute core, run INSIDE the caller's
14483/// transaction. Derives the FULL-corpus mean from the un-centered
14484/// `vector_default.embedding` BLOBs, writes `mean_vec`, and re-quantizes
14485/// EVERY row via the existing [`run_pin_and_requantize_pass`] so no row is
14486/// left under a stale centering.
14487///
14488/// This generalizes the EU-5f open-time recovery pin: it has NO "no mean
14489/// pinned yet" guard, so it equally serves the FIRST pin (recovery) and a
14490/// REFRESH of an already-pinned mean (PR-2b drift / `doctor recompute-mean`).
14491/// The caller owns the transaction boundary, which is what makes a fault
14492/// between the `mean_vec` UPDATE and re-quantize completion roll back
14493/// wholesale (`dev/design/embedder.md` §0.5 atomicity). It does NOT publish
14494/// any event — that is the caller's job, strictly post-durable-commit.
14495fn recompute_mean_in_tx(
14496    tx: &rusqlite::Transaction<'_>,
14497    identity: &EmbedderIdentity,
14498) -> Result<MeanRecomputeReport, EngineError> {
14499    recompute_mean_in_tx_inner(tx, identity, false)
14500}
14501
14502/// 0.7.2 PR-2b — recompute core with an optional fault-injection point. The
14503/// `fail_after_mean_update` flag (debug builds only, set via a test seam)
14504/// errors AFTER the `mean_vec` UPDATE but BEFORE the re-quantize completes,
14505/// so the caller's tx rolls back the partial recentering.
14506fn recompute_mean_in_tx_inner(
14507    tx: &rusqlite::Transaction<'_>,
14508    identity: &EmbedderIdentity,
14509    fail_after_mean_update: bool,
14510) -> Result<MeanRecomputeReport, EngineError> {
14511    let started = Instant::now();
14512    let dim = identity.dimension as usize;
14513    // The previously-pinned mean (if any) is read first so we can report
14514    // the pre-recompute drift cosine.
14515    let old_mean = read_pinned_mean_vec(tx, identity.dimension)?;
14516    let rows: Vec<(i64, Vec<u8>)> = {
14517        let mut statement = tx
14518            .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
14519            .map_err(|_| EngineError::Storage)?;
14520        let mapped = statement
14521            .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
14522            .map_err(|_| EngineError::Storage)?;
14523        let mut out = Vec::new();
14524        for r in mapped {
14525            out.push(r.map_err(|_| EngineError::Storage)?);
14526        }
14527        out
14528    };
14529    let mut accumulator = MeanAccumulator::new(dim);
14530    for (_rowid, blob) in &rows {
14531        if blob.len() != dim * 4 {
14532            return Err(EngineError::Storage);
14533        }
14534        accumulator.add(&decode_vector_blob(blob));
14535    }
14536    let old_doc_count = accumulator.count();
14537    let mean = accumulator.materialize();
14538    let drift_cos_before = match &old_mean {
14539        Some(old) => cosine_similarity(&mean, old),
14540        None => 1.0,
14541    };
14542    tx.execute(
14543        "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14544        params![encode_vector_blob(&mean)],
14545    )
14546    .map_err(|_| EngineError::Storage)?;
14547    if fail_after_mean_update {
14548        // Injected fault: bail before re-quantizing so the caller's tx
14549        // rolls back the `mean_vec` UPDATE too (crash-atomicity proof).
14550        return Err(EngineError::Storage);
14551    }
14552    let (doc_count, _) = run_pin_and_requantize_pass(tx, &rows, &mean)?;
14553    Ok(MeanRecomputeReport {
14554        dim: u32::try_from(dim).unwrap_or(u32::MAX),
14555        old_doc_count,
14556        doc_count_requantized: doc_count,
14557        drift_cos_before,
14558        mean_was_pinned: old_mean.is_some(),
14559        elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
14560    })
14561}
14562
14563/// Cap sweep over the op-store mutation log: keeps the newest `cap` SWEEPABLE
14564/// rows, dropping the oldest by `id`.
14565///
14566/// **Pending-redaction exemption (0.8.20 Slice 5 fix-1).**
14567/// [`ERASURE_PENDING_REDACTION_COLLECTION`] is exempt on the same principle for a
14568/// stronger reason: that row is not a record of a discharged obligation but an
14569/// UNDISCHARGED one. Sweeping it would silently drop an erasure the engine still
14570/// owes, and the next retry would then report success with the leaked stable ids
14571/// still in the telemetry sink — exactly the R-20-E5 violation this mechanism
14572/// exists to prevent.
14573///
14574/// **Erasure-audit exemption (0.8.20 Slice 5b, design v5 §2 defect D-A;
14575/// HITL-ruled 2026-07-19: *"there must be an auditable record of deletion
14576/// event."*).** Rows in [`ERASURE_AUDIT_COLLECTIONS`] are excluded from BOTH the
14577/// count and the DELETE, and are therefore **never removed by retention
14578/// pressure**. Previously this swept `operational_mutations` cap-first,
14579/// oldest-`id`-first, with no collection filter — so the `excise_source_audit`
14580/// row proving an erasure occurred shared one retention pool with the very
14581/// payloads it must prove erased, and (being written before whatever workload
14582/// followed) was among the first evicted. Accountability is a distinct
14583/// obligation from erasure; a sweep must not silently discharge it.
14584///
14585/// Consequence of excluding audit rows from the count: `cap` is a cap on
14586/// SWEEPABLE rows, not on the physical table size. That is deliberate — the
14587/// alternative (counting exempt rows toward the cap) would let a growing audit
14588/// trail evict ordinary provenance ever more aggressively, and in the limit
14589/// leave nothing sweepable while the sweep churned every write.
14590fn enforce_provenance_retention(connection: &Connection, cap: u64) -> rusqlite::Result<()> {
14591    if cap == 0 {
14592        return Ok(());
14593    }
14594    // Static, engine-internal identifiers — no caller input reaches this SQL.
14595    let exempt = ERASURE_AUDIT_COLLECTIONS
14596        .iter()
14597        .copied()
14598        .chain(std::iter::once(ERASURE_PENDING_REDACTION_COLLECTION))
14599        .map(|name| format!("'{name}'"))
14600        .collect::<Vec<_>>()
14601        .join(", ");
14602    let slack = cap.max(20) / 20;
14603    let upper = cap.saturating_add(slack.max(1));
14604    let count: u64 = connection.query_row(
14605        &format!(
14606            "SELECT COUNT(*) FROM operational_mutations
14607             WHERE collection_name NOT IN ({exempt})"
14608        ),
14609        [],
14610        |row| row.get(0),
14611    )?;
14612    if count <= upper {
14613        return Ok(());
14614    }
14615    let to_delete = count.saturating_sub(cap);
14616    connection.execute(
14617        &format!(
14618            "DELETE FROM operational_mutations
14619             WHERE id IN (
14620                 SELECT id FROM operational_mutations
14621                 WHERE collection_name NOT IN ({exempt})
14622                 ORDER BY id
14623                 LIMIT ?1
14624             )"
14625        ),
14626        [to_delete],
14627    )?;
14628    Ok(())
14629}
14630
14631/// 0.8.20 Slice 5b (R-20-E6) — the prefixed stable ids
14632/// ([`IdSpace::to_prefixed`]) of the canonical rows an erasure verb is about to
14633/// delete, so they can be redacted from the telemetry sink.
14634///
14635/// Must be called INSIDE the erasing transaction and BEFORE the DELETEs — after
14636/// them the rows, and with them the `logical_id`/`body` the ids derive from, are
14637/// gone. Both queries take one bound parameter (`?1`), applied to nodes and
14638/// edges respectively; `derive_stable_id` reproduces exactly what
14639/// `capture_telemetry` wrote into `result_stable_ids`.
14640fn collect_erased_stable_ids(
14641    tx: &Connection,
14642    node_sql: &str,
14643    edge_sql: &str,
14644    bind: &str,
14645) -> Result<Vec<String>, EngineError> {
14646    let mut ids = Vec::new();
14647    for sql in [node_sql, edge_sql] {
14648        let mut stmt = tx.prepare(sql).map_err(|_| EngineError::Storage)?;
14649        let rows = stmt
14650            .query_map(params![bind], |row| {
14651                Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?))
14652            })
14653            .map_err(|_| EngineError::Storage)?;
14654        for row in rows {
14655            let (logical_id, body) = row.map_err(|_| EngineError::Storage)?;
14656            ids.push(
14657                derive_stable_id(logical_id.as_deref(), body.as_deref().unwrap_or(""))
14658                    .to_prefixed(),
14659            );
14660        }
14661    }
14662    ids.sort_unstable();
14663    ids.dedup();
14664    Ok(ids)
14665}
14666
14667/// 0.8.20 Slice 5 fix-1 (codex §9 P2) — record, INSIDE the erasing transaction,
14668/// that a telemetry redaction is owed for `erased_stable_ids`.
14669///
14670/// Must be called in the same transaction as the DELETEs. That is the whole
14671/// point: "the rows are gone" and "a redaction is owed for them" then commit
14672/// atomically, so no crash or failure can leave the first true and the second
14673/// unrecorded. [`Engine::discharge_pending_redactions`] drains the queue and
14674/// deletes the entry only once the sink has actually been rewritten.
14675///
14676/// `record_key` is the VERB, never a stable id — the ids live in the payload,
14677/// which is deleted on discharge.
14678fn enqueue_pending_redaction(
14679    tx: &Connection,
14680    verb: &str,
14681    erased_stable_ids: &[String],
14682    write_cursor: u64,
14683) -> Result<(), EngineError> {
14684    if erased_stable_ids.is_empty() {
14685        return Ok(());
14686    }
14687    let payload =
14688        serde_json::json!({ "verb": verb, "erased_stable_ids": erased_stable_ids }).to_string();
14689    tx.execute(
14690        "INSERT INTO operational_mutations(
14691            collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
14692         ) VALUES(?1, ?2, 'append', ?3, NULL, ?4)",
14693        params![ERASURE_PENDING_REDACTION_COLLECTION, verb, payload, write_cursor],
14694    )
14695    .map_err(|_| EngineError::Storage)?;
14696    Ok(())
14697}
14698
14699/// 0.8.20 Slice 5b (R-20-E7) — the audit handle for an erased op-store record:
14700/// `SHA-256(collection + 0x1F + record_key)`, lowercase hex.
14701///
14702/// A record key is arbitrary caller-supplied text and may itself be the
14703/// identifier being erased, so a durable audit row must not echo it. `0x1F`
14704/// (ASCII unit separator) is the delimiter because it cannot appear in a
14705/// well-formed collection name, keeping the pairing unambiguous.
14706#[cfg(feature = "operator")]
14707fn digest_record_identity(collection: &str, record_key: &str) -> String {
14708    let mut hasher = Sha256::new();
14709    hasher.update(collection.as_bytes());
14710    hasher.update([0x1f_u8]);
14711    hasher.update(record_key.as_bytes());
14712    hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
14713}
14714
14715fn projection_status(
14716    connection: &Connection,
14717    kind: &str,
14718) -> Result<lifecycle::ProjectionStatus, EngineError> {
14719    let latest = connection
14720        .query_row(
14721            "SELECT COALESCE(MAX(write_cursor), 0) FROM canonical_nodes WHERE kind = ?1",
14722            [kind],
14723            |row| row.get::<_, u64>(0),
14724        )
14725        .map_err(|_| EngineError::Storage)?;
14726    if latest == 0 {
14727        return Ok(lifecycle::ProjectionStatus::UpToDate);
14728    }
14729    let pending: u64 = connection
14730        .query_row(
14731            "SELECT COUNT(*)
14732             FROM canonical_nodes
14733             LEFT JOIN _fathomdb_projection_terminal
14734               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
14735             WHERE canonical_nodes.kind = ?1
14736               AND _fathomdb_projection_terminal.write_cursor IS NULL",
14737            [kind],
14738            |row| row.get(0),
14739        )
14740        .map_err(|_| EngineError::Storage)?;
14741    if pending > 0 {
14742        return Ok(lifecycle::ProjectionStatus::Pending);
14743    }
14744    match terminal_state_for_cursor(connection, latest).map_err(|_| EngineError::Storage)? {
14745        Some(state) if state == "failed" => Ok(lifecycle::ProjectionStatus::Failed),
14746        _ => Ok(lifecycle::ProjectionStatus::UpToDate),
14747    }
14748}
14749
14750fn canonical_database_path(path: &Path) -> Result<PathBuf, EngineOpenError> {
14751    let parent = path
14752        .parent()
14753        .filter(|parent| !parent.as_os_str().is_empty())
14754        .unwrap_or_else(|| Path::new("."));
14755    let canonical_parent = parent.canonicalize().map_err(|_| EngineOpenError::Io {
14756        message: "database parent directory is not accessible".to_string(),
14757    })?;
14758    let file_name = path.file_name().ok_or_else(|| EngineOpenError::Io {
14759        message: "database path has no file name".to_string(),
14760    })?;
14761
14762    Ok(canonical_parent.join(file_name))
14763}
14764
14765fn acquire_lock(path: &Path) -> Result<File, EngineOpenError> {
14766    let lock_path = lock_path(path);
14767    let mut options = OpenOptions::new();
14768    options.read(true).write(true).create(true);
14769    #[cfg(unix)]
14770    options.mode(0o600);
14771
14772    let mut file = options.open(&lock_path).map_err(|_| EngineOpenError::Io {
14773        message: "could not open database lock file".to_string(),
14774    })?;
14775
14776    match file.try_lock() {
14777        Ok(()) => {
14778            let pid = std::process::id().to_string();
14779            let _ = file.set_len(0);
14780            let _ = file.seek(SeekFrom::Start(0));
14781            let _ = file.write_all(pid.as_bytes());
14782            Ok(file)
14783        }
14784        Err(std::fs::TryLockError::WouldBlock) => {
14785            Err(EngineOpenError::DatabaseLocked { holder_pid: read_holder_pid(&lock_path) })
14786        }
14787        Err(_) => {
14788            Err(EngineOpenError::Io { message: "could not acquire database lock".to_string() })
14789        }
14790    }
14791}
14792
14793fn lock_path(path: &Path) -> PathBuf {
14794    let mut lock_path = path.as_os_str().to_os_string();
14795    lock_path.push(LOCK_SUFFIX);
14796    PathBuf::from(lock_path)
14797}
14798
14799fn read_holder_pid(path: &Path) -> Option<u32> {
14800    std::fs::read_to_string(path).ok()?.trim().parse().ok()
14801}
14802
14803fn map_migration_error(err: SchemaMigrationError) -> EngineOpenError {
14804    match err {
14805        SchemaMigrationError::IncompatibleSchemaVersion { seen, supported } => {
14806            EngineOpenError::IncompatibleSchemaVersion { seen, supported }
14807        }
14808        SchemaMigrationError::MigrationError(report) => EngineOpenError::MigrationError {
14809            schema_version_before: report.schema_version_before,
14810            schema_version_current: report.schema_version_current,
14811            step_id: report.migration_steps.last().map_or(0, |step| step.step_id),
14812        },
14813        SchemaMigrationError::Storage { message } => {
14814            EngineOpenError::Io { message: message.to_string() }
14815        }
14816    }
14817}
14818
14819/// 0.7.0 perf-experiments hook: process-start `sqlite3_config` calls.
14820/// Runs exactly once per process; must precede any `Connection::open`.
14821/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. Each individual config
14822/// option is opt-in via its own env var so unrelated experiments do
14823/// not implicitly co-fire.
14824///
14825/// Currently supports:
14826/// - `FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF=1`:
14827///   `sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)` — drops the
14828///   allocator stats locking surface (whitepaper § 7.4). Composes
14829///   with other levers; small payoff alone.
14830///
14831/// Pattern: shutdown → config → initialize, mirroring B.1 attempt #2
14832/// (`d448263`, reverted). The captured rc for each config call is
14833/// logged to stderr so experiments can verify the call took effect.
14834fn init_perf_experiments_runtime() {
14835    static INIT: Once = Once::new();
14836    INIT.call_once(|| {
14837        if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
14838            return;
14839        }
14840        let memstatus_off =
14841            std::env::var_os("FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF").is_some_and(|v| v == "1");
14842        // FATHOMDB_PERF_SQLITE_PAGECACHE=<page_size_bytes>:<page_count>
14843        // E.g. "4096:5000" => pre-allocate 4096 B × 5000 pages = 20 MB
14844        // global page-cache backing. SQLite distributes this across
14845        // connections; reduces global allocator pressure for page
14846        // cache fills.
14847        let pagecache = std::env::var("FATHOMDB_PERF_SQLITE_PAGECACHE").ok();
14848        // FATHOMDB_PERF_SQLITE_PCACHE2=1 installs the per-instance
14849        // custom page-cache allocator (pcache2.rs). Targets AC-020
14850        // residual contention on the default pcache1 mutex.
14851        let pcache2_on =
14852            std::env::var_os("FATHOMDB_PERF_SQLITE_PCACHE2").is_some_and(|v| v == "1");
14853        if !memstatus_off && pagecache.is_none() && !pcache2_on {
14854            return;
14855        }
14856        // SAFETY: sqlite3_shutdown / sqlite3_initialize are documented
14857        // as safe to call before any other SQLite API; sqlite3_config
14858        // must be called between shutdown and initialize. We pre-empt
14859        // rusqlite's lazy first-call sqlite3_initialize via this
14860        // explicit shutdown-then-config-then-initialize sequence,
14861        // identical to B.1 attempt #2's plumbing.
14862        unsafe {
14863            let rc_shutdown = rusqlite::ffi::sqlite3_shutdown();
14864            let rc_memstatus = if memstatus_off {
14865                rusqlite::ffi::sqlite3_config(rusqlite::ffi::SQLITE_CONFIG_MEMSTATUS, 0_i32)
14866            } else {
14867                -1
14868            };
14869            // SQLITE_CONFIG_PAGECACHE = 7 per sqlite3.h. With buffer=NULL,
14870            // SQLite allocates the backing memory itself but still
14871            // partitions it for use as the page-cache pool.
14872            let rc_pagecache = if let Some(spec) = pagecache.as_ref() {
14873                let mut parts = spec.split(':');
14874                let sz = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
14875                let n = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
14876                if sz > 0 && n > 0 {
14877                    rusqlite::ffi::sqlite3_config(
14878                        7, // SQLITE_CONFIG_PAGECACHE
14879                        std::ptr::null_mut::<std::ffi::c_void>(),
14880                        sz,
14881                        n,
14882                    )
14883                } else {
14884                    eprintln!(
14885                        "perf-experiment: bad FATHOMDB_PERF_SQLITE_PAGECACHE spec '{spec}' (expect '<bytes>:<count>')"
14886                    );
14887                    -1
14888                }
14889            } else {
14890                -1
14891            };
14892            let rc_pcache2 = if pcache2_on {
14893                // SQLITE_CONFIG_PCACHE2 = 18 per sqlite3.h. The methods
14894                // table must outlive the SQLite engine; we pass a
14895                // pointer to our static.
14896                rusqlite::ffi::sqlite3_config(
14897                    rusqlite::ffi::SQLITE_CONFIG_PCACHE2,
14898                    &raw const pcache2::PCACHE2_METHODS.0,
14899                )
14900            } else {
14901                -1
14902            };
14903            let rc_init = rusqlite::ffi::sqlite3_initialize();
14904            eprintln!(
14905                "perf-experiment: runtime-config rcs shutdown={rc_shutdown} \
14906                 memstatus={rc_memstatus} pagecache={rc_pagecache} pcache2={rc_pcache2} \
14907                 initialize={rc_init} (0=SQLITE_OK; 21=SQLITE_MISUSE; -1=not configured)"
14908            );
14909        }
14910    });
14911}
14912
14913fn register_sqlite_vec_extension() {
14914    static REGISTER: Once = Once::new();
14915    REGISTER.call_once(|| unsafe {
14916        let entrypoint: unsafe extern "C" fn(
14917            *mut rusqlite::ffi::sqlite3,
14918            *mut *const std::os::raw::c_char,
14919            *const rusqlite::ffi::sqlite3_api_routines,
14920        ) -> std::os::raw::c_int = std::mem::transmute(sqlite3_vec_init as *const ());
14921        rusqlite::ffi::sqlite3_auto_extension(Some(entrypoint));
14922    });
14923}
14924
14925fn probe_open_integrity(connection: &Connection) -> Result<(), EngineOpenError> {
14926    // `SELECT COUNT(*) FROM sqlite_schema` forces a full traversal of the
14927    // sqlite_schema b-tree; this surfaces page-1 b-tree corruption that a
14928    // bare `PRAGMA schema_version` (which only reads the schema cookie
14929    // out of the file header) would miss.
14930    connection
14931        .query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| row.get::<_, i64>(0))
14932        .map(|_| ())
14933        .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))
14934}
14935
14936fn probe_database_header(connection: &Connection) -> Result<(), EngineOpenError> {
14937    connection
14938        .query_row("PRAGMA application_id", [], |row| row.get::<_, i64>(0))
14939        .map(|_| ())
14940        .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))
14941}
14942
14943/// Pre-`pragma WAL` sidecar validation. SQLite silently discards a WAL
14944/// file whose header magic is wrong or whose advertised page size is
14945/// outside `[512, SQLITE_MAX_PAGE_SIZE]`, which would cause us to lose
14946/// committed frames at open time. AC-035a requires that we instead
14947/// refuse to open with `Corruption(WalReplayFailure)` rather than
14948/// silently rebuild from a truncated WAL.
14949fn probe_wal_sidecar(db_path: &Path) -> Result<(), EngineOpenError> {
14950    let mut wal_path = db_path.as_os_str().to_owned();
14951    wal_path.push("-wal");
14952    let wal_path = PathBuf::from(wal_path);
14953    // Bounded read: the WAL header is fixed-layout in the first 32
14954    // bytes (magic + format + page-size + checkpoint-seq + salts +
14955    // checksums); frame data starts at offset 32 and is irrelevant to
14956    // the magic + page-size pre-check. A `std::fs::read` of the whole
14957    // sidecar would force an unclean-shutdown open path to allocate
14958    // and copy the entire WAL into memory before SQLite touches
14959    // recovery — a real latency + RSS regression on AC-035.
14960    use std::io::Read;
14961    let mut file = match std::fs::File::open(&wal_path) {
14962        Ok(file) => file,
14963        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
14964        Err(_) => return Ok(()),
14965    };
14966    let mut bytes = [0u8; 32];
14967    if file.read_exact(&mut bytes).is_err() {
14968        // A short (< 32-byte) sidecar carries no committed frames;
14969        // SQLite treats it as empty and re-initializes WAL state.
14970        return Ok(());
14971    }
14972    let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
14973    let page_size = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
14974    // WAL_MAGIC mask per SQLite `walIndexRecover`: low bit distinguishes
14975    // big-endian vs little-endian checksum encoding; the rest of the
14976    // magic is fixed.
14977    const WAL_MAGIC_MASK: u32 = 0xFFFF_FFFE;
14978    const WAL_MAGIC: u32 = 0x377F_0682;
14979    const SQLITE_MAX_PAGE_SIZE: u32 = 65536;
14980    let magic_ok = (magic & WAL_MAGIC_MASK) == WAL_MAGIC;
14981    let page_size_ok =
14982        page_size.is_power_of_two() && (512..=SQLITE_MAX_PAGE_SIZE).contains(&page_size);
14983    if magic_ok && page_size_ok {
14984        return Ok(());
14985    }
14986    Err(EngineOpenError::Corruption(CorruptionDetail {
14987        kind: CorruptionKind::WalReplayFailure,
14988        stage: OpenStage::WalReplay,
14989        locator: CorruptionLocator::FileOffset { offset: if !magic_ok { 0 } else { 8 } },
14990        recovery_hint: RecoveryHint {
14991            code: "E_CORRUPT_WAL_REPLAY",
14992            doc_anchor: "design/recovery.md#wal-replay-failures",
14993        },
14994    }))
14995}
14996
14997fn reject_legacy_shape(connection: &Connection) -> Result<(), EngineOpenError> {
14998    let has_legacy_table = table_exists(connection, "fathom_nodes")
14999        || table_exists(connection, "fathom_edges")
15000        || table_exists(connection, "fathom_chunks");
15001    if !has_legacy_table {
15002        return Ok(());
15003    }
15004
15005    let seen =
15006        connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)).unwrap_or(0);
15007    Err(EngineOpenError::IncompatibleSchemaVersion { seen, supported: SCHEMA_VERSION })
15008}
15009
15010fn table_exists(connection: &Connection, table: &str) -> bool {
15011    connection
15012        .query_row(
15013            "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
15014            [table],
15015            |_row| Ok(()),
15016        )
15017        .is_ok()
15018}
15019
15020#[cfg(feature = "operator")]
15021fn read_schema_objects(
15022    connection: &Connection,
15023    obj_type: &str,
15024) -> Result<Vec<SchemaObject>, EngineError> {
15025    let mut stmt = connection
15026        .prepare(
15027            "SELECT name, sql FROM sqlite_schema
15028             WHERE type = ?1 AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL
15029             ORDER BY name",
15030        )
15031        .map_err(|_| EngineError::Storage)?;
15032    let rows = stmt
15033        .query_map([obj_type], |row| {
15034            Ok(SchemaObject { name: row.get::<_, String>(0)?, sql: row.get::<_, String>(1)? })
15035        })
15036        .map_err(|_| EngineError::Storage)?;
15037    let mut out = Vec::new();
15038    for row in rows {
15039        out.push(row.map_err(|_| EngineError::Storage)?);
15040    }
15041    Ok(out)
15042}
15043
15044#[cfg(feature = "operator")]
15045fn order_canonical_first(mut objects: Vec<SchemaObject>) -> Vec<SchemaObject> {
15046    let mut canonical: Vec<SchemaObject> = Vec::new();
15047    for name in CANONICAL_TABLES {
15048        if let Some(pos) = objects.iter().position(|o| o.name == *name) {
15049            canonical.push(objects.remove(pos));
15050        }
15051    }
15052    canonical.extend(objects);
15053    canonical
15054}
15055
15056fn load_default_profile(connection: &Connection) -> rusqlite::Result<EmbedderIdentity> {
15057    connection.query_row(
15058        "SELECT name, revision, dimension FROM _fathomdb_embedder_profiles WHERE profile = ?1",
15059        [DEFAULT_VECTOR_PROFILE],
15060        |row| {
15061            Ok(EmbedderIdentity::new(
15062                row.get::<_, String>(0)?,
15063                row.get::<_, String>(1)?,
15064                row.get::<_, u32>(2)?,
15065            ))
15066        },
15067    )
15068}
15069
15070fn default_profile_dimension(connection: &Connection) -> Result<u32, EngineError> {
15071    load_default_profile(connection)
15072        .map(|identity| identity.dimension)
15073        .map_err(|_| EngineError::Storage)
15074}
15075
15076fn kind_is_vector_indexed(connection: &Connection, kind: &str) -> Result<bool, EngineError> {
15077    connection
15078        .query_row("SELECT 1 FROM _fathomdb_vector_kinds WHERE kind = ?1", [kind], |_row| Ok(()))
15079        .map(|_| true)
15080        .or_else(|err| match err {
15081            rusqlite::Error::QueryReturnedNoRows => Ok(false),
15082            _ => Err(EngineError::Storage),
15083        })
15084}
15085
15086fn ensure_vector_partition(connection: &mut Connection, dimension: u32) -> rusqlite::Result<()> {
15087    // 0.7.0 Pack 1 schema per dev/design/0.7.0-vector-quant-pack1.md D1/D2:
15088    // f32 `embedding` + binary-quant sibling `embedding_bin` + `source_type`
15089    // partition key + `kind` + `created_at`. The vec0 column type is
15090    // dim-parameterized, so the reshape lives here rather than in the
15091    // SQL-only migration framework — see fathomdb-schema migration step 9
15092    // and dev/plans/runs/0.7.0-PVQ-P1-IMPL-output.json for the deviation
15093    // from the design memo's "Choose (a)" guidance.
15094    //
15095    // Three paths:
15096    //   (1) no vector_default       -> CREATE at new shape.
15097    //   (2) old single-column shape -> stage + drop + recreate at new shape
15098    //                                  + repopulate with vec_quantize_binary.
15099    //   (3) already new shape       -> no-op.
15100    let existing_sql: Option<String> = connection
15101        .query_row(
15102            "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
15103            [DEFAULT_VECTOR_PARTITION],
15104            |row| row.get::<_, String>(0),
15105        )
15106        .optional()?;
15107
15108    // Slice 10 / G10 — 3-way shape-sentinel (fixes the prior
15109    // `contains("embedding_bin")` no-op that hid the `status` column from
15110    // existing Pack-1 DBs):
15111    //   `status` present       -> Pack-2 (current) shape, no-op.
15112    //   `embedding_bin` present -> Pack-1 -> stage + recreate + back-fill status.
15113    //   neither                 -> legacy single-column -> migrate to current.
15114    match existing_sql {
15115        None => create_vector_partition(connection, dimension),
15116        Some(sql) if sql.contains("status") => Ok(()),
15117        Some(sql) if sql.contains("embedding_bin") => {
15118            migrate_vector_partition_pack1_to_pack2(connection, dimension)
15119        }
15120        Some(_) => migrate_vector_partition_to_pack1(connection, dimension),
15121    }
15122}
15123
15124/// The current (Pack-2) `vector_default` vec0 shape. Slice 10 / G10 adds a plain
15125/// `status TEXT` metadata column — **not** aux (`+status`): aux columns
15126/// hard-error under a KNN `WHERE`, and the G10 filter constrains `status` in the
15127/// phase-1 KNN statement. `status` ships NULL plumbing only (no population source
15128/// yet).
15129///
15130/// 0.8.20 Slice 15e — `attr_cols` are the declared-`filterable` attribute columns
15131/// (byte-safe `attr_<hex>` identifiers, see [`attr_vec0_column`]), each a PLAIN
15132/// `TEXT` metadata column (never aux `+`), appended after `status`. **When
15133/// `attr_cols` is empty the produced SQL is byte-identical to the shipped shape**
15134/// — every existing caller passes `&[]`, so no shipped behaviour changes.
15135fn vector_partition_create_sql(
15136    dimension: u32,
15137    if_not_exists: bool,
15138    attr_cols: &[String],
15139) -> String {
15140    let guard = if if_not_exists { "IF NOT EXISTS " } else { "" };
15141    let mut attrs = String::new();
15142    for col in attr_cols {
15143        attrs.push_str(&format!(",{col} TEXT"));
15144    }
15145    format!(
15146        "CREATE VIRTUAL TABLE {guard}{DEFAULT_VECTOR_PARTITION} USING vec0(\
15147            embedding float[{dimension}],\
15148            embedding_bin bit[{dimension}],\
15149            source_type TEXT partition key,\
15150            kind TEXT,\
15151            created_at INTEGER,\
15152            status TEXT{attrs}\
15153         )"
15154    )
15155}
15156
15157fn create_vector_partition(connection: &Connection, dimension: u32) -> rusqlite::Result<()> {
15158    connection.execute_batch(&vector_partition_create_sql(dimension, true, &[]))
15159}
15160
15161/// 0.8.20 Slice 15e — encode an arbitrary registry attribute NAME into a vec0-safe
15162/// column identifier: `attr_` + lowercase hex of the name's UTF-8 bytes.
15163///
15164/// vec0 rejects quoted column identifiers, and a Slice-15d-validated attribute
15165/// name may contain spaces / unicode / `-`, so the raw name cannot be a column
15166/// identifier. Hex is injective (so the map is reversible by
15167/// [`decode_attr_vec0_column`]), matches `^attr_[0-9a-f]+$`, and can never collide
15168/// with a built-in metadata column (`embedding`, `embedding_bin`, `source_type`,
15169/// `kind`, `created_at`, `status` — none carry the `attr_` prefix followed by an
15170/// even-length hex string of the name).
15171fn attr_vec0_column(name: &str) -> String {
15172    let mut s = String::from("attr_");
15173    for b in name.as_bytes() {
15174        s.push_str(&format!("{b:02x}"));
15175    }
15176    s
15177}
15178
15179/// 0.8.20 Slice 15e — inverse of [`attr_vec0_column`]. Returns the original
15180/// attribute name for an `attr_<hex>` column, or `None` if `col` is not a
15181/// well-formed encoded attribute column (so the built-in metadata columns and any
15182/// vec0 shadow columns are skipped when enumerating a live table's attribute set).
15183fn decode_attr_vec0_column(col: &str) -> Option<String> {
15184    let hex = col.strip_prefix("attr_")?;
15185    if hex.is_empty() || hex.len() % 2 != 0 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
15186        return None;
15187    }
15188    let mut bytes = Vec::with_capacity(hex.len() / 2);
15189    let raw = hex.as_bytes();
15190    let mut i = 0;
15191    while i < raw.len() {
15192        let hi = (raw[i] as char).to_digit(16)?;
15193        let lo = (raw[i + 1] as char).to_digit(16)?;
15194        bytes.push((hi * 16 + lo) as u8);
15195        i += 2;
15196    }
15197    String::from_utf8(bytes).ok()
15198}
15199
15200/// 0.8.20 Slice 15e — the DESIRED `attr_<hex>` columns implied by the durable
15201/// projection registry: one per `filterable` projection, sorted by attribute name
15202/// (⇒ sorted by column, since hex encoding preserves byte order). This is the
15203/// derived-cache source the reshape reconciles the live vec0 shape against.
15204fn desired_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15205    let registry = load_projection_registry(conn)?;
15206    let mut cols: Vec<String> = registry
15207        .iter()
15208        .filter(|(_, stored)| stored.roles.contains(&ProjectionRole::Filterable))
15209        .map(|(name, _)| attr_vec0_column(name))
15210        .collect();
15211    cols.sort();
15212    Ok(cols)
15213}
15214
15215/// 0.8.20 Slice 15e — the `attr_<hex>` columns actually present on the live
15216/// `vector_default` vec0 table, parsed from its `CREATE VIRTUAL TABLE` SQL and
15217/// sorted. Empty when the table is absent. Parsing the SQL (rather than a PRAGMA)
15218/// keeps this robust across vec0 versions and shadow-table layouts.
15219fn actual_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15220    let sql: Option<String> = conn
15221        .query_row(
15222            "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
15223            [DEFAULT_VECTOR_PARTITION],
15224            |row| row.get::<_, String>(0),
15225        )
15226        .optional()?;
15227    let Some(sql) = sql else {
15228        return Ok(Vec::new());
15229    };
15230    let mut cols: Vec<String> = Vec::new();
15231    // Tokenize on any non-identifier byte; a token is an attribute column iff it
15232    // decodes as a well-formed `attr_<hex>` identifier.
15233    let mut token = String::new();
15234    let flush = |token: &mut String, cols: &mut Vec<String>| {
15235        if !token.is_empty() {
15236            if decode_attr_vec0_column(token).is_some() && !cols.contains(token) {
15237                cols.push(token.clone());
15238            }
15239            token.clear();
15240        }
15241    };
15242    for ch in sql.chars() {
15243        if ch.is_ascii_alphanumeric() || ch == '_' {
15244            token.push(ch);
15245        } else {
15246            flush(&mut token, &mut cols);
15247        }
15248    }
15249    flush(&mut token, &mut cols);
15250    cols.sort();
15251    Ok(cols)
15252}
15253
15254/// TC-76 (sqlite-vec issue **#99**) — **the** way to delete ONE `vector_default`
15255/// row by rowid. Every by-rowid vec0 delete in this crate goes through here.
15256///
15257/// # The upstream defect this works around
15258///
15259/// A vec0 plain-TEXT metadata column stores a 16-byte inline view per row: a
15260/// 4-byte length plus the first 12 bytes of the value
15261/// (`VEC0_METADATA_TEXT_VIEW_DATA_LENGTH`). A value longer than those 12 bytes
15262/// ALSO gets a row in the `<tbl>_metadatatext<NN>` shadow table.
15263///
15264/// In `sqlite-vec` `=0.1.7`, `vec0Update_Delete_ClearMetadata`
15265/// (`sqlite-vec.c:8888`) deletes that shadow row (`sqlite-vec.c:8934-8952`) and
15266/// then returns `sqlite3_step`'s `SQLITE_DONE` (101) verbatim — it never resets
15267/// `rc` to `SQLITE_OK`. `vec0Update_Delete` (`sqlite-vec.c:9027`) reads
15268/// `101 != SQLITE_OK` as a failure and aborts the entire `DELETE`. The
15269/// INSERT/UPDATE twin of that code (`sqlite-vec.c:8258-8320`) is saved by an
15270/// unconditional `rc = sqlite3_blob_close(...)` after its switch, which is why
15271/// only DELETE carries the defect — and why the neutralizing `UPDATE` below is a
15272/// sound workaround rather than the same bug by another name.
15273///
15274/// # Why FathomDB is exposed
15275///
15276/// `vector_default` carries three plain-TEXT metadata columns
15277/// ([`vector_partition_create_sql`]): `kind`, `status` and one `attr_<hex>` per
15278/// declared `filterable` projection. Only the `attr_*` VALUES are caller-supplied
15279/// and unbounded, and Slice 15e stores them marker-encoded (`\x01 || V`), so a
15280/// raw attribute value of **12 or more UTF-8 bytes** trips #99 and makes
15281/// `erase_source` / `purge` / edge supersession / the open-path orphan sweep fail
15282/// with [`EngineError::Storage`], leaving the row AND its shadowed value at rest.
15283/// `kind` is bounded to `resolve_source_type`'s locked vocabulary (max 9 bytes)
15284/// at every enrolment door via [`kind_is_vector_committable`], and `status` ships
15285/// the `''` sentinel — so neither needs neutralizing, and neither is touched.
15286///
15287/// # The workaround
15288///
15289/// Blank the `attr_*` columns FIRST. vec0's UPDATE path takes the `n <= 12 &&
15290/// prev_n > 12` branch (`sqlite-vec.c:8300-8319`), which deletes the shadow row
15291/// AND returns `SQLITE_OK`, then the DELETE no longer has an over-length value to
15292/// clear. Measured: the `embedding` bytes and the other metadata columns survive
15293/// the metadata-only UPDATE verbatim, and the `_metadatatext<NN>` row is gone —
15294/// so this is erasure-COMPLETE, not merely error-suppressing.
15295///
15296/// Pinned by `tests/tc76_vec0_long_metadata_delete.rs`, whose bare-vec0 test is
15297/// the tripwire: it fails once `sqlite-vec` ships a fix for #99, which is the
15298/// signal to delete the neutralize step.
15299///
15300/// A no-op UPDATE (no `attr_*` columns declared — every corpus before a
15301/// `filterable` projection exists) is skipped entirely, so the shipped
15302/// no-projection path issues the same single `DELETE` it always did.
15303fn delete_vector_partition_row(conn: &Connection, rowid: i64) -> rusqlite::Result<usize> {
15304    neutralize_vector_partition_attr_values(conn, Some(rowid))?;
15305    conn.execute(&format!("DELETE FROM {DEFAULT_VECTOR_PARTITION} WHERE rowid = ?1"), [rowid])
15306}
15307
15308/// TC-76 (sqlite-vec **#99**) — blank every `attr_<hex>` value on `vector_default`
15309/// (one row when `rowid` is `Some`, the whole table when `None`) so that a
15310/// following `DELETE` never has an over-length TEXT metadata value to clear. See
15311/// [`delete_vector_partition_row`] for the mechanism and the evidence.
15312///
15313/// A pure no-op when no `filterable` projection is declared — which is every
15314/// corpus that predates Slice 15e — so the shipped delete paths issue exactly the
15315/// statements they always did.
15316fn neutralize_vector_partition_attr_values(
15317    conn: &Connection,
15318    rowid: Option<i64>,
15319) -> rusqlite::Result<()> {
15320    let attr_cols = actual_vector_attr_columns(conn)?;
15321    if attr_cols.is_empty() {
15322        return Ok(());
15323    }
15324    // `attr_cols` are `^attr_[0-9a-f]+$` identifiers derived by
15325    // `attr_vec0_column`, never caller text: safe to interpolate.
15326    let sets = attr_cols.iter().map(|c| format!("{c}=''")).collect::<Vec<_>>().join(", ");
15327    match rowid {
15328        Some(rowid) => {
15329            conn.execute(
15330                &format!("UPDATE {DEFAULT_VECTOR_PARTITION} SET {sets} WHERE rowid = ?1"),
15331                [rowid],
15332            )?;
15333        }
15334        None => {
15335            conn.execute(&format!("UPDATE {DEFAULT_VECTOR_PARTITION} SET {sets}"), [])?;
15336        }
15337    }
15338    Ok(())
15339}
15340
15341/// 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns with
15342/// the registry's `filterable` set (TC-46: HITL-ratified NON-DESTRUCTIVE reshape,
15343/// following the shipped `migrate_vector_partition_pack1_to_pack2` precedent).
15344///
15345/// Diffs the DESIRED columns (from the registry) against the ACTUAL columns (on
15346/// the live table). When they already match — which is EVERY idempotent
15347/// re-registration and every boot re-derive that replays the same set — this is a
15348/// pure no-op: no reshape, no re-insert, vec0 untouched (so boot never silently
15349/// wipes a corpus). When they differ, performs ONE non-destructive reshape.
15350///
15351/// Returns `true` iff a reshape was performed. Runs the DDL directly on the passed
15352/// connection/transaction (no nested transaction), so a caller already inside a
15353/// write transaction (`configure_projections`) gets the reshape atomically with
15354/// its registry mutation. A no-op (and returns `false`) when `vector_default` does
15355/// not exist (a DB opened without an embedder).
15356fn reconcile_vector_attr_columns(conn: &Connection, dimension: u32) -> rusqlite::Result<bool> {
15357    let table_exists: bool = conn
15358        .query_row(
15359            "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
15360            [DEFAULT_VECTOR_PARTITION],
15361            |_| Ok(true),
15362        )
15363        .optional()?
15364        .unwrap_or(false);
15365    if !table_exists {
15366        return Ok(false);
15367    }
15368    let desired = desired_vector_attr_columns(conn)?;
15369    let actual = actual_vector_attr_columns(conn)?;
15370    if desired == actual {
15371        return Ok(false);
15372    }
15373    reshape_vector_partition_nondestructive(conn, dimension, &desired, &actual, false)?;
15374    Ok(true)
15375}
15376
15377/// Rebuild vec0 attribute metadata from the canonical EAV projection while
15378/// preserving the existing column set. A changed nested source can leave the
15379/// `attr_<hex>` schema untouched while changing every value behind that column.
15380fn refresh_vector_attr_values(conn: &Connection, dimension: u32) -> rusqlite::Result<()> {
15381    let desired = desired_vector_attr_columns(conn)?;
15382    let actual = actual_vector_attr_columns(conn)?;
15383    if desired != actual || !desired.is_empty() {
15384        reshape_vector_partition_nondestructive(conn, dimension, &desired, &actual, true)?;
15385    }
15386    Ok(())
15387}
15388
15389/// Refresh one reactivated node's vec0 metadata without reshaping the corpus.
15390///
15391/// Activation re-projects this node's canonical attributes after a source change
15392/// that could have happened while it was deleted. vec0 accepts metadata `UPDATE`s,
15393/// so only this row needs to be refreshed; a full partition reshape belongs to
15394/// registry shape/source reconciliation, not the ordinary lifecycle path.
15395fn refresh_vector_attr_values_for_row(
15396    conn: &Connection,
15397    rowid: i64,
15398    body: &str,
15399) -> rusqlite::Result<()> {
15400    let (cols_sql, _, mut values) = vector_attr_insert_fragments(conn, body, 1)?;
15401    if cols_sql.is_empty() {
15402        return Ok(());
15403    }
15404    let assignments = cols_sql
15405        .trim_start_matches(", ")
15406        .split(", ")
15407        .enumerate()
15408        .map(|(index, col)| format!("{col} = ?{}", index + 1))
15409        .collect::<Vec<_>>()
15410        .join(", ");
15411    values.push(rusqlite::types::Value::Integer(rowid));
15412    let rowid_index = values.len();
15413    conn.execute(
15414        &format!(
15415            "UPDATE {DEFAULT_VECTOR_PARTITION} SET {assignments} WHERE rowid = ?{rowid_index}"
15416        ),
15417        rusqlite::params_from_iter(values.iter()),
15418    )?;
15419    Ok(())
15420}
15421
15422/// 0.8.20 Slice 15e — the NON-DESTRUCTIVE reshape itself. Stages every live row
15423/// (base columns + all ACTUAL attribute columns), drops + recreates
15424/// `vector_default` at the DESIRED shape, then re-inserts each row.
15425///
15426/// THE FOUR LOAD-BEARING CONDITIONS (any one broken ⇒ silently wrong results):
15427///   1. `rowid` is listed EXPLICITLY in the re-insert (a vec0 row maps to its node
15428///      by `rowid == write_cursor`; auto-assigned rowids would decouple every
15429///      embedding from its node);
15430///   2. each attribute column is PLAIN `TEXT` metadata (via
15431///      [`vector_partition_create_sql`]), never a vec0 `aux`/`+` column (aux
15432///      hard-errors a filtered KNN);
15433///   3. a DESIRED column with no ACTUAL predecessor back-fills each row from the
15434///      already-populated `canonical_attributes` (fix-1 finding 2) so a
15435///      pre-existing row whose body carries the attribute is immediately
15436///      filterable; the `''` sentinel (vec0 TEXT metadata is NOT-NULL-able) is
15437///      used ONLY where the attribute is genuinely absent, so an absent row
15438///      cleanly fails-to-match instead of erroring;
15439///   4. `embedding_bin` is copied VERBATIM via `vec_bit(...)` — NOT re-quantized —
15440///      so old rows keep their (possibly mean-centered) bits and stay Hamming-
15441///      comparable to new rows.
15442///
15443/// Runs on `conn` directly (the caller owns the transaction). Transactional
15444/// atomicity + reader isolation are the caller's responsibility, exactly as for
15445/// `migrate_vector_partition_pack1_to_pack2`.
15446fn reshape_vector_partition_nondestructive(
15447    conn: &Connection,
15448    dimension: u32,
15449    desired_cols: &[String],
15450    actual_cols: &[String],
15451    refresh_values: bool,
15452) -> rusqlite::Result<()> {
15453    // Stage: base columns + every ACTUAL attribute column (so no at-rest value is
15454    // lost, even for a column being dropped).
15455    let mut stage_defs = String::new();
15456    let mut stage_names =
15457        String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15458    for col in actual_cols {
15459        stage_defs.push_str(&format!(",\n             {col} TEXT"));
15460        stage_names.push_str(&format!(", {col}"));
15461    }
15462    conn.execute_batch(&format!(
15463        "CREATE TABLE _fathomdb_vector_reshape_stage (
15464             rowid         INTEGER PRIMARY KEY,
15465             embedding     BLOB NOT NULL,
15466             embedding_bin BLOB NOT NULL,
15467             source_type   TEXT,
15468             kind          TEXT,
15469             created_at    INTEGER,
15470             status        TEXT{stage_defs}
15471         );
15472         INSERT INTO _fathomdb_vector_reshape_stage({stage_names})
15473             SELECT {stage_names} FROM {DEFAULT_VECTOR_PARTITION};
15474         DROP TABLE {DEFAULT_VECTOR_PARTITION};"
15475    ))?;
15476
15477    // Recreate at the DESIRED shape (plain TEXT attr columns — condition #2).
15478    conn.execute_batch(&vector_partition_create_sql(dimension, false, desired_cols))?;
15479
15480    // Re-insert. `rowid` explicit (condition #1); `vec_bit(embedding_bin)`
15481    // verbatim, never re-quantized (condition #4); `status` and each surviving
15482    // attribute column carried forward; each NEW desired column back-fills from
15483    // `canonical_attributes` (the `''` sentinel only where genuinely absent —
15484    // condition #3).
15485    let mut insert_cols =
15486        String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15487    let mut select_exprs = String::from(
15488        "rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, status",
15489    );
15490    for col in desired_cols {
15491        insert_cols.push_str(&format!(", {col}"));
15492        if actual_cols.iter().any(|a| a == col) && !refresh_values {
15493            // Surviving column — carry its value forward (never NULL: vec0 TEXT
15494            // metadata is `''`-sentinelled, but COALESCE defends the stage table).
15495            select_exprs.push_str(&format!(", COALESCE({col}, '')"));
15496        } else {
15497            // New column — back-fill from the ALREADY-populated `canonical_attributes`
15498            // (fix-1 finding 2 [P2]). `configure_projections` runs `backfill_attribute`
15499            // (which fills `canonical_attributes` from each active row's body) BEFORE
15500            // this reshape, so a pre-existing row whose body carries the attribute is
15501            // immediately filterable — no false negative until a re-embed. The `''`
15502            // sentinel (condition #3) is used ONLY where the attribute is genuinely
15503            // ABSENT for that row (no `canonical_attributes` row ⇒ COALESCE → '').
15504            // The EAV value equals the vec0 write-time value by construction (both go
15505            // through `extract_scalar_attribute`), so pre-existing and freshly-written
15506            // rows share one filter semantics.
15507            match decode_attr_vec0_column(col) {
15508                Some(name) => {
15509                    // vec0/execute_batch takes no bind params; embed the decoded name
15510                    // as a SQL string literal, escaping single quotes.
15511                    //
15512                    // fix-3 [P2] — a PRESENT row (a canonical_attributes row exists)
15513                    // encodes its RAW `attr_value` as `\x01 || attr_value` (`char(1) ||
15514                    // ca.attr_value`), matching the write-time vec0 encoding so a
15515                    // pre-existing present-empty row (attr_value='') becomes the bare
15516                    // marker, NOT `''`. An ABSENT row (no canonical_attributes row) is
15517                    // the COALESCE default `''` (condition #3). `canonical_attributes`
15518                    // itself stays RAW — only this vec0 column is encoded.
15519                    let escaped = name.replace('\'', "''");
15520                    select_exprs.push_str(&format!(
15521                        ", COALESCE((SELECT char(1) || ca.attr_value FROM canonical_attributes ca \
15522                         WHERE ca.write_cursor = _fathomdb_vector_reshape_stage.rowid \
15523                           AND ca.attr_name = '{escaped}' LIMIT 1), '')"
15524                    ));
15525                }
15526                // A desired column always decodes (built by `attr_vec0_column`); if it
15527                // somehow does not, fall back to the sentinel rather than panic.
15528                None => select_exprs.push_str(", ''"),
15529            }
15530        }
15531    }
15532    conn.execute_batch(&format!(
15533        "INSERT INTO {DEFAULT_VECTOR_PARTITION}({insert_cols})
15534             SELECT {select_exprs} FROM _fathomdb_vector_reshape_stage;
15535         DROP TABLE _fathomdb_vector_reshape_stage;"
15536    ))?;
15537    Ok(())
15538}
15539
15540/// Slice 10 / G10 — stage + recreate + back-fill upgrade of an existing
15541/// **Pack-1** `vector_default` (has `embedding_bin`, lacks `status`) to the
15542/// Pack-2 shape. The existing `embedding_bin` blob is preserved verbatim (it may
15543/// be mean-centered; re-quantizing from `embedding` would drop the centering),
15544/// and `status` back-fills NULL. Same transactional discipline as
15545/// `migrate_vector_partition_to_pack1`: a single `Connection::transaction()`;
15546/// reader handles are not opened until `ensure_vector_partition` returns, and
15547/// cross-process access is serialized by the sidecar lock, so readers never see
15548/// a partial reshape.
15549fn migrate_vector_partition_pack1_to_pack2(
15550    connection: &mut Connection,
15551    dimension: u32,
15552) -> rusqlite::Result<()> {
15553    let tx = connection.transaction()?;
15554    tx.execute_batch(
15555        "CREATE TABLE _fathomdb_vector_pack2_stage (
15556             rowid         INTEGER PRIMARY KEY,
15557             embedding     BLOB NOT NULL,
15558             embedding_bin BLOB NOT NULL,
15559             source_type   TEXT,
15560             kind          TEXT,
15561             created_at    INTEGER
15562         );
15563         INSERT INTO _fathomdb_vector_pack2_stage(
15564             rowid, embedding, embedding_bin, source_type, kind, created_at
15565         )
15566             SELECT rowid, embedding, embedding_bin, source_type, kind, created_at
15567             FROM vector_default;
15568         DROP TABLE vector_default;",
15569    )?;
15570    tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15571    // `vec_bit(...)` re-tags the staged blob with the BIT subtype vec0's bit
15572    // column requires (a raw blob loses the subtype and fails the type check).
15573    // This preserves the existing (possibly mean-centered) bits verbatim — no
15574    // re-quantize, so centering survives the upgrade. `status` back-fills the
15575    // empty-string sentinel (vec0 TEXT metadata is NOT NULL-able; reserved-gap
15576    // candidate 13).
15577    tx.execute_batch(
15578        "INSERT INTO vector_default(
15579             rowid, embedding, embedding_bin, source_type, kind, created_at, status
15580         )
15581             SELECT rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, ''
15582             FROM _fathomdb_vector_pack2_stage;
15583         DROP TABLE _fathomdb_vector_pack2_stage;",
15584    )?;
15585    tx.commit()
15586}
15587
15588/// SQL fragment implementing the D3 `kind -> source_type` map.
15589/// Used both by the Pack 1 reshape migration and by the drift-detection
15590/// unit test that pins it to [`resolve_source_type`].
15591const KIND_TO_SOURCE_TYPE_CASE_SQL: &str = "CASE s.kind
15592    WHEN 'email'   THEN 'email'
15593    WHEN 'article' THEN 'article'
15594    WHEN 'paper'   THEN 'paper'
15595    WHEN 'meeting' THEN 'meeting'
15596    WHEN 'note'    THEN 'note'
15597    WHEN 'todo'    THEN 'todo'
15598    WHEN 'doc'     THEN 'article'
15599    ELSE 'article'
15600END";
15601
15602/// Pack 1 in-place reshape of `vector_default`. Stages the existing
15603/// f32 corpus + each row's `kind`, drops the old single-column vec0
15604/// table, recreates at the runtime `dimension` with the Pack 1
15605/// columns, then repopulates with SQL-side `vec_quantize_binary` +
15606/// the D3 `kind -> source_type` mapping. The preflight CHECK on
15607/// unknown kinds has already run as migration step 9 by the time we
15608/// get here.
15609///
15610/// Atomicity: the DROP+CREATE+repopulate sequence runs inside a
15611/// rusqlite `Connection::transaction()` (DEFERRED begin per rusqlite
15612/// `transaction.rs:417`). Cross-process serialization is provided by
15613/// the engine's sidecar `acquire_lock` at `open_with_migrations`
15614/// (`lib.rs:1127` area); reader handles are not opened until
15615/// `ensure_vector_partition` returns (`lib.rs:1241` area), so readers
15616/// never observe a partial reshape.
15617fn migrate_vector_partition_to_pack1(
15618    connection: &mut Connection,
15619    dimension: u32,
15620) -> rusqlite::Result<()> {
15621    let tx = connection.transaction()?;
15622    tx.execute_batch(
15623        "CREATE TABLE _fathomdb_vector_migration_v0_7_0 (
15624             rowid     INTEGER PRIMARY KEY,
15625             embedding BLOB NOT NULL,
15626             kind      TEXT NOT NULL
15627         );
15628         INSERT INTO _fathomdb_vector_migration_v0_7_0(rowid, embedding, kind)
15629             SELECT v.rowid, v.embedding, r.kind
15630             FROM vector_default v
15631             JOIN _fathomdb_vector_rows r ON r.rowid = v.rowid;
15632         DROP TABLE vector_default;",
15633    )?;
15634    // Slice 10 / G10 — recreate directly at the Pack-2 shape (adds `status`), so
15635    // a legacy single-column DB lands the current shape in one reshape.
15636    tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15637    // `status` back-fills the empty-string sentinel (vec0 TEXT metadata is NOT
15638    // NULL-able; reserved-gap candidate 13). Legacy single-column DBs predate
15639    // mean-centering, so re-quantizing from the un-centered `embedding` is
15640    // correct here.
15641    let repopulate_sql = format!(
15642        "INSERT INTO vector_default(
15643             rowid, embedding, embedding_bin, source_type, kind, created_at, status
15644         )
15645         SELECT
15646             s.rowid,
15647             s.embedding,
15648             vec_quantize_binary(s.embedding),
15649             {KIND_TO_SOURCE_TYPE_CASE_SQL},
15650             s.kind,
15651             strftime('%s', 'now'),
15652             ''
15653         FROM _fathomdb_vector_migration_v0_7_0 s;
15654         DROP TABLE _fathomdb_vector_migration_v0_7_0;"
15655    );
15656    tx.execute_batch(&repopulate_sql)?;
15657    tx.commit()
15658}
15659
15660fn encode_vector_blob(vector: &[f32]) -> Vec<u8> {
15661    vector.iter().flat_map(|value| value.to_le_bytes()).collect()
15662}
15663
15664fn decode_vector_blob(bytes: &[u8]) -> Vec<f32> {
15665    debug_assert_eq!(bytes.len() % 4, 0, "f32 BLOB length must be multiple of 4");
15666    bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()
15667}
15668
15669/// EU-5a2 — does the live embedder identity request mean-centering?
15670/// Identity-name compare per EU-5a1's BGE_SMALL_EMBEDDER_NAME constant
15671/// (`dev/design/embedder.md` §0.6). NoopEmbedder returns `false`.
15672fn identity_requires_mean_centering(identity: &EmbedderIdentity) -> bool {
15673    identity.name == BGE_SMALL_EMBEDDER_NAME
15674}
15675
15676/// EU-5a2 — read the pinned mean vector from
15677/// `_fathomdb_embedder_profiles.mean_vec` for the default profile.
15678/// Returns `Ok(None)` when the column is NULL or the row is missing;
15679/// returns `Err(EngineError::Storage)` on dimension drift (the open-time
15680/// `check_embedder_profile` already fails closed for this, so a runtime
15681/// drift here would be an internal-inconsistency signal).
15682fn read_pinned_mean_vec(
15683    connection: &Connection,
15684    dimension: u32,
15685) -> Result<Option<Vec<f32>>, EngineError> {
15686    let bytes: Option<Vec<u8>> = connection
15687        .query_row(
15688            "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
15689            [],
15690            |row| row.get::<_, Option<Vec<u8>>>(0),
15691        )
15692        .or_else(|err| match err {
15693            rusqlite::Error::QueryReturnedNoRows => Ok(None),
15694            other => Err(other),
15695        })
15696        .map_err(|_| EngineError::Storage)?;
15697    let Some(bytes) = bytes else { return Ok(None) };
15698    let expected_len = (dimension as usize).saturating_mul(4);
15699    if bytes.len() != expected_len {
15700        return Err(EngineError::Storage);
15701    }
15702    let mut out = Vec::with_capacity(dimension as usize);
15703    for chunk in bytes.chunks_exact(4) {
15704        let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
15705        out.push(f32::from_le_bytes(arr));
15706    }
15707    Ok(Some(out))
15708}
15709
15710/// EU-5a2 — pointwise `v - mean`. Length-checked debug-assert; caller
15711/// guarantees equal length via `read_pinned_mean_vec` + dimension check.
15712fn subtract_mean(v: &[f32], mean: &[f32]) -> Vec<f32> {
15713    debug_assert_eq!(v.len(), mean.len(), "subtract_mean dim mismatch");
15714    v.iter().zip(mean.iter()).map(|(a, b)| *a - *b).collect()
15715}
15716
15717/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — parse the committed 45-probe
15718/// fixture into an ordered `Vec<&str>` (one probe per non-empty, non-`#`-comment
15719/// line). Order is stable so `probe_ordinal` is deterministic across opens.
15720fn vector_equivalence_probes() -> Vec<&'static str> {
15721    VECTOR_EQUIVALENCE_PROBE_FIXTURE
15722        .lines()
15723        .map(str::trim_end)
15724        .filter(|line| {
15725            let t = line.trim_start();
15726            !t.is_empty() && !t.starts_with('#')
15727        })
15728        .collect()
15729}
15730
15731/// 0.8.18 Slice 5 — outcome of the open-time #5 self-check.
15732struct VectorEquivalenceOutcome {
15733    dense_disabled: bool,
15734    reason: Option<String>,
15735}
15736
15737/// 0.8.18 Slice 5 — embed one probe under panic isolation. The probe runs at
15738/// open time on the writer connection BEFORE the projection workers spawn, so a
15739/// caller-supplied embedder that PANICS (or returns an error / a wrong-dimension
15740/// vector) must never wedge `Engine::open`. A panic/error/shape-mismatch yields
15741/// `None`; the CALLERS then fail-SAFE (fix-1 DEFECT #1) — a `None` at population
15742/// or check time means the vector arm cannot be established/verified, so dense is
15743/// REFUSED (`dense_disabled=true`), never silently served. `Engine::open` still
15744/// succeeds (no wedge; ADR-0.6.0 Invariant-5 posture, mirrored open-side).
15745fn probe_embed(embedder: &dyn Embedder, text: &str, dimension: usize) -> Option<Vec<f32>> {
15746    let embedded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(text)));
15747    match embedded {
15748        Ok(Ok(vector)) if vector.len() == dimension => Some(vector),
15749        _ => None,
15750    }
15751}
15752
15753/// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
15754/// self-check. Per `dev/design/0.8.18-slice-0-vector-equivalence-publish-design.md`
15755/// §U1 + `dev/adr/ADR-0.8.18-vector-equivalence-self-check.md`.
15756///
15757/// Runs AFTER open-time mean-recovery/requantize + `ensure_vector_partition`
15758/// (U1-b) so it reads the FINAL live `mean_vec`. Two paths:
15759///
15760///  - **First vector-kind registration** (probe table empty): re-embed the 45
15761///    committed probes with the LIVE embedder and persist their **UN-centered
15762///    f32 reference vectors** + embedder identity (R-VEQ-1). Store f32 ONLY —
15763///    the P1 bits are NEVER persisted (U1-d). Returns `dense_disabled=false`.
15764///  - **Subsequent open** (probe table populated): re-embed the 45 probes and
15765///    assert BOTH dense-pipeline representations against the stored references:
15766///    **(P1)** the Phase-1 mean-centered `embedding_bin` sign-flip count via the
15767///    SAME `vec_quantize_binary(sign(x − mean_vec))` path as
15768///    `build_vector_phase1_sql` (floor = 0, exact); **(P2)** the un-centered
15769///    Phase-2 L2 (`vec_distance_l2` semantics) within `VECTOR_EQUIVALENCE_L2_EPSILON`.
15770///    Divergence beyond EITHER floor ⇒ `dense_disabled=true` (R-VEQ-2/3).
15771///
15772/// Mean-centering is gated by `identity_requires_mean_centering(identity)` ∧
15773/// `mean_pinned`, applied symmetrically to reference + reembed (un-centered
15774/// fallback otherwise; NoopEmbedder no-op) — R-VEQ-3c.
15775///
15776/// Fail-SAFE, never fail-open (0.8.18 Slice 5 fix-1, DEFECT #1): any inability to
15777/// RUN or VERIFY the probe — a probe embed that panics/errors/returns wrong-dim, a
15778/// malformed/missing reference row, an unreadable pinned mean, or a
15779/// `vec_quantize_binary`/L2 SQL failure — yields `dense_disabled=true` with a clear
15780/// reason (refuse the un-verifiable dense/fused arm; the text-only/FTS path still
15781/// serves). `Engine::open` still SUCCEEDS (never wedges on a panicking caller
15782/// embedder). The distinct-identity cross-vendor refusal (`check_embedder_profile`)
15783/// remains the PRIMARY gate; this probe is ADDITIVE-ONLY (R-VEQ-5), but on the
15784/// vector arm it fails CLOSED, not open — same-identity backend drift on an
15785/// un-verifiable arm is exactly what #5 must catch (R-VEQ-4 "loud typed refuse,
15786/// never silent").
15787fn run_vector_equivalence_probe(
15788    connection: &Connection,
15789    embedder: Option<&dyn Embedder>,
15790    identity: &EmbedderIdentity,
15791    mean_pinned: bool,
15792) -> VectorEquivalenceOutcome {
15793    let not_disabled = VectorEquivalenceOutcome { dense_disabled: false, reason: None };
15794
15795    // No live embedder ⇒ no dense arm to guard (EmbedderChoice::None). The probe
15796    // is inert; dense writes/queries already fail with EmbedderNotConfigured.
15797    let Some(embedder) = embedder else { return not_disabled };
15798
15799    // Gate: the probe only engages once the workspace has REGISTERED a vector
15800    // kind (`_fathomdb_vector_kinds` non-empty). A fresh workspace that has never
15801    // committed to vector indexing has no dense arm to guard yet, so the probe
15802    // does ZERO embed work at that open — this keeps `Engine::open` free of the
15803    // 45-probe re-embed on empty/vector-less workspaces (and inert for the
15804    // pathological single-session hang/panic embedder tests, which register their
15805    // kind AFTER open and never reopen).
15806    //
15807    // fix-1 DEFECT #4 — the baseline is established at OPEN, at the first open
15808    // where a vector kind already exists (population path below). This covers BOTH:
15809    //   (b) the v18→v19 UPGRADE with pre-existing vector kinds: the baseline is
15810    //       captured here, at the first v19 open, from the identity-matched
15811    //       embedder (identity is already gated by `check_embedder_profile`, so the
15812    //       baseline is the same *claimed* embedder; future backend drift is caught);
15813    //   (a) a vector kind registered POST-OPEN in a prior session: the baseline is
15814    //       captured at the NEXT open (this gate + population), again identity-gated.
15815    // It is deliberately NOT captured in the registering session's write path: a
15816    // write must NEVER block on the embedder (the async-projection invariant —
15817    // `ac_029_canonical_writes_complete_under_projection_stall` and the PR-9 embed
15818    // watchdog/thread-leak bounds), and 45 synchronous probe embeds there would
15819    // violate it and hang/degrade under a stalling embedder. Serving vector queries
15820    // in the registering session is SAFE regardless: the serving backend IS the
15821    // backend that built those vectors, so there is nothing to diverge from. The
15822    // residual — a same-*identity* backend that drifted between the registering
15823    // session and the next open is not retroactively caught — is IDENTICAL to the
15824    // accepted upgrade residual (R-VEQ-5 additive-only; U3 same-identity candle
15825    // CPU↔CUDA = 0/17280). See `dev/design/0.8.18-slice-5-vector-equivalence-probe.md`.
15826    let vector_kind_registered: bool = connection
15827        .query_row("SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds)", [], |r| r.get(0))
15828        .unwrap_or(false);
15829    if !vector_kind_registered {
15830        return not_disabled;
15831    }
15832
15833    match probe_populate_or_check(connection, embedder, identity, mean_pinned) {
15834        Ok(()) => not_disabled,
15835        Err(reason) => VectorEquivalenceOutcome { dense_disabled: true, reason: Some(reason) },
15836    }
15837}
15838
15839/// 0.8.18 Slice 5 — either PERSIST the baseline (probe table empty) or CHECK
15840/// against it (probe table populated). `Err(reason)` ⇒ refuse the dense arm
15841/// (`dense_disabled=true`); `Ok(())` ⇒ dense served. Fail-SAFE throughout.
15842fn probe_populate_or_check(
15843    connection: &Connection,
15844    embedder: &dyn Embedder,
15845    identity: &EmbedderIdentity,
15846    mean_pinned: bool,
15847) -> Result<(), String> {
15848    let probes = vector_equivalence_probes();
15849    if probes.is_empty() {
15850        // Fail-SAFE: the compiled-in probe fixture is empty ⇒ nothing to verify
15851        // the vector arm against. (Defensive; the fixture is drift-guarded
15852        // non-empty at 45 probes.)
15853        return Err(
15854            "vector-equivalence probe fixture is empty; cannot verify the dense arm".to_string()
15855        );
15856    }
15857
15858    let existing: i64 = connection
15859        .query_row("SELECT COUNT(*) FROM _fathomdb_embed_probe", [], |r| r.get(0))
15860        .map_err(|e| format!("could not read the probe reference table: {e}; cannot verify"))?;
15861
15862    if existing == 0 {
15863        // Populate, then CONFIRM the just-written baseline is complete before
15864        // enabling dense (fix-2 DEFECT #1 residual): a population that committed
15865        // a short/garbled set must never leave dense enabled on the same open.
15866        probe_populate_baseline(connection, embedder, identity, &probes)?;
15867        probe_check_against_baseline(connection, embedder, identity, mean_pinned, &probes)
15868    } else {
15869        probe_check_against_baseline(connection, embedder, identity, mean_pinned, &probes)
15870    }
15871}
15872
15873/// 0.8.18 Slice 5 — FIRST vector-kind registration: persist the 45 UN-centered
15874/// f32 reference vectors (R-VEQ-1; store f32 ONLY, never the P1 bits — U1-d).
15875/// Fail-SAFE (fix-1 DEFECT #1): if the embedder cannot produce EVERY reference
15876/// (panic/error/wrong-dim) no baseline can be established ⇒ `Err` (refuse dense).
15877/// The inserts run in a single transaction so a partial/mismatched set is NEVER
15878/// persisted (rolled back on any error).
15879fn probe_populate_baseline(
15880    connection: &Connection,
15881    embedder: &dyn Embedder,
15882    identity: &EmbedderIdentity,
15883    probes: &[&str],
15884) -> Result<(), String> {
15885    let dimension = identity.dimension as usize;
15886    // Embed ALL probes first; a single failure aborts population (store nothing).
15887    let mut rows: Vec<(i64, &str, Vec<f32>)> = Vec::with_capacity(probes.len());
15888    for (ordinal, probe) in probes.iter().enumerate() {
15889        match probe_embed(embedder, probe, dimension) {
15890            Some(vec) => rows.push((ordinal as i64, probe, vec)),
15891            None => {
15892                return Err(format!(
15893                    "embedder failed to produce a reference vector for probe {ordinal}; \
15894                     cannot establish a vector-equivalence baseline (dense arm refused)"
15895                ));
15896            }
15897        }
15898    }
15899    // Atomic insert — a partial reference set is never persisted (rollback on
15900    // any error, so a later open cleanly retries population).
15901    let tx = connection
15902        .unchecked_transaction()
15903        .map_err(|e| format!("could not open the probe-baseline transaction: {e}"))?;
15904    for (ordinal, probe, vec) in &rows {
15905        let blob = encode_vector_blob(vec);
15906        tx.execute(
15907            "INSERT OR REPLACE INTO _fathomdb_embed_probe(
15908                 probe_ordinal, probe_text, reference_vec,
15909                 embedder_name, embedder_revision, dim
15910             ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
15911            params![ordinal, probe, blob, identity.name, identity.revision, identity.dimension],
15912        )
15913        .map_err(|e| format!("could not persist the probe baseline: {e}"))?;
15914    }
15915    tx.commit().map_err(|e| format!("could not commit the probe baseline: {e}"))?;
15916    Ok(())
15917}
15918
15919/// 0.8.20 Slice 22 (TC-68) — one row of the stored probe baseline:
15920/// `(probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim)`.
15921type StoredProbeRow = (i64, String, Vec<u8>, String, String, i64);
15922
15923/// 0.8.20 Slice 22 (TC-68) — length-prefixed field feed for the verdict
15924/// fingerprint. The `u64` length prefix makes the concatenation UNAMBIGUOUS: two
15925/// different input tuples can never produce the same byte stream by sliding a
15926/// delimiter (e.g. name `"ab"` + revision `"c"` vs name `"a"` + revision `"bc"`).
15927fn hash_fingerprint_field(hasher: &mut Sha256, bytes: &[u8]) {
15928    hasher.update((bytes.len() as u64).to_le_bytes());
15929    hasher.update(bytes);
15930}
15931
15932/// 0.8.20 Slice 22 (TC-68) — the **embedder-identity fingerprint** the cached
15933/// equivalence verdict is keyed on: a SHA-256 over EVERY input the probe's verdict
15934/// depends on. Two opens sharing a fingerprint would, by construction, compute the
15935/// same P1/P2 answer, so the second may reuse the first's.
15936///
15937/// The inputs, and why each is load-bearing:
15938///
15939/// - **the recipe tag** ([`VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE`]) — bumping it
15940///   invalidates every cached verdict in the field at once;
15941/// - **`identity.{name, revision, dimension}`** — the nominal embedder. This is
15942///   *defence in depth only*: `check_embedder_profile` already REFUSES the open
15943///   with `EmbedderIdentityMismatch`/`EmbedderDimensionMismatch` before the probe
15944///   is reached, so an identity change is never observed here in practice;
15945/// - **the live pinned `mean_vec`** (and whether centering is applied at all) —
15946///   this one is NOT optional. P1 quantizes through
15947///   `vec_quantize_binary(sign(x − mean_vec))`, so rewriting the pinned mean
15948///   changes the verdict *for the same embedder and the same baseline*. A
15949///   fingerprint over the identity triple alone would be stale by construction,
15950///   because open-time mean-recovery/requantize and the operator `recompute_mean`
15951///   verb both rewrite it;
15952/// - **the committed probe fixture** — a cached verdict computed over a different
15953///   probe set means nothing. (`vector_equivalence_probe_fixture_drift.rs` does
15954///   NOT make this redundant: it pins the engine's copy equal to the embedder
15955///   crate's copy — it guards COPY drift between two committed files, not the
15956///   fixture's content across releases. The stored-baseline completeness check
15957///   below does fail closed first on a fixture edit, so this input is belt-and-
15958///   braces rather than the only guard; it is one hash of a ~3 KB constant.);
15959/// - **both D4 floors** — a verdict that passed under a loose ε must not be
15960///   inherited by a build that tightened it;
15961/// - **the STORED baseline rows, reference blobs included** — the 0.8.18 fix-2
15962///   completeness check pins each row's shape (count, ordinal, text, blob LENGTH,
15963///   identity) but not the blob CONTENT, which the re-embed comparison used to
15964///   catch. Hashing the blobs keeps that external-tamper closure intact at
15965///   negligible cost (~69 KB of SHA-256 against 45 model invocations).
15966fn probe_verification_fingerprint(
15967    identity: &EmbedderIdentity,
15968    mean_vec: Option<&[f32]>,
15969    stored: &[StoredProbeRow],
15970) -> String {
15971    let mut hasher = Sha256::new();
15972    hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE.as_bytes());
15973    hash_fingerprint_field(&mut hasher, identity.name.as_bytes());
15974    hash_fingerprint_field(&mut hasher, identity.revision.as_bytes());
15975    hash_fingerprint_field(&mut hasher, &identity.dimension.to_le_bytes());
15976    match mean_vec {
15977        Some(mean) => {
15978            hash_fingerprint_field(&mut hasher, b"mean-centered");
15979            hash_fingerprint_field(&mut hasher, &encode_vector_blob(mean));
15980        }
15981        None => hash_fingerprint_field(&mut hasher, b"un-centered"),
15982    }
15983    hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_PROBE_FIXTURE.as_bytes());
15984    hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_P1_FLIP_FLOOR.to_le_bytes());
15985    hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_L2_EPSILON.to_le_bytes());
15986    hash_fingerprint_field(&mut hasher, &(stored.len() as u64).to_le_bytes());
15987    for (ordinal, probe_text, reference_vec, name, revision, dim) in stored {
15988        hash_fingerprint_field(&mut hasher, &ordinal.to_le_bytes());
15989        hash_fingerprint_field(&mut hasher, probe_text.as_bytes());
15990        hash_fingerprint_field(&mut hasher, reference_vec);
15991        hash_fingerprint_field(&mut hasher, name.as_bytes());
15992        hash_fingerprint_field(&mut hasher, revision.as_bytes());
15993        hash_fingerprint_field(&mut hasher, &dim.to_le_bytes());
15994    }
15995    hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
15996}
15997
15998/// 0.8.20 Slice 22 (TC-68) — is `fingerprint` the fingerprint under which the
15999/// probe last RAN and PASSED on this workspace?
16000///
16001/// Fail-SAFE against ACCIDENT (R-VEQ-4): **every** failure mode answers `false`,
16002/// which means "run the probe". A missing `_fathomdb_open_state` table, an absent
16003/// row, a non-TEXT value, a truncated or garbled value, a stale fingerprint, any
16004/// SQL error — none of them can be mistaken for a pass.
16005///
16006/// # What a `true` does and does not mean (fix-1, codex §9 round 2 [P1])
16007///
16008/// `true` means: **the fingerprint inputs are unchanged since *some* engine
16009/// recorded a pass.** It does NOT mean "this engine verified this backend", and it
16010/// cannot: the fingerprint is a SHA-256 over deterministic, publicly derivable DB
16011/// and build inputs, so an actor with write access to the file can compute the
16012/// current digest and write it here, skipping the 45-probe verification. This
16013/// marker is not — and cannot be — an authenticated attestation; an embedded
16014/// local-first engine holds no secret with which to authenticate one, and a salt
16015/// would be readable by the same actor.
16016///
16017/// The same actor also defeats the same arm through the **pre-slice** path, by
16018/// re-baselining `_fathomdb_embed_probe`'s `reference_vec` blobs to their drifted
16019/// backend's own output — the probe then runs in full and verifies the drifted
16020/// backend against itself. Measured by
16021/// `tests/tc68_probe_fingerprint_cache.rs::a_forged_stored_baseline_defeats_the_probe_even_when_it_fully_runs`
16022/// (marker deleted, all 45 embeds performed, dense still enabled), with the
16023/// un-forged control caught.
16024///
16025/// **That is the same actor, NOT the same cost, and fix-2 struck the claim that it
16026/// was.** Forging this marker needs only a publicly computable digest — usually the
16027/// value already sitting in the row. Re-baselining additionally needs the target
16028/// backend's 45 exact embeddings, encoded into every row. **So the cache IS a
16029/// cheaper bypass** for a writer of the database file.
16030///
16031/// What bounds it is the ruled residual, not this marker. A same-identity backend
16032/// drift moves no fingerprint input, so a marker recorded by an **honest** earlier
16033/// open already skips the probe and already serves the drifted backend, with no
16034/// forgery anywhere
16035/// (`residual_same_identity_backend_drift_is_not_caught_on_a_cached_open`). Forgery
16036/// adds capability only on an open where no valid marker exists for the *current*
16037/// fingerprint — and a digest is valid only for the state it was computed over, so
16038/// it stops working at the next change to any fingerprint input.
16039///
16040/// The equivalence probe is a **correctness self-check against backend drift, not
16041/// tamper evidence**; `dense_disabled` is not a tamper signal. Threat model, with
16042/// the concession and the bound: §8.4/§8.5 of
16043/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
16044fn probe_verification_is_cached(connection: &Connection, fingerprint: &str) -> bool {
16045    connection
16046        .query_row(
16047            "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
16048            [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
16049            |row| row.get::<_, String>(0),
16050        )
16051        .map(|cached| cached == fingerprint)
16052        .unwrap_or(false)
16053}
16054
16055/// 0.8.20 Slice 22 (TC-68) — record that the probe RAN and PASSED under
16056/// `fingerprint`.
16057///
16058/// A write failure is deliberately SWALLOWED rather than turned into a verdict
16059/// failure. The arm has just been verified, so refusing dense because a marker
16060/// could not be persisted (read-only file, disk full) would be a false refusal;
16061/// and the consequence of the missing marker is simply that the next open re-runs
16062/// the probe — more work, never less. That is the fail-safe direction.
16063fn record_probe_verification(connection: &Connection, fingerprint: &str) {
16064    let _ = connection.execute(
16065        "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
16066         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
16067        params![VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY, fingerprint],
16068    );
16069}
16070
16071/// 0.8.20 Slice 22 (TC-68) — drop any cached verdict.
16072///
16073/// Called on EVERY failure path of the check, so a workspace that could not be
16074/// verified never carries a marker a later open might match. A failing verdict is
16075/// therefore never cached: the probe re-runs each open until it passes again.
16076fn clear_probe_verification(connection: &Connection) {
16077    let _ = connection.execute(
16078        "DELETE FROM _fathomdb_open_state WHERE key = ?1",
16079        [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
16080    );
16081}
16082
16083/// 0.8.18 Slice 5 — SUBSEQUENT open: re-embed the 45 probes and assert BOTH
16084/// dense-pipeline representations against the stored references — **(P1)** the
16085/// mean-centered `embedding_bin` sign-flip count (floor 0, exact) and **(P2)** the
16086/// un-centered Phase-2 L2 (within `VECTOR_EQUIVALENCE_L2_EPSILON`).
16087///
16088/// 0.8.20 Slice 22 (TC-68) — this wrapper adds the failure half of the verdict
16089/// cache: ANY `Err` from the inner check drops the cached marker, so a workspace
16090/// that could not be verified never leaves a stale "verified" marker behind for a
16091/// later open to match. (A failing verdict is never *written*; this also clears a
16092/// marker left by an earlier, passing open whose fingerprint has since changed.)
16093fn probe_check_against_baseline(
16094    connection: &Connection,
16095    embedder: &dyn Embedder,
16096    identity: &EmbedderIdentity,
16097    mean_pinned: bool,
16098    probes: &[&str],
16099) -> Result<(), String> {
16100    let outcome =
16101        probe_check_against_baseline_inner(connection, embedder, identity, mean_pinned, probes);
16102    if outcome.is_err() {
16103        clear_probe_verification(connection);
16104    }
16105    outcome
16106}
16107
16108/// 0.8.18 Slice 5 — the check proper. Fail-SAFE (fix-1 DEFECT #1): a probe embed
16109/// that panics/errors/returns wrong-dim, a malformed/missing reference row, an
16110/// unreadable pinned mean, or a `vec_quantize_binary`/L2 SQL failure each ⇒ `Err`
16111/// (cannot verify ⇒ refuse dense), never a silent skip-and-serve.
16112///
16113/// fix-2 (DEFECT #1 residual): BEFORE the divergence check, the STORED baseline is
16114/// validated to be EXACTLY the committed probe set — the expected row count, a
16115/// contiguous 0-based `probe_ordinal` per committed probe, each `probe_text` equal
16116/// to the committed fixture text at that ordinal, each `reference_vec` a well-formed
16117/// `4 * dim` f32 blob, and the stored embedder identity/dim matching the current
16118/// one. This closes the partial-baseline / external-tamper fail-open (a 44-of-45
16119/// table, or a re-attributed/mangled row, previously verified only the rows present
16120/// or re-embedded a tampered `probe_text` against itself). Any mismatch ⇒ `Err`.
16121///
16122/// 0.8.20 Slice 22 (TC-68) — the 45 re-embeds are CACHED against
16123/// [`probe_verification_fingerprint`]. Note WHERE the cache check sits: after the
16124/// mean resolution and after the fix-2 completeness validation, before the
16125/// re-embed loop. That split is deliberate — everything cheap keeps running on
16126/// EVERY open (so a short, re-attributed, mangled or fixture-mismatched baseline
16127/// still fails closed immediately), and only the expensive part, the 45 model
16128/// invocations, is skipped. The residual this buys is recorded in
16129/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
16130fn probe_check_against_baseline_inner(
16131    connection: &Connection,
16132    embedder: &dyn Embedder,
16133    identity: &EmbedderIdentity,
16134    mean_pinned: bool,
16135    probes: &[&str],
16136) -> Result<(), String> {
16137    let dimension = identity.dimension as usize;
16138
16139    // Resolve the live mean. Fail-SAFE: if centering is required + pinned but the
16140    // mean cannot be read, we cannot reproduce `embedding_bin` ⇒ refuse (P1
16141    // un-verifiable). NoopEmbedder / no-pin ⇒ un-centered on BOTH sides (R-VEQ-3c).
16142    let mean_vec = if identity_requires_mean_centering(identity) && mean_pinned {
16143        match read_pinned_mean_vec(connection, identity.dimension) {
16144            Ok(Some(mean)) => Some(mean),
16145            Ok(None) => {
16146                return Err("mean-centering is required and pinned but mean_vec is absent; \
16147                     cannot verify P1 (dense arm refused)"
16148                    .to_string());
16149            }
16150            Err(_) => {
16151                return Err(
16152                    "could not read the pinned mean_vec; cannot verify P1 (dense arm refused)"
16153                        .to_string(),
16154                );
16155            }
16156        }
16157    } else {
16158        None
16159    };
16160
16161    let mut stmt = connection
16162        .prepare(
16163            "SELECT probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim \
16164             FROM _fathomdb_embed_probe ORDER BY probe_ordinal",
16165        )
16166        .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))?;
16167    let stored: Vec<StoredProbeRow> = stmt
16168        .query_map([], |row| {
16169            Ok((
16170                row.get::<_, i64>(0)?,
16171                row.get::<_, String>(1)?,
16172                row.get::<_, Vec<u8>>(2)?,
16173                row.get::<_, String>(3)?,
16174                row.get::<_, String>(4)?,
16175                row.get::<_, i64>(5)?,
16176            ))
16177        })
16178        .and_then(|rows| rows.collect::<rusqlite::Result<Vec<_>>>())
16179        .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))?;
16180
16181    // fix-2 (DEFECT #1 residual) — COMPLETENESS validation of the STORED baseline.
16182    // `COUNT(*) > 0` is NOT proof of a complete, trustworthy baseline: a partially
16183    // populated or externally-tampered probe table (44 of 45 rows, a gap/dupe in the
16184    // ordinals, a mangled reference blob, a mismatched probe_text, or a foreign
16185    // embedder identity) is UNVERIFIABLE stored state. The prior code re-embedded
16186    // the STORED probe_text and compared it to its OWN reference, so a tampered
16187    // probe_text verified against itself and a short table verified only the rows
16188    // present — both fail-OPEN. Atomic population stops the ENGINE from writing a
16189    // partial set; this closes external corruption, a manual edit, and a future
16190    // migration bug the engine did not author. Any mismatch ⇒ fail CLOSED (dense
16191    // refused); the text-only/FTS path still serves. The stored baseline must be
16192    // EXACTLY the committed probe set, in order, under the current identity.
16193    if stored.len() != probes.len() {
16194        return Err(format!(
16195            "the probe reference table has {} rows but the committed fixture defines {}; \
16196             the stored baseline is incomplete or corrupt — cannot verify the dense arm (refused)",
16197            stored.len(),
16198            probes.len()
16199        ));
16200    }
16201    for (idx, (ordinal, probe_text, ref_blob, name, revision, dim)) in stored.iter().enumerate() {
16202        // Contiguous 0-based ordinals, one per committed probe (no gaps/dupes).
16203        if *ordinal != idx as i64 {
16204            return Err(format!(
16205                "probe reference ordinals are non-contiguous (row {idx} carries ordinal {ordinal}); \
16206                 the stored baseline is corrupt — cannot verify the dense arm (refused)"
16207            ));
16208        }
16209        // The stored text MUST be the committed fixture text at this ordinal —
16210        // otherwise a tampered probe_text re-embeds and verifies against ITSELF,
16211        // masking drift (the exact fail-open this fix closes).
16212        if probe_text != probes[idx] {
16213            return Err(format!(
16214                "probe reference {ordinal} text does not match the committed fixture; \
16215                 the stored baseline is tampered or corrupt — cannot verify the dense arm (refused)"
16216            ));
16217        }
16218        // Well-formed f32[dim] reference (4*dim little-endian bytes).
16219        if ref_blob.len() != dimension * 4 {
16220            return Err(format!(
16221                "probe reference {ordinal} is malformed (len {} != {}); \
16222                 cannot verify the dense arm (refused)",
16223                ref_blob.len(),
16224                dimension * 4
16225            ));
16226        }
16227        // The stored embedder identity/dim must match the CURRENT expected identity
16228        // (defence-in-depth beyond `check_embedder_profile`: catches a baseline row
16229        // re-attributed to a foreign embedder by external edit/migration).
16230        if *dim != identity.dimension as i64
16231            || name != &identity.name
16232            || revision != &identity.revision
16233        {
16234            return Err(format!(
16235                "probe reference {ordinal} was captured under embedder {name}/{revision}/dim={dim} \
16236                 but the current embedder is {}/{}/dim={}; the stored baseline does not match — \
16237                 cannot verify the dense arm (refused)",
16238                identity.name, identity.revision, identity.dimension
16239            ));
16240        }
16241    }
16242
16243    // 0.8.20 Slice 22 (TC-68) — the CACHE gate. Everything above this line ran on
16244    // this open and still fails closed; everything below it is the 45 model
16245    // invocations that made `Engine::open` cost a flat 45 embeds FOREVER (measured
16246    // at `94bb33ef`: 0 with no enrolled kind, 90 on the one-time population open,
16247    // 45 on every open thereafter — independent of the enrolled-kind count, since
16248    // the probe gate is an `EXISTS` and the body never iterates kinds).
16249    //
16250    // If the probe already RAN and PASSED under this exact fingerprint, re-running
16251    // it is a pure re-computation of a known answer, so the verdict is reused.
16252    // Fail-SAFE: `probe_verification_is_cached` answers `false` for every failure
16253    // mode — missing table, absent row, garbled value, SQL error — so an
16254    // unreadable cache RUNS the probe, it never short-circuits to trusting it.
16255    let fingerprint = probe_verification_fingerprint(identity, mean_vec.as_deref(), &stored);
16256    if probe_verification_is_cached(connection, &fingerprint) {
16257        return Ok(());
16258    }
16259
16260    let mut total_flips: u64 = 0;
16261    let mut max_l2: f32 = 0.0;
16262    let mut worst_probe: Option<String> = None;
16263
16264    for (ordinal, probe_text, ref_blob, _, _, _) in &stored {
16265        let reference = decode_vector_blob(ref_blob);
16266        let reembed = probe_embed(embedder, probe_text, dimension).ok_or_else(|| {
16267            format!(
16268                "embedder failed/panicked re-embedding probe {ordinal}; \
16269                 cannot verify the dense arm (refused)"
16270            )
16271        })?;
16272
16273        // (P2) un-centered L2 — `vec_distance_l2(embedding, vec_f32(query))`.
16274        let l2 = l2_distance(&reembed, &reference);
16275        if l2 > max_l2 {
16276            max_l2 = l2;
16277            worst_probe = Some(probe_text.clone());
16278        }
16279
16280        // (P1) mean-centered Phase-1 flip count — same
16281        // `vec_quantize_binary(sign(x − mean_vec))` path as build_vector_phase1_sql.
16282        let (ref_c, reembed_c) = match &mean_vec {
16283            Some(mean) => (subtract_mean(&reference, mean), subtract_mean(&reembed, mean)),
16284            None => (reference.clone(), reembed.clone()),
16285        };
16286        let ref_bits = quantize_binary_via_sql(connection, &ref_c).ok_or_else(|| {
16287            format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16288        })?;
16289        let reembed_bits = quantize_binary_via_sql(connection, &reembed_c).ok_or_else(|| {
16290            format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16291        })?;
16292        total_flips = total_flips.saturating_add(hamming_bytes(&ref_bits, &reembed_bits));
16293    }
16294
16295    let p1_tripped = total_flips > VECTOR_EQUIVALENCE_P1_FLIP_FLOOR;
16296    let p2_tripped = max_l2 > VECTOR_EQUIVALENCE_L2_EPSILON;
16297    if p1_tripped || p2_tripped {
16298        let probe_hint = worst_probe.as_deref().unwrap_or("<unknown>");
16299        return Err(format!(
16300            "P1 mean-centered embedding_bin flips={total_flips} (floor={VECTOR_EQUIVALENCE_P1_FLIP_FLOOR}), \
16301             P2 max un-centered L2={max_l2:.3e} (epsilon={VECTOR_EQUIVALENCE_L2_EPSILON:.3e}); \
16302             worst probe {probe_hint:?}"
16303        ));
16304    }
16305
16306    // 0.8.20 Slice 22 (TC-68) — the probe RAN and PASSED; record the fingerprint
16307    // so the next open with identical inputs need not repeat it. Only a verdict
16308    // this engine reached itself is ever written (the failure paths above return
16309    // early, and the wrapper clears any prior marker on them).
16310    record_probe_verification(connection, &fingerprint);
16311    Ok(())
16312}
16313
16314/// 0.8.18 Slice 5 — un-centered Euclidean (L2) distance, matching the
16315/// `vec_distance_l2` semantics used by the Phase-2 rerank.
16316fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
16317    a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
16318}
16319
16320/// 0.8.18 Slice 5 — produce the packed 1-bit `embedding_bin` blob for a (possibly
16321/// mean-centered) f32 vector via the SAME SQL `vec_quantize_binary` the production
16322/// Phase-1 path uses, so the probe's bits are byte-equal to the engine's
16323/// `embedding_bin` production. `None` on any SQL/serialization error.
16324fn quantize_binary_via_sql(connection: &Connection, vector: &[f32]) -> Option<Vec<u8>> {
16325    let json = serde_json::to_string(vector).ok()?;
16326    connection
16327        .query_row("SELECT vec_quantize_binary(vec_f32(?1))", [json], |row| {
16328            row.get::<_, Vec<u8>>(0)
16329        })
16330        .ok()
16331}
16332
16333/// 0.8.18 Slice 5 — Hamming distance (differing bit count) between two equal-length
16334/// packed bit blobs. Unequal lengths ⇒ count every bit of the length delta as
16335/// differing (a shape divergence is a divergence).
16336fn hamming_bytes(a: &[u8], b: &[u8]) -> u64 {
16337    let common = a.len().min(b.len());
16338    let mut flips: u64 = 0;
16339    for i in 0..common {
16340        flips += u64::from((a[i] ^ b[i]).count_ones());
16341    }
16342    let extra = a.len().abs_diff(b.len());
16343    flips + (extra as u64) * 8
16344}
16345
16346/// Maps the writer-facing `kind` value to the locked Pack 1
16347/// `source_type` partition-key vocabulary. Must stay in lockstep with
16348/// the CASE WHEN inlined in migration step 9
16349/// (`fathomdb-schema/src/lib.rs`); the drift-detection unit test in
16350/// this module's `tests` mod enforces that. Per
16351/// `dev/design/0.7.0-vector-quant-pack1.md` D3.
16352fn resolve_source_type(kind: &str) -> Result<&'static str, EngineError> {
16353    Ok(match kind {
16354        "email" => "email",
16355        "article" => "article",
16356        "paper" => "paper",
16357        "meeting" => "meeting",
16358        "note" => "note",
16359        "todo" => "todo",
16360        // Synthetic AC-013 test fixture; coerced so the 6-value HITL lock holds.
16361        "doc" => "article",
16362        // G11 (Slice 15) — edge-body projection; separate `source_type` partition
16363        // key distinguishes edge vectors from node vectors in `vector_default`.
16364        "edge_fact" => "edge_fact",
16365        _ => return Err(EngineError::Storage),
16366    })
16367}
16368
16369/// 0.8.20 Slice 20c fix-2 (codex §9 [P1]) — **can the vector writer COMMIT a row
16370/// of this kind?** The ONE definition of the vector pipeline's kind domain, shared
16371/// by every enrolment path.
16372///
16373/// [`commit_projection_outcomes`] resolves `kind -> source_type` through
16374/// [`resolve_source_type`] and returns `Err` for anything outside its locked
16375/// vocabulary — *before* it records the row's terminal. `PreparedWrite::Node`, by
16376/// contrast, accepts ANY non-empty `kind` (`validate_write` constrains the body,
16377/// the identity and the validity window, never the kind against that vocabulary),
16378/// so a corpus can legitimately hold e.g. an `"invoice"` node.
16379///
16380/// Enrolling such a kind is therefore a permanent LIVENESS WEDGE for the whole
16381/// workspace: the scheduler picks the row up, the commit fails, no terminal is
16382/// ever written, the scanner re-enqueues it forever, `drain` burns its entire
16383/// timeout into [`EngineError::Scheduler`] and `dense_readiness` sticks on
16384/// `embedding` — starving the rows whose kinds ARE commit-able along with it.
16385///
16386/// So enrolment is RESTRICTED to this predicate rather than the vector writer
16387/// being taught arbitrary kinds (which would reach into `resolve_source_type`'s
16388/// locked Pack-1 partition-key semantics — `dev/design/0.7.0-vector-quant-pack1.md`
16389/// D3, a HITL lock). A non-commit-able kind simply gets NO dense arm, which is
16390/// precisely its pre-slice status quo; it is deliberately **not** a new typed
16391/// error and adds no governed surface.
16392///
16393/// It DELEGATES to `resolve_source_type` instead of restating the list. A
16394/// hand-copied second vocabulary is the TC-56 defect shape (a mirror that silently
16395/// drifts from its original), and here the drift would be silent in the worst
16396/// direction: a kind added to `resolve_source_type` but missing from a copied
16397/// filter would just never be embedded.
16398fn kind_is_vector_committable(kind: &str) -> bool {
16399    resolve_source_type(kind).is_ok()
16400}
16401
16402/// G11 (Slice 15) — derive a stable hex-encoded sha256 logical_id from a
16403/// `(kind, name)` pair. Both inputs are lowercased before hashing so that
16404/// entity identity is case-insensitive (`"Alice"` == `"alice"`). The
16405/// canonical form is `sha256("<kind>:<name>")` — identical to the
16406/// ADR-0.8.1-byo-llm derivation rule.
16407///
16408/// fix-34 [P1]: because `:` is the delimiter, a `:` in `kind` would let the
16409/// split point move and collide two distinct `(kind, name)` pairs onto one
16410/// identity (e.g. `("a:b","c")` and `("a","b:c")` both hash `"a:b:c"`),
16411/// silently dropping one entity via batch dedup / G0 supersession. An empty
16412/// `name` collapses every name-less entity of a kind onto `sha256("<kind>:")`.
16413/// We reject both at the boundary; this preserves the ADR derivation rule
16414/// (a colon-free `kind` makes the first `:` an unambiguous delimiter, so a `:`
16415/// in `name` stays safe — edge keys deliberately rely on that).
16416fn derive_logical_id(kind: &str, name: &str) -> Result<String, EngineError> {
16417    if kind.contains(':') || name.is_empty() {
16418        return Err(EngineError::Extractor);
16419    }
16420    let input = format!("{}:{}", kind.to_lowercase(), name.to_lowercase());
16421    let mut hasher = Sha256::new();
16422    hasher.update(input.as_bytes());
16423    // digest 0.11 returns `hybrid_array::Array`, which (unlike the old
16424    // `GenericArray`) does not implement `LowerHex`. Format the bytes
16425    // explicitly — byte-identical lowercase, zero-padded hex to the prior
16426    // `{:x}` rendering, preserving the load-bearing logical-id derivation.
16427    Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
16428}
16429
16430/// Cause-A (0.8.11.2) / C-2 (0.8.19, TC-8) — derive the typed **stable hit-id**
16431/// ([`IdSpace`]) carried on [`SearchHit::id`] for cross-session real-gold keying.
16432///
16433/// The stable id is the active canonical node's `logical_id` — the post-G0
16434/// supersession-stable identity, preserved across re-projection/re-ingest by the
16435/// tombstone-then-insert contract (whereas the engine-internal `write_cursor` is
16436/// reassigned on every re-ingest). When `logical_id` is NULL — the doc-seeded
16437/// node case, the *dominant* corpus hit type today — we fall back to a content
16438/// hash of the body so doc hits still carry a re-ingest-survivable key.
16439///
16440/// The result is a typed [`IdSpace`]; its `to_prefixed()` reproduces the pre-C-2
16441/// `stable_id` string byte-for-byte so real-gold keying is a no-op:
16442/// - [`IdSpace::logical`] (`"l:<logical_id>"`) — entities + edges (graph-arm,
16443///   vector-node, and edge hits when `logical_id` is present);
16444/// - [`IdSpace::content`] (`"h:<sha256(body)>"`) — doc nodes with NULL
16445///   `logical_id`, and any branch that cannot cheaply resolve a `logical_id`.
16446///
16447/// Behaviour-neutral: the value never participates in ranking/scoring (same
16448/// additive posture as `source_id` / `ce_score`).
16449fn derive_stable_id(logical_id: Option<&str>, body: &str) -> IdSpace {
16450    match logical_id {
16451        Some(lid) if !lid.is_empty() => IdSpace::logical(lid),
16452        _ => {
16453            let mut hasher = Sha256::new();
16454            hasher.update(body.as_bytes());
16455            IdSpace::content(
16456                hasher.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>(),
16457            )
16458        }
16459    }
16460}
16461
16462/// fix-34 [P2]: dedup a batch of [`PreparedWrite`]s by `logical_id`, keeping the
16463/// first occurrence. Shared by the entity and edge arms of the BYO-LLM ingest
16464/// path so a harness that returns the same node/edge twice in one response does
16465/// not write a row that immediately supersedes its sibling.
16466///
16467/// **TC-32 (0.8.20) — single-provenance entity dedupe is INTENTIONAL and
16468/// ACCEPTED.** Because dedupe keeps the FIRST occurrence, same-name entities
16469/// collapse onto one `logical_id` row that carries only the FIRST document's
16470/// `source_id`; erasing a later document therefore does not remove the shared
16471/// entity row. The HITL has ruled this acceptable for now and explicitly
16472/// declined a multi-source-provenance model. Tracked as TC-32 — do not "fix"
16473/// this by changing dedupe behaviour without a fresh decision.
16474fn dedup_prepared_by_logical_id(batch: Vec<PreparedWrite>) -> Vec<PreparedWrite> {
16475    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
16476    batch
16477        .into_iter()
16478        .filter(|w| match w {
16479            PreparedWrite::Node { logical_id: Some(id), .. }
16480            | PreparedWrite::Edge { logical_id: Some(id), .. } => seen.insert(id.clone()),
16481            _ => true,
16482        })
16483        .collect()
16484}
16485
16486/// 0.8.6 Slice 5 (ADR-0.8.6) — the family of caller-supplied provider tasks that
16487/// ride the one NDJSON-over-stdio transport. Each task maps to a wire protocol
16488/// string `fathomdb.<task>.v1` and a task discriminator name. `Extract` shipped
16489/// in 0.8.6; `Consolidate` (0.8.12 Slice 15, OPP-2) is the SECOND consumer of
16490/// this one transport — it adds only a variant, a payload, and an `EngineError`
16491/// leaf, WITHOUT a second handshake or a second transport.
16492#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16493enum ProviderTask {
16494    Extract,
16495    /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — consolidation / recency provider.
16496    Consolidate,
16497}
16498
16499impl ProviderTask {
16500    /// The wire task discriminator, e.g. `"extract"`. Used for `supported_tasks`
16501    /// negotiation and as the request envelope `type`.
16502    fn name(self) -> &'static str {
16503        match self {
16504            ProviderTask::Extract => "extract",
16505            ProviderTask::Consolidate => "consolidate",
16506        }
16507    }
16508
16509    /// The protocol string FathomDB sends in `hello`/requests and requires in
16510    /// `ready`. For `Extract` this is the UNCHANGED `fathomdb.extract.v1` —
16511    /// byte-identical back-compat for existing ELPS harnesses (ADR-0.8.6 §2.1).
16512    /// For `Consolidate` it is `fathomdb.consolidate.v1` (ADR-0.8.12 §2).
16513    fn protocol(self) -> &'static str {
16514        match self {
16515            ProviderTask::Extract => "fathomdb.extract.v1",
16516            ProviderTask::Consolidate => "fathomdb.consolidate.v1",
16517        }
16518    }
16519}
16520
16521/// 0.8.6 Slice 5 (ADR-0.8.6) — an open provider transport session: the spawned
16522/// caller subprocess, the buffered stdin writer, the detached stdout-drain
16523/// channel, the bounded-recv timeout, and the negotiated handshake state
16524/// (`model` provenance + `max_docs_per_request`). One session serves one task
16525/// family; the `request`/framing is identical across tasks. `Drop` reaps the
16526/// child (sends stdin EOF via the writer field's own drop, then kill/wait),
16527/// replacing the prior explicit outer kill/wait.
16528struct ProviderSession {
16529    task: ProviderTask,
16530    child: std::process::Child,
16531    writer: std::io::BufWriter<std::process::ChildStdin>,
16532    line_rx: Receiver<std::io::Result<String>>,
16533    io_timeout: Duration,
16534    /// `ready.model`, recorded as output-row provenance (`extractor_model_id`).
16535    model: Option<String>,
16536    max_docs_per_request: usize,
16537}
16538
16539impl Drop for ProviderSession {
16540    fn drop(&mut self) {
16541        // The detached stdout-drain thread exits when the child's stdout closes;
16542        // kill() guarantees that even for a child that ignores stdin EOF. The
16543        // `writer` field drops after this (declaration order) sending EOF too.
16544        let _ = self.child.kill();
16545        let _ = self.child.wait();
16546    }
16547}
16548
16549impl ProviderSession {
16550    /// Run the `hello` → `ready` handshake and `supported_tasks` negotiation.
16551    /// Validates protocol + schema_version (fix-23 [P2]); rejects a zero
16552    /// `max_docs_per_request` (fix-1 [P2]); and, when the harness advertises
16553    /// `supported_tasks`, refuses to proceed unless this session's task is in it.
16554    /// When `supported_tasks` is absent, the harness is assumed to serve the
16555    /// requested task (back-compat: existing extract-only harnesses unchanged).
16556    fn handshake(&mut self) -> Result<(), EngineError> {
16557        let protocol = self.task.protocol();
16558        let hello = serde_json::json!({
16559            "protocol": protocol,
16560            "type": "hello",
16561            "schema_version": 1,
16562        });
16563        let hello_line = serde_json::to_string(&hello).map_err(|_| EngineError::Extractor)?;
16564        writeln!(self.writer, "{hello_line}").map_err(|_| EngineError::Extractor)?;
16565        self.writer.flush().map_err(|_| EngineError::Extractor)?;
16566
16567        let line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
16568        let ready: Value = serde_json::from_str(line.trim()).map_err(|_| EngineError::Extractor)?;
16569        // fix-23 [P2]: validate protocol + schema_version in the ready message per ADR.
16570        if ready.get("type").and_then(|v| v.as_str()) != Some("ready")
16571            || ready.get("protocol").and_then(|v| v.as_str()) != Some(protocol)
16572            || ready.get("schema_version").and_then(|v| v.as_u64()) != Some(1)
16573        {
16574            return Err(EngineError::Extractor);
16575        }
16576
16577        // 0.8.6 Slice 5 (ADR-0.8.6 §2.2): additive, optional `supported_tasks`
16578        // negotiation. If present, the harness must advertise this session's task
16579        // or FathomDB refuses to dispatch it. If absent, default to "serves the
16580        // requested task" so extract-only harnesses keep working unchanged.
16581        if let Some(supported) = ready.get("supported_tasks").and_then(|v| v.as_array()) {
16582            let task_name = self.task.name();
16583            let advertised = supported.iter().any(|t| t.as_str() == Some(task_name));
16584            if !advertised {
16585                return Err(EngineError::Extractor);
16586            }
16587        }
16588
16589        self.model = ready.get("model").and_then(|v| v.as_str()).map(|s| s.to_string());
16590        let max_docs =
16591            ready.get("max_docs_per_request").and_then(|v| v.as_u64()).unwrap_or(8) as usize;
16592        // fix-1 [P2]: reject zero max_docs_per_request to prevent chunks(0) panic.
16593        if max_docs == 0 {
16594            return Err(EngineError::Extractor);
16595        }
16596        self.max_docs_per_request = max_docs;
16597        Ok(())
16598    }
16599
16600    /// Send one framed request for this session's task and receive its matching
16601    /// response. `payload` carries the task-specific fields; the envelope keys
16602    /// (`protocol`, `type`, `request_id`) are added here. The response must have
16603    /// `type == "result"` and a matching `request_id` (fix-24 [P2]); anything
16604    /// else (error, wrong id, missing type) is a protocol fault. For `Extract`
16605    /// the serialized request bytes are identical to the pre-0.8.6 path (serde_json
16606    /// serializes map keys sorted, independent of insertion order).
16607    fn request(
16608        &mut self,
16609        request_id: &str,
16610        payload: Vec<(String, Value)>,
16611    ) -> Result<Value, EngineError> {
16612        let mut req = serde_json::Map::new();
16613        req.insert("protocol".to_string(), Value::from(self.task.protocol()));
16614        req.insert("type".to_string(), Value::from(self.task.name()));
16615        req.insert("request_id".to_string(), Value::from(request_id));
16616        for (k, v) in payload {
16617            req.insert(k, v);
16618        }
16619        let req_line =
16620            serde_json::to_string(&Value::Object(req)).map_err(|_| EngineError::Extractor)?;
16621        writeln!(self.writer, "{req_line}").map_err(|_| EngineError::Extractor)?;
16622        self.writer.flush().map_err(|_| EngineError::Extractor)?;
16623
16624        let result_line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
16625        let result: Value =
16626            serde_json::from_str(result_line.trim()).map_err(|_| EngineError::Extractor)?;
16627        let resp_type = result.get("type").and_then(|v| v.as_str());
16628        let resp_id = result.get("request_id").and_then(|v| v.as_str());
16629        if resp_type != Some("result") || resp_id != Some(request_id) {
16630            return Err(EngineError::Extractor);
16631        }
16632        Ok(result)
16633    }
16634}
16635
16636/// fix-35 [P2]: BYO-LLM extractor I/O timeout. Defaults to 300s to accommodate
16637/// slow LLM harnesses; override (in milliseconds) via
16638/// `FATHOMDB_EXTRACTOR_TIMEOUT_MS` (tests use this to exercise the hung-harness
16639/// path quickly).
16640fn extractor_io_timeout() -> Duration {
16641    std::env::var("FATHOMDB_EXTRACTOR_TIMEOUT_MS")
16642        .ok()
16643        .and_then(|s| s.parse::<u64>().ok())
16644        .map(Duration::from_millis)
16645        .unwrap_or_else(|| Duration::from_secs(300))
16646}
16647
16648/// fix-35 [P1/P2]: receive one line from the stdout reader thread, bounded by
16649/// `timeout`. A timeout, a closed channel (reader thread ended / child EOF), or
16650/// an underlying io error all map to [`EngineError::Extractor`].
16651fn recv_extractor_line(
16652    rx: &Receiver<std::io::Result<String>>,
16653    timeout: Duration,
16654) -> Result<String, EngineError> {
16655    match rx.recv_timeout(timeout) {
16656        Ok(Ok(line)) => Ok(line),
16657        _ => Err(EngineError::Extractor),
16658    }
16659}
16660
16661fn map_runtime_embedder_error(err: RuntimeEmbedderError) -> EngineError {
16662    match err {
16663        RuntimeEmbedderError::Failed { .. } | RuntimeEmbedderError::Timeout => {
16664            EngineError::Embedder
16665        }
16666    }
16667}
16668
16669fn default_embedder_identity() -> EmbedderIdentity {
16670    EmbedderIdentity::new(
16671        DEFAULT_EMBEDDER_NAME,
16672        DEFAULT_EMBEDDER_REVISION,
16673        DEFAULT_EMBEDDER_DIMENSION,
16674    )
16675}
16676
16677fn check_embedder_profile(
16678    connection: &Connection,
16679    supplied: &EmbedderIdentity,
16680) -> Result<bool, EngineOpenError> {
16681    // Returns `true` iff `_fathomdb_embedder_profiles.mean_vec IS NOT NULL`
16682    // for the default profile (and its byte length matches `4 * dimension`
16683    // per `dev/design/embedder.md` §0.2). EU-5a2: column lands in step 10.
16684    let mut statement = match connection.prepare(
16685        "SELECT name, revision, dimension, mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
16686    ) {
16687        Ok(statement) => statement,
16688        Err(_) => return Ok(false),
16689    };
16690    let mut rows = statement.query([]).map_err(|_| {
16691        EngineOpenError::Corruption(CorruptionDetail {
16692            kind: CorruptionKind::EmbedderIdentityDrift,
16693            stage: OpenStage::EmbedderIdentity,
16694            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
16695            recovery_hint: RecoveryHint {
16696                code: "E_CORRUPT_EMBEDDER_IDENTITY",
16697                doc_anchor: "design/recovery.md#embedder-identity-drift",
16698            },
16699        })
16700    })?;
16701
16702    let Some(row) = rows.next().map_err(|_| {
16703        EngineOpenError::Corruption(CorruptionDetail {
16704            kind: CorruptionKind::EmbedderIdentityDrift,
16705            stage: OpenStage::EmbedderIdentity,
16706            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
16707            recovery_hint: RecoveryHint {
16708                code: "E_CORRUPT_EMBEDDER_IDENTITY",
16709                doc_anchor: "design/recovery.md#embedder-identity-drift",
16710            },
16711        })
16712    })?
16713    else {
16714        connection
16715            .execute(
16716                "INSERT INTO _fathomdb_embedder_profiles(profile, name, revision, dimension)
16717                 VALUES(?1, ?2, ?3, ?4)",
16718                params![
16719                    DEFAULT_VECTOR_PROFILE,
16720                    supplied.name,
16721                    supplied.revision,
16722                    supplied.dimension
16723                ],
16724            )
16725            .map_err(|_| EngineOpenError::Io {
16726                message: "could not persist embedder profile".to_string(),
16727            })?;
16728        return Ok(false);
16729    };
16730
16731    let stored_name = row.get::<_, String>(0).map_err(|_| {
16732        EngineOpenError::Corruption(CorruptionDetail {
16733            kind: CorruptionKind::EmbedderIdentityDrift,
16734            stage: OpenStage::EmbedderIdentity,
16735            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16736            recovery_hint: RecoveryHint {
16737                code: "E_CORRUPT_EMBEDDER_IDENTITY",
16738                doc_anchor: "design/recovery.md#embedder-identity-drift",
16739            },
16740        })
16741    })?;
16742    let stored_revision = row.get::<_, String>(1).map_err(|_| {
16743        EngineOpenError::Corruption(CorruptionDetail {
16744            kind: CorruptionKind::EmbedderIdentityDrift,
16745            stage: OpenStage::EmbedderIdentity,
16746            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16747            recovery_hint: RecoveryHint {
16748                code: "E_CORRUPT_EMBEDDER_IDENTITY",
16749                doc_anchor: "design/recovery.md#embedder-identity-drift",
16750            },
16751        })
16752    })?;
16753    let dimension = row.get::<_, u32>(2).map_err(|_| {
16754        EngineOpenError::Corruption(CorruptionDetail {
16755            kind: CorruptionKind::EmbedderIdentityDrift,
16756            stage: OpenStage::EmbedderIdentity,
16757            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16758            recovery_hint: RecoveryHint {
16759                code: "E_CORRUPT_EMBEDDER_IDENTITY",
16760                doc_anchor: "design/recovery.md#embedder-identity-drift",
16761            },
16762        })
16763    })?;
16764
16765    let stored = EmbedderIdentity::new(stored_name, stored_revision, dimension);
16766
16767    if stored.name != supplied.name || stored.revision != supplied.revision {
16768        return Err(EngineOpenError::EmbedderIdentityMismatch {
16769            stored,
16770            supplied: supplied.clone(),
16771        });
16772    }
16773    if dimension != supplied.dimension {
16774        return Err(EngineOpenError::EmbedderDimensionMismatch {
16775            stored: dimension,
16776            supplied: supplied.dimension,
16777        });
16778    }
16779
16780    // EU-5a2 / `dev/design/embedder.md` §0.2 invariant: if `mean_vec` is
16781    // populated, byte length MUST equal `4 * dimension`. Debug builds
16782    // assert; release builds fail closed via EmbedderIdentityMismatch
16783    // (the same fail-closed channel the rest of profile drift takes).
16784    let mean_vec: Option<Vec<u8>> = row.get::<_, Option<Vec<u8>>>(3).map_err(|_| {
16785        EngineOpenError::Corruption(CorruptionDetail {
16786            kind: CorruptionKind::EmbedderIdentityDrift,
16787            stage: OpenStage::EmbedderIdentity,
16788            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
16789            recovery_hint: RecoveryHint {
16790                code: "E_CORRUPT_EMBEDDER_IDENTITY",
16791                doc_anchor: "design/recovery.md#embedder-identity-drift",
16792            },
16793        })
16794    })?;
16795    let pinned = match mean_vec {
16796        Some(bytes) => {
16797            let expected_len = (dimension as usize).saturating_mul(4);
16798            // `dev/design/embedder.md` §0.2 invariant: when populated,
16799            // `mean_vec` byte length MUST equal `4 * dimension`. Fail
16800            // closed via the existing identity-drift channel in both
16801            // debug and release builds — tests deliberately poke
16802            // malformed values to exercise this branch.
16803            if bytes.len() != expected_len {
16804                return Err(EngineOpenError::EmbedderIdentityMismatch {
16805                    stored,
16806                    supplied: supplied.clone(),
16807                });
16808            }
16809            true
16810        }
16811        None => false,
16812    };
16813
16814    Ok(pinned)
16815}
16816
16817#[derive(Clone, Debug, Eq, PartialEq)]
16818enum WritePlan {
16819    Node,
16820    Edge,
16821    AppendOnlyLog,
16822    LatestState,
16823    AdminSchema,
16824}
16825
16826fn validate_batch(
16827    connection: &Connection,
16828    batch: &[PreparedWrite],
16829) -> Result<Vec<WritePlan>, EngineError> {
16830    batch.iter().map(|write| validate_write(connection, write)).collect()
16831}
16832
16833fn collect_projection_jobs(
16834    connection: &Connection,
16835    batch: &[PreparedWrite],
16836) -> Result<Vec<ProjectionJob>, EngineError> {
16837    let mut jobs = Vec::new();
16838    for write in batch {
16839        if let PreparedWrite::Node { kind, body, .. } = write {
16840            // 0.8.20 Slice 20c — this probe decides whether `notify_new_work` is
16841            // called, so `Engine::enrol_batch_vector_kinds` MUST already have run
16842            // on this batch: a kind enrolled after this point would be enqueued in
16843            // the database with the dispatcher left asleep on
16844            // `pending_scan == false`, and because `drain` is a passive barrier
16845            // (C4 rider: never a trigger) the next `drain` would burn its ENTIRE
16846            // timeout and return `EngineError::Scheduler` on work ready to run.
16847            if kind_is_vector_indexed(connection, kind)? {
16848                jobs.push(ProjectionJob { cursor: 0, kind: kind.clone(), body: body.clone() });
16849            }
16850        }
16851    }
16852    Ok(jobs)
16853}
16854
16855fn validate_write(
16856    connection: &Connection,
16857    write: &PreparedWrite,
16858) -> Result<WritePlan, EngineError> {
16859    match write {
16860        PreparedWrite::Node { kind, body, logical_id, valid_from, valid_until, .. } => {
16861            if kind.trim().is_empty() || body.trim().is_empty() {
16862                return Err(EngineError::WriteValidation);
16863            }
16864            // 0.8.20 Slice 15b (TC-34) — the validity window is HALF-OPEN
16865            // `[valid_from, valid_until)`, so a pair with `from >= until` selects
16866            // no instant at all: the row would be written but no default read
16867            // could ever return it. Silently accepting that is a trap, so it is a
16868            // typed refusal.
16869            //
16870            // 0.8.20 Slice 22 (R-20-VC) — **decision #18, SETTLED: one family.**
16871            // This site used to return `EngineError::InvalidArgument { msg }`
16872            // carrying both bounds, which made `validate_write` — ONE function —
16873            // reject across TWO error families, so the same `write` call raised
16874            // `InvalidArgumentError` for an inverted window and
16875            // `WriteValidationError` for a non-integer bound. `dev/design/errors.md`
16876            // (status: locked) defines `WriteValidationError` as "malformed typed
16877            // write shape" / "the submitted typed write is malformed **before**
16878            // schema-sensitive payload checks run" — which is exactly this
16879            // boundary — so the code now agrees with the taxonomy of record.
16880            // `InvalidArgument` stays the family for caller-argument rejections
16881            // OUTSIDE this boundary (see the errors.md 2026-07-28 amendment).
16882            //
16883            // **The cost, stated:** `WriteValidation` is a UNIT variant and both
16884            // bindings map it to a fixed message-less string, so the offending
16885            // bounds are no longer recoverable from the error. That is a breaking
16886            // behaviour change on a published surface (CHANGELOG 0.8.20) and it is
16887            // the diagnostic the prior split existed to preserve. Restoring it
16888            // needs a message-carrying `WriteValidation { msg }`, which is a
16889            // cross-cutting change across every engine + binding raise site and
16890            // both binding payload shapes — its own slice, not this one.
16891            //
16892            // Only the PAIR can be empty. A one-sided window is unbounded on the
16893            // missing side and can never be empty, so it is never refused.
16894            if let (Some(from), Some(until)) = (valid_from, valid_until) {
16895                if from >= until {
16896                    return Err(EngineError::WriteValidation);
16897                }
16898            }
16899            // R-20-E3: `source_id` needs no emptiness check here — `SourceId`
16900            // cannot hold an empty or reserved id, so the check has moved from
16901            // this branch into the type's constructor.
16902            // G0 — an explicit logical_id must be non-empty (NULL/None is the
16903            // legacy default; an empty string is never a valid identity).
16904            // Also reject char(30) = \x1e (ASCII RS), which is the BFS cycle-guard
16905            // delimiter; allowing it would corrupt the visited-path substring test.
16906            if let Some(logical_id) = logical_id {
16907                if logical_id.is_empty() || logical_id.contains('\x1e') {
16908                    return Err(EngineError::WriteValidation);
16909                }
16910            }
16911            Ok(WritePlan::Node)
16912        }
16913        PreparedWrite::Edge { kind, from, to, logical_id, t_valid, t_invalid, .. } => {
16914            if kind.trim().is_empty() || from.trim().is_empty() || to.trim().is_empty() {
16915                return Err(EngineError::WriteValidation);
16916            }
16917            // Reject char(30) in from/to: these become from_id/to_id in canonical_edges
16918            // and appear in BFS visited strings — an \x1e there would corrupt the guard.
16919            if from.contains('\x1e') || to.contains('\x1e') {
16920                return Err(EngineError::WriteValidation);
16921            }
16922            // R-20-E3: see the Node branch — emptiness is a `SourceId` invariant.
16923            if let Some(logical_id) = logical_id {
16924                if logical_id.is_empty() || logical_id.contains('\x1e') {
16925                    return Err(EngineError::WriteValidation);
16926                }
16927            }
16928            // TC-33 fix-1 (codex §9 P2) — an epoch SQLite cannot render to
16929            // ISO-8601 must be UNSTORABLE. The governed integer surface is the
16930            // only way to reach one (inbound ISO normalisation maxes at year
16931            // 9999), so this write boundary is where it is stopped, before it
16932            // can render to a silent `null` on the consolidation wire and
16933            // resurrect an invalidated edge. Structural primary layer; the
16934            // render site keeps a defensive hard-assert as the backstop.
16935            reject_unrenderable_edge_epoch("t_valid", *t_valid)?;
16936            reject_unrenderable_edge_epoch("t_invalid", *t_invalid)?;
16937            Ok(WritePlan::Edge)
16938        }
16939        PreparedWrite::AdminSchema { name, kind, schema_json, retention_json } => {
16940            if name.trim().is_empty()
16941                || !matches!(kind.as_str(), "append_only_log" | "latest_state")
16942                || serde_json::from_str::<Value>(schema_json).is_err()
16943                || serde_json::from_str::<Value>(retention_json).is_err()
16944                || contains_external_ref(schema_json)
16945            {
16946                return Err(EngineError::SchemaValidation);
16947            }
16948            Ok(WritePlan::AdminSchema)
16949        }
16950        PreparedWrite::OpStore { collection, record_key, schema_id, body } => {
16951            if collection.trim().is_empty() || record_key.trim().is_empty() {
16952                return Err(EngineError::WriteValidation);
16953            }
16954            let (kind, schema_json) = collection_metadata(connection, collection)?;
16955            if let Some(schema_id) = schema_id {
16956                if schema_id != collection {
16957                    return Err(EngineError::SchemaValidation);
16958                }
16959                validate_payload(&schema_json, body)?;
16960            } else if serde_json::from_str::<Value>(body).is_err() {
16961                return Err(EngineError::SchemaValidation);
16962            }
16963
16964            match kind.as_str() {
16965                "append_only_log" => Ok(WritePlan::AppendOnlyLog),
16966                "latest_state" => Ok(WritePlan::LatestState),
16967                _ => Err(EngineError::OpStore),
16968            }
16969        }
16970    }
16971}
16972
16973fn collection_metadata(
16974    connection: &Connection,
16975    collection: &str,
16976) -> Result<(String, String), EngineError> {
16977    connection
16978        .query_row(
16979            "SELECT kind, schema_json FROM operational_collections WHERE name = ?1",
16980            [collection],
16981            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
16982        )
16983        .map_err(|_| EngineError::OpStore)
16984}
16985
16986fn validate_payload(schema_json: &str, body: &str) -> Result<(), EngineError> {
16987    let schema =
16988        serde_json::from_str::<Value>(schema_json).map_err(|_| EngineError::SchemaValidation)?;
16989    let payload = serde_json::from_str::<Value>(body).map_err(|_| EngineError::SchemaValidation)?;
16990
16991    let compiled = JSONSchema::compile(&schema).map_err(|_| EngineError::SchemaValidation)?;
16992    compiled.validate(&payload).map_err(|_| EngineError::SchemaValidation)?;
16993
16994    Ok(())
16995}
16996
16997fn contains_external_ref(schema_json: &str) -> bool {
16998    let Ok(value) = serde_json::from_str::<Value>(schema_json) else {
16999        return false;
17000    };
17001    value_contains_external_ref(&value)
17002}
17003
17004fn value_contains_external_ref(value: &Value) -> bool {
17005    match value {
17006        Value::Object(object) => object.iter().any(|(key, value)| {
17007            if key == "$ref" {
17008                return value.as_str().is_some_and(|uri| !uri.starts_with('#'));
17009            }
17010            value_contains_external_ref(value)
17011        }),
17012        Value::Array(values) => values.iter().any(value_contains_external_ref),
17013        _ => false,
17014    }
17015}
17016
17017// fix-30 [P2]: helpers to collect active edge write_cursors BEFORE a supersession
17018// UPDATE so the callers can prune stale vector_default rows.
17019fn prior_edge_cursors_by_logical_id(
17020    tx: &rusqlite::Transaction<'_>,
17021    logical_id: &str,
17022) -> rusqlite::Result<Vec<i64>> {
17023    let mut s = tx.prepare_cached(
17024        "SELECT write_cursor FROM canonical_edges \
17025         WHERE logical_id = ?1 AND superseded_at IS NULL",
17026    )?;
17027    let rows = s.query_map(params![logical_id], |r| r.get(0))?;
17028    rows.collect()
17029}
17030
17031/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — the active (non-superseded) NODE
17032/// cursors for a `logical_id`, collected BEFORE the tombstone-then-insert
17033/// supersession UPDATE so the caller can purge the about-to-be-superseded row's
17034/// row-owned attribute projections. Mirrors [`prior_edge_cursors_by_logical_id`].
17035/// The partial-unique-active index means this is at most one cursor; a `Vec`
17036/// keeps it robust and symmetric with the edge path.
17037fn prior_node_cursors_by_logical_id(
17038    tx: &rusqlite::Transaction<'_>,
17039    logical_id: &str,
17040) -> rusqlite::Result<Vec<i64>> {
17041    let mut s = tx.prepare_cached(
17042        "SELECT write_cursor FROM canonical_nodes \
17043         WHERE logical_id = ?1 AND superseded_at IS NULL",
17044    )?;
17045    let rows = s.query_map(params![logical_id], |r| r.get(0))?;
17046    rows.collect()
17047}
17048
17049fn prior_edge_cursors_by_triple(
17050    tx: &rusqlite::Transaction<'_>,
17051    from: &str,
17052    to: &str,
17053    kind: &str,
17054) -> rusqlite::Result<Vec<i64>> {
17055    let mut s = tx.prepare_cached(
17056        "SELECT write_cursor FROM canonical_edges \
17057         WHERE from_id = ?1 AND to_id = ?2 AND kind = ?3 AND superseded_at IS NULL",
17058    )?;
17059    let rows = s.query_map(params![from, to, kind], |r| r.get(0))?;
17060    rows.collect()
17061}
17062
17063/// EXP-S (0.8.14 Slice 5, D2) — the set of coexisting indexes a `row_kind`
17064/// projects into. `fts` = the FTS index (`search_index`), written SYNCHRONOUSLY
17065/// in the write transaction; `vector` = the vec0 vector index, written
17066/// ASYNCHRONOUSLY by the projection worker pool (and additionally gated per
17067/// doc-type `kind` by [`kind_is_vector_indexed`]).
17068#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17069struct IndexTargetSet {
17070    fts: bool,
17071    vector: bool,
17072}
17073
17074/// 0.8.20 Slice 5a (R-20-E1) — the class a row-owned projection table belongs
17075/// to, so the four maintenance sites can each truncate exactly the subset they
17076/// own without re-deriving a hand-rolled table list.
17077///
17078/// - `NodeFts` — same-txn lexical projection of a canonical NODE body.
17079/// - `EdgeFts` — same-txn lexical projection of a canonical EDGE body.
17080/// - `Vector` — the async vec0 materialization (written by the embed worker,
17081///   not by the write path — see [`project_canonical_node_row`]).
17082/// - `Readiness` — the terminal-cursor bookkeeping that lets
17083///   `advance_projection_cursor` walk past a row.
17084#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17085enum ProjectionClass {
17086    NodeFts,
17087    EdgeFts,
17088    Vector,
17089    Readiness,
17090    /// 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store (`filterable` +
17091    /// the value-at-rest for `searchable`). Same-transaction, row-owned.
17092    Attribute,
17093    /// 0.8.20 Slice 15d (R-20-EAV) — the property-FTS5 shadow of attribute
17094    /// values (`searchable→FTS`). Same-transaction, row-owned.
17095    PropertyFts,
17096}
17097
17098/// 0.8.20 Slice 5a (R-20-E1) — one ROW-OWNED projection table: a shadow whose
17099/// rows are 1:1 with a canonical row's `write_cursor` and therefore MUST die
17100/// with that row.
17101#[derive(Clone, Copy, Debug)]
17102struct RowOwnedProjection {
17103    /// Table name. `'static` and never caller-derived: safe to interpolate.
17104    table: &'static str,
17105    /// The column carrying the owning canonical row's `write_cursor`. For the
17106    /// vec0 table this is `rowid` — vec0 rowid IS the write_cursor (see the
17107    /// `_fathomdb_vector_rows.write_cursor UNIQUE` identity).
17108    cursor_column: &'static str,
17109    class: ProjectionClass,
17110}
17111
17112/// 0.8.20 Slice 5a (R-20-E1) — **the** registry of row-owned projections.
17113///
17114/// Every table here is 1:1 with a canonical `write_cursor` and is erased by
17115/// [`erase_row_projections`] whenever that canonical row is erased. Adding a
17116/// projection table WITHOUT registering it here re-opens the defect this slice
17117/// closes (`search_index_v2` was written by one site and deleted by one site,
17118/// out of five that maintain projections — so `excise_source` left the erased
17119/// body on disk in a content-storing FTS5 table). The `guard_row_owned_registry`
17120/// unit test introspects `sqlite_master` and fails if a `write_cursor`-keyed
17121/// table is missing from this list.
17122///
17123/// **NOT here, deliberately (design v5 §1.1): `_fathomdb_projection_state`.**
17124/// That table is KIND-owned — keyed by `kind`, holding a per-kind enqueue
17125/// watermark. Erasing one row must NOT rewind a whole kind's watermark, so it
17126/// must never be deleted per-cursor. A rebuild resets it deliberately; erasure
17127/// leaves it alone.
17128const ROW_OWNED_PROJECTIONS: &[RowOwnedProjection] = &[
17129    RowOwnedProjection {
17130        table: "search_index",
17131        cursor_column: "write_cursor",
17132        class: ProjectionClass::NodeFts,
17133    },
17134    RowOwnedProjection {
17135        table: "search_index_v2",
17136        cursor_column: "write_cursor",
17137        class: ProjectionClass::NodeFts,
17138    },
17139    RowOwnedProjection {
17140        table: "search_index_edges",
17141        cursor_column: "write_cursor",
17142        class: ProjectionClass::EdgeFts,
17143    },
17144    RowOwnedProjection {
17145        table: "vector_default",
17146        cursor_column: "rowid",
17147        class: ProjectionClass::Vector,
17148    },
17149    RowOwnedProjection {
17150        table: "_fathomdb_vector_rows",
17151        cursor_column: "write_cursor",
17152        class: ProjectionClass::Vector,
17153    },
17154    RowOwnedProjection {
17155        table: "_fathomdb_projection_terminal",
17156        cursor_column: "write_cursor",
17157        class: ProjectionClass::Readiness,
17158    },
17159    // 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store and its property-FTS
17160    // shadow both hold declared attribute VALUES at rest (potential PII), keyed
17161    // 1:1 with the owning node's write_cursor. They MUST be reachable by
17162    // `purge`/`excise_source`: registering them here is what makes
17163    // `erase_row_projections` delete them without a hand-rolled list (an
17164    // unregistered content-storing table is exactly the `search_index_v2` leak
17165    // class this registry closes). The `guard_row_owned_registry` unit test
17166    // FAILS if either is left unregistered.
17167    RowOwnedProjection {
17168        table: "canonical_attributes",
17169        cursor_column: "write_cursor",
17170        class: ProjectionClass::Attribute,
17171    },
17172    RowOwnedProjection {
17173        table: "property_search_index",
17174        cursor_column: "write_cursor",
17175        class: ProjectionClass::PropertyFts,
17176    },
17177];
17178
17179/// 0.8.20 Slice 5a (R-20-E1) — erase EVERY row-owned projection for one
17180/// canonical `write_cursor`. Returns the number of shadow rows deleted.
17181///
17182/// This is the single erasure primitive: `purge_inner` and `excise_source_inner`
17183/// both call it, so a new projection table becomes erasable by registering it in
17184/// [`ROW_OWNED_PROJECTIONS`] — not by remembering to patch two hand-rolled
17185/// delete lists (the omission that left erased bodies in `search_index_v2`).
17186fn erase_row_projections(tx: &Connection, write_cursor: i64) -> rusqlite::Result<u64> {
17187    let mut deleted: u64 = 0;
17188    for projection in ROW_OWNED_PROJECTIONS {
17189        deleted =
17190            saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
17191    }
17192    Ok(deleted)
17193}
17194
17195/// TC-76 — delete one row-owned projection's rows for one `write_cursor`. The vec0
17196/// partition is routed through [`delete_vector_partition_row`] (sqlite-vec `#99`
17197/// makes a naked `DELETE` fail whenever a TEXT metadata value spills the 12-byte
17198/// inline view); every other table is the plain registry-driven statement.
17199fn delete_row_owned_projection(
17200    tx: &Connection,
17201    projection: &RowOwnedProjection,
17202    write_cursor: i64,
17203) -> rusqlite::Result<usize> {
17204    if projection.table == DEFAULT_VECTOR_PARTITION {
17205        return delete_vector_partition_row(tx, write_cursor);
17206    }
17207    let sql = format!("DELETE FROM {} WHERE {} = ?1", projection.table, projection.cursor_column);
17208    tx.execute(&sql, [write_cursor])
17209}
17210
17211fn saturating_add_u64(acc: u64, n: usize) -> u64 {
17212    acc.saturating_add(n as u64)
17213}
17214
17215/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — purge the row-owned projections in
17216/// `classes` for ONE canonical `write_cursor`. Same registry-driven mechanism as
17217/// [`erase_row_projections`] (iterate [`ROW_OWNED_PROJECTIONS`], delete by the
17218/// declared cursor column) but scoped to a class SUBSET, so the write path can
17219/// drop a SUPERSEDED node's `Attribute` + `PropertyFts` rows — making the at-rest
17220/// property projection active-only — WITHOUT touching the `NodeFts`/`Vector`
17221/// shadows, whose stale rows the node read path already excludes by joining
17222/// `canonical_nodes WHERE superseded_at IS NULL`. Consistent with the erasure
17223/// model: an unregistered table is unreachable here, exactly as with erasure.
17224fn purge_row_projections_for_cursor_in(
17225    tx: &Connection,
17226    write_cursor: i64,
17227    classes: &[ProjectionClass],
17228) -> rusqlite::Result<u64> {
17229    let mut deleted: u64 = 0;
17230    for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
17231        deleted =
17232            saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
17233    }
17234    Ok(deleted)
17235}
17236
17237/// 0.8.20 Slice 5a (R-20-E1) — truncate the row-owned projections in `classes`.
17238/// Returns the number of shadow rows deleted.
17239fn truncate_row_projections_in(
17240    tx: &Connection,
17241    classes: &[ProjectionClass],
17242) -> rusqlite::Result<u64> {
17243    let mut deleted: u64 = 0;
17244    for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
17245        if projection.table == DEFAULT_VECTOR_PARTITION {
17246            // TC-76 (sqlite-vec `#99`) — an unqualified `DELETE FROM vector_default`
17247            // still dispatches vec0's per-row delete, so it fails on the FIRST row
17248            // whose TEXT metadata spills the 12-byte inline view. Blank the whole
17249            // column first.
17250            neutralize_vector_partition_attr_values(tx, None)?;
17251        }
17252        let sql = format!("DELETE FROM {}", projection.table);
17253        deleted = deleted.saturating_add(tx.execute(&sql, [])? as u64);
17254    }
17255    Ok(deleted)
17256}
17257
17258/// 0.8.20 Slice 5a (R-20-E1) — truncate EVERY row-owned projection (the full
17259/// `rebuild_projections` invalidation). Kind-owned watermark state
17260/// (`_fathomdb_projection_state`) is deliberately untouched; the rebuild resets
17261/// readiness by rewinding the projection cursor instead.
17262#[cfg(feature = "operator")]
17263fn truncate_all_row_projections(tx: &Connection) -> rusqlite::Result<u64> {
17264    truncate_row_projections_in(
17265        tx,
17266        &[
17267            ProjectionClass::NodeFts,
17268            ProjectionClass::EdgeFts,
17269            ProjectionClass::Vector,
17270            ProjectionClass::Readiness,
17271            ProjectionClass::Attribute,
17272            ProjectionClass::PropertyFts,
17273        ],
17274    )
17275}
17276
17277/// 0.8.20 Slice 5a (R-20-E1) — which half of a projector's work a call site
17278/// wants. The projectors are TOTAL (they own every row-owned projection for a
17279/// canonical row); the pass selects the subset a replay site is rebuilding, so
17280/// no call site re-implements projection SQL inline.
17281#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17282enum ProjectionPass {
17283    /// The write path: same-txn FTS **and** async vector enqueue / readiness
17284    /// termination.
17285    Write,
17286    /// Lexical replay only (the open-path tokenizer reproject). Readiness and
17287    /// vector state are already correct and must not be perturbed.
17288    FtsOnly,
17289    /// Readiness + async-vector replay only (`rebuild_vec0`, i.e. a rebuild with
17290    /// `include_fts = false`): the FTS shadows are not being rebuilt in this
17291    /// pass, so they must not be written.
17292    ///
17293    /// Only the `operator` rebuild seam constructs this pass, so the DEFAULT
17294    /// (recovery-clean) build sees it as unconstructed — same gate rationale as
17295    /// the operator methods themselves (feature = gate, not delete).
17296    #[cfg_attr(not(feature = "operator"), allow(dead_code))]
17297    VectorOnly,
17298}
17299
17300impl ProjectionPass {
17301    fn writes_fts(self) -> bool {
17302        matches!(self, ProjectionPass::Write | ProjectionPass::FtsOnly)
17303    }
17304
17305    fn writes_vector_state(self) -> bool {
17306        matches!(self, ProjectionPass::Write | ProjectionPass::VectorOnly)
17307    }
17308
17309    /// 0.8.20 Slice 15d (R-20-EAV) — whether this pass (re)projects the declared
17310    /// attribute set into the EAV store + property-FTS. Only the full `Write`
17311    /// pass does: the `FtsOnly` tokenizer-upgrade reproject predates step 24 (no
17312    /// registry/attribute tables exist at that migration point, so it must not
17313    /// touch them), and `VectorOnly` rebuilds only the async vector shadows. The
17314    /// operator FTS rebuild uses `Write`, so a full `rebuild_projections`
17315    /// re-derives attributes cleanly after `truncate_all_row_projections` clears
17316    /// the two attribute classes.
17317    fn writes_attributes(self) -> bool {
17318        matches!(self, ProjectionPass::Write)
17319    }
17320}
17321
17322/// 0.8.20 Slice 15d (R-20-PR) — the on-disk registry row for one declared
17323/// projection, read back from `_fathomdb_projection_registry`.
17324///
17325/// **On-disk encoding of the optional sub-objects.** The `fts_tokenizer` column
17326/// is tri-valued: SQL `NULL` = no `fts` sub-object; empty string `""` = `fts`
17327/// present with the engine-default tokenizer; a non-empty string = `fts` with a
17328/// custom tokenizer. This is what lets `searchable→FTS with default tokenizer`
17329/// be distinguished durably from `searchable` with no FTS sub-target. `vector`
17330/// mirrors it with an explicit `vector_declared` bit plus a nullable
17331/// `vector_embedder`.
17332#[derive(Clone, Debug, Eq, PartialEq)]
17333struct StoredProjection {
17334    roles: BTreeSet<ProjectionRole>,
17335    fts_present: bool,
17336    /// `Some(custom)` custom tokenizer; `None` = engine default (only
17337    /// meaningful when `fts_present`).
17338    fts_tokenizer: Option<String>,
17339    vector_declared: bool,
17340    vector_embedder: Option<String>,
17341    source: Option<Vec<String>>,
17342}
17343
17344impl StoredProjection {
17345    /// True iff the declared roles want the attribute VALUE stored at rest in
17346    /// the EAV store: `filterable` (the value IS the filter target) or
17347    /// `searchable` (the value is the retrievable meaning, and Slice 20's vector
17348    /// embed will read it from here). `rankable`-only wants no value at rest.
17349    fn wants_eav(&self) -> bool {
17350        self.roles.contains(&ProjectionRole::Filterable)
17351            || self.roles.contains(&ProjectionRole::Searchable)
17352    }
17353
17354    /// True iff a `searchable→FTS` property-FTS row should be written: the
17355    /// `searchable` role AND an `fts` sub-object.
17356    fn wants_property_fts(&self) -> bool {
17357        self.roles.contains(&ProjectionRole::Searchable) && self.fts_present
17358    }
17359
17360    /// 0.8.20 Slice 21c (ledger `TC-71`) — **THE `searchable→vector` predicate.**
17361    /// True iff this declaration puts the attribute on the dense arm: the
17362    /// `searchable` role AND a `vector` sub-object. The exact analogue of
17363    /// [`StoredProjection::wants_property_fts`], for the same reason — the
17364    /// sub-object SELECTS a sub-target of `searchable`; it does not confer one.
17365    ///
17366    /// [`vector_projection_declared`] — the corpus-wide predicate gating all
17367    /// three enrolment paths (declare-time backfill, its drop inverse, and the
17368    /// write path's late enrolment) — routes through this so no call site can
17369    /// re-derive the rule and drift. Before it existed, that predicate keyed off
17370    /// `vector_declared` ALONE, so `{roles: [filterable], vector: {}}` — the
17371    /// combination Slice 15d documented as inert-but-round-trippable — enrolled
17372    /// node kinds, backfilled the corpus and made every later write enqueue an
17373    /// embedding in any session with a live embedder.
17374    ///
17375    /// **0.8.20 Slice 23 (`R-20-SV`):** that combination is no longer DECLARABLE
17376    /// — [`apply_projection_config`] rejects it as an invalid spec. This
17377    /// predicate still governs, because the shape survives at rest in every
17378    /// database that declared it while the engine accepted it, and it is read
17379    /// from the registry, not from a caller's spec.
17380    ///
17381    /// **Deliberately NOT the same as [`StoredProjection::has_deferred`]**, which
17382    /// keys off `vector_declared` alone and must keep doing so: that one feeds
17383    /// `ProjectionDelta.deferred`, a REPORTING field, and the round-trip contract
17384    /// wants a stored-but-unbuilt `vector` sub-object reported however it was
17385    /// declared. TC-71 changes what the engine DOES, not what it says.
17386    fn wants_vector(&self) -> bool {
17387        self.roles.contains(&ProjectionRole::Searchable) && self.vector_declared
17388    }
17389
17390    /// The `fts_tokenizer` column value: `None` (SQL NULL) when no `fts`
17391    /// sub-object, else the custom tokenizer or `""` for engine-default.
17392    fn fts_column(&self) -> Option<String> {
17393        if self.fts_present {
17394            Some(self.fts_tokenizer.clone().unwrap_or_default())
17395        } else {
17396            None
17397        }
17398    }
17399
17400    /// Build from the public [`ProjectionSpec`].
17401    ///
17402    /// 0.8.20 Slice 20 (R-20-DR) — note what is DELIBERATELY not read here:
17403    /// `spec.vector.dense_readiness`. Readiness is engine-set READ METADATA, not
17404    /// part of the declaration, so it never reaches the durable registry. That
17405    /// is what makes a caller-supplied value INERT (the engine always reports the
17406    /// derived truth) and what keeps it out of the destructive-change diff — a
17407    /// readiness difference can never look like a projection change.
17408    fn from_spec(spec: &ProjectionSpec) -> Self {
17409        StoredProjection {
17410            roles: spec.roles.clone(),
17411            fts_present: spec.fts.is_some(),
17412            fts_tokenizer: spec
17413                .fts
17414                .as_ref()
17415                .and_then(|f| f.tokenizer.clone())
17416                .filter(|t| !t.is_empty()),
17417            vector_declared: spec.vector.is_some(),
17418            vector_embedder: spec
17419                .vector
17420                .as_ref()
17421                .and_then(|v| v.embedder.clone())
17422                .filter(|e| !e.is_empty()),
17423            source: spec.source.clone(),
17424        }
17425    }
17426
17427    /// Reconstruct the public [`ProjectionSpec`] for `read_projections`.
17428    fn to_spec(&self, name: &str) -> ProjectionSpec {
17429        ProjectionSpec {
17430            name: name.to_string(),
17431            roles: self.roles.clone(),
17432            fts: if self.fts_present {
17433                Some(ProjectionFts { tokenizer: self.fts_tokenizer.clone() })
17434            } else {
17435                None
17436            },
17437            vector: if self.vector_declared {
17438                // 0.8.20 Slice 20 (R-20-DR) — the registry knows nothing about
17439                // readiness (it is DERIVED, never stored), so the durable shape
17440                // reconstructs with `dense_readiness: None`.
17441                // [`Engine::read_projections`] fills it from
17442                // [`derive_dense_readiness`] on the way out.
17443                Some(ProjectionVector {
17444                    embedder: self.vector_embedder.clone(),
17445                    dense_readiness: None,
17446                })
17447            } else {
17448                None
17449            },
17450            source: self.source.clone(),
17451        }
17452    }
17453
17454    /// The set of ROLE spellings this declaration DEFERS rather than builds:
17455    /// `rankable` (F9 not live) and, since 15d builds no embedding, the
17456    /// `searchable→vector` sub-target. Used to populate `ProjectionDelta.deferred`.
17457    fn has_deferred(&self) -> bool {
17458        self.roles.contains(&ProjectionRole::Rankable) || self.vector_declared
17459    }
17460}
17461
17462/// 0.8.20 Slice 15d (R-20-PR) — is `name` a well-formed attribute name?
17463///
17464/// Establishes the invariant "a name that `configure_projections` ACCEPTS must be
17465/// POPULATABLE": the write-path extraction compiles the SQLite JSON path
17466/// `$."<name>"` (double-quoted key). A name must therefore round-trip through
17467/// that quoted-key form unchanged. Rejects:
17468///   - empty;
17469///   - a double-quote `"` (would terminate the quoted key early → malformed path,
17470///     ERRORing inside the write transaction);
17471///   - a BACKSLASH `\` (fix-4 finding 1 [P2]): SQLite treats `\` as an escape
17472///     introducer inside the double-quoted JSON-path key, so a body key literally
17473///     containing `\` (e.g. `a\b`) is NOT matched by `$."a\b"`. Pre-fix the name
17474///     was accepted yet the attribute silently NEVER populated
17475///     `canonical_attributes` — an accept-then-never-populate footgun. Rejecting
17476///     it keeps the accept ⟹ works contract (mirrors the TC-33 hard-reject
17477///     philosophy);
17478///   - any ASCII control char (incl. NUL): not a safe/legible key spelling and
17479///     not reliably matchable through the quoted-key form.
17480///
17481/// Projection names are app-declared identifiers, so this charset restriction is
17482/// a legitimate contract. Caller-supplied, so it is validated at
17483/// `configure_projections` time (spec names AND the `drop` list).
17484fn is_valid_attribute_name(name: &str) -> bool {
17485    !name.is_empty()
17486        && !name.contains('"')
17487        && !name.contains('\\')
17488        && !name.chars().any(|c| c.is_control())
17489}
17490
17491/// 0.8.20 Slice 15d — the SQLite JSON path that extracts attribute `name` from a
17492/// node body. `name` is pre-validated by [`is_valid_attribute_name`]; the path
17493/// is bound as a PARAMETER (never interpolated into SQL), so this is not an
17494/// injection surface even before that validation.
17495fn attribute_json_path(name: &str) -> String {
17496    format!("$.\"{name}\"")
17497}
17498
17499/// SQLite JSON path for one declared projection. A declared source is an ordered
17500/// list of literal object-member names; it is never a caller-provided JSONPath.
17501/// Every segment is validated before persistence, and the resulting path is
17502/// always bound as a parameter rather than interpolated into SQL.
17503fn projection_json_path(name: &str, stored: &StoredProjection) -> String {
17504    match &stored.source {
17505        None => attribute_json_path(name),
17506        Some(segments) => {
17507            let mut path = String::from("$");
17508            for segment in segments {
17509                path.push_str(".\"");
17510                path.push_str(segment);
17511                path.push('"');
17512            }
17513            path
17514        }
17515    }
17516}
17517
17518/// Refuse a source path that cannot be safely represented as SQLite quoted
17519/// member selectors. Empty paths would select the whole body rather than one
17520/// member and therefore do not meet the scalar-projection contract.
17521fn is_valid_projection_source(source: &[String]) -> bool {
17522    !source.is_empty() && source.iter().all(|segment| is_valid_attribute_name(segment))
17523}
17524
17525/// Reject only nested-source object/array terminals. Legacy top-level
17526/// projections retain their shipped skip-composite behaviour for compatibility.
17527fn nested_projection_terminal_is_composite(
17528    conn: &Connection,
17529    body: &str,
17530    name: &str,
17531    stored: &StoredProjection,
17532) -> rusqlite::Result<bool> {
17533    if stored.source.is_none() {
17534        return Ok(false);
17535    }
17536    let path = projection_json_path(name, stored);
17537    let terminal: Option<String> = conn.query_row(
17538        "SELECT CASE WHEN json_valid(?1) THEN json_type(?1, ?2) END",
17539        params![body, path],
17540        |row| row.get(0),
17541    )?;
17542    Ok(matches!(terminal.as_deref(), Some("object") | Some("array")))
17543}
17544
17545/// Validate nested source terminals across the active rows a declaration would
17546/// backfill. The caller owns the transaction, so `WriteValidation` aborts it
17547/// rather than leaving a partly reconfigured registry.
17548fn validate_projection_source_backfill(
17549    conn: &Connection,
17550    name: &str,
17551    stored: &StoredProjection,
17552) -> Result<(), EngineError> {
17553    if stored.source.is_none() {
17554        return Ok(());
17555    }
17556    let mut stmt = conn
17557        .prepare(
17558            "SELECT body FROM canonical_nodes
17559             WHERE superseded_at IS NULL AND state = 'active'",
17560        )
17561        .map_err(|_| EngineError::Storage)?;
17562    let bodies =
17563        stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
17564    for body in bodies {
17565        let body = body.map_err(|_| EngineError::Storage)?;
17566        if nested_projection_terminal_is_composite(conn, &body, name, stored)
17567            .map_err(|_| EngineError::Storage)?
17568        {
17569            return Err(EngineError::WriteValidation);
17570        }
17571    }
17572    Ok(())
17573}
17574
17575/// Validate every nested source present in a normal node write before the write
17576/// transaction starts. This keeps an object/array terminal in the existing
17577/// `WriteValidation` family and guarantees the whole batch is rejected.
17578fn validate_nested_projection_sources_for_write(
17579    conn: &Connection,
17580    batch: &[PreparedWrite],
17581) -> Result<(), EngineError> {
17582    let registry = load_projection_registry(conn).map_err(|_| EngineError::Storage)?;
17583    if registry.values().all(|stored| stored.source.is_none()) {
17584        return Ok(());
17585    }
17586    for write in batch {
17587        let PreparedWrite::Node { body, .. } = write else { continue };
17588        validate_nested_projection_sources_for_body_in_registry(conn, body, &registry)?;
17589    }
17590    Ok(())
17591}
17592
17593fn validate_nested_projection_sources_for_body(
17594    conn: &Connection,
17595    body: &str,
17596) -> Result<(), EngineError> {
17597    let registry = load_projection_registry(conn).map_err(|_| EngineError::Storage)?;
17598    validate_nested_projection_sources_for_body_in_registry(conn, body, &registry)
17599}
17600
17601/// Validate one body against a registry snapshot owned by the caller. Batch
17602/// writes load this snapshot once, keeping validation proportional to bodies
17603/// plus declarations rather than repeating registry I/O for every row.
17604fn validate_nested_projection_sources_for_body_in_registry(
17605    conn: &Connection,
17606    body: &str,
17607    registry: &BTreeMap<String, StoredProjection>,
17608) -> Result<(), EngineError> {
17609    for (name, stored) in registry {
17610        if nested_projection_terminal_is_composite(conn, body, name, stored)
17611            .map_err(|_| EngineError::Storage)?
17612        {
17613            return Err(EngineError::WriteValidation);
17614        }
17615    }
17616    Ok(())
17617}
17618
17619/// 0.8.20 Slice 15d (R-20-PR) — load the durable projection registry
17620/// (`_fathomdb_projection_registry`) into a name→[`StoredProjection`] map. This
17621/// is the derived-cache source (Q5) that boot re-derive and every
17622/// `configure_projections` diff read.
17623fn load_projection_registry(
17624    conn: &Connection,
17625) -> rusqlite::Result<BTreeMap<String, StoredProjection>> {
17626    let mut out = BTreeMap::new();
17627    // The registry table is created by schema step 24; a DB migrated to a
17628    // pre-24 head (e.g. a compatibility/partial-migration test open) does not
17629    // have it. Absent ⇒ no projections declared ⇒ empty registry, not an error.
17630    // This keeps boot re-derive and the write-path attribute projector safe on
17631    // every pre-24 schema.
17632    let table_exists: bool = conn
17633        .query_row(
17634            "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_fathomdb_projection_registry'",
17635            [],
17636            |_| Ok(true),
17637        )
17638        .optional()?
17639        .unwrap_or(false);
17640    if !table_exists {
17641        return Ok(out);
17642    }
17643    let mut stmt = conn.prepare(
17644        "SELECT name, roles, fts_tokenizer, vector_embedder, vector_declared, source
17645         FROM _fathomdb_projection_registry",
17646    )?;
17647    let rows = stmt.query_map([], |row| {
17648        let name: String = row.get(0)?;
17649        let roles_json: String = row.get(1)?;
17650        let fts_tokenizer: Option<String> = row.get(2)?;
17651        let vector_embedder: Option<String> = row.get(3)?;
17652        let vector_declared: i64 = row.get(4)?;
17653        let source_json: Option<String> = row.get(5)?;
17654        Ok((name, roles_json, fts_tokenizer, vector_embedder, vector_declared, source_json))
17655    })?;
17656    for row in rows {
17657        let (name, roles_json, fts_col, vector_embedder, vector_declared, source_json) = row?;
17658        let roles: BTreeSet<ProjectionRole> = parse_roles_json(&roles_json);
17659        let fts_present = fts_col.is_some();
17660        let fts_tokenizer = fts_col.filter(|t| !t.is_empty());
17661        let source = source_json
17662            .map(|encoded| serde_json::from_str::<Vec<String>>(&encoded))
17663            .transpose()
17664            .map_err(|err| {
17665                rusqlite::Error::FromSqlConversionFailure(
17666                    5,
17667                    rusqlite::types::Type::Text,
17668                    Box::new(err),
17669                )
17670            })?;
17671        out.insert(
17672            name,
17673            StoredProjection {
17674                roles,
17675                fts_present,
17676                fts_tokenizer,
17677                vector_declared: vector_declared != 0,
17678                vector_embedder,
17679                source,
17680            },
17681        );
17682    }
17683    Ok(out)
17684}
17685
17686/// Roles are persisted as a compact, sorted, comma-separated list (set
17687/// semantics; order-independent). Unknown tokens are ignored (forward-compat).
17688fn parse_roles_json(s: &str) -> BTreeSet<ProjectionRole> {
17689    s.split(',').filter_map(|t| ProjectionRole::from_str_opt(t.trim())).collect()
17690}
17691
17692fn roles_to_storage(roles: &BTreeSet<ProjectionRole>) -> String {
17693    roles.iter().map(|r| r.as_str()).collect::<Vec<_>>().join(",")
17694}
17695
17696/// 0.8.20 Slice 15d (R-20-PR) — write/overwrite one registry row.
17697fn persist_projection_row(
17698    tx: &Connection,
17699    name: &str,
17700    stored: &StoredProjection,
17701) -> rusqlite::Result<()> {
17702    let source = stored
17703        .source
17704        .as_ref()
17705        .map(serde_json::to_string)
17706        .transpose()
17707        .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?;
17708    tx.execute(
17709        "INSERT INTO _fathomdb_projection_registry
17710             (name, roles, fts_tokenizer, vector_embedder, vector_declared, source)
17711         VALUES(?1, ?2, ?3, ?4, ?5, ?6)
17712         ON CONFLICT(name) DO UPDATE SET
17713             roles = excluded.roles,
17714             fts_tokenizer = excluded.fts_tokenizer,
17715             vector_embedder = excluded.vector_embedder,
17716             vector_declared = excluded.vector_declared,
17717             source = excluded.source",
17718        params![
17719            name,
17720            roles_to_storage(&stored.roles),
17721            stored.fts_column(),
17722            stored.vector_embedder,
17723            i64::from(stored.vector_declared),
17724            source,
17725        ],
17726    )?;
17727    Ok(())
17728}
17729
17730/// 0.8.20 Slice 15d (R-20-PR) — delete one registry row.
17731fn remove_projection_row(tx: &Connection, name: &str) -> rusqlite::Result<()> {
17732    tx.execute("DELETE FROM _fathomdb_projection_registry WHERE name = ?1", params![name])?;
17733    Ok(())
17734}
17735
17736/// 0.8.20 Slice 15d (R-20-EAV) — delete every EAV + property-FTS row for one
17737/// attribute `name` (all owning nodes). The idempotent-rebuild primitive: a
17738/// changed or dropped projection clears its rows before (re)backfill.
17739fn clear_attribute_projection(tx: &Connection, name: &str) -> rusqlite::Result<()> {
17740    tx.execute("DELETE FROM property_search_index WHERE attr_name = ?1", params![name])?;
17741    tx.execute("DELETE FROM canonical_attributes WHERE attr_name = ?1", params![name])?;
17742    Ok(())
17743}
17744
17745/// 0.8.20 Slice 15d (R-20-EAV) — project ONE attribute value for ONE node row
17746/// into the EAV store and (if `searchable→FTS`) the property-FTS shadow. Skips a
17747/// NULL/absent extraction (an absent attribute means no row, so a `filterable`
17748/// equality simply never matches it — correct). Shared by the write path and
17749/// the backfill so they cannot drift.
17750fn project_one_attribute(
17751    tx: &Connection,
17752    cursor: i64,
17753    body: &str,
17754    name: &str,
17755    stored: &StoredProjection,
17756) -> rusqlite::Result<()> {
17757    if !stored.wants_eav() {
17758        return Ok(());
17759    }
17760    // json_extract over a non-JSON body would error; guard with json_valid so a
17761    // plain-text body simply yields no attribute rows. The canonical scalar
17762    // extraction is shared with the Slice-15e vec0 pre-KNN column via
17763    // [`extract_scalar_attribute`], so the EAV value and the `attr_<hex>` value
17764    // are IDENTICAL by construction.
17765    //
17766    // fix-1 finding 1 [P2] — project EVERY JSON scalar type, not just strings.
17767    // The prior form read the extraction as `Option<String>`; for a JSON number
17768    // or bool, `json_extract` returns an INTEGER/REAL, the `get::<Option<String>>`
17769    // conversion FAILED, and `.unwrap_or(None)` silently treated the attribute as
17770    // absent — so a numeric/boolean filterable value never projected. We now
17771    // render a single canonical TEXT form per JSON type, keyed on `json_type` so
17772    // the stored value is deterministic and the SAME value flows to BOTH
17773    // `canonical_attributes` and `property_search_index` (consistency by
17774    // construction — one `value` binding below):
17775    //   - string  -> the text verbatim
17776    //   - integer -> decimal text (CAST AS TEXT); e.g. 3 -> "3"
17777    //   - real    -> decimal text (CAST AS TEXT); e.g. 3.5 -> "3.5"
17778    //   - true    -> "true", false -> "false"  (preserve the JSON literal, NOT the
17779    //                SQLite `1`/`0` that a bare `CAST(json_extract(...) AS TEXT)`
17780    //                would yield — so a bool filter matches the value the caller
17781    //                wrote, and "true" never collides with the number 1).
17782    //   - null / absent path -> SQL NULL -> no row (an absent attribute correctly
17783    //                never matches a `filterable` equality).
17784    //   - object / array -> DELIBERATELY SKIPPED (SQL NULL -> no row): a composite
17785    //                value is not a scalar filter/FTS target in 15d; projecting its
17786    //                raw JSON text would be a footgun (nested-field filtering is the
17787    //                >=0.9.x multi-field work). Skipping is deliberate, not an
17788    //                accidental type-conversion drop — no scalar type is dropped.
17789    let Some(value) = extract_scalar_attribute(tx, body, name, stored)? else {
17790        return Ok(());
17791    };
17792    tx.execute(
17793        "INSERT INTO canonical_attributes(write_cursor, attr_name, attr_value)
17794         VALUES(?1, ?2, ?3)",
17795        params![cursor, name, value],
17796    )?;
17797    if stored.wants_property_fts() {
17798        tx.execute(
17799            "INSERT INTO property_search_index(attr_value, attr_name, write_cursor)
17800             VALUES(?1, ?2, ?3)",
17801            params![value, name, cursor],
17802        )?;
17803    }
17804    Ok(())
17805}
17806
17807/// 0.8.20 Slice 15e fix-3 [P2] — the leading marker byte that the vec0
17808/// `attr_<hex>` FILTER column prepends to every PRESENT scalar value, so that
17809/// PRESENT and ABSENT are DISJOINT in a NOT-NULL TEXT column.
17810///
17811/// The `''` empty-string sentinel used to mean BOTH "attribute absent" AND
17812/// "attribute present with value `''`", so a `status == ""` equality filter
17813/// false-matched every absent row. vec0 TEXT metadata is NOT-NULL-able (TC-46
17814/// condition #3), so absent cannot be `NULL`; instead absent stays `''` and every
17815/// PRESENT value `V` is encoded `enc(V) = "\x01" || V`. This is injective and
17816/// non-empty for ALL `V` (including `V=""`, whose encoding is the bare marker),
17817/// so `attr_<hex> = enc("")` matches present-empty but NEVER the `''`-absent rows.
17818///
17819/// This encoding is CONFINED to the vec0 filter column and the filter-value
17820/// lowering ([`vector_filter_values`]). `property_search_index` (the searchable→FTS
17821/// projection) and `canonical_attributes.attr_value` keep the RAW value — the FTS
17822/// arm distinguishes absent from present-empty by canonical_attributes row
17823/// EXISTENCE instead (see [`hit_attributes_pass_filter`]).
17824const ATTR_VEC0_PRESENT_MARKER: char = '\u{1}';
17825
17826/// 0.8.20 Slice 15e fix-3 — encode a PRESENT scalar value for the vec0 filter
17827/// column / filter-value lowering (see [`ATTR_VEC0_PRESENT_MARKER`]). ABSENT is
17828/// NOT encoded (it stays the bare `''` sentinel), so this is only ever called on a
17829/// value known to be present.
17830fn encode_attr_vec0_present(value: &str) -> String {
17831    let mut s = String::with_capacity(value.len() + 1);
17832    s.push(ATTR_VEC0_PRESENT_MARKER);
17833    s.push_str(value);
17834    s
17835}
17836
17837/// 0.8.20 Slice 15e — extract the canonical TEXT form of attribute `name` from a
17838/// node `body`, using the SAME `json_type` CASE as [`project_one_attribute`] so
17839/// the vec0 pre-KNN `attr_<hex>` column value equals the EAV
17840/// `canonical_attributes` value (consistency by construction — a `filterable`
17841/// filter routed pre-KNN sees exactly what the EAV path stored). Returns `None`
17842/// for an absent / null / object / array / non-JSON extraction (⇒ the `''`
17843/// sentinel at the vec0 column, ⇒ fail-to-match).
17844fn extract_scalar_attribute(
17845    conn: &Connection,
17846    body: &str,
17847    name: &str,
17848    stored: &StoredProjection,
17849) -> rusqlite::Result<Option<String>> {
17850    let path = projection_json_path(name, stored);
17851    let value: Option<String> = conn
17852        .query_row(
17853            "SELECT CASE WHEN json_valid(?1) THEN
17854                 CASE json_type(?1, ?2)
17855                     WHEN 'true'   THEN 'true'
17856                     WHEN 'false'  THEN 'false'
17857                     WHEN 'null'   THEN NULL
17858                     WHEN 'object' THEN NULL
17859                     WHEN 'array'  THEN NULL
17860                     ELSE CAST(json_extract(?1, ?2) AS TEXT)
17861                 END
17862             END",
17863            params![body, path],
17864            |row| row.get::<_, Option<String>>(0),
17865        )
17866        .unwrap_or(None);
17867    Ok(value)
17868}
17869
17870/// 0.8.20 Slice 15e — for a node `body`, build the `, attr_<hex>` column suffix,
17871/// the `, ?N` placeholder suffix (numbered from `start_idx`), and the bound TEXT
17872/// values for EVERY attribute column CURRENTLY on the live `vector_default` (read
17873/// from the table's own SQL, so the INSERT always matches the table shape exactly —
17874/// vec0 rejects a partial-column INSERT). Each value is the body's canonical
17875/// scalar extraction, or the `''` sentinel when absent. Returns empty fragments
17876/// (and no values) when the table has no attribute columns, so the INSERT stays
17877/// byte-identical to the shipped statement.
17878fn vector_attr_insert_fragments(
17879    conn: &Connection,
17880    body: &str,
17881    start_idx: usize,
17882) -> rusqlite::Result<(String, String, Vec<rusqlite::types::Value>)> {
17883    let cols = actual_vector_attr_columns(conn)?;
17884    let registry = load_projection_registry(conn)?;
17885    let mut col_sql = String::new();
17886    let mut ph_sql = String::new();
17887    let mut values: Vec<rusqlite::types::Value> = Vec::new();
17888    for (i, col) in cols.iter().enumerate() {
17889        let name = decode_attr_vec0_column(col).unwrap_or_default();
17890        // fix-3 [P2] — a PRESENT scalar value is encoded `\x01 || V` so it is
17891        // DISJOINT from the `''`-absent sentinel (present-empty ⇒ the bare marker,
17892        // never `''`). Absent stays the bare `''` sentinel.
17893        let scalar = match registry.get(&name) {
17894            Some(stored) => extract_scalar_attribute(conn, body, &name, stored)?,
17895            None => None,
17896        };
17897        let value = match scalar {
17898            Some(v) => encode_attr_vec0_present(&v),
17899            None => String::new(),
17900        };
17901        col_sql.push_str(&format!(", {col}"));
17902        ph_sql.push_str(&format!(", ?{}", start_idx + i));
17903        values.push(rusqlite::types::Value::Text(value));
17904    }
17905    Ok((col_sql, ph_sql, values))
17906}
17907
17908/// 0.8.20 Slice 15d (R-20-EAV) — the write-path attribute projector: for a
17909/// just-inserted node, project EVERY declared attribute (reading the live
17910/// registry from `tx`). Same-transaction, so the node is filter/FTS-retrievable
17911/// on commit. A no-op when the registry is empty (the pre-`configure_projections`
17912/// default), so it costs one empty-table scan per node and is behaviour-neutral
17913/// until a projection is declared.
17914fn project_node_attributes(tx: &Connection, cursor: i64, body: &str) -> rusqlite::Result<()> {
17915    let registry = load_projection_registry(tx)?;
17916    for (name, stored) in &registry {
17917        project_one_attribute(tx, cursor, body, name, stored)?;
17918    }
17919    Ok(())
17920}
17921
17922/// 0.8.20 Slice 15d (R-20-PR) — backfill ONE attribute across every ACTIVE,
17923/// non-superseded canonical node. Called by `configure_projections` when a
17924/// projection is added/changed (after `clear_attribute_projection`), and by boot
17925/// re-derive. Idempotent when paired with the clear.
17926fn backfill_attribute(
17927    tx: &Connection,
17928    name: &str,
17929    stored: &StoredProjection,
17930) -> rusqlite::Result<()> {
17931    if !stored.wants_eav() {
17932        return Ok(());
17933    }
17934    let rows: Vec<(i64, String)> = {
17935        let mut stmt = tx.prepare(
17936            "SELECT write_cursor, body FROM canonical_nodes
17937             WHERE superseded_at IS NULL AND state = 'active'",
17938        )?;
17939        let collected = stmt
17940            .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))?
17941            .collect::<rusqlite::Result<Vec<_>>>()?;
17942        collected
17943    };
17944    for (cursor, body) in rows {
17945        project_one_attribute(tx, cursor, &body, name, stored)?;
17946    }
17947    Ok(())
17948}
17949
17950/// 0.8.20 Slice 15d (R-20-PR) — is `desired` an INCOMPATIBLE/DESTRUCTIVE change
17951/// to a live `existing` projection? A destructive change discards an
17952/// expensive-to-rebuild resource and so REQUIRES an explicit `drop` (C3): a role
17953/// REMOVAL, dropping the `fts`/`vector` sub-target, or changing the tokenizer /
17954/// embedder. Purely ADDITIVE changes (adding a role, adding an `fts`/`vector`
17955/// sub-object) are non-destructive and applied in place.
17956fn is_destructive_projection_change(
17957    existing: &StoredProjection,
17958    desired: &StoredProjection,
17959) -> bool {
17960    if existing.roles.iter().any(|r| !desired.roles.contains(r)) {
17961        return true;
17962    }
17963    if existing.fts_present
17964        && (!desired.fts_present || existing.fts_tokenizer != desired.fts_tokenizer)
17965    {
17966        return true;
17967    }
17968    if existing.vector_declared
17969        && (!desired.vector_declared || existing.vector_embedder != desired.vector_embedder)
17970    {
17971        return true;
17972    }
17973    if existing.source != desired.source {
17974        return true;
17975    }
17976    false
17977}
17978
17979/// Human-readable summary of the destructive delta, surfaced in
17980/// [`EngineError::ProjectionDestructive`] so the caller sees WHAT it must drop.
17981fn describe_projection_delta(existing: &StoredProjection, desired: &StoredProjection) -> String {
17982    let mut parts: Vec<String> = Vec::new();
17983    for r in &existing.roles {
17984        if !desired.roles.contains(r) {
17985            parts.push(format!("role '{}' removed", r.as_str()));
17986        }
17987    }
17988    if existing.fts_present && !desired.fts_present {
17989        parts.push("fts sub-target removed".to_string());
17990    } else if existing.fts_present && existing.fts_tokenizer != desired.fts_tokenizer {
17991        parts.push("fts tokenizer changed".to_string());
17992    }
17993    if existing.vector_declared && !desired.vector_declared {
17994        parts.push("vector sub-target removed".to_string());
17995    } else if existing.vector_declared && existing.vector_embedder != desired.vector_embedder {
17996        parts.push("vector embedder changed".to_string());
17997    }
17998    if existing.source != desired.source {
17999        parts.push("source path changed".to_string());
18000    }
18001    if parts.is_empty() {
18002        "incompatible change".to_string()
18003    } else {
18004        parts.join("; ")
18005    }
18006}
18007
18008/// 0.8.20 Slice 15d (R-20-PR) — the declarative, idempotent diff+backfill apply
18009/// that backs [`Engine::configure_projections`]. Runs inside the caller's write
18010/// transaction `tx`. Order: apply `drop`s first (so a drop+re-declare in one
18011/// call rebuilds fresh), then diff each spec. Idempotent re-registration diffs to
18012/// an empty delta (`unchanged`). A destructive change without an explicit drop is
18013/// refused with [`EngineError::ProjectionDestructive`].
18014///
18015/// 0.8.20 Slice 20c (R-20-DR remainder) — returns `(delta, enqueued_backfill)`.
18016/// The second member is `true` iff [`enqueue_declared_vector_backfill`] put
18017/// deferred embed work on the queue, in which case the CALLER must
18018/// `notify_new_work()` after committing (the flag cannot ride on
18019/// [`ProjectionDelta`]: that is the caller-facing diff, and this is a runtime
18020/// signal, not part of the declaration's result).
18021///
18022/// 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — and the symmetric inverse: a call
18023/// that removes the LAST `searchable→vector` declaration un-enrols the node kinds
18024/// the forward path enrolled ([`unenrol_registry_vector_node_kinds`]), on this
18025/// same transaction. It deletes no embedding.
18026///
18027/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2]) — and, beside that transition, a
18028/// state-keyed RECONCILIATION ([`registry_governs_an_inert_dense_arm`]) so that a
18029/// database already carrying an inert enrolment from before the Slice-21c role
18030/// gate is healed by any `configure_projections` call, not only by a
18031/// searchable-vector-to-none transition it may never perform. The boot arm is
18032/// [`reconcile_inert_vector_enrolments_on_boot`].
18033fn apply_projection_config(
18034    tx: &Connection,
18035    specs: &[ProjectionSpec],
18036    drop: &[String],
18037    dense_arm_live: bool,
18038) -> Result<(ProjectionDelta, bool), EngineError> {
18039    // Validate up-front so a bad name aborts before any write.
18040    //
18041    // fix-6 finding [P2] — REJECT a duplicate projection `name` within `specs`
18042    // (and a duplicate entry within `drop`) up front. The diff loop below diffs
18043    // every spec against the ONE pre-loop registry snapshot, so a name repeated
18044    // in `specs` diffed the SECOND spec against state that never saw the first
18045    // spec's just-persisted row: on a fresh DB `[status(searchable+fts),
18046    // status(rankable-only)]` reported `built=[status]` in the delta while the
18047    // registry ended rankable-only (which builds nothing) — the returned delta
18048    // DIVERGED from the persisted registry, breaking the fix-4 "accept ⟹ correct"
18049    // contract. A duplicate `drop` entry likewise reported the drop twice though
18050    // the row was removed once. A single request naming the same projection twice
18051    // is ambiguous/malformed, so we refuse it (rejection, not last-wins coalesce)
18052    // — a rejected request is a total no-op, keeping the registry and delta
18053    // consistent with the accepted input. A name that appears in BOTH `specs` and
18054    // `drop` is NOT a duplicate: that is the documented drop-then-rebuild-fresh
18055    // pattern (drops apply first, then the fresh spec builds), so it is allowed.
18056    let mut seen_spec_names: BTreeSet<&str> = BTreeSet::new();
18057    for spec in specs {
18058        if !is_valid_attribute_name(&spec.name) {
18059            return Err(EngineError::InvalidArgument {
18060                msg: format!("invalid projection attribute name: {:?}", spec.name),
18061            });
18062        }
18063        if spec.roles.is_empty() {
18064            return Err(EngineError::InvalidArgument {
18065                msg: format!("projection '{}' declares no roles", spec.name),
18066            });
18067        }
18068        if let Some(source) = &spec.source {
18069            if !is_valid_projection_source(source) {
18070                return Err(EngineError::InvalidArgument {
18071                    msg: format!("invalid projection source path for {:?}", spec.name),
18072                });
18073            }
18074        }
18075        if !seen_spec_names.insert(spec.name.as_str()) {
18076            return Err(EngineError::InvalidArgument {
18077                msg: format!("duplicate projection name in one request: '{}'", spec.name),
18078            });
18079        }
18080    }
18081    let mut seen_drop_names: BTreeSet<&str> = BTreeSet::new();
18082    for name in drop {
18083        if !is_valid_attribute_name(name) {
18084            return Err(EngineError::InvalidArgument {
18085                msg: format!("invalid projection drop name: {name:?}"),
18086            });
18087        }
18088        if !seen_drop_names.insert(name.as_str()) {
18089            return Err(EngineError::InvalidArgument {
18090                msg: format!("duplicate projection drop in one request: '{name}'"),
18091            });
18092        }
18093    }
18094
18095    // 0.8.20 Slice 23 (`R-20-SV`) — REJECT an `fts` or `vector` sub-object
18096    // declared WITHOUT the `searchable` role.
18097    //
18098    // HITL ruling 2026-07-24 (`dev/plans/plan-0.8.20.md` §11 item 4, option (b)):
18099    // *"it is a meaningless config; fail-fast matches the hard-reject philosophy,
18100    // and additive strictness is safe pre-1.0"*, to be implemented "at the next
18101    // `configure_projections` slice". This OVERTURNS the shipped 15d fix-4
18102    // position, which accepted the shape because it round-tripped faithfully.
18103    //
18104    // WHY it is meaningless: `searchable→FTS` and `searchable→vector` are TIER
18105    // LABELS, not roles ([`ProjectionRole`] has exactly three members). The
18106    // sub-objects SELECT a sub-target of `searchable`; they do not CONFER one —
18107    // both build predicates ([`StoredProjection::wants_property_fts`] and
18108    // [`StoredProjection::wants_vector`]) are conjunctions with
18109    // `roles.contains(Searchable)`. So without the role the declaration builds no
18110    // property-FTS, enrols no kind and embeds nothing: it names a sub-target of a
18111    // projection that does not exist. The reject is therefore keyed on the
18112    // ABSENCE of `searchable` and on nothing else — `filterable` / `rankable` are
18113    // orthogonal axes that neither supply nor substitute for it.
18114    //
18115    // FAMILY: [`EngineError::WriteValidation`], per decision #18 (0.8.20 Slice 22)
18116    // — the write-SHAPE boundary is ONE family, and this is a shape rejection.
18117    // Deliberately a SEPARATE loop from the name checks above: those are NAME
18118    // rejections that keep `InvalidArgument { msg }` because the message naming
18119    // the offending value is the caller's only handle on it. `dev/design/errors.md`
18120    // ("Validation boundary") states that split; keeping the two loops apart keeps
18121    // the split visible in the code and this change one-line-reversible.
18122    //
18123    // KNOWN COST (TC-95/TC-98, HITL-deferred): `WriteValidation` is a UNIT
18124    // variant, so this refusal cannot name WHICH spec in `specs` was invalid —
18125    // strictly worse than the name rejections above. Recorded, not worked around.
18126    for spec in specs {
18127        if spec.roles.contains(&ProjectionRole::Searchable) {
18128            continue;
18129        }
18130        if spec.fts.is_some() || spec.vector.is_some() {
18131            return Err(EngineError::WriteValidation);
18132        }
18133    }
18134
18135    // A destructive source change retains the normal drop-first error precedence.
18136    // Check the pre-drop registry before inspecting a proposed source's backfill
18137    // rows; otherwise a composite at that source could mask ProjectionDestructive.
18138    let pre_drop = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18139    let mut refresh_vector_attributes = false;
18140    for spec in specs {
18141        let desired = StoredProjection::from_spec(spec);
18142        if let Some(existing) = pre_drop.get(&spec.name) {
18143            let replacing = drop.iter().any(|name| name == &spec.name);
18144            if !replacing && is_destructive_projection_change(existing, &desired) {
18145                return Err(EngineError::ProjectionDestructive {
18146                    name: spec.name.clone(),
18147                    delta: describe_projection_delta(existing, &desired),
18148                });
18149            }
18150            if replacing
18151                && existing.source != desired.source
18152                && desired.roles.contains(&ProjectionRole::Filterable)
18153            {
18154                refresh_vector_attributes = true;
18155            }
18156        }
18157    }
18158
18159    // A declared nested source is scalar-only. Validate the complete backfill
18160    // set before any registry mutation so a composite terminal rolls the whole
18161    // configuration request back with the existing write-validation family.
18162    for spec in specs {
18163        let desired = StoredProjection::from_spec(spec);
18164        validate_projection_source_backfill(tx, &spec.name, &desired)?;
18165    }
18166
18167    let mut delta = ProjectionDelta::default();
18168
18169    // 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — snapshot "is the dense arm
18170    // declared?" BEFORE any registry mutation. Together with the same read taken
18171    // after them it identifies the ONE transition that owns the inverse of this
18172    // slice's enrolment: declared -> not-declared. See
18173    // [`unenrol_registry_vector_node_kinds`] for why the inverse is keyed to that
18174    // TRANSITION rather than to the bare post-state.
18175    let vector_declared_before =
18176        vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
18177
18178    // (1) Explicit drops. Omission never drops (C3); only this list does.
18179    let before_drop = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18180    for name in drop {
18181        if before_drop.contains_key(name) {
18182            clear_attribute_projection(tx, name).map_err(|_| EngineError::Storage)?;
18183            remove_projection_row(tx, name).map_err(|_| EngineError::Storage)?;
18184            delta.dropped.push(name.clone());
18185        }
18186        // dropping an absent projection is an idempotent no-op, not an error.
18187    }
18188
18189    // (2) Diff each spec against the post-drop registry.
18190    let current = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18191    for spec in specs {
18192        let desired = StoredProjection::from_spec(spec);
18193        match current.get(&spec.name) {
18194            Some(existing) if existing == &desired => {
18195                // Idempotent re-registration — no-op (the keystone acceptance).
18196            }
18197            Some(existing) => {
18198                if is_destructive_projection_change(existing, &desired) {
18199                    return Err(EngineError::ProjectionDestructive {
18200                        name: spec.name.clone(),
18201                        delta: describe_projection_delta(existing, &desired),
18202                    });
18203                }
18204                persist_projection_row(tx, &spec.name, &desired)
18205                    .map_err(|_| EngineError::Storage)?;
18206                clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
18207                backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
18208                if desired.wants_eav() {
18209                    delta.built.push(spec.name.clone());
18210                }
18211                // 0.8.20 Slice 15e fix-2 finding 2 [P2] — this arm ONLY runs when
18212                // the registry row actually CHANGED (`existing != desired`), so the
18213                // delta MUST reflect that change; otherwise an accepted mutation
18214                // reports `unchanged = true` — a no-op lie to SDK callers. The prior
18215                // `&& !existing.has_deferred()` guard suppressed a deferred-ONLY
18216                // change (e.g. `rankable` → `rankable + vector`, which builds no EAV
18217                // so `built` stays empty): the row persisted but `delta` came back
18218                // empty. Mirror the fresh-registration push (`if
18219                // desired.has_deferred()`). Every valid non-empty spec has
18220                // `wants_eav()` OR `has_deferred()`, so on a real change at least one
18221                // of `built`/`deferred` is now populated ⇒ `unchanged` can never be
18222                // `true` on a persisted change. A genuine no-op (identical spec)
18223                // takes the idempotent arm above and is untouched.
18224                if desired.has_deferred() {
18225                    delta.deferred.push(spec.name.clone());
18226                }
18227            }
18228            None => {
18229                persist_projection_row(tx, &spec.name, &desired)
18230                    .map_err(|_| EngineError::Storage)?;
18231                clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
18232                backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
18233                if desired.wants_eav() {
18234                    delta.built.push(spec.name.clone());
18235                }
18236                if desired.has_deferred() {
18237                    delta.deferred.push(spec.name.clone());
18238                }
18239            }
18240        }
18241    }
18242
18243    // 0.8.20 Slice 15e — after the registry mutations, reconcile the live vec0
18244    // shape with the (possibly changed) `filterable` set: a NON-DESTRUCTIVE
18245    // reshape adds/removes the `attr_<hex>` pre-KNN columns preserving every
18246    // row's embedding (TC-46, HITL Option 1). Runs on the caller's write
18247    // transaction, so the reshape commits atomically with the registry row. On an
18248    // idempotent re-registration the desired set equals the live set, so this is a
18249    // no-op (vec0 untouched) and `delta.unchanged` above is unaffected. Skipped
18250    // when there is no embedder profile (⇒ no `vector_default` to reshape).
18251    if let Ok(dimension) = default_profile_dimension(tx) {
18252        if refresh_vector_attributes {
18253            refresh_vector_attr_values(tx, dimension).map_err(|_| EngineError::Storage)?;
18254        } else {
18255            reconcile_vector_attr_columns(tx, dimension).map_err(|_| EngineError::Storage)?;
18256        }
18257    }
18258
18259    delta.unchanged =
18260        delta.built.is_empty() && delta.dropped.is_empty() && delta.deferred.is_empty();
18261
18262    // 0.8.20 Slice 20c (R-20-DR remainder) — THE C4 RIDER. Everything above has
18263    // only *persisted* the `searchable→vector` declaration and pushed its name
18264    // onto `delta.deferred`. Acknowledging deferred work and then dropping it on
18265    // the floor is what made `drain` a FALSE-READY barrier; this call is where the
18266    // deferred work is actually enqueued onto the runtime `drain` waits on.
18267    //
18268    // fix-1 (codex §9 [P2]) — and its SYMMETRIC INVERSE, on the same
18269    // transaction. If this call removed the last `searchable→vector` declaration,
18270    // un-enrol the node kinds the forward path enrols; otherwise enrolment is a
18271    // one-way door and the write path keeps embedding for a projection the
18272    // registry no longer declares.
18273    let vector_declared_after = vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
18274    let enqueued = if vector_declared_after {
18275        // 0.8.20 Slice 22 (R-20-VC / TC-67) — THE REPORT. Scoped to a live dense
18276        // -arm declaration (with no `searchable→vector` projection there is
18277        // nothing for a kind to be unsupported FOR, so reporting would be noise
18278        // on every `filterable`/FTS-only call), but deliberately OUTSIDE the
18279        // `dense_arm_live` gate below — see [`unsupported_vector_kinds`].
18280        //
18281        // Placed AFTER `delta.unchanged` is computed, and it does not feed it:
18282        // this is a STATE report, not a diff, so an idempotent re-apply still
18283        // carries it (that is also the documented refresh path for the
18284        // declare-time residual).
18285        delta.vector_unsupported_kinds =
18286            unsupported_vector_kinds(tx).map_err(|_| EngineError::Storage)?;
18287        if dense_arm_live {
18288            enqueue_declared_vector_backfill(tx).map_err(|_| EngineError::Storage)?
18289        } else {
18290            false
18291        }
18292    } else {
18293        // fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — the transition arm is
18294        // KEPT as-is and a state-keyed reconciliation is added BESIDE it; neither
18295        // subsumes the other. The transition fires when this very call removed the
18296        // last dense-arm declaration, including the case where it removed the last
18297        // `vector` sub-object with it (which leaves
18298        // `registry_governs_an_inert_dense_arm` false). The reconciliation covers
18299        // the ALREADY-AFFECTED database whose user calls `configure_projections`
18300        // again with anything at all: there `before` is already `false`, so the
18301        // transition arm is inert and the inert enrolment used to survive
18302        // indefinitely. `||` short-circuits, so the transition case pays nothing
18303        // extra; the other case pays two cached `EXISTS` probes on a governed call,
18304        // never on the hot write path.
18305        if vector_declared_before
18306            || registry_governs_an_inert_dense_arm(tx).map_err(|_| EngineError::Storage)?
18307        {
18308            unenrol_registry_vector_node_kinds(tx).map_err(|_| EngineError::Storage)?;
18309        }
18310        false
18311    };
18312    Ok((delta, enqueued))
18313}
18314
18315/// 0.8.20 Slice 20c fix-1 (codex §9 [P2] "Stop embedding after vector projection
18316/// drops") — **the inverse of [`enqueue_declared_vector_backfill`]'s enrolment.**
18317///
18318/// Slice 20c gave `_fathomdb_vector_kinds` its first governed-call-reachable
18319/// writer for a NODE kind (before it, the only one was the `#[doc(hidden)]`
18320/// `configure_vector_kind_for_test` hook). Forward without reverse is the defect:
18321/// after `drop`ping the last `searchable→vector` declaration,
18322/// [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate and
18323/// [`connection_has_pending_projection_work`] both still see the enrolment, so
18324/// subsequent writes keep enqueueing embeds and `drain` keeps waiting on work for
18325/// a projection [`Engine::read_projections`] no longer reports.
18326///
18327/// # It DELETES NO EMBEDDING — that is the point
18328///
18329/// The shipped drop arm ([`clear_attribute_projection`] +
18330/// [`remove_projection_row`]) has never touched vec0, `_fathomdb_vector_rows` or
18331/// `_fathomdb_vector_kinds`, so "vectors already at rest survive a drop" is
18332/// ALREADY the shipped contract. Removing one registry row PRESERVES it; deleting
18333/// embeddings would be the destructive delta, and is not done here.
18334///
18335/// # Why keyed to the TRANSITION, not to the bare post-state
18336///
18337/// The rule is "this call removed the last vector declaration"
18338/// (`declared_before && !declared_after`), not "no vector declaration exists
18339/// now". A workspace can hold enrolments this registry never made — the test hook
18340/// does exactly that, and several shipped suites enrol a kind through it and then
18341/// declare an unrelated `filterable`-only projection (e.g.
18342/// `slice15e_prekn_filterable`). Firing on the bare post-state would un-enrol
18343/// those and silently kill a dense arm the registry never owned. In production
18344/// the two readings coincide: before this slice
18345/// `production_vector_kind_surface=[]`, so a node kind can only be enrolled
18346/// because a `searchable→vector` declaration existed.
18347///
18348/// It is still STATE-keyed, not delta-keyed: both members are reads of the
18349/// registry, never "was this spec new". Re-applying the same drop finds
18350/// `declared_before == false` and is a total no-op, and nothing re-enrols it
18351/// ([`Engine::enrol_vector_kind_if_declared`] is gated on
18352/// [`vector_projection_declared`]).
18353///
18354/// # 0.8.20 Slice 21 fix-1 — a SECOND, narrower authorisation now exists
18355///
18356/// The reasoning above is why the bare post-state cannot authorise this DELETE,
18357/// and it still stands. What it does not cover is a database that ran the
18358/// PRE-Slice-21c code and enrolled node kinds off a `{filterable, vector}`
18359/// declaration: there the registry DID own the enrolment, and no transition will
18360/// ever fire for it. [`registry_governs_an_inert_dense_arm`] adds exactly that
18361/// case — positively conditioned on the registry existing AND declaring a
18362/// `vector` sub-object AND declaring no `searchable→vector` projection, which is
18363/// strictly narrower than the bare post-state and in particular excludes every
18364/// workspace whose enrolment the registry never made. Its callers are
18365/// [`reconcile_inert_vector_enrolments_on_boot`] and the drop arm of
18366/// [`apply_projection_config`].
18367///
18368/// # `'edge_fact'` is excluded, deliberately
18369///
18370/// [`project_canonical_edge_row`] (G11) auto-registers `'edge_fact'` off the
18371/// presence of an edge BODY, unconditionally and independently of the projection
18372/// registry. That lifecycle predates this slice and is not the registry's to end,
18373/// so a node-projection drop must not take the edge dense arm down with it.
18374///
18375/// # What it deliberately does NOT do
18376///
18377/// It touches no `_fathomdb_projection_terminal` row and no readiness watermark.
18378/// A row enqueued-but-not-yet-embedded when the drop lands keeps its absent
18379/// terminal, which pins the watermark below it — harmless, because both the
18380/// scheduler and the pending-work probe join `_fathomdb_vector_kinds` and so no
18381/// longer see it, and it is precisely what lets a later RE-declaration pick the
18382/// row up again instead of stranding it.
18383fn unenrol_registry_vector_node_kinds(tx: &Connection) -> rusqlite::Result<()> {
18384    tx.execute("DELETE FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact'", [])?;
18385    Ok(())
18386}
18387
18388/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`, ledger `TC-71`) — **does the
18389/// registry GOVERN the dense arm while declaring none?** The narrow,
18390/// positively-conditioned predicate that authorises
18391/// [`unenrol_registry_vector_node_kinds`] on a bare STATE rather than on the
18392/// `declared_before && !declared_after` transition.
18393///
18394/// # Why a state-keyed authorisation exists at all
18395///
18396/// Slice 21c gated the dense arm on the `searchable` ROLE, which closes the three
18397/// FORWARD doors. It cannot reach a database that already ran the old code: those
18398/// node kinds are already in `_fathomdb_vector_kinds`, and
18399///
18400/// - [`Engine::vector_kind_needs_enrolment`] returns early the moment
18401///   [`kind_is_vector_indexed`] is true, so it never consults the new role-aware
18402///   predicate for an EXISTING registration; and
18403/// - [`project_canonical_node_row`] gates the embed enqueue solely on registry
18404///   membership (deliberately — that is the hot write path, and the decision is
18405///   meant to live upstream).
18406///
18407/// So without this, upgrading does not actually stop the billable, unexpected
18408/// embeddings for exactly the population TC-71 was raised for — the finding's
18409/// whole harm survives the fix unless the user happens to perform a
18410/// searchable-vector-to-none transition later.
18411///
18412/// # THE TRAP: why it is not `!vector_projection_declared`
18413///
18414/// [`vector_projection_declared`] answers `false` when the registry table is
18415/// ABSENT (pre-step-24) or merely EMPTY — which is every LEGACY database, many of
18416/// which have a legitimately working dense arm enrolled by other means (the
18417/// `#[doc(hidden)]` `configure_vector_kind_for_test` hook is one; before this
18418/// slice `production_vector_kind_surface=[]`, but a workspace is not obliged to
18419/// have reached its enrolment through the registry). Un-enrolling on that bare
18420/// negative would silently switch vector search OFF for all of them — a far worse
18421/// regression than TC-71 itself. So the rule is POSITIVE on all three counts:
18422///
18423///   1. `_fathomdb_projection_registry` EXISTS; **and**
18424///   2. at least one row carries a `vector` sub-object (`vector_declared = 1`) —
18425///      someone actually asked for a dense arm through the registry, which is
18426///      precisely what identifies the affected population; **and**
18427///   3. NO projection satisfies [`StoredProjection::wants_vector`], i.e. none of
18428///      them is `searchable`.
18429///
18430/// Condition 2 is the load-bearing one. It leaves untouched a registry-governed
18431/// database that declares no `vector` sub-object at all but holds enrolments from
18432/// a pre-registry era (`slice15e_prekn_filterable` is exactly that shape). Being
18433/// conservative here is the correct direction: never destroy a working dense arm.
18434///
18435/// Conditions 1+2 are the SAME two `prepare_cached` `EXISTS` probes
18436/// [`vector_projection_declared`] opens with, so a workspace that never declared
18437/// a `vector` sub-object — the overwhelmingly common shape — pays nothing beyond
18438/// them and never reaches the typed [`load_projection_registry`] read. Condition 3
18439/// is delegated to [`vector_projection_declared`] verbatim rather than re-derived,
18440/// so the authorisation and the gate cannot drift.
18441fn registry_governs_an_inert_dense_arm(conn: &Connection) -> rusqlite::Result<bool> {
18442    // (1) the registry must EXIST. A pre-step-24 database has no registry at all
18443    // and is therefore not registry-governed — hands off.
18444    let table_exists: bool = conn
18445        .prepare_cached(
18446            "SELECT EXISTS(
18447                 SELECT 1 FROM sqlite_master
18448                 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18449             )",
18450        )?
18451        .query_row([], |row| row.get(0))?;
18452    if !table_exists {
18453        return Ok(false);
18454    }
18455    // (2) …and it must actually DECLARE a `vector` sub-object somewhere. An empty
18456    // or vector-less registry governs no dense arm, so any enrolment present came
18457    // from outside it and is not ours to remove.
18458    let any_vector_subobject: bool = conn
18459        .prepare_cached(
18460            "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18461        )?
18462        .query_row([], |row| row.get(0))?;
18463    if !any_vector_subobject {
18464        return Ok(false);
18465    }
18466    // (3) …while declaring no `searchable→vector` projection. THE predicate,
18467    // reused, so this can never disagree with the gate the write path applies.
18468    Ok(!vector_projection_declared(conn)?)
18469}
18470
18471/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`) — the BOOT arm of the
18472/// reconciliation: on every open, bring an already-enrolled inert vector kind
18473/// into agreement with the role-aware decision, so an affected database
18474/// self-heals without the user calling anything. Returns `true` iff it un-enrolled
18475/// something.
18476///
18477/// Authorised by [`registry_governs_an_inert_dense_arm`] (read that for the trap
18478/// this must not fall into), and performed by
18479/// [`unenrol_registry_vector_node_kinds`] — the SAME writer the drop inverse uses,
18480/// so `'edge_fact'` is excluded (G11 auto-registers it off the presence of an edge
18481/// body, independently of the projection registry) and **no embedding is deleted**.
18482///
18483/// # It mirrors the drop inverse exactly, because that inverse does nothing else
18484///
18485/// `apply_projection_config`'s drop arm is a single call to
18486/// [`unenrol_registry_vector_node_kinds`]: no terminal record is touched, no
18487/// readiness watermark is rewound, no row is un-stranded, and nothing is notified
18488/// (it returns `enqueued = false`). So leaving the database in "the state a drop
18489/// transition would have left it in" is exactly that one `DELETE`, and there is
18490/// no second half to mirror.
18491///
18492/// # Cheap when there is nothing to do, and idempotent
18493///
18494/// A workspace with no `vector` sub-object pays only the two cached `EXISTS`
18495/// probes the authorisation opens with. When the authorisation DOES fire, a third
18496/// cached `EXISTS` checks whether any node kind is actually enrolled, so the
18497/// steady state after the first healing open is a pure READ — no write
18498/// transaction, no `DELETE`, nothing to oscillate. `DELETE … WHERE kind <>
18499/// 'edge_fact'` is a single statement, hence atomic on its own; no explicit
18500/// transaction is opened around it.
18501///
18502/// # Placement
18503///
18504/// Runs inside `open_locked` on the writer connection, single-threaded, before
18505/// readers and the projection workers spawn — alongside the other boot
18506/// reconciliations ([`rederive_projections_on_boot`],
18507/// [`reconcile_vector_attr_columns`]) and therefore BEFORE
18508/// [`run_vector_equivalence_probe`], which is deliberate: on a database whose only
18509/// enrolment was the inert one, reconciling first leaves `_fathomdb_vector_kinds`
18510/// empty, so the probe correctly finds no dense arm to guard and the healing open
18511/// spends no embed calls at all.
18512///
18513/// # Not a data migration
18514///
18515/// It removes a registration row inside ONE live database to match that
18516/// database's own declarations. It converts no row across a version step; the
18517/// reconciliation itself introduces no migration.
18518fn reconcile_inert_vector_enrolments_on_boot(conn: &Connection) -> rusqlite::Result<bool> {
18519    if !registry_governs_an_inert_dense_arm(conn)? {
18520        return Ok(false);
18521    }
18522    // Nothing enrolled beyond the G11 edge arm ⇒ nothing to do. Keeps the steady
18523    // state a pure read instead of a no-op write transaction on every open.
18524    let any_node_kind: bool = conn
18525        .prepare_cached(
18526            "SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact')",
18527        )?
18528        .query_row([], |row| row.get(0))?;
18529    if !any_node_kind {
18530        return Ok(false);
18531    }
18532    unenrol_registry_vector_node_kinds(conn)?;
18533    Ok(true)
18534}
18535
18536/// 0.8.20 Slice 20c (R-20-DR remainder) — is ANY `searchable→vector` projection
18537/// declared in the durable registry?
18538///
18539/// This is the corpus-wide "the dense arm is live" predicate. It is corpus-wide
18540/// rather than per-attribute for the same reason [`derive_dense_readiness`] is:
18541/// Slice 15d persists the `searchable→vector` sub-object but defers building any
18542/// per-attribute embedding, so every declared vector projection is served by the
18543/// ONE engine vector pipeline. When per-attribute embedding lands, this is where
18544/// the scoping goes — the same seam as readiness.
18545///
18546/// Safe on a pre-step-24 schema (the registry table is created by step 24): an
18547/// absent table means nothing is declared, not an error. Mirrors the guard in
18548/// [`load_projection_registry`], and uses `prepare_cached` because the write path
18549/// calls this once per un-registered-kind row.
18550///
18551/// # 0.8.20 Slice 21c (ledger `TC-71`) — it requires the `searchable` ROLE
18552///
18553/// This used to answer `EXISTS(… WHERE vector_declared = 1)`, reading the stored
18554/// `vector` sub-object and never the `roles` column. But the sub-object SELECTS
18555/// a sub-target of `searchable`; it does not confer one (exactly as `fts` does
18556/// not — see [`StoredProjection::wants_property_fts`]). So
18557/// `{roles: [filterable], vector: {}}`, which Slice 15d documents as
18558/// inert-but-round-trippable, turned the dense arm ON in any session with a live
18559/// embedder: it enrolled node kinds, backfilled the corpus, and made every later
18560/// write of those kinds enqueue an embedding. Wasted embed work and unexpected
18561/// vectors at rest for a projection meant to do nothing. The answer now comes
18562/// from [`StoredProjection::wants_vector`], the ONE predicate, so the three
18563/// gated paths cannot drift.
18564///
18565/// **This flips the forward AND inverse arms of [`apply_projection_config`] at
18566/// once**, which is a real semantic consequence and not an accident: demoting
18567/// the last `{searchable, vector}` projection to `{filterable, vector}` (or
18568/// dropping it while an inert `{filterable, vector}` sibling survives) now reads
18569/// `declared → not-declared` and therefore UN-ENROLS, where before the surviving
18570/// `vector_declared = 1` row masked the transition and the write path kept
18571/// embedding. Pinned in `tests/slice21c_vector_role_gate.rs`.
18572///
18573/// # Why the cheap `EXISTS` survives as a pre-filter
18574///
18575/// The write path calls this once per un-registered-kind row, and the
18576/// overwhelmingly common shape is a workspace that declared no `vector`
18577/// sub-object at all. `EXISTS(… vector_declared = 1)` is a NECESSARY condition
18578/// for [`StoredProjection::wants_vector`], so keeping it as a fast negative
18579/// leaves that workspace paying exactly the two cached `EXISTS` probes it paid
18580/// before — no typed load, no `BTreeMap`, no uncached `prepare`. Only a
18581/// workspace that HAS a `vector` sub-object somewhere pays the
18582/// [`load_projection_registry`] read, and there the registry is a handful of
18583/// app-declared rows; in the ordinary `searchable→vector` case the kind is
18584/// enrolled after the first probe and `kind_is_vector_indexed` short-circuits
18585/// this call entirely from then on.
18586fn vector_projection_declared(conn: &Connection) -> rusqlite::Result<bool> {
18587    let table_exists: bool = conn
18588        .prepare_cached(
18589            "SELECT EXISTS(
18590                 SELECT 1 FROM sqlite_master
18591                 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18592             )",
18593        )?
18594        .query_row([], |row| row.get(0))?;
18595    if !table_exists {
18596        return Ok(false);
18597    }
18598    // Fast negative: no `vector` sub-object anywhere ⇒ certainly no dense arm.
18599    let any_vector_subobject: bool = conn
18600        .prepare_cached(
18601            "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18602        )?
18603        .query_row([], |row| row.get(0))?;
18604    if !any_vector_subobject {
18605        return Ok(false);
18606    }
18607    // `roles` is persisted as a comma-joined sorted string, so it is not a
18608    // trustworthy SQL predicate (a `LIKE` would match a forward-compat token that
18609    // merely CONTAINS a role spelling). Answer through the typed registry and the
18610    // ONE predicate instead.
18611    Ok(load_projection_registry(conn)?.values().any(StoredProjection::wants_vector))
18612}
18613
18614/// 0.8.20 Slice 20c (R-20-DR remainder) — enrol `kind` in the vector pipeline.
18615///
18616/// `INSERT OR IGNORE`, so it is idempotent and never disturbs an existing
18617/// registration's `profile`/`created_at`. Same statement shape the G11 edge path
18618/// uses for `'edge_fact'` ([`project_canonical_edge_row`]).
18619fn register_vector_kind(tx: &Connection, kind: &str) -> rusqlite::Result<()> {
18620    tx.execute(
18621        "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
18622         VALUES(?1, ?2, 0)",
18623        params![kind, DEFAULT_VECTOR_PROFILE],
18624    )?;
18625    Ok(())
18626}
18627
18628/// 0.8.20 Slice 20c (R-20-DR remainder) — **the flush barrier's enqueue half**
18629/// (`api-surface.md` **C4** rider: `drain` is a barrier, not a trigger, so
18630/// deferred/backfill rows must be enqueued on the same projection runtime `drain`
18631/// waits on).
18632///
18633/// Runs on the caller's `configure_projections` write transaction, AFTER the
18634/// registry mutations, so the enrolment + re-enqueue commit atomically with the
18635/// declaration that caused them. Returns `true` iff work was enqueued — the
18636/// caller must then `notify_new_work()` (after the commit; the dispatcher opens
18637/// its own connection).
18638///
18639/// # The defect this closes
18640///
18641/// `project_canonical_node_row` writes a PERMANENT `'up_to_date'` terminal for
18642/// any row whose kind was not vector-registered *at write time*, and before this
18643/// slice NOTHING but the `#[doc(hidden)]` test hook ever registered a node kind
18644/// (`slice-G0-design.md`: `production_vector_kind_surface=[]`). So the ordinary
18645/// "turn the dense arm on over an existing corpus" flow — write rows, then
18646/// declare `searchable→vector` — left every row terminally marked done with no
18647/// vector and no way to get one short of an operator `rebuild`. Both
18648/// `drain`/`wait_for_idle` and `derive_dense_readiness` read that terminal
18649/// through [`connection_has_pending_projection_work`], so the corpus reported
18650/// `ready` while nothing would ever embed it.
18651///
18652/// # Shape (deliberately the `run_rebuild` shape, scoped)
18653///
18654/// `run_rebuild` truncates the readiness terminals and rewinds the projection
18655/// cursor so the scheduler re-walks the corpus. This does the same, but scoped to
18656/// the rows the declaration newly covers, and it does NOT truncate anything else:
18657///
18658/// 1. enrol every vector-eligible node kind present in `canonical_nodes`
18659///    (`row_kind IN ('leaf','coverage')` — the `index_targets_for_row_kind`
18660///    vector-eligibility predicate; `graph` rows are lexically searchable but
18661///    never embedded, so enrolling on them would silently start embedding
18662///    structural rows) **that the vector writer can commit**
18663///    ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]);
18664/// 2. (and 3.) un-strand the rows that enrolment now covers, via
18665///    [`reenqueue_stranded_vector_rows`] — shared verbatim with the write path's
18666///    late enrolment.
18667///
18668/// # Why it is IDEMPOTENT (R-20-PR: "re-registration is a no-op")
18669///
18670/// Every step keys off *state*, not off "was this declaration new": step 1 is
18671/// `INSERT OR IGNORE`; steps 2-3 act only on rows that are stranded RIGHT NOW.
18672/// Once the backfill has been drained those rows carry vectors, so a re-apply
18673/// finds an empty stranded set, returns `false`, and touches neither the
18674/// terminals nor the cursor. No rewind, no re-embed, no spurious `embedding`
18675/// window.
18676///
18677/// # Not a data migration
18678///
18679/// This re-enqueues embed work inside ONE live database at the caller's request.
18680/// It converts no rows across a version step and introduces no migration (HITL
18681/// 2026-07-21; cf. TC-46's in-place vec0 reshape).
18682/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — the ONE scan of "which node kinds in
18683/// this corpus are candidates for the dense arm?".
18684///
18685/// `row_kind IN ('leaf', 'coverage')` is the `index_targets_for_row_kind` vector
18686/// -eligibility predicate: `graph` rows are lexically searchable but NEVER
18687/// embedded, so they are excluded here on a ROW-KIND axis that has nothing to do
18688/// with the `kind` vocabulary — including them would make TC-67 report structural
18689/// rows as "unsupported kinds", which is a different (and false) statement.
18690///
18691/// Extracted so [`enqueue_declared_vector_backfill`] (which enrols the
18692/// commit-able half) and [`unsupported_vector_kinds`] (which reports the other
18693/// half) partition ONE list rather than running two hand-copied queries that
18694/// could drift — the same TC-56 anti-drift discipline that made
18695/// [`kind_is_vector_committable`] delegate to [`resolve_source_type`].
18696///
18697/// `SELECT DISTINCT … ORDER BY kind` gives the caller a sorted, de-duplicated
18698/// list for free, which is the reported ordering.
18699fn vector_eligible_node_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
18700    let mut stmt = tx.prepare(
18701        "SELECT DISTINCT kind FROM canonical_nodes
18702         WHERE row_kind IN ('leaf', 'coverage')
18703         ORDER BY kind",
18704    )?;
18705    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
18706    rows.collect::<rusqlite::Result<Vec<String>>>()
18707}
18708
18709/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **the report that replaces the
18710/// silence.** The vector-eligible node kinds present in the corpus that
18711/// [`kind_is_vector_committable`] excludes, i.e. the exact complement of the set
18712/// [`enqueue_declared_vector_backfill`] enrols.
18713///
18714/// Populates [`ProjectionDelta::vector_unsupported_kinds`]. Read that field's
18715/// doc-comment for the naming, the state-not-diff semantics and the residual;
18716/// what belongs HERE is the one thing the call SITE decides:
18717///
18718/// **It is deliberately NOT gated on `dense_arm_live`.** The enrolment it mirrors
18719/// is (`apply_projection_config` only calls `enqueue_declared_vector_backfill`
18720/// with a live embedder, the Q6a graceful-absent path), but this answer does not
18721/// depend on the session: [`resolve_source_type`]'s vocabulary is a compile-time
18722/// constant, so "this kind can never be embedded" is equally true with no
18723/// embedder attached. Gating it would hide the permanent fact behind the
18724/// transient one, which is the very conflation TC-67 exists to end — a
18725/// no-embedder caller is exactly the caller who most needs to know that
18726/// attaching an embedder later will still not embed these kinds.
18727fn unsupported_vector_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
18728    Ok(vector_eligible_node_kinds(tx)?
18729        .into_iter()
18730        .filter(|kind| !kind_is_vector_committable(kind))
18731        .collect())
18732}
18733
18734fn enqueue_declared_vector_backfill(tx: &Connection) -> rusqlite::Result<bool> {
18735    if !vector_projection_declared(tx)? {
18736        return Ok(false);
18737    }
18738
18739    // (1) Enrol the vector-eligible kinds the live corpus actually contains —
18740    // RESTRICTED to the ones the vector writer can actually commit
18741    // ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]). Enrolling a kind
18742    // outside `resolve_source_type`'s locked vocabulary wedges the projection
18743    // worker forever and starves every other kind with it.
18744    //
18745    // 0.8.20 Slice 22 (TC-67) — the kinds this filter DROPS are what
18746    // [`unsupported_vector_kinds`] reports; both read the same scan through
18747    // [`vector_eligible_node_kinds`] so the report can never describe a
18748    // different set from the one actually excluded.
18749    let kinds = vector_eligible_node_kinds(tx)?;
18750    for kind in kinds.iter().filter(|kind| kind_is_vector_committable(kind)) {
18751        register_vector_kind(tx, kind)?;
18752    }
18753
18754    // (2)+(3) Un-strand the rows the new enrolment now covers.
18755    reenqueue_stranded_vector_rows(tx)
18756}
18757
18758/// 0.8.20 Slice 20c — steps (2) and (3) of the declared-backfill above, as their
18759/// own function because **both** enrolment doors owe this treatment.
18760///
18761/// fix-2 (codex §9 [P2]): [`enqueue_declared_vector_backfill`] is the DECLARE-time
18762/// door; [`Engine::enrol_batch_vector_kinds`] is the WRITE-time one, and it used to
18763/// enrol a kind while enqueueing only the row in its own batch. A database that
18764/// persisted a `searchable→vector` declaration while opened WITHOUT an embedder
18765/// (Q6a graceful-absent: it defers, enrolling nothing), then reopened WITH one and
18766/// wrote the same kind BEFORE re-applying the projection, therefore drained the new
18767/// row and reported `ready` while every row from the no-embedder session kept its
18768/// permanent `'up_to_date'` terminal and no vector. That is a FALSE READY — the
18769/// exact defect class R-20-DR exists to eliminate — so the two doors share ONE
18770/// implementation rather than one of them carrying a partial copy.
18771///
18772/// Returns `true` iff work was re-enqueued; the caller must then `notify_new_work()`
18773/// (after its commit — the dispatcher opens its own connection).
18774///
18775///   2. find the STRANDED rows — vector-eligible, now vector-kind-registered,
18776///      carrying an `'up_to_date'` terminal, and carrying NO `_fathomdb_vector_rows`
18777///      row — and delete their terminals so the scheduler's `terminal IS NULL`
18778///      predicate sees them again;
18779///   3. rewind the readiness watermark to just below the lowest stranded cursor, so
18780///      the scheduler's `write_cursor > cursor` filter reaches them.
18781///
18782/// The `_fathomdb_vector_kinds` join is what scopes this to the dense arm: a kind
18783/// that is not enrolled (including one that is not commit-able, per
18784/// [`kind_is_vector_committable`]) is not stranded — it has no dense arm to be
18785/// behind on.
18786///
18787/// Idempotent by construction: it acts only on rows that are stranded RIGHT NOW, so
18788/// once drained the set is empty, it returns `false`, and neither the terminals nor
18789/// the cursor are touched. A `'failed'` terminal is deliberately NOT re-enqueued
18790/// (the filter is `'up_to_date'`): re-enqueueing it would loop a permanently-failing
18791/// row forever, and the documented failure boundary is that a terminally-failed
18792/// embed stops being outstanding work (see [`derive_dense_readiness`]).
18793fn reenqueue_stranded_vector_rows(tx: &Connection) -> rusqlite::Result<bool> {
18794    // (2) The stranded set: covered by the dense arm, terminally marked done, no
18795    // vector. `MIN` first so a no-op apply costs one indexed probe and stops.
18796    let lowest_stranded: Option<u64> = tx.query_row(
18797        "SELECT MIN(n.write_cursor)
18798         FROM canonical_nodes n
18799         JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
18800         JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
18801         LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
18802         WHERE n.row_kind IN ('leaf', 'coverage')
18803           AND t.state = 'up_to_date'
18804           AND v.write_cursor IS NULL",
18805        [],
18806        |row| row.get::<_, Option<u64>>(0),
18807    )?;
18808    let Some(lowest_stranded) = lowest_stranded else {
18809        return Ok(false);
18810    };
18811
18812    tx.execute(
18813        "DELETE FROM _fathomdb_projection_terminal
18814         WHERE write_cursor IN (
18815             SELECT n.write_cursor
18816             FROM canonical_nodes n
18817             JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
18818             JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
18819             LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
18820             WHERE n.row_kind IN ('leaf', 'coverage')
18821               AND t.state = 'up_to_date'
18822               AND v.write_cursor IS NULL
18823         )",
18824        [],
18825    )?;
18826
18827    // (3) Rewind the readiness watermark just below the lowest stranded row so the
18828    // scheduler's `write_cursor > cursor` filter reaches it. Never move it
18829    // FORWARD: rows above the watermark that still hold their terminals are
18830    // skipped by the scheduler's `terminal IS NULL` predicate, and
18831    // `advance_projection_cursor` walks the watermark back up over them.
18832    let rewind_to = lowest_stranded.saturating_sub(1);
18833    if load_projection_cursor(tx)? > rewind_to {
18834        store_projection_cursor(tx, rewind_to)?;
18835    }
18836    Ok(true)
18837}
18838
18839/// 0.8.20 Slice 15d (R-20-PR, Q5) — BOOT re-derive: the engine `ProjectionSpec`
18840/// is a DERIVED cache, re-driven idempotently on boot. For every persisted
18841/// registry declaration, clear + backfill its EAV / property-FTS rows from the
18842/// canonical nodes — so a DB whose registry row survives but whose projection
18843/// rows are missing/partial (a crash window, a restored registry) CONVERGES on
18844/// the next open. A no-op (single empty-table read) when no projections are
18845/// declared — which is every pre-`configure_projections` DB. Runs on the writer
18846/// connection, single-threaded, before readers spawn.
18847fn rederive_projections_on_boot(conn: &Connection) -> rusqlite::Result<()> {
18848    let registry = load_projection_registry(conn)?;
18849    if registry.is_empty() {
18850        return Ok(());
18851    }
18852    conn.execute_batch("BEGIN IMMEDIATE")?;
18853    let result = (|| {
18854        for (name, stored) in &registry {
18855            clear_attribute_projection(conn, name)?;
18856            backfill_attribute(conn, name, stored)?;
18857        }
18858        Ok(())
18859    })();
18860    match result {
18861        Ok(()) => conn.execute_batch("COMMIT"),
18862        Err(err) => {
18863            let _ = conn.execute_batch("ROLLBACK");
18864            Err(err)
18865        }
18866    }
18867}
18868
18869/// EXP-S (0.8.14 Slice 5) — the `row_kind -> index-target set` dispatch
18870/// (ADR-0.8.14 §D2), and the OPP-12 forward-compat seam (ADR-0.8.14 §D5(a) /
18871/// ledger `TC-1`).
18872///
18873/// This is deliberately a per-kind LOOKUP rather than branching inlined at each
18874/// write call-site: it is the single seam a later declarative OPP-12 projection
18875/// registry (`dev/design/projection-registry-and-async-embed.md`) would wrap to
18876/// populate `row_kind -> {filterable, searchable->FTS (same-txn), searchable->
18877/// vector (async)}` without reshaping the substrate. Per D5, EXP-S implements
18878/// NO OPP-12 surface here (OPP-12 lands >=0.9.x; re-check at its scheduling) —
18879/// this function only records the index-target intent so the async-vs-sync split
18880/// (D5(b)) and the per-kind-extensible terminal-cursor readiness (D5(c)) stay
18881/// wrappable.
18882///
18883/// `Leaf` MUST preserve today's behavior exactly: FTS (sync) + vector (async,
18884/// gated by `kind_is_vector_indexed`).
18885fn index_targets_for_row_kind(row_kind: RowKind) -> IndexTargetSet {
18886    match row_kind {
18887        // Normal record — identical to pre-EXP-S behavior.
18888        RowKind::Leaf => IndexTargetSet { fts: true, vector: true },
18889        // Coverage/summary rows — searchable and embeddable.
18890        RowKind::Coverage => IndexTargetSet { fts: true, vector: true },
18891        // Graph structural rows — lexically searchable, not embedded.
18892        RowKind::Graph => IndexTargetSet { fts: true, vector: false },
18893    }
18894}
18895
18896/// EXP-S (0.8.14 Slice 5, D2/D5) — apply the per-`row_kind` index-target
18897/// dispatch for one just-inserted canonical node row (write_cursor `cursor`).
18898///
18899/// Preserves the OPP-12-shaped split (D5(b)): FTS is written in THIS
18900/// transaction (same-txn `searchable->FTS`); vector work is only *enqueued*
18901/// here into `_fathomdb_projection_state` and embedded later, asynchronously,
18902/// by the projection worker pool (`searchable->vector`). When the row projects
18903/// into no async vector index, its readiness is terminated up-front (D5(c),
18904/// per-kind-extensible) so `advance_projection_cursor` can walk past it.
18905///
18906/// Returns `true` iff async vector work was enqueued (the caller must then
18907/// `notify_new_work`). For `RowKind::Leaf` this is behavior-identical to the
18908/// pre-EXP-S inline node path.
18909fn project_canonical_node_row(
18910    tx: &Connection,
18911    cursor: u64,
18912    kind: &str,
18913    body: &str,
18914    row_kind: RowKind,
18915    pass: ProjectionPass,
18916    node_active: bool,
18917) -> rusqlite::Result<bool> {
18918    let targets = index_targets_for_row_kind(row_kind);
18919    if targets.fts && pass.writes_fts() {
18920        tx.execute(
18921            "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
18922            params![body, kind, cursor],
18923        )?;
18924        // F5 (0.8.14 Slice 10) — same coexisting `searchable->FTS` target also
18925        // populates the multi-column `search_index_v2` (kind/body/status) so a
18926        // BM25F query can field-weight the lexical arm. Written SYNCHRONOUSLY in
18927        // THIS transaction, exactly like `search_index` (rowid==write_cursor
18928        // identity preserved). The `status` field mirrors the migration-17
18929        // O(N) re-index: `$.status` from a JSON body, guarded by `json_valid` so
18930        // non-JSON bodies index an empty status. NOTE (codex fix-1 finding 2):
18931        // this is F5's OWN `$.status`-derived field for the BM25F `status`
18932        // column — it is NOT (yet) the value the shipped G10 SearchFilter reads.
18933        // G10 filtering reads the vec0 `status` column, which is still hardwired
18934        // to the empty-string sentinel; wiring G10 onto this field is out of
18935        // scope for F5. Determinism (R-SUB-2) is preserved: the derivation is
18936        // a pure function of `body`, evaluated in-SQL identically on every run.
18937        tx.execute(
18938            "INSERT INTO search_index_v2(kind, body, status, write_cursor)
18939             VALUES(
18940                 ?1,
18941                 ?2,
18942                 CASE WHEN json_valid(?2)
18943                      THEN COALESCE(json_extract(?2, '$.status'), '')
18944                      ELSE '' END,
18945                 ?3
18946             )",
18947            params![kind, body, cursor],
18948        )?;
18949    }
18950    // 0.8.20 Slice 15d (R-20-EAV) — same-transaction attribute projection. Only
18951    // the full `Write` pass re-derives attributes (see `writes_attributes`): the
18952    // FtsOnly tokenizer reproject predates step 24 and must not touch the
18953    // registry/attribute tables; VectorOnly rebuilds only vector shadows. A full
18954    // operator FTS rebuild uses `Write`, so it re-derives attributes after the
18955    // truncate.
18956    //
18957    // fix-2 [P2]: gated on `node_active`. The at-rest attribute projection tracks
18958    // EXACTLY the backfill's row set — `state = 'active' AND superseded_at IS NULL`
18959    // (see `backfill_attribute`). Unlike node-FTS / vector shadows (whose stale
18960    // versions are excluded by the canonical read path's `superseded_at IS NULL`
18961    // / `state = 'active'` join), the property tables carry NO read-side lifecycle
18962    // filter (`property_search_index` is an FTS5 table that cannot), so a pending
18963    // or superseded node's attribute values would otherwise LEAK into a
18964    // same-session property filter / property-FTS. The write path passes
18965    // `state == Active`; a projector-replay rebuild passes `active ∧ non-superseded`
18966    // per row. Lifecycle transitions maintain the store directly (see
18967    // `Engine::transition`). Passes where `writes_attributes()` is false ignore the
18968    // flag entirely.
18969    if pass.writes_attributes() && node_active {
18970        project_node_attributes(tx, cursor as i64, body)?;
18971    }
18972    // 0.8.20 Slice 20c (R-20-DR remainder) — UNCHANGED, deliberately. Late
18973    // enrolment of a kind first written AFTER a `searchable→vector` declaration
18974    // happens in [`Engine::enrol_vector_kind_if_declared`], upstream of this
18975    // transaction, NOT here: the decision needs the engine's LIVE embedder, which
18976    // a free function holding only a `Connection` cannot see. Enrolling without
18977    // one would queue embeds that can only retry-then-fail.
18978    let enqueue_vector = targets.vector && kind_is_vector_indexed(tx, kind).unwrap_or(false);
18979    if pass.writes_vector_state() {
18980        if enqueue_vector {
18981            tx.execute(
18982                "INSERT INTO _fathomdb_projection_state(kind, last_enqueued_cursor, updated_at)
18983                 VALUES(?1, ?2, 0)
18984                 ON CONFLICT(kind) DO UPDATE SET last_enqueued_cursor = excluded.last_enqueued_cursor",
18985                params![kind, cursor],
18986            )?;
18987        } else {
18988            // Never-vector-projected rows terminate the cursor up-front so
18989            // `advance_projection_cursor` can advance the readiness watermark.
18990            record_projection_terminal(tx, cursor, "up_to_date")?;
18991        }
18992    }
18993    Ok(enqueue_vector)
18994}
18995
18996/// 0.8.20 Slice 5a (R-20-E1, work item 1) — the EDGE half of the total
18997/// projector, extracted verbatim from the inlined `commit_batch` edge arm.
18998///
18999/// Before this extraction there was NO edge projector function: `commit_batch`
19000/// inlined the edge FTS insert + the edge vector enqueue, and
19001/// `rebuild_shadow_state` re-implemented a SUBSET of it (edge FTS only, and only
19002/// for body-carrying edges), so a projector-replay rebuild silently dropped the
19003/// rest — notably the `up_to_date` readiness terminal that the write path
19004/// records for a body-less structural edge. With both sites now calling this one
19005/// function, the write path and the rebuild path produce identical edge
19006/// projections by construction.
19007///
19008/// Mirrors [`project_canonical_node_row`]'s split (ADR-0.8.14 §D5(b)): FTS in
19009/// THIS transaction; vector work only ENQUEUED, embedded later by the worker
19010/// pool. Edge bodies enqueue under the fixed kind `"edge_fact"` so
19011/// `resolve_source_type` maps them to `source_type = "edge_fact"` in
19012/// `vector_default` (partition correctness); that kind is auto-registered in
19013/// `_fathomdb_vector_kinds` (idempotent).
19014///
19015/// Returns `true` iff async vector work was enqueued.
19016fn project_canonical_edge_row(
19017    tx: &Connection,
19018    cursor: u64,
19019    kind: &str,
19020    body: Option<&str>,
19021    pass: ProjectionPass,
19022) -> rusqlite::Result<bool> {
19023    // G11 — edge FTS projection into `search_index_edges` (separate table from
19024    // node-body `search_index` — Option B partition). Body-less structural
19025    // edges carry no lexical content and project no FTS row.
19026    if pass.writes_fts() {
19027        if let Some(edge_body) = body {
19028            tx.execute(
19029                "INSERT INTO search_index_edges(body, kind, write_cursor)
19030                 VALUES(?1, ?2, ?3)",
19031                params![edge_body, kind, cursor],
19032            )?;
19033        }
19034    }
19035    let enqueue_vector = body.is_some();
19036    if pass.writes_vector_state() {
19037        if enqueue_vector {
19038            let now_unix =
19039                SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
19040            tx.execute(
19041                "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
19042                 VALUES('edge_fact', 'default', ?1)",
19043                params![now_unix],
19044            )?;
19045            tx.execute(
19046                "INSERT INTO _fathomdb_projection_state(
19047                     kind, last_enqueued_cursor, updated_at
19048                 ) VALUES('edge_fact', ?1, 0)
19049                 ON CONFLICT(kind) DO UPDATE
19050                     SET last_enqueued_cursor = excluded.last_enqueued_cursor",
19051                params![cursor],
19052            )?;
19053            // Do NOT call record_projection_terminal — let the scheduler embed
19054            // the body and mark it terminal after projection.
19055        } else {
19056            record_projection_terminal(tx, cursor, "up_to_date")?;
19057        }
19058    }
19059    Ok(enqueue_vector)
19060}
19061
19062/// F5 (0.8.14 Slice 10, fix-1) — tokenizer for the in-engine BM25F scorer.
19063///
19064/// Tokenizes `text` through the SAME FTS5 tokenizer that `search_index_v2` uses
19065/// for candidate recall (`porter unicode61 remove_diacritics 2`), so the scorer
19066/// measures term-frequency, document-frequency, field length, and average field
19067/// length under the index's own tokenization — porter stemming + unicode61
19068/// case-fold + diacritic folding. The previous implementation hand-rolled a
19069/// second lowercase-alnum splitter; a stemmed/diacritic variant recalled by
19070/// `MATCH` (e.g. query `run` vs indexed `running`, or `cafe` vs `café`) was then
19071/// scored as if the term were absent, so ranking was wrong for exactly those
19072/// variants (codex §9 fix-1 finding 1). Reusing FTS5 itself makes scoring
19073/// tokenization-faithful without re-implementing porter/unicode61 in Rust.
19074///
19075/// Mechanism: round-trip `text` through a temp single-column FTS5 table with the
19076/// identical tokenizer, then read the emitted token instances back via the
19077/// `fts5vocab(..., 'instance')` companion. The token multiset is returned in
19078/// index order (duplicates kept) so callers count tf and field length directly.
19079/// Query terms and every candidate field go through this one path, so all four
19080/// statistics are consistent with each other and with the FTS5 index the scorer
19081/// ranks.
19082fn fts5_tokenize(connection: &Connection, text: &str) -> rusqlite::Result<Vec<String>> {
19083    connection.execute_batch(
19084        "CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok
19085             USING fts5(t, tokenize = 'porter unicode61 remove_diacritics 2');
19086         CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok_vocab
19087             USING fts5vocab('bm25f_tok', 'instance');
19088         DELETE FROM temp.bm25f_tok;",
19089    )?;
19090    connection.execute("INSERT INTO temp.bm25f_tok(t) VALUES(?1)", params![text])?;
19091    let mut stmt =
19092        connection.prepare("SELECT term FROM temp.bm25f_tok_vocab ORDER BY \"offset\"")?;
19093    let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
19094    rows.collect()
19095}
19096
19097/// F5 (0.8.14 Slice 10) — build the FTS5 `MATCH` expression for candidate
19098/// recall from the query's tokens: each token is a double-quoted FTS5 string
19099/// (tokens are FTS5-emitted stems — unicode61 alnum, no embedded quotes),
19100/// OR-joined.
19101fn bm25f_match_expression(terms: &[String]) -> String {
19102    terms.iter().map(|t| format!("\"{t}\"")).collect::<Vec<_>>().join(" OR ")
19103}
19104
19105/// F5 (0.8.14 Slice 10) — the BM25F score for one candidate document.
19106///
19107/// Standard BM25F: per query term, accumulate a length-normalized,
19108/// field-weighted pseudo term-frequency across the fields, then apply the BM25
19109/// saturation once. `norm_f = 1 - b + b*(len_f/avglen_f)` is the per-field
19110/// length normalization (this is where tunable `b` bites); `weight_f` is the
19111/// field boost (this is where the R-F5-1 field weighting bites).
19112fn bm25f_score_doc(
19113    plan: &Bm25fQueryPlan,
19114    query_terms: &[String],
19115    // (weight, doc field length, corpus avg field length, per-term tf in field)
19116    fields: &[(f64, f64, f64, &HashMap<String, u32>)],
19117    doc_count: usize,
19118    df: &HashMap<String, usize>,
19119) -> f64 {
19120    let mut score = 0.0_f64;
19121    for term in query_terms {
19122        let mut weighted_tf = 0.0_f64;
19123        for (weight, len_f, avglen_f, tf_map) in fields {
19124            if *weight == 0.0 || *avglen_f <= 0.0 {
19125                continue;
19126            }
19127            let tf = *tf_map.get(term).unwrap_or(&0) as f64;
19128            if tf == 0.0 {
19129                continue;
19130            }
19131            let norm = 1.0 - plan.b + plan.b * (len_f / avglen_f);
19132            if norm <= 0.0 {
19133                continue;
19134            }
19135            weighted_tf += weight * tf / norm;
19136        }
19137        if weighted_tf <= 0.0 {
19138            continue;
19139        }
19140        let dfq = *df.get(term).unwrap_or(&0);
19141        if dfq == 0 {
19142            continue;
19143        }
19144        let n = doc_count as f64;
19145        let idf = ((n - dfq as f64 + 0.5) / (dfq as f64 + 0.5) + 1.0).ln();
19146        score += idf * (weighted_tf * (plan.k1 + 1.0)) / (plan.k1 + weighted_tf);
19147    }
19148    score
19149}
19150
19151/// F5 (0.8.14 Slice 10) — connection-level implementation of the BM25F lexical
19152/// arm. See [`Engine::bm25f_search`].
19153fn bm25f_search_inner(
19154    connection: &Connection,
19155    query: &str,
19156    plan: &Bm25fQueryPlan,
19157) -> rusqlite::Result<Vec<(u64, f64)>> {
19158    let query_terms: Vec<String> = {
19159        let mut seen = BTreeSet::new();
19160        fts5_tokenize(connection, query)?.into_iter().filter(|t| seen.insert(t.clone())).collect()
19161    };
19162    if query_terms.is_empty() {
19163        return Ok(Vec::new());
19164    }
19165
19166    // Corpus pass over ACTIVE rows (superseded versions excluded): accumulate
19167    // N, total field length per field (for avg field length), and per-term
19168    // document frequency — all under the SAME FTS5 tokenization the index uses.
19169    let mut doc_count: usize = 0;
19170    let mut total_len = [0.0_f64; 3]; // kind, body, status
19171    let mut df: HashMap<String, usize> = HashMap::new();
19172    {
19173        let mut stmt = connection.prepare(
19174            "SELECT v.kind, v.body, v.status
19175             FROM search_index_v2 v
19176             JOIN canonical_nodes cn ON cn.write_cursor = v.write_cursor
19177             WHERE cn.superseded_at IS NULL AND cn.state = 'active'",
19178        )?;
19179        let mut rows = stmt.query([])?;
19180        while let Some(row) = rows.next()? {
19181            let fields =
19182                [row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?];
19183            doc_count += 1;
19184            let mut present: BTreeSet<String> = BTreeSet::new();
19185            for (i, field) in fields.iter().enumerate() {
19186                let toks = fts5_tokenize(connection, field)?;
19187                total_len[i] += toks.len() as f64;
19188                for tok in toks {
19189                    if query_terms.contains(&tok) {
19190                        present.insert(tok);
19191                    }
19192                }
19193            }
19194            for term in present {
19195                *df.entry(term).or_insert(0) += 1;
19196            }
19197        }
19198    }
19199    if doc_count == 0 {
19200        return Ok(Vec::new());
19201    }
19202    let avglen = [
19203        total_len[0] / doc_count as f64,
19204        total_len[1] / doc_count as f64,
19205        total_len[2] / doc_count as f64,
19206    ];
19207
19208    // Active write_cursor set, to filter FTS5 MATCH candidates (search_index_v2
19209    // retains superseded versions, exactly like search_index).
19210    let active: BTreeSet<i64> = {
19211        let mut stmt = connection
19212            .prepare("SELECT write_cursor FROM canonical_nodes WHERE superseded_at IS NULL AND state = 'active'")?;
19213        let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
19214        rows.collect::<rusqlite::Result<BTreeSet<i64>>>()?
19215    };
19216
19217    // Candidate recall through the FTS5 index (this is what makes the v2 index
19218    // load-bearing), then score each candidate with the in-engine BM25F.
19219    let match_expr = bm25f_match_expression(&query_terms);
19220    let mut scored: Vec<(u64, f64)> = Vec::new();
19221    {
19222        let mut stmt = connection.prepare(
19223            "SELECT write_cursor, kind, body, status
19224             FROM search_index_v2
19225             WHERE search_index_v2 MATCH ?1",
19226        )?;
19227        let mut rows = stmt.query([match_expr.as_str()])?;
19228        while let Some(row) = rows.next()? {
19229            let wc = row.get::<_, i64>(0)?;
19230            if !active.contains(&wc) {
19231                continue;
19232            }
19233            let kind = row.get::<_, String>(1)?;
19234            let body = row.get::<_, String>(2)?;
19235            let status = row.get::<_, String>(3)?;
19236
19237            let mut tf_kind: HashMap<String, u32> = HashMap::new();
19238            let mut len_kind = 0.0_f64;
19239            for tok in fts5_tokenize(connection, &kind)? {
19240                len_kind += 1.0;
19241                *tf_kind.entry(tok).or_insert(0) += 1;
19242            }
19243            let mut tf_body: HashMap<String, u32> = HashMap::new();
19244            let mut len_body = 0.0_f64;
19245            for tok in fts5_tokenize(connection, &body)? {
19246                len_body += 1.0;
19247                *tf_body.entry(tok).or_insert(0) += 1;
19248            }
19249            let mut tf_status: HashMap<String, u32> = HashMap::new();
19250            let mut len_status = 0.0_f64;
19251            for tok in fts5_tokenize(connection, &status)? {
19252                len_status += 1.0;
19253                *tf_status.entry(tok).or_insert(0) += 1;
19254            }
19255
19256            let fields = [
19257                (plan.weights.kind, len_kind, avglen[0], &tf_kind),
19258                (plan.weights.body, len_body, avglen[1], &tf_body),
19259                (plan.weights.status, len_status, avglen[2], &tf_status),
19260            ];
19261            let score = bm25f_score_doc(plan, &query_terms, &fields, doc_count, &df);
19262            scored.push((wc as u64, score));
19263        }
19264    }
19265
19266    // Descending score; write_cursor ascending as the deterministic tiebreak.
19267    scored.sort_by(|a, b| {
19268        b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))
19269    });
19270    Ok(scored)
19271}
19272
19273fn commit_batch(
19274    connection: &mut Connection,
19275    batch: &[PreparedWrite],
19276    plans: &[WritePlan],
19277    base_cursor: u64,
19278    provenance_row_cap: u64,
19279) -> rusqlite::Result<u64> {
19280    // 0.8.20 Slice 21a-2 (TC-57) — `BEGIN IMMEDIATE`, not rusqlite's `BEGIN
19281    // DEFERRED` default. Take the WAL write lock AT `BEGIN`, before the
19282    // supersession SELECT below, so this transaction never has to PROMOTE a read
19283    // lock to a write lock.
19284    //
19285    // The defect this closes (characterized in
19286    // `dev/design/0.8.20-tc57-write-race-characterization.md`, repro 10/10 at
19287    // baseline `41a81c17`): for a GOVERNED write (`logical_id: Some`) the first
19288    // statement in this transaction is a read —
19289    // `prior_node_cursors_by_logical_id` — and the second is the supersession
19290    // UPDATE. When the async projection worker holds the write lock on its own
19291    // connection at that instant, SQLite refuses the promotion with plain
19292    // `SQLITE_BUSY` (5) and SKIPS the busy handler entirely, for deadlock
19293    // avoidance (`sqlite3_busy_handler`: "if SQLite determines that invoking the
19294    // busy handler could result in a deadlock, it will go ahead and return
19295    // SQLITE_BUSY"). MEASURED: handler invoked ZERO times, error returned in 0 ms
19296    // against rusqlite's 5 000 ms default timeout. So NO `busy_timeout` value
19297    // could ever have absorbed it, and the caller saw an opaque, un-retryable
19298    // `EngineError::Storage` mid-ingest. The same shape also has a second,
19299    // narrower exit — `SQLITE_BUSY_SNAPSHOT` (517) when the WAL advances past the
19300    // read snapshot — which this closes too, by construction.
19301    //
19302    // UNCONDITIONAL rather than gated on `logical_id`, deliberately: an anonymous
19303    // batch's first statement is already the INSERT below, so it takes the write
19304    // lock essentially immediately anyway and the delta is microseconds, whereas a
19305    // content-dependent transaction behaviour would be a NEW correctness surface
19306    // (mixed batches, edge arms, future write kinds) with a place to be wrong in
19307    // each. MEASURED cost on the anonymous arm: none detectable
19308    // (`tc57_worker_commit_pressure.rs`).
19309    //
19310    // `BEGIN IMMEDIATE` can itself return `SQLITE_BUSY` — but WITH the busy
19311    // handler consulted, i.e. absorbed by the existing 5 s default instead of
19312    // surfaced (pinned by `tc57_mechanism_control_write_first_is_retryable`).
19313    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
19314
19315    for (i, (write, plan)) in batch.iter().zip(plans).enumerate() {
19316        // Per-row cursor: row i gets `base_cursor + i + 1`. See the
19317        // comment in `Engine::write_inner`.
19318        let cursor = base_cursor.saturating_add((i as u64).saturating_add(1));
19319        match (write, plan) {
19320            (
19321                PreparedWrite::Node {
19322                    kind,
19323                    body,
19324                    source_id,
19325                    logical_id,
19326                    state,
19327                    reason,
19328                    valid_from,
19329                    valid_until,
19330                },
19331                WritePlan::Node,
19332            ) => {
19333                // G0 — supersession is tombstone-then-insert in this same txn:
19334                // mark the prior active version superseded BEFORE inserting the
19335                // new active row, so the partial-unique-active index never sees
19336                // two active rows for one logical_id. Scoped to logical_id ALONE
19337                // (Decision 5, HITL-SIGNED 2026-06-05): a kind-change re-ingest of
19338                // the same logical_id SUPERSEDES, never forks. No-op when logical_id
19339                // is None (legacy/own-identity insert, behavior-identical to 0.7.x).
19340                if let Some(logical_id) = logical_id {
19341                    // fix-1 finding 2 [P2]: collect the prior active cursor(s)
19342                    // BEFORE tombstoning so we can purge the superseded row's
19343                    // row-owned attribute projections and keep the at-rest EAV /
19344                    // property-FTS store ACTIVE-ONLY. Without this, a same-session
19345                    // property filter / property-FTS saw BOTH the stale and the
19346                    // current value until a boot re-derive/reconfigure cleared the
19347                    // table — a stale read that violates the active-only invariant.
19348                    let prior_g0 = prior_node_cursors_by_logical_id(&tx, logical_id)?;
19349                    tx.execute(
19350                        "UPDATE canonical_nodes SET superseded_at = ?1
19351                         WHERE logical_id = ?2 AND superseded_at IS NULL",
19352                        params![cursor, logical_id],
19353                    )?;
19354                    // Purge only the Attribute + PropertyFts classes: those tables
19355                    // have NO `superseded_at IS NULL` read-side filter (the FTS5
19356                    // `property_search_index` cannot carry one), so their stale rows
19357                    // MUST be deleted at rest. The NodeFts (`search_index` /
19358                    // `search_index_v2`) + Vector shadows are left intact — the node
19359                    // read path already excludes their superseded rows via the
19360                    // `canonical_nodes WHERE superseded_at IS NULL` join, so purging
19361                    // them here would be a behaviour change outside this fix's scope.
19362                    for sc in &prior_g0 {
19363                        purge_row_projections_for_cursor_in(
19364                            &tx,
19365                            *sc,
19366                            &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
19367                        )?;
19368                    }
19369                }
19370                // EXP-S (0.8.14 Slice 5, D1) — a `PreparedWrite::Node` is the
19371                // `leaf` structural row_kind (a normal record). coverage/graph
19372                // rows are written via internal paths (row_kind is a SEPARATE
19373                // axis from the doc-type `kind`, and there is no public SDK
19374                // surface for it this release). Writing `leaf` explicitly is
19375                // value-identical to the column DEFAULT.
19376                // OPP-12 Phase-1 (0.8.19 Slice 5) — persist the create-time
19377                // existence state + advisory reason. `InitialState::Active`
19378                // (the default) writes `state = 'active'`, value-identical to the
19379                // migration step-20 column DEFAULT; `Pending` quarantines the node
19380                // out of default retrieval (the `state = 'active'` read exclusion).
19381                // 0.8.20 Slice 15b (TC-34) — persist the world-time validity
19382                // window. A `None` binds SQL NULL, which is what the migration
19383                // step-22 columns already hold for every pre-existing row and what
19384                // `ReadView::validity_sql` reads as UNBOUNDED on that side. So a
19385                // write that omits the window is byte-identical on disk to a
19386                // pre-slice write, and default-view visibility cannot drift.
19387                tx.execute(
19388                    "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind, state, reason, valid_from, valid_until)
19389                     VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
19390                    params![cursor, kind, body, source_id.as_str(), logical_id, RowKind::Leaf.as_str(), state.as_str(), reason, valid_from, valid_until],
19391                )?;
19392                // EXP-S (D2/D5) — per-row_kind index-target dispatch. For `leaf`
19393                // this is behavior-identical to the pre-EXP-S inline path: FTS
19394                // (sync, in-tx) + vector (async, gated by kind_is_vector_indexed);
19395                // else the cursor is terminated up-front.
19396                // fix-2 [P2]: gate the attribute projection on the create-time
19397                // state. A fresh insert is always non-superseded, so the backfill
19398                // predicate (`state = 'active' AND superseded_at IS NULL`) reduces
19399                // to `state == Active` here. A `Pending` node is quarantined out of
19400                // the canonical read model — its declared attributes must NOT reach
19401                // the property store until a `transition(pending → active)` promotes
19402                // it (which projects them then). Node-FTS / vector shadows are left
19403                // to their read-side lifecycle filter, exactly as for supersession.
19404                project_canonical_node_row(
19405                    &tx,
19406                    cursor,
19407                    kind,
19408                    body,
19409                    RowKind::Leaf,
19410                    ProjectionPass::Write,
19411                    matches!(state, InitialState::Active),
19412                )?;
19413            }
19414            (
19415                PreparedWrite::Edge {
19416                    kind,
19417                    from,
19418                    to,
19419                    source_id,
19420                    logical_id,
19421                    body,
19422                    t_valid,
19423                    t_invalid,
19424                    confidence,
19425                    extractor_model_id,
19426                    temporal_fallback,
19427                },
19428                WritePlan::Edge,
19429            ) => {
19430                // G0 — identical tombstone-then-insert supersession on edges,
19431                // keyed by logical_id ALONE (Decision 5, HITL-SIGNED 2026-06-05;
19432                // edge `kind` is relationship-type, not identity — a kind-change
19433                // re-ingest of the same edge logical_id SUPERSEDES, never forks).
19434                // No-op when logical_id is None.
19435                if let Some(logical_id) = logical_id {
19436                    // fix-30 [P2]: collect prior active cursors BEFORE tombstoning
19437                    // so stale vector_default rows can be pruned.
19438                    let prior_g0 = prior_edge_cursors_by_logical_id(&tx, logical_id)?;
19439                    tx.execute(
19440                        "UPDATE canonical_edges SET superseded_at = ?1
19441                         WHERE logical_id = ?2 AND superseded_at IS NULL",
19442                        params![cursor, logical_id],
19443                    )?;
19444                    for sc in &prior_g0 {
19445                        delete_vector_partition_row(&tx, *sc)?;
19446                        tx.execute(
19447                            "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
19448                            [sc],
19449                        )?;
19450                        // fix-32 [P2]: record terminal so advance_projection_cursor
19451                        // can walk past this now-superseded cursor.
19452                        // TC-45: the token MUST be 'up_to_date', NOT 'superseded'.
19453                        // The terminal table (schema step 7) carries
19454                        // CHECK(state IN ('failed','up_to_date')) and the writer is
19455                        // INSERT OR IGNORE, which SILENTLY SKIPS a CHECK-violating
19456                        // row — so 'superseded' was dropped without error and this
19457                        // cursor stalled forever (nothing backfills it: the job
19458                        // query and the pending-work probe both exclude superseded
19459                        // edges). 'up_to_date' is the CHECK-valid, non-'failed'
19460                        // terminal and is semantically exact here: the row is
19461                        // tombstoned and its vector shadow just deleted, so there is
19462                        // no further projection work for this cursor. Same reasoning
19463                        // and same token as the step-23 backfill (fix-4, TC-33).
19464                        record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19465                    }
19466                }
19467                // G11 — invalidate-not-accumulate: for fact-edges (body IS NOT NULL),
19468                // tombstone any prior active edge on the same (from_id, to_id, kind)
19469                // BEFORE inserting the new row. This is DIFFERENT from the G0
19470                // logical_id tombstone: it is keyed on the triple, not the identity.
19471                // Regular edges (body=None) skip this path — they retain G0 semantics.
19472                if body.is_some() {
19473                    // fix-30 [P2]: collect and prune vector shadow for the superseded edge.
19474                    let prior_g11 = prior_edge_cursors_by_triple(&tx, from, to, kind)?;
19475                    tx.execute(
19476                        "UPDATE canonical_edges SET superseded_at = ?1
19477                         WHERE from_id = ?2 AND to_id = ?3 AND kind = ?4 AND superseded_at IS NULL",
19478                        params![cursor, from, to, kind],
19479                    )?;
19480                    for sc in &prior_g11 {
19481                        delete_vector_partition_row(&tx, *sc)?;
19482                        tx.execute(
19483                            "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
19484                            [sc],
19485                        )?;
19486                        // fix-32 [P2]: mark terminal so projection cursor can advance.
19487                        // TC-45: 'up_to_date', NOT 'superseded' — see the identical
19488                        // note on the G0 prune loop above. The step-7 CHECK admits
19489                        // only ('failed','up_to_date') and INSERT OR IGNORE swallows
19490                        // a violating row, so 'superseded' never landed and wedged
19491                        // the shared readiness watermark.
19492                        record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19493                    }
19494                }
19495                let temporal_fallback_i: Option<i64> =
19496                    temporal_fallback.and_then(|f| if f { Some(1) } else { None });
19497                tx.execute(
19498                    "INSERT INTO canonical_edges(
19499                         write_cursor, kind, from_id, to_id, source_id, logical_id,
19500                         body, t_valid, t_invalid, confidence, extractor_model_id,
19501                         temporal_fallback
19502                     ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
19503                    params![
19504                        cursor,
19505                        kind,
19506                        from,
19507                        to,
19508                        source_id.as_str(),
19509                        logical_id,
19510                        body,
19511                        t_valid,
19512                        t_invalid,
19513                        confidence,
19514                        extractor_model_id,
19515                        temporal_fallback_i
19516                    ],
19517                )?;
19518                // 0.8.20 Slice 5a (R-20-E1, work item 1) — edge projection is no
19519                // longer inlined here: the write path and the rebuild replay
19520                // share ONE projector, so they cannot drift.
19521                project_canonical_edge_row(
19522                    &tx,
19523                    cursor,
19524                    kind,
19525                    body.as_deref(),
19526                    ProjectionPass::Write,
19527                )?;
19528            }
19529            (
19530                PreparedWrite::AdminSchema { name, kind, schema_json, retention_json },
19531                WritePlan::AdminSchema,
19532            ) => {
19533                tx.execute(
19534                    "INSERT INTO operational_collections(
19535                        name, kind, schema_json, retention_json, format_version, created_at
19536                     ) VALUES(?1, ?2, ?3, ?4, 1, 0)
19537                     ON CONFLICT(name) DO UPDATE SET
19538                        schema_json = excluded.schema_json,
19539                        retention_json = excluded.retention_json",
19540                    params![name, kind, schema_json, retention_json],
19541                )?;
19542                record_projection_terminal(&tx, cursor, "up_to_date")?;
19543            }
19544            (
19545                PreparedWrite::OpStore { collection, record_key, schema_id, body },
19546                WritePlan::AppendOnlyLog,
19547            ) => {
19548                tx.execute(
19549                    "INSERT INTO operational_mutations(
19550                        collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
19551                     ) VALUES(?1, ?2, 'append', ?3, ?4, ?5)",
19552                    params![collection, record_key, body, schema_id, cursor],
19553                )?;
19554                record_projection_terminal(&tx, cursor, "up_to_date")?;
19555            }
19556            (
19557                PreparedWrite::OpStore { collection, record_key, schema_id, body },
19558                WritePlan::LatestState,
19559            ) => {
19560                tx.execute(
19561                    "INSERT INTO operational_state(
19562                        collection_name, record_key, payload_json, schema_id, write_cursor
19563                     ) VALUES(?1, ?2, ?3, ?4, ?5)
19564                     ON CONFLICT(collection_name, record_key) DO UPDATE SET
19565                        payload_json = excluded.payload_json,
19566                        schema_id = excluded.schema_id,
19567                        write_cursor = excluded.write_cursor",
19568                    params![collection, record_key, body, schema_id, cursor],
19569                )?;
19570                record_projection_terminal(&tx, cursor, "up_to_date")?;
19571            }
19572            _ => return Err(rusqlite::Error::InvalidQuery),
19573        }
19574    }
19575
19576    // G8 (Slice 20 / F10) — cross-row dangling-edge flag-and-count. This runs
19577    // AFTER the batch loop (so every same-batch node is already on disk in `tx`
19578    // and a same-batch later-inserted endpoint is visible) and BEFORE retention /
19579    // projection-cursor / commit. It is the cross-row reason this lives here and
19580    // not in single-row pre-insert `validate_write`. Default is FLAG-AND-COUNT:
19581    // we only COUNT, never roll back (strict-mode rollback is deferred to
19582    // reserved-gap band 22 — adding a write-options surface is out of scope).
19583    //
19584    // Probe is `logical_id`-alone against the step-12 partial index
19585    // `canonical_nodes_logical_active_idx ON canonical_nodes(logical_id)
19586    // WHERE superseded_at IS NULL` (its leading column + partial predicate), so
19587    // it SEARCHes the index with no SCAN (see `tests/pr_g8_dangling_edges.rs`
19588    // case (f)). There is no node-kind to match: `canonical_edges` stores only
19589    // the edge's own kind, not the endpoint node's kind.
19590    let dangling_edge_endpoints = {
19591        // O(N) pre-pass: record, per `logical_id`, the LAST (highest) index at
19592        // which an `Edge { logical_id: Some(_), .. }` with that id appears. Keyed
19593        // by `logical_id` ALONE (Decision 5, HITL-SIGNED 2026-06-05) to match the
19594        // supersession UPDATE, which keys by logical_id alone: a kind-change
19595        // re-ingest of the same edge logical_id SUPERSEDES the earlier one.
19596        // Iterating front-to-back and overwriting means the stored value ends up
19597        // as the final index for each id. An edge at index `i` with that id is
19598        // then in-batch-superseded iff `last_index[lid] > i`. This is
19599        // behavior-identical to the prior per-edge `batch[i+1..]` `.any(..)` scan
19600        // (which was O(N²) under the single-writer txn) — same skip-set, same count.
19601        let mut last_index: HashMap<&str, usize> = HashMap::new();
19602        for (i, write) in batch.iter().enumerate() {
19603            if let PreparedWrite::Edge { logical_id: Some(lid), .. } = write {
19604                last_index.insert(lid.as_str(), i);
19605            }
19606        }
19607
19608        let mut probe = tx.prepare(
19609            "SELECT 1 FROM canonical_nodes WHERE logical_id = ?1 AND superseded_at IS NULL LIMIT 1",
19610        )?;
19611        let mut count: u64 = 0;
19612        for (i, write) in batch.iter().enumerate() {
19613            if let PreparedWrite::Edge { from, to, logical_id, .. } = write {
19614                // Honor `edge.superseded_at IS NULL`: an edge inserted in this
19615                // batch is active unless a LATER same-batch edge with the same
19616                // `Some(logical_id)` tombstoned it (the loop's supersession
19617                // UPDATE). Skip such an in-batch-superseded edge. Edges with
19618                // `logical_id: None` are never superseded-in-batch.
19619                if let Some(lid) = logical_id {
19620                    let superseded_in_batch =
19621                        last_index.get(lid.as_str()).is_some_and(|&last| last > i);
19622                    if superseded_in_batch {
19623                        continue;
19624                    }
19625                }
19626                // Probe `from_id` and `to_id` independently (0, 1, or 2 per edge).
19627                for endpoint in [from, to] {
19628                    if !probe.exists(params![endpoint])? {
19629                        count = count.saturating_add(1);
19630                    }
19631                }
19632            }
19633        }
19634        count
19635    };
19636
19637    enforce_provenance_retention(&tx, provenance_row_cap)?;
19638    advance_projection_cursor(&tx)?;
19639
19640    tx.commit()?;
19641    Ok(dangling_edge_endpoints)
19642}
19643
19644fn load_next_cursor(connection: &Connection) -> u64 {
19645    let nodes = max_cursor(connection, "canonical_nodes").unwrap_or(0);
19646    let edges = max_cursor(connection, "canonical_edges").unwrap_or(0);
19647    let mutations = max_cursor(connection, "operational_mutations").unwrap_or(0);
19648    let state = max_cursor(connection, "operational_state").unwrap_or(0);
19649    // TC-33: schema step 23 RECREATES `canonical_edges` (no data migration), so
19650    // the edge rows that used to hold the high-water mark are gone. Without this
19651    // term the allocator can hand out a cursor a PREVIOUS edge already used —
19652    // and stale `_fathomdb_projection_terminal` / `_fathomdb_vector_rows` / vec0
19653    // rows still key on it, so a brand-new row would be treated as
19654    // already-projected and never get indexed. Step 23 stashes the pre-drop
19655    // maximum here; folding it in keeps cursors monotonic across the migration.
19656    let reserved = reserved_write_cursor(connection);
19657    nodes.max(edges).max(mutations).max(state).max(reserved)
19658}
19659
19660/// The write-cursor high-water mark reserved by schema step 23, or 0 when the
19661/// key is absent (fresh DB, or a DB that never had edges). Never fails the
19662/// caller: a missing/unparseable value degrades to 0, which is the pre-TC-33
19663/// behaviour.
19664fn reserved_write_cursor(connection: &Connection) -> u64 {
19665    connection
19666        .query_row(
19667            "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
19668            params![fathomdb_schema::RESERVED_WRITE_CURSOR_KEY],
19669            |row| row.get::<_, String>(0),
19670        )
19671        .ok()
19672        .and_then(|raw| raw.parse::<u64>().ok())
19673        .unwrap_or(0)
19674}
19675
19676fn max_cursor(connection: &Connection, table: &str) -> rusqlite::Result<u64> {
19677    let sql = format!("SELECT COALESCE(MAX(write_cursor), 0) FROM {table}");
19678    connection.query_row(&sql, [], |row| row.get::<_, u64>(0))
19679}
19680
19681/// Map a rusqlite error to its stable SQLite extended-code name.
19682///
19683/// Returns `None` for non-`SqliteFailure` variants (e.g. JSON conversion
19684/// failures, type mismatches at the rusqlite layer) — those are not
19685/// SQLite-internal events and should not be surfaced under
19686/// `EventSource::SqliteInternal`. The names returned here are the
19687/// canonical `SQLITE_*` symbol names from `sqlite3.h` and are stable
19688/// dispatch keys for AC-021 / AC-006 binding adapters.
19689///
19690/// Only the subset of codes the engine can reach in 0.6.0 is enumerated
19691/// — bare-extended-code matching covers the rest with a stable
19692/// `"SQLITE_UNKNOWN"` fallback so subscribers always see a typed code.
19693///
19694/// Diagnostic completeness for unmapped codes — **corrected 0.8.20 Slice 21a-2
19695/// (TC-57)**. This comment used to claim that when the helper returns
19696/// `"SQLITE_UNKNOWN"` the numeric extended code "is not lost — it remains on the
19697/// underlying `rusqlite::Error::SqliteFailure` carried in the engine error chain
19698/// that subscribers can inspect via `EngineError`'s `source()`". **That is
19699/// false.** There is no such chain: `EngineError::Storage` is a UNIT variant with
19700/// no payload and no `source()`, and `write_inner` drops the `rusqlite::Error`
19701/// immediately after emitting the lifecycle event. So for an unmapped code the
19702/// numeric value IS lost, and the only signal a host receives is the string
19703/// `"SQLITE_UNKNOWN"`.
19704///
19705/// Concretely: `SQLITE_BUSY_SNAPSHOT` (517) matches none of the PRIMARY constants
19706/// below — the match is on the EXTENDED value — so it reaches subscribers as
19707/// `"SQLITE_UNKNOWN"` and is unrecoverable from the public API. Restructuring the
19708/// error path so busy codes are distinguishable (and surfacing the numeric code as
19709/// a typed payload field) is candidate R2 of
19710/// `dev/design/0.8.20-tc57-write-race-characterization.md` §7, explicitly OUT of
19711/// scope for the 21a-2 fix and recorded here rather than silently carried.
19712fn sqlite_extended_code_name(err: &rusqlite::Error) -> Option<&'static str> {
19713    let sqlite_error = err.sqlite_error()?;
19714    let extended = sqlite_error.extended_code;
19715    Some(match extended {
19716        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
19717        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
19718        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
19719        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
19720        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
19721        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
19722        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
19723        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
19724        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
19725        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
19726        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
19727        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
19728        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
19729        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
19730        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
19731        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
19732        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
19733        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
19734        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
19735        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
19736        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
19737        _ => "SQLITE_UNKNOWN",
19738    })
19739}
19740
19741fn sqlite_extended_code_name_from_int(extended: i32) -> &'static str {
19742    match extended {
19743        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
19744        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
19745        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
19746        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
19747        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
19748        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
19749        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
19750        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
19751        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
19752        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
19753        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
19754        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
19755        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
19756        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
19757        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
19758        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
19759        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
19760        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
19761        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
19762        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
19763        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
19764        _ => "SQLITE_UNKNOWN",
19765    }
19766}
19767
19768fn map_open_sqlite_error(err: rusqlite::Error, stage: OpenStage) -> EngineOpenError {
19769    let Some(sqlite_error) = err.sqlite_error() else {
19770        return EngineOpenError::Io { message: "could not open database".to_string() };
19771    };
19772    match sqlite_error.extended_code {
19773        rusqlite::ffi::SQLITE_CORRUPT | rusqlite::ffi::SQLITE_NOTADB => {
19774            EngineOpenError::Corruption(CorruptionDetail {
19775                kind: match stage {
19776                    OpenStage::WalReplay => CorruptionKind::WalReplayFailure,
19777                    OpenStage::HeaderProbe => CorruptionKind::HeaderMalformed,
19778                    OpenStage::SchemaProbe => CorruptionKind::SchemaInconsistent,
19779                    OpenStage::EmbedderIdentity => CorruptionKind::EmbedderIdentityDrift,
19780                },
19781                stage,
19782                locator: CorruptionLocator::OpaqueSqliteError {
19783                    sqlite_extended_code: sqlite_error.extended_code,
19784                },
19785                recovery_hint: RecoveryHint {
19786                    code: match stage {
19787                        OpenStage::WalReplay => "E_CORRUPT_WAL_REPLAY",
19788                        OpenStage::HeaderProbe => "E_CORRUPT_HEADER",
19789                        OpenStage::SchemaProbe => "E_CORRUPT_SCHEMA",
19790                        OpenStage::EmbedderIdentity => "E_CORRUPT_EMBEDDER_IDENTITY",
19791                    },
19792                    doc_anchor: match stage {
19793                        OpenStage::WalReplay => "design/recovery.md#wal-replay-failures",
19794                        OpenStage::HeaderProbe => "design/recovery.md#header-malformed",
19795                        OpenStage::SchemaProbe => "design/recovery.md#schema-inconsistent",
19796                        OpenStage::EmbedderIdentity => "design/recovery.md#embedder-identity-drift",
19797                    },
19798                },
19799            })
19800        }
19801        _ => EngineOpenError::Io { message: "could not open database".to_string() },
19802    }
19803}
19804
19805fn emit_open_error_event(subscriber: &Arc<dyn lifecycle::Subscriber>, err: &EngineOpenError) {
19806    if let EngineOpenError::Corruption(detail) = err {
19807        let code = match detail.locator {
19808            CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
19809                Some(sqlite_extended_code_name_from_int(sqlite_extended_code))
19810            }
19811            _ => None,
19812        };
19813        let event = lifecycle::Event {
19814            phase: lifecycle::Phase::Failed,
19815            source: lifecycle::EventSource::SqliteInternal,
19816            category: lifecycle::EventCategory::Corruption,
19817            code,
19818        };
19819        subscriber.on_event(&event);
19820    }
19821}
19822
19823/// Install a `sqlite3_profile` callback on `connection` that dispatches
19824/// per-statement profile records and slow-statement signals to the
19825/// engine's subscriber registry.
19826///
19827/// Why FFI rather than `rusqlite::Connection::profile`: the safe API
19828/// (rusqlite 0.31) accepts only a `fn(&str, Duration)` with no
19829/// environment, so it cannot carry a per-engine subscriber-registry
19830/// pointer. We use `sqlite3_profile` directly with a leaked-into-`Box`
19831/// context whose pointer is tied to the engine's lifetime via
19832/// `Engine::profile_contexts`.
19833///
19834/// `sqlite3_profile` is documented as deprecated in favor of
19835/// `sqlite3_trace_v2`, but it remains supported and is sufficient for
19836/// the wall-clock + SQL-text payload required by AC-005a/b.
19837#[allow(clippy::vec_box)]
19838fn install_profile_callback(
19839    connection: &Connection,
19840    subscribers: &Arc<lifecycle::SubscriberRegistry>,
19841    profiling_enabled: &Arc<AtomicBool>,
19842    slow_threshold_ms: &Arc<AtomicU64>,
19843    contexts: &mut Vec<Box<ProfileContext>>,
19844) {
19845    let mut ctx = Box::new(ProfileContext {
19846        subscribers: Arc::clone(subscribers),
19847        profiling_enabled: Arc::clone(profiling_enabled),
19848        slow_threshold_ms: Arc::clone(slow_threshold_ms),
19849    });
19850    let ctx_ptr: *mut ProfileContext = &mut *ctx;
19851
19852    // SAFETY: the Box outlives the connection. Rust drops struct fields
19853    // in declaration order. `connection` and `reader_pool` are declared
19854    // before `profile_contexts`. `ReaderWorkerPool::Drop` joins every
19855    // reader worker, and each worker uninstalls and drops its owned
19856    // connection inside `reader_worker_loop` before the worker thread
19857    // returns. Therefore all connections — and SQLite's internal
19858    // profile-callback state with them — are torn down before the
19859    // `Box<ProfileContext>` allocations are freed. `Engine::close`
19860    // additionally clears the callback via
19861    // `sqlite3_profile(handle, None, NULL)` before connection close to
19862    // drain any in-flight callback dispatch.
19863    unsafe {
19864        rusqlite::ffi::sqlite3_profile(
19865            connection.handle(),
19866            Some(profile_callback_trampoline),
19867            ctx_ptr.cast::<std::ffi::c_void>(),
19868        );
19869    }
19870    contexts.push(ctx);
19871}
19872
19873/// Uninstall the profile callback so SQLite stops calling into our
19874/// freed `Box<ProfileContext>` pointer once a connection is being torn
19875/// down. Call before dropping `profile_contexts`.
19876fn uninstall_profile_callback(connection: &Connection) {
19877    // SAFETY: passing `None` as the callback unregisters the previous
19878    // callback; SQLite documents this as legal and idempotent.
19879    unsafe {
19880        rusqlite::ffi::sqlite3_profile(connection.handle(), None, std::ptr::null_mut());
19881    }
19882}
19883
19884/// Pack 6.G G.1 — configure SQLite per-connection lookaside on a reader
19885/// worker connection. Must be called BEFORE any statement is prepared
19886/// or any PRAGMA is run on `connection`; per the SQLite docs
19887/// (https://www.sqlite.org/malloc.html §3) lookaside is silently
19888/// ignored if reconfigured after the first allocation on the
19889/// connection. Passing `NULL` for the buffer pointer lets SQLite
19890/// allocate the lookaside backing memory itself.
19891///
19892/// rusqlite 0.31's `set_db_config` only handles the boolean
19893/// `DbConfig::*` variants; `SQLITE_DBCONFIG_LOOKASIDE` is not surfaced
19894/// (it is commented out in `rusqlite/src/config.rs`), so we call the
19895/// raw FFI directly.
19896///
19897/// Returns the rc of `sqlite3_db_config` so callers can debug-assert
19898/// `SQLITE_OK` and surface configuration failure under
19899/// `debug_assertions` test builds without expanding the public surface.
19900/// 0.7.0 perf-experiments hook: apply caller-supplied reader PRAGMAs
19901/// from the `FATHOMDB_PERF_READER_PRAGMAS` env var. Format:
19902/// comma-separated `name=value` pairs (e.g.
19903/// `cache_size=-262144,mmap_size=268435456,temp_store=MEMORY`).
19904///
19905/// **Gated on `FATHOMDB_PERF_EXPERIMENTS=1`.** No-op if the gate env
19906/// var is unset, so production paths are never affected. Failures to
19907/// apply individual PRAGMAs are logged to stderr (via `eprintln!`) but
19908/// do not error the connection open — experiments are best-effort,
19909/// not contract.
19910///
19911/// Scope: 0.7.0 perf-experiment campaign per
19912/// `dev/plans/0.7.0-perf-experiments.md`. Once Wave 5 picks the
19913/// landing combination, the chosen PRAGMAs are hardcoded as the new
19914/// reader-open default and this hook is removed.
19915/// 0.7.0 perf-experiments hook: apply writer-side PRAGMAs from
19916/// `FATHOMDB_PERF_WRITER_PRAGMAS` (same format as reader hook).
19917/// **Runs BEFORE migrations** so PRAGMAs like `page_size` that must
19918/// precede any table creation take effect on a fresh DB.
19919///
19920/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. No-op otherwise.
19921fn apply_perf_experiment_writer_pragmas(connection: &Connection) {
19922    if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
19923        return;
19924    }
19925    let raw = match std::env::var("FATHOMDB_PERF_WRITER_PRAGMAS") {
19926        Ok(s) if !s.is_empty() => s,
19927        _ => return,
19928    };
19929    for entry in raw.split(',') {
19930        let entry = entry.trim();
19931        if entry.is_empty() {
19932            continue;
19933        }
19934        let (name, value) = match entry.split_once('=') {
19935            Some((n, v)) => (n.trim(), v.trim()),
19936            None => {
19937                eprintln!("perf-experiment: bad writer pragma entry (expect name=value): {entry}");
19938                continue;
19939            }
19940        };
19941        if name.is_empty() {
19942            eprintln!("perf-experiment: empty pragma name in writer entry: {entry}");
19943            continue;
19944        }
19945        match connection.pragma_update(None, name, value) {
19946            Ok(()) => {
19947                eprintln!(
19948                    "perf-experiment: applied PRAGMA {name}={value} on writer (pre-migration)"
19949                );
19950            }
19951            Err(err) => {
19952                eprintln!("perf-experiment: writer PRAGMA {name}={value} failed: {err}");
19953            }
19954        }
19955    }
19956}
19957
19958fn apply_perf_experiment_reader_pragmas(connection: &Connection) {
19959    if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
19960        return;
19961    }
19962    let raw = match std::env::var("FATHOMDB_PERF_READER_PRAGMAS") {
19963        Ok(s) if !s.is_empty() => s,
19964        _ => return,
19965    };
19966    for entry in raw.split(',') {
19967        let entry = entry.trim();
19968        if entry.is_empty() {
19969            continue;
19970        }
19971        let (name, value) = match entry.split_once('=') {
19972            Some((n, v)) => (n.trim(), v.trim()),
19973            None => {
19974                eprintln!("perf-experiment: bad pragma entry (expect name=value): {entry}");
19975                continue;
19976            }
19977        };
19978        if name.is_empty() {
19979            eprintln!("perf-experiment: empty pragma name in entry: {entry}");
19980            continue;
19981        }
19982        match connection.pragma_update(None, name, value) {
19983            Ok(()) => {
19984                eprintln!("perf-experiment: applied PRAGMA {name}={value} on reader");
19985            }
19986            Err(err) => {
19987                eprintln!("perf-experiment: PRAGMA {name}={value} failed: {err}");
19988            }
19989        }
19990    }
19991}
19992
19993fn configure_reader_lookaside(connection: &Connection) -> std::os::raw::c_int {
19994    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
19995    // the lifetime of `connection`. The variadic
19996    // `sqlite3_db_config(LOOKASIDE)` call expects three trailing
19997    // arguments of types `void*`, `int`, `int` — the prototype shape
19998    // documented in `sqlite3.h`. We pass a null buffer so SQLite owns
19999    // the lookaside backing allocation, and the slot size / count from
20000    // the G.1 constants. No allocations happen on the connection
20001    // before this call (reader open path is `Connection::open` ->
20002    // `configure_reader_lookaside` -> first PRAGMA).
20003    unsafe {
20004        rusqlite::ffi::sqlite3_db_config(
20005            connection.handle(),
20006            rusqlite::ffi::SQLITE_DBCONFIG_LOOKASIDE,
20007            std::ptr::null_mut::<std::ffi::c_void>(),
20008            READER_LOOKASIDE_SLOT_SIZE,
20009            READER_LOOKASIDE_SLOT_COUNT,
20010        )
20011    }
20012}
20013
20014/// Read the high-water-mark for `SQLITE_DBSTATUS_LOOKASIDE_USED` on
20015/// `connection`. The `current` out-param is the live checked-out slot
20016/// count and decays as transactions finalize, so it is unreliable as
20017/// post-warmup evidence. The `hiwtr` out-param latches the largest
20018/// observed `current` value since the last reset and is the right
20019/// signal that lookaside was honored at any point on this connection.
20020/// Reset flag is `0` so reading does not clear the high-water mark.
20021#[cfg(debug_assertions)]
20022fn read_lookaside_used_hiwtr(connection: &Connection) -> std::os::raw::c_int {
20023    let mut current: std::os::raw::c_int = 0;
20024    let mut hiwtr: std::os::raw::c_int = 0;
20025    // SAFETY: handle is valid; both out pointers are to local stack
20026    // ints; reset flag 0 is documented as legal.
20027    unsafe {
20028        rusqlite::ffi::sqlite3_db_status(
20029            connection.handle(),
20030            rusqlite::ffi::SQLITE_DBSTATUS_LOOKASIDE_USED,
20031            &mut current,
20032            &mut hiwtr,
20033            0,
20034        );
20035    }
20036    hiwtr
20037}
20038
20039/// Pack 6.G G.3.5 — read the three page-cache pressure counters on
20040/// `connection`: `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and
20041/// `_CACHE_USED`. Returns `(hit, miss, used_bytes)`. Hit/miss are
20042/// monotonic counters (reset flag = 0 here); used_bytes is the live
20043/// page-cache memory footprint at call time. The caller is expected to
20044/// take pre/post snapshots and do delta arithmetic explicitly.
20045#[cfg(debug_assertions)]
20046fn read_cache_status(
20047    connection: &Connection,
20048) -> (std::os::raw::c_int, std::os::raw::c_int, std::os::raw::c_int) {
20049    let mut hit_current: std::os::raw::c_int = 0;
20050    let mut hit_hiwtr: std::os::raw::c_int = 0;
20051    let mut miss_current: std::os::raw::c_int = 0;
20052    let mut miss_hiwtr: std::os::raw::c_int = 0;
20053    let mut used_current: std::os::raw::c_int = 0;
20054    let mut used_hiwtr: std::os::raw::c_int = 0;
20055    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
20056    // the lifetime of `connection`. All out-pointers are to local stack
20057    // ints. Reset flag 0 is documented as legal (no counter is reset).
20058    unsafe {
20059        rusqlite::ffi::sqlite3_db_status(
20060            connection.handle(),
20061            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_HIT,
20062            &mut hit_current,
20063            &mut hit_hiwtr,
20064            0,
20065        );
20066        rusqlite::ffi::sqlite3_db_status(
20067            connection.handle(),
20068            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_MISS,
20069            &mut miss_current,
20070            &mut miss_hiwtr,
20071            0,
20072        );
20073        rusqlite::ffi::sqlite3_db_status(
20074            connection.handle(),
20075            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_USED,
20076            &mut used_current,
20077            &mut used_hiwtr,
20078            0,
20079        );
20080    }
20081    // CACHE_HIT / CACHE_MISS are monotonic counters reported in the
20082    // `current` out-param; CACHE_USED is the live byte count, also in
20083    // `current`. The hiwtr values are unused for this telemetry.
20084    (hit_current, miss_current, used_current)
20085}
20086
20087/// FFI trampoline for `sqlite3_profile`.
20088///
20089/// Invoked by SQLite at statement-finish with the SQL text and the
20090/// statement's wall-clock cost in nanoseconds. We dispatch a
20091/// `ProfileRecord` (when profiling is enabled) and a `SlowStatement`
20092/// signal (when `wall_clock_ms` exceeds the configured slow threshold).
20093///
20094/// Per `dev/design/lifecycle.md` § Public record shape, the public
20095/// payload exposes `wall_clock_ms`, `step_count`, and `cache_delta`.
20096/// `sqlite3_profile` does not surface per-statement step counts or
20097/// cache-hit deltas in its callback; we emit `0` for those fields and
20098/// document the hazard. AC-005b requires the fields be typed numeric,
20099/// not that they carry non-zero values for every backend.
20100unsafe extern "C" fn profile_callback_trampoline(
20101    user_data: *mut std::ffi::c_void,
20102    sql: *const std::os::raw::c_char,
20103    nanoseconds: u64,
20104) {
20105    if user_data.is_null() || sql.is_null() {
20106        return;
20107    }
20108    let ctx = unsafe { &*(user_data.cast::<ProfileContext>()) };
20109    let sql_text = match unsafe { std::ffi::CStr::from_ptr(sql) }.to_str() {
20110        Ok(s) => s,
20111        Err(_) => return,
20112    };
20113
20114    let wall_clock_ms = nanoseconds / 1_000_000;
20115
20116    if ctx.profiling_enabled.load(Ordering::Relaxed) {
20117        let record = lifecycle::ProfileRecord {
20118            wall_clock_ms,
20119            // step_count / cache_delta are not surfaced by
20120            // sqlite3_profile; placeholder 0 satisfies AC-005b's
20121            // "typed numeric" contract. A future profiling refactor
20122            // around sqlite3_stmt_status + sqlite3_db_status would
20123            // populate them with non-zero deltas.
20124            step_count: 0,
20125            cache_delta: 0,
20126        };
20127        ctx.subscribers.dispatch_profile(&record);
20128    }
20129
20130    let threshold = ctx.slow_threshold_ms.load(Ordering::Relaxed);
20131    if wall_clock_ms > threshold {
20132        let signal = lifecycle::SlowStatement { statement: sql_text.to_string(), wall_clock_ms };
20133        ctx.subscribers.dispatch_slow_statement(&signal);
20134    }
20135}
20136
20137#[cfg(test)]
20138mod tests {
20139    use super::{
20140        derive_stable_id, resolve_source_type, Engine, IdSpace, IdSpaceKind, PreparedWrite,
20141        KIND_TO_SOURCE_TYPE_CASE_SQL, ROW_OWNED_PROJECTIONS,
20142    };
20143    use rusqlite::Connection;
20144    use tempfile::TempDir;
20145
20146    /// 0.8.20 Slice 5a (R-20-E1, work item 2) — the registry GUARD.
20147    ///
20148    /// Introspects `sqlite_master` on a freshly migrated database and asserts
20149    /// that EVERY `write_cursor`-keyed table is accounted for: either it is a
20150    /// registered row-owned projection, or it is one of the explicitly named
20151    /// canonical / operational tables that are sources of truth, not shadows.
20152    /// A future projection table therefore cannot be added without either
20153    /// registering it in [`ROW_OWNED_PROJECTIONS`] (making it erasable at every
20154    /// maintenance site at once) or consciously failing this test.
20155    ///
20156    /// **`_fathomdb_projection_state` is allowlisted as KIND-owned** (design v5
20157    /// §1.1): it is keyed by `kind`, not by `write_cursor`, and holds a per-kind
20158    /// enqueue watermark. Erasing one row must not rewind a whole kind's
20159    /// watermark, so it must NEVER be deleted per-cursor. The test asserts both
20160    /// halves of that claim — that it carries no `write_cursor` column, and that
20161    /// it is absent from the row-owned registry.
20162    #[test]
20163    fn guard_row_owned_registry() {
20164        /// Canonical + operational tables: `write_cursor`-carrying SOURCES OF
20165        /// TRUTH, never row-owned projections of another row.
20166        const NON_PROJECTION_CURSOR_TABLES: &[&str] =
20167            &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
20168
20169        let dir = TempDir::new().unwrap();
20170        let path = dir.path().join("registry_guard.fathomdb");
20171        Engine::open(&path).expect("open").engine.close().expect("close");
20172        let conn = Connection::open(&path).expect("open sqlite");
20173
20174        let table_names: Vec<String> = conn
20175            .prepare(
20176                "SELECT name FROM sqlite_master
20177                 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
20178            )
20179            .expect("prepare")
20180            .query_map([], |row| row.get::<_, String>(0))
20181            .expect("query")
20182            .collect::<rusqlite::Result<Vec<_>>>()
20183            .expect("collect");
20184        assert!(table_names.len() > 5, "sqlite_master introspection returned nothing useful");
20185
20186        let has_write_cursor = |table: &str| -> bool {
20187            conn.prepare(&format!("PRAGMA table_info({table})"))
20188                .and_then(|mut stmt| {
20189                    let names = stmt
20190                        .query_map([], |row| row.get::<_, String>(1))?
20191                        .collect::<rusqlite::Result<Vec<_>>>()?;
20192                    Ok(names.iter().any(|n| n == "write_cursor"))
20193                })
20194                .unwrap_or(false)
20195        };
20196
20197        let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
20198
20199        // (1) Every write_cursor-keyed table is registered or explicitly excused.
20200        for table in &table_names {
20201            if !has_write_cursor(table) {
20202                continue;
20203            }
20204            assert!(
20205                registered.contains(&table.as_str())
20206                    || NON_PROJECTION_CURSOR_TABLES.contains(&table.as_str()),
20207                "table `{table}` is keyed by write_cursor but is neither registered in \
20208                 ROW_OWNED_PROJECTIONS nor listed as a non-projection source of truth. \
20209                 If it is a projection, register it — otherwise erasure will leave its \
20210                 rows on disk (the `search_index_v2` defect)."
20211            );
20212        }
20213
20214        // (2) Every registered projection actually exists and is erasable by its
20215        //     declared cursor column (vec0's `rowid` included).
20216        for projection in ROW_OWNED_PROJECTIONS {
20217            assert!(
20218                table_names.iter().any(|t| t == projection.table),
20219                "registered projection `{}` does not exist in the schema",
20220                projection.table
20221            );
20222            conn.query_row(
20223                &format!(
20224                    "SELECT COUNT(*) FROM {} WHERE {} = 0",
20225                    projection.table, projection.cursor_column
20226                ),
20227                [],
20228                |row| row.get::<_, u64>(0),
20229            )
20230            .unwrap_or_else(|err| {
20231                panic!(
20232                    "registered projection `{}` is not erasable by `{}`: {err}",
20233                    projection.table, projection.cursor_column
20234                )
20235            });
20236        }
20237
20238        // (3) `_fathomdb_projection_state` is KIND-owned, not row-owned.
20239        assert!(
20240            !has_write_cursor("_fathomdb_projection_state"),
20241            "_fathomdb_projection_state gained a write_cursor column — re-decide its ownership \
20242             class before treating it as kind-owned"
20243        );
20244        assert!(
20245            !registered.contains(&"_fathomdb_projection_state"),
20246            "_fathomdb_projection_state is KIND-owned (per-kind enqueue watermark) and must \
20247             never be deleted per-cursor: erasing one row would rewind a whole kind's watermark"
20248        );
20249    }
20250
20251    /// 0.8.20 Slice 15d (R-20-EAV) — PROVE THE GUARD BITES. The two net-new
20252    /// content-storing projection tables (`canonical_attributes`,
20253    /// `property_search_index`) are `write_cursor`-keyed and hold attribute
20254    /// values at rest. This test asserts (1) they ARE registered in
20255    /// `ROW_OWNED_PROJECTIONS` (so `erase_row_projections` reaches them), and (2)
20256    /// the guard's core predicate — "registered OR a named source of truth" —
20257    /// FAILS for either table if it is (hypothetically) removed from the
20258    /// registry. This is what makes forgetting to register a future
20259    /// content-storing projection a red test, not a silent erasure leak.
20260    #[test]
20261    fn slice15d_attribute_projections_registered_and_guard_bites() {
20262        const NON_PROJECTION_CURSOR_TABLES: &[&str] =
20263            &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
20264
20265        let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
20266
20267        // (1) Both new content-storing projections are registered as row-owned.
20268        for table in ["canonical_attributes", "property_search_index"] {
20269            assert!(
20270                registered.contains(&table),
20271                "{table} holds attribute values at rest and MUST be in ROW_OWNED_PROJECTIONS \
20272                 so purge/excise_source reach it"
20273            );
20274        }
20275
20276        // (2) The guard predicate BITES: pretend one of them was never
20277        //     registered — the guard's "registered OR source-of-truth" check must
20278        //     reject it (the exact assertion `guard_row_owned_registry` runs).
20279        for hidden in ["canonical_attributes", "property_search_index"] {
20280            let as_if_unregistered: Vec<&str> =
20281                registered.iter().copied().filter(|t| *t != hidden).collect();
20282            let accepted = as_if_unregistered.contains(&hidden)
20283                || NON_PROJECTION_CURSOR_TABLES.contains(&hidden);
20284            assert!(
20285                !accepted,
20286                "if {hidden} were unregistered the guard would still (incorrectly) accept it — \
20287                 the guard does not actually bite"
20288            );
20289        }
20290    }
20291
20292    /// Cause-A (0.8.11.2) / C-2 (0.8.19) — `derive_stable_id` id-space contract:
20293    /// a present `logical_id` yields a `Logical` (`"l:"`) [`IdSpace`]; a NULL or
20294    /// empty `logical_id` falls back to a deterministic `Content` (`"h:"`) sha256
20295    /// content-hash of the body. The typed spaces are prefix-distinguishable and
20296    /// the value is behaviour-neutral (never used in ranking). Post-C-2 the helper
20297    /// returns a typed [`IdSpace`] whose `to_prefixed()` reproduces the pre-swap
20298    /// string byte-for-byte (eu7 no-op basis).
20299    #[test]
20300    fn derive_stable_id_id_space_contract() {
20301        // logical_id present → Logical space, body-independent.
20302        assert_eq!(derive_stable_id(Some("alice-1"), "any body"), IdSpace::logical("alice-1"));
20303        assert_eq!(
20304            derive_stable_id(Some("alice-1"), "a different body"),
20305            IdSpace::logical("alice-1")
20306        );
20307        // Byte-identical prefixed form to the pre-C-2 `stable_id` string.
20308        assert_eq!(derive_stable_id(Some("alice-1"), "any body").to_prefixed(), "l:alice-1");
20309
20310        // NULL logical_id → Content space, deterministic on body.
20311        let h1 = derive_stable_id(None, "stable body text");
20312        let h2 = derive_stable_id(None, "stable body text");
20313        assert_eq!(h1, h2, "content-hash is deterministic");
20314        assert_eq!(h1.space, IdSpaceKind::Content);
20315        let h1s = h1.to_prefixed();
20316        assert!(h1s.starts_with("h:"));
20317        assert_eq!(h1s.len(), 2 + 64, "h: + sha256 hex");
20318        assert!(h1s["h:".len()..].chars().all(|c| c.is_ascii_hexdigit()));
20319
20320        // Empty logical_id is treated as absent (falls back to content-hash).
20321        assert_eq!(derive_stable_id(Some(""), "stable body text"), h1);
20322
20323        // Distinct bodies → distinct content-hashes (no collision).
20324        assert_ne!(derive_stable_id(None, "body A"), derive_stable_id(None, "body B"));
20325    }
20326
20327    /// C-2 (0.8.19 / TC-8) — [`IdSpace`] parse/format round-trip is stable across
20328    /// all three spaces, including a value that itself contains `":"`.
20329    #[test]
20330    fn id_space_parse_format_round_trip() {
20331        let cases = [
20332            IdSpace::logical("alice-1"),
20333            IdSpace::content("a".repeat(64)),
20334            IdSpace::passage("7"),
20335            IdSpace::logical("l:weird:value"), // value contains the delimiter
20336        ];
20337        for id in cases {
20338            assert_eq!(IdSpace::parse(&id.to_prefixed()), Some(id.clone()), "round-trip {id:?}");
20339        }
20340        assert_eq!(IdSpace::logical("x").to_prefixed(), "l:x");
20341        assert_eq!(IdSpace::content("y").to_prefixed(), "h:y");
20342        assert_eq!(IdSpace::passage("3").to_prefixed(), "p:3");
20343        assert_eq!(IdSpace::parse("untagged"), None);
20344    }
20345
20346    // Pack 1 drift-detection: the Rust helper used by the two writer
20347    // sites must agree with the CASE WHEN used by the Pack 1 reshape
20348    // migration in `migrate_vector_partition_to_pack1`. The CASE SQL
20349    // is exported as `KIND_TO_SOURCE_TYPE_CASE_SQL`; this test
20350    // exercises it against an in-memory SQLite (no sqlite-vec extension
20351    // required — only the CASE) and asserts byte-equal output with the
20352    // Rust helper for every kind in the locked Pack 1 vocabulary
20353    // (incl. the synthetic `doc` -> `article` coercion). See
20354    // `dev/design/0.7.0-vector-quant-pack1.md` D3 / D4.
20355    #[test]
20356    fn resolve_source_type_drift_check() {
20357        let kinds = ["email", "article", "paper", "meeting", "note", "todo", "doc"];
20358
20359        // 1. Rust helper return values (table is the contract: changes
20360        //    here must be reflected in the SQL CASE or this test fails).
20361        let want: &[(&str, &str)] = &[
20362            ("email", "email"),
20363            ("article", "article"),
20364            ("paper", "paper"),
20365            ("meeting", "meeting"),
20366            ("note", "note"),
20367            ("todo", "todo"),
20368            ("doc", "article"),
20369        ];
20370        for (kind, expected) in want {
20371            let got = resolve_source_type(kind).unwrap_or_else(|_| {
20372                panic!("resolve_source_type({kind}) returned Err; want Ok({expected})")
20373            });
20374            assert_eq!(got, *expected, "Rust helper drift for kind={kind}");
20375        }
20376        assert!(
20377            resolve_source_type("banana").is_err(),
20378            "unknown kind must surface as writer error"
20379        );
20380
20381        // 2. SQL CASE evaluated against the same kinds. Build a
20382        //    one-row staging row per kind and SELECT through
20383        //    KIND_TO_SOURCE_TYPE_CASE_SQL; assert each row equals the
20384        //    Rust helper's output. Drift in either direction fails.
20385        let conn = Connection::open_in_memory().expect("in-memory sqlite");
20386        conn.execute_batch("CREATE TABLE s(kind TEXT NOT NULL)").expect("create s");
20387        for kind in &kinds {
20388            conn.execute("INSERT INTO s(kind) VALUES (?1)", [kind]).expect("insert kind");
20389        }
20390        let sql = format!("SELECT s.kind, {KIND_TO_SOURCE_TYPE_CASE_SQL} FROM s");
20391        let mut stmt = conn.prepare(&sql).expect("prepare CASE");
20392        let rows: Vec<(String, String)> = stmt
20393            .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
20394            .expect("query")
20395            .map(|r| r.expect("row"))
20396            .collect();
20397        assert_eq!(rows.len(), kinds.len(), "row count drift");
20398        for (kind, sql_result) in &rows {
20399            let rust_result = resolve_source_type(kind).expect("known kind");
20400            assert_eq!(
20401                sql_result, rust_result,
20402                "SQL CASE vs Rust helper drift for kind={kind}: SQL={sql_result}, Rust={rust_result}"
20403            );
20404        }
20405    }
20406
20407    #[test]
20408    fn write_advances_cursor() {
20409        let dir = TempDir::new().unwrap();
20410        let opened = Engine::open(dir.path().join("rewrite.sqlite")).expect("engine should open");
20411        let receipt = opened
20412            .engine
20413            .write(&[PreparedWrite::Node {
20414                kind: "doc".to_string(),
20415                body: "hello".to_string(),
20416                source_id: crate::SourceId::new("test:fixture").expect("test source id"),
20417                logical_id: None,
20418                state: crate::InitialState::Active,
20419                reason: None,
20420                valid_from: None,
20421                valid_until: None,
20422            }])
20423            .expect("write should succeed");
20424
20425        assert_eq!(receipt.cursor, 1);
20426    }
20427}