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    /// Test-only vector-candidate fanout override for the search hot path.
548    /// It defaults to `SEARCH_RERANK_LIMIT` (10); the test seam may raise it
549    /// so recall tests can inspect deeper vector candidates. It never changes
550    /// the caller-requested final result limit or visible result cardinality.
551    search_limit_override: AtomicUsize,
552    /// Slice 10 / G12-recency — dedicated recency-reweight flag, **off by
553    /// default** (NOT `fusion_mode`). When set, fused hits are reweighted toward
554    /// the more recent `write_cursor` AFTER bit-KNN. Flipped by the
555    /// `set_recency_reweight_enabled_for_test` seam; no production toggle yet.
556    recency_reweight_enabled: AtomicBool,
557    /// 0.8.16 Slice 5 / F9 — dedicated importance/confidence reweight flag,
558    /// **off by default** (mirrors `recency_reweight_enabled`; NOT `fusion_mode`).
559    /// When set, fused hits are multiplicatively reweighted by node `importance`
560    /// (`canonical_nodes.importance`) and edge `confidence`
561    /// (`canonical_edges.confidence`) AFTER bit-KNN + RRF fusion — `NULL ⇒ neutral
562    /// (1.0)`. Flipped by `set_importance_reweight_enabled_for_test`; no production
563    /// toggle yet (F9 ships OFF-by-default as a MECHANISM, no eval-quality claim).
564    importance_reweight_enabled: AtomicBool,
565    /// GA-2 / Slice-40 (◆ B-1) measurement seam, **off by default**. When set,
566    /// `read_search_in_tx` returns the pre-fusion VECTOR-branch ranking
567    /// (bit-KNN K=192 + f32 rerank) verbatim — the ANN-quantization fidelity
568    /// signal — INSTEAD of the unconditional RRF-fused result. This changes
569    /// nothing for any production caller (the flag is never set outside the
570    /// `eu7` recall harness via `set_vector_stage_only_for_test`); it does NOT
571    /// reintroduce a `fusion_mode` knob (RRF stays unconditional) and does NOT
572    /// alter `fuse_rrf` / `rerank_fused` / recency. It only lets the AC-075
573    /// recall gate measure ANN+ vector top-10 vs the exact-f32 VECTOR top-10
574    /// ground truth in isolation (the quantization-FIDELITY axis the 0.90 floor
575    /// is defined to measure), not the hybrid `search()` output.
576    vector_stage_only_for_test: AtomicBool,
577    /// 0.7.2 PR-2b — debug-only fault injection: when set, `recompute_mean_in_tx`
578    /// errors AFTER writing `mean_vec` but BEFORE finishing the re-quantize
579    /// pass, so the crash-atomicity test can prove the whole recompute rolls
580    /// back (no half-recentered corpus). One-shot (cleared on consume).
581    #[cfg(debug_assertions)]
582    force_recompute_failure: AtomicBool,
583    /// TC-91 — one-shot worker-commit fault seam. `0` is disabled, `1`
584    /// requests a synthetic SQLite busy error, and `2` a rusqlite-layer
585    /// storage error immediately before commit.
586    /// Kept entirely in the runtime and compiled only for tests.
587    #[cfg(debug_assertions)]
588    force_projection_commit_failure: AtomicUsize,
589    /// TC-91 test-only rendezvous after error reporting and before worker
590    /// cleanup. It proves a stop in that window leaves canonical pending work
591    /// for the next open rather than relying on an in-memory retry queue.
592    #[cfg(debug_assertions)]
593    projection_commit_failure_pause: Mutex<Option<(Arc<Barrier>, Arc<Barrier>)>>,
594    /// TC-91 test-only acknowledgement after `stopping` is set and before a
595    /// close joins workers, used with `projection_commit_failure_pause`.
596    #[cfg(debug_assertions)]
597    projection_stop_ack: Mutex<Option<Arc<Barrier>>>,
598}
599
600impl std::fmt::Debug for ProjectionRuntimeShared {
601    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
602        f.debug_struct("ProjectionRuntimeShared")
603            .field("path", &self.path)
604            .field("embedder_identity", &self.embedder_identity)
605            .finish_non_exhaustive()
606    }
607}
608
609#[derive(Debug)]
610struct ProjectionRuntime {
611    shared: Arc<ProjectionRuntimeShared>,
612    dispatcher: Mutex<Option<JoinHandle<()>>>,
613    workers: Mutex<Vec<JoinHandle<()>>>,
614}
615
616impl std::fmt::Debug for Engine {
617    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
618        f.debug_struct("Engine")
619            .field("path", &self.path)
620            .field("closed", &self.closed.load(Ordering::SeqCst))
621            .field("runtime_embedder_identity", &self.runtime_embedder_identity)
622            .finish_non_exhaustive()
623    }
624}
625
626/// Per-connection profile-callback context.
627///
628/// Holds the registry handle the callback dispatches to, plus shared
629/// references to the engine's profiling toggle and slow-statement
630/// threshold. The `Arc` clones here mirror the same atomics held by
631/// `Engine`, so `set_profiling` / `set_slow_threshold_ms` mutations are
632/// visible inside the callback without restart (REQ-006a / AC-005a /
633/// AC-007b runtime-toggle contract).
634#[derive(Debug)]
635struct ProfileContext {
636    subscribers: Arc<lifecycle::SubscriberRegistry>,
637    profiling_enabled: Arc<AtomicBool>,
638    slow_threshold_ms: Arc<AtomicU64>,
639}
640
641/// Thread-affine reader worker pool (Pack 6 F.0).
642///
643/// Per `dev/design/engine.md` § Writer / reader split, reader connections
644/// must not serialize behind a single mutex. Each worker thread owns
645/// exactly one read-only `Connection` for its lifetime; `Connection`
646/// objects never cross thread boundaries after startup. `Engine::search`
647/// dispatches a request via a per-worker bounded channel using a
648/// lock-free round-robin counter on the hot path.
649struct ReaderWorkerPool {
650    senders: Vec<SyncSender<ReaderRequest>>,
651    handles: Mutex<Option<Vec<JoinHandle<()>>>>,
652    next: AtomicUsize,
653    shutdown: AtomicBool,
654    live_workers: Arc<AtomicUsize>,
655}
656
657impl std::fmt::Debug for ReaderWorkerPool {
658    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
659        f.debug_struct("ReaderWorkerPool")
660            .field("worker_count", &self.senders.len())
661            .field("live_workers", &self.live_workers.load(Ordering::Relaxed))
662            .field("shutdown", &self.shutdown.load(Ordering::Relaxed))
663            .finish()
664    }
665}
666
667/// One request handled by exactly one reader worker. The response is
668/// returned through a fresh oneshot channel so requests cannot be
669/// routed to or duplicated across workers.
670enum ReaderRequest {
671    /// Slice 60 — property-FTS search stays on a reader-owned connection, never
672    /// the writer connection. It shares the snapshot-local filter validation of
673    /// the hybrid search path.
674    SearchProjectedText {
675        query: String,
676        name: String,
677        filter: Option<Box<SearchFilter>>,
678        limit: usize,
679        view: ReadView,
680        respond: SyncSender<ProjectedTextReaderResponse>,
681    },
682    Search {
683        compiled: fathomdb_query::CompiledQuery,
684        /// Un-centered f32 query vector serialized for `vec_f32`. Phase 2
685        /// f32 rerank uses this verbatim.
686        query_vector: Option<String>,
687        /// EU-5a2 — (possibly centered) f32 query vector for the phase 1
688        /// `vec_quantize_binary` sign-quant. Equal to `query_vector` for
689        /// non-MC-required identities (the EU-5a2 default).
690        query_vector_bin: Option<String>,
691        /// Public limit applied after ranking and filtering.
692        result_limit: usize,
693        /// Vector candidate fanout. The private test seam may raise this above
694        /// `result_limit`, but never changes caller-visible cardinality.
695        candidate_limit: usize,
696        /// `Some` only for the explicit direct text-only public API. Its fixed
697        /// bound keeps the node FTS input independent of the caller's final
698        /// result limit before node/edge body deduplication and ranking.
699        direct_text_candidate_limit: Option<usize>,
700        /// G10 — optional closed metadata filter (`None` = unfiltered, the
701        /// byte-identical-to-0.7.2 path). Applied in the phase-1 candidates
702        /// statement (vector branch) and as a Rust post-filter (text branch).
703        /// Boxed so the `ReaderRequest::Search` variant stays small (the request
704        /// rides a `Result<(), ReaderRequest>` retry channel).
705        filter: Option<Box<SearchFilter>>,
706        /// G12-recency — whether the dedicated recency reweight is enabled for
707        /// this request (read from `recency_reweight_enabled`, off by default).
708        recency_enabled: bool,
709        /// F9 (0.8.16 Slice 5) — whether the dedicated importance/confidence
710        /// reweight is enabled for this request (read from
711        /// `importance_reweight_enabled`, off by default).
712        importance_enabled: bool,
713        /// GA-2 / Slice-40 (◆ B-1) measurement seam — when true the worker
714        /// returns the pre-fusion vector-branch ranking instead of the fused
715        /// result (read from `vector_stage_only_for_test`, off by default).
716        vector_stage_only: bool,
717        /// 0.8.1 Slice 10 (R1) — raw query text for the CE reranker. Passed
718        /// from `search_inner` to `read_search_in_tx` → `rerank_fused`.
719        /// FIX-4: `Box<str>` (16 bytes) instead of `String` (24 bytes) to keep
720        /// the Search variant smaller (mirroring the boxed `filter` field).
721        raw_query: Box<str>,
722        /// 0.8.1 Slice 10 (R1) — per-request rerank depth (snapshot of
723        /// `ProjectionRuntimeShared::rerank_depth`). `0` = identity path.
724        rerank_depth: usize,
725        /// 0.8.1 Slice 30 (R3) — when `true`, run the graph-BFS arm (seeded
726        /// from top-10 fused hits, depth ≤ 3, cap 50, temporal filter) and
727        /// fuse its candidates into the final ranking via `fuse_three_arms`.
728        /// When `false` (the default), the graph arm pool is `vec![]` and
729        /// results are byte-identical to the pre-Slice-30 two-arm pipeline.
730        use_graph_arm: bool,
731        /// 0.8.5 (EXP-0) — CE-blend weight (clamped to `[0,1]` in `ce_rerank`).
732        /// `0.3` is the byte-identical default; `1.0` is the measured-parity config.
733        alpha: f64,
734        /// 0.8.5 (EXP-0) — reranked-pool size (clamped to the hit count). The
735        /// binding resolves `pool_n.unwrap_or(rerank_depth)` before dispatch.
736        pool_n: usize,
737        /// 0.8.8 EXP-OBS (Slice 5) — when `true`, capture per-arm ranks + the
738        /// fused/CE score breakdown + query trace into a `SearchResult`
739        /// `Explanation` sidecar. `false` (the default for `search`/`search_filtered`/
740        /// `search_reranked`) does ZERO extra work and returns `explanation = None`
741        /// (R-OBS-2 zero-cost; byte-identical `results`).
742        explain: bool,
743        /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the VALIDITY view the
744        /// node-hydration SELECTs filter by. `ReadView::default()` reproduces
745        /// the pre-fix predicate on any corpus that never authored a window
746        /// (step 22 back-filled NULL/NULL with no DEFAULT, and `validity_sql`
747        /// treats NULL as unbounded ⇒ the conjunct is a provable no-op there).
748        /// The existence axis is refused upstream, never carried here.
749        view: ReadView,
750        respond: SyncSender<ReaderResponse>,
751    },
752    /// Slice 30 (G2) — active-only point lookup by `logical_id`. Returns one
753    /// slot per requested id, in request order, `None` where no active row
754    /// carries that id. Its own typed `respond` channel keeps the `Search`
755    /// `ReaderResponse` byte-identical (no Search regression).
756    GetById {
757        logical_ids: Vec<String>,
758        /// R-20-RV — the read view this lookup runs under. `ReadView::default()`
759        /// is the strict (pre-slice) view.
760        view: ReadView,
761        respond: SyncSender<rusqlite::Result<Vec<Option<NodeRecord>>>>,
762    },
763    /// Slice 30 (G3) — paginated op-store read-back over `operational_mutations`
764    /// for a `collection`, `ORDER BY id`, with a MANDATORY (already-clamped)
765    /// limit + optional after-id cursor.
766    ReadCollection {
767        collection: String,
768        after_id: Option<i64>,
769        limit: usize,
770        respond: SyncSender<rusqlite::Result<Vec<OpStoreRow>>>,
771    },
772    /// Slice 35 (G4) — list active canonical nodes of a `kind`, filtered by
773    /// zero or more `Predicate`s (AND-combined), up to `limit` rows.
774    /// Path validation already happened at `Predicate` construction time;
775    /// the worker only compiles + executes parameterized SQL.
776    ReadList {
777        kind: String,
778        predicates: Vec<Predicate>,
779        limit: usize,
780        /// R-20-RV — the read view this listing runs under.
781        view: ReadView,
782        respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
783    },
784    /// Slice 20 (G5) — bounded BFS from a single root node over
785    /// `canonical_edges`. Returns the set of reachable nodes (excluding the
786    /// root) within `depth` hops, limited to the hard cap 50.
787    GraphNeighbors {
788        root_logical_id: String,
789        depth: u32,
790        direction: TraversalDirection,
791        /// R-20-RV — the read view applied at EVERY node position of the BFS
792        /// CTE (anchor, recursive join, final projection), for every direction.
793        view: ReadView,
794        respond: SyncSender<rusqlite::Result<Vec<NodeRecord>>>,
795    },
796    /// 0.8.20 Slice 10b (R-20-NV) — nodes that crossed a validity boundary in
797    /// `(since, view-instant]`.
798    CrossedBoundarySince {
799        since: i64,
800        view: ReadView,
801        respond: SyncSender<rusqlite::Result<Vec<BoundaryCrossing>>>,
802    },
803    /// Slice 20 (G6) — compose the previous search result with BFS expansion.
804    /// Resolves search hit `write_cursor`s to `logical_id`s, runs G5 traversal
805    /// for each root, deduplicates, and returns a `SearchExpandResult`.
806    SearchExpand {
807        search_hits: Vec<SearchHit>,
808        depth: u32,
809        respond: SyncSender<rusqlite::Result<SearchExpandResult>>,
810    },
811    /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL for
812    /// the given root/depth/direction and return the plan detail lines.
813    #[doc(hidden)]
814    ExplainGraphNeighbors {
815        root_logical_id: String,
816        depth: u32,
817        direction: TraversalDirection,
818        respond: SyncSender<rusqlite::Result<Vec<String>>>,
819    },
820    Shutdown,
821    /// Pack 6.G G.1 — debug-only request that asks a worker to read its
822    /// own connection's `SQLITE_DBSTATUS_LOOKASIDE_USED` and return the
823    /// high-water mark (`hiwtr` out-param). Used solely by the integration
824    /// test that asserts post-warmup lookaside slots were consumed; not
825    /// on any production path.
826    #[cfg(debug_assertions)]
827    LookasideStatus {
828        respond: SyncSender<i32>,
829    },
830    /// Pack 6.G G.3.5 — debug-only request that asks a worker to read
831    /// `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and `_CACHE_USED`
832    /// off its own connection and return them as `(hit, miss, used_bytes)`.
833    /// `snapshot_label` is opaque to the worker; the caller uses it to
834    /// distinguish pre/post snapshots in its own bookkeeping.
835    #[cfg(debug_assertions)]
836    CacheStatus {
837        snapshot_label: String,
838        respond: SyncSender<(String, i32, i32, i32)>,
839    },
840    /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — debug-only request
841    /// that asks a worker to read its own connection's `PRAGMA secure_delete`
842    /// and return it (`0`/`1`). Used solely by the gap-4 test that asserts the
843    /// standing secure_delete flag is ON at EVERY open, not just the writer.
844    #[cfg(debug_assertions)]
845    SecureDeleteStatus {
846        respond: SyncSender<i64>,
847    },
848}
849
850// G0 Phase-2: the Search response carries a 4th element — the graph-arm frontier
851// meter (`GraphFrontierStats`). It rides the internal channel but is dropped before
852// `SearchResult` is built (kept OFF the governed surface); the
853// `_graph_frontier_stats_for_test` seam captures it. Default (all-zero) on non-graph paths.
854// 0.8.8 EXP-OBS (Slice 5): the Search response carries a 5th element — the opt-in
855// retrieval `Explanation` (`None` on every default `explain=false` path; `Some`
856// only on the `search_explained` path). Like the `GraphFrontierStats` 4th element
857// it rides the internal channel as a side-channel; unlike it, the explanation IS
858// surfaced (onto `SearchResult.explanation`) when requested.
859type ReaderResponse = Result<
860    (u64, Option<SoftFallback>, Vec<SearchHit>, GraphFrontierStats, Option<Explanation>),
861    SearchReaderError,
862>;
863
864type ProjectedTextReaderResponse = Result<SearchResult, SearchReaderError>;
865
866/// 0.8.20 keystone closeout fix-3 (codex §9 [P2], TOCTOU) — the error a Search
867/// reader worker can return. Two arms:
868///   * `Sqlite` — a backend/storage failure (the pre-fix-3 `rusqlite::Result`
869///     behaviour verbatim; the caller emits the internal-error event and maps to
870///     `EngineError::Storage`);
871///   * `InvalidFilter` — a filter naming an UNDECLARED `filterable` attribute,
872///     detected on the reader's OWN transaction snapshot (see
873///     [`validate_filter_attributes_on_snapshot`]). The caller re-raises it as the
874///     EXISTING `EngineError::InvalidFilter { reason }` typed variant.
875///
876/// Why a channel-carried variant and not a pre-dispatch check on the writer
877/// connection: fix-2 validated on `self.connection` BEFORE dispatch, then the
878/// reader prepared the vec0 query on a DIFFERENT connection/snapshot. A
879/// `configure_projections` DROP landing in that window let the vec0 `attr_<hex>`
880/// column vanish AFTER validation passed → an opaque `no such column` `Storage`
881/// error (the exact untyped failure fix-2 meant to prevent). Validating INSIDE
882/// the reader transaction that also compiles+executes the search binds the check
883/// and the query to ONE snapshot, closing the race; carrying the typed reason
884/// back through this variant keeps the outcome `InvalidFilter`, never `Storage`.
885enum SearchReaderError {
886    Sqlite(rusqlite::Error),
887    InvalidFilter(String),
888}
889
890impl From<rusqlite::Error> for SearchReaderError {
891    fn from(err: rusqlite::Error) -> Self {
892        SearchReaderError::Sqlite(err)
893    }
894}
895
896/// Pack 6.G G.3.5 — per-worker cache-pressure snapshot. Carried only on
897/// the debug-only `CacheStatus` broadcast path and the test accessor;
898/// not part of the public 0.6.0 surface.
899#[cfg(debug_assertions)]
900#[doc(hidden)]
901#[derive(Clone, Debug)]
902pub struct CacheStatusReply {
903    pub worker_idx: usize,
904    pub snapshot_label: String,
905    pub cache_hit: i32,
906    pub cache_miss: i32,
907    pub cache_used_bytes: i32,
908}
909
910/// Per-worker outbound channel capacity. Round-robin dispatch keeps
911/// queue depth at ~0 on hot paths; the small slack absorbs jitter
912/// without a runtime mutex.
913const READER_WORKER_CHANNEL_CAPACITY: usize = 4;
914
915impl ReaderWorkerPool {
916    fn new(connections: Vec<Connection>) -> Self {
917        let live_workers = Arc::new(AtomicUsize::new(0));
918        let mut senders = Vec::with_capacity(connections.len());
919        let mut handles = Vec::with_capacity(connections.len());
920        for (idx, connection) in connections.into_iter().enumerate() {
921            let (tx, rx) = mpsc::sync_channel::<ReaderRequest>(READER_WORKER_CHANNEL_CAPACITY);
922            let live = Arc::clone(&live_workers);
923            let handle = thread::Builder::new()
924                .name(format!("fathomdb-reader-{idx}"))
925                .spawn(move || reader_worker_loop(connection, rx, live))
926                .expect("spawn reader worker");
927            senders.push(tx);
928            handles.push(handle);
929        }
930        Self {
931            senders,
932            handles: Mutex::new(Some(handles)),
933            next: AtomicUsize::new(0),
934            shutdown: AtomicBool::new(false),
935            live_workers,
936        }
937    }
938
939    fn worker_count(&self) -> usize {
940        self.senders.len()
941    }
942
943    fn live_count(&self) -> usize {
944        self.live_workers.load(Ordering::SeqCst)
945    }
946
947    /// Pack 6.G G.1 — broadcast a `LookasideStatus` request to every
948    /// worker (not round-robin) and collect each worker's
949    /// `SQLITE_DBSTATUS_LOOKASIDE_USED`. Used only by the debug
950    /// integration test for post-warmup lookaside-slot consumption.
951    #[cfg(debug_assertions)]
952    fn lookaside_used_per_worker(&self) -> Vec<i32> {
953        let mut results = Vec::with_capacity(self.senders.len());
954        for sender in &self.senders {
955            let (tx, rx) = mpsc::sync_channel::<i32>(1);
956            if sender.send(ReaderRequest::LookasideStatus { respond: tx }).is_ok() {
957                results.push(rx.recv().unwrap_or(-1));
958            } else {
959                results.push(-1);
960            }
961        }
962        results
963    }
964
965    /// Pack 6.G G.3.5 — broadcast a `CacheStatus` request to every
966    /// worker and collect each worker's `(cache_hit, cache_miss,
967    /// cache_used_bytes)` triple. Same broadcast pattern as G.1's
968    /// `lookaside_used_per_worker`. Returns one `CacheStatusReply` per
969    /// worker in worker-index order.
970    #[cfg(debug_assertions)]
971    fn cache_status_per_worker(&self, snapshot_label: &str) -> Vec<CacheStatusReply> {
972        let mut results = Vec::with_capacity(self.senders.len());
973        for (idx, sender) in self.senders.iter().enumerate() {
974            let (tx, rx) = mpsc::sync_channel::<(String, i32, i32, i32)>(1);
975            let request = ReaderRequest::CacheStatus {
976                snapshot_label: snapshot_label.to_string(),
977                respond: tx,
978            };
979            if sender.send(request).is_ok() {
980                if let Ok((label, hit, miss, used)) = rx.recv() {
981                    results.push(CacheStatusReply {
982                        worker_idx: idx,
983                        snapshot_label: label,
984                        cache_hit: hit,
985                        cache_miss: miss,
986                        cache_used_bytes: used,
987                    });
988                    continue;
989                }
990            }
991            results.push(CacheStatusReply {
992                worker_idx: idx,
993                snapshot_label: snapshot_label.to_string(),
994                cache_hit: -1,
995                cache_miss: -1,
996                cache_used_bytes: -1,
997            });
998        }
999        results
1000    }
1001
1002    /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — broadcast a
1003    /// `SecureDeleteStatus` request to every worker and collect each worker's
1004    /// `PRAGMA secure_delete` value. Same broadcast pattern as G.1's
1005    /// `lookaside_used_per_worker`. Proves the standing secure_delete flag is ON
1006    /// on the reader-pool connections, not just the writer.
1007    #[cfg(debug_assertions)]
1008    fn secure_delete_per_worker(&self) -> Vec<i64> {
1009        let mut results = Vec::with_capacity(self.senders.len());
1010        for sender in &self.senders {
1011            let (tx, rx) = mpsc::sync_channel::<i64>(1);
1012            if sender.send(ReaderRequest::SecureDeleteStatus { respond: tx }).is_ok() {
1013                results.push(rx.recv().unwrap_or(-1));
1014            } else {
1015                results.push(-1);
1016            }
1017        }
1018        results
1019    }
1020
1021    /// Hot path. Lock-free dispatch: `AtomicUsize::fetch_add` selects
1022    /// the worker, then a single `SyncSender::send` enqueues the
1023    /// request. No global mutex is taken on the request path.
1024    // The `Search` variant contains a SyncSender and boxed fields (filter, raw_query);
1025    // even after FIX-4 (raw_query: Box<str>), the variant remains large due to the
1026    // SyncSender channel ownership. The Err return is only ever a no-worker/shutdown
1027    // signal, never heap-allocated repeatedly, so the allow is justified by the
1028    // channel ownership model.
1029    #[allow(clippy::result_large_err)]
1030    fn dispatch(&self, request: ReaderRequest) -> Result<(), ReaderRequest> {
1031        if self.shutdown.load(Ordering::Relaxed) {
1032            return Err(request);
1033        }
1034        let n = self.senders.len();
1035        if n == 0 {
1036            return Err(request);
1037        }
1038        let idx = self.next.fetch_add(1, Ordering::Relaxed) % n;
1039        self.senders[idx].send(request).map_err(|err| err.0)
1040    }
1041
1042    /// Signal every worker to exit and join its thread. Idempotent —
1043    /// safe to call from `Engine::close` and again from
1044    /// `ReaderWorkerPool::Drop`.
1045    fn shutdown(&self) {
1046        if self.shutdown.swap(true, Ordering::SeqCst) {
1047            return;
1048        }
1049        for sender in &self.senders {
1050            let _ = sender.send(ReaderRequest::Shutdown);
1051        }
1052        if let Ok(mut slot) = self.handles.lock() {
1053            if let Some(handles) = slot.take() {
1054                for handle in handles {
1055                    let _ = handle.join();
1056                }
1057            }
1058        }
1059    }
1060}
1061
1062impl Drop for ReaderWorkerPool {
1063    fn drop(&mut self) {
1064        self.shutdown();
1065    }
1066}
1067
1068fn reader_worker_loop(
1069    mut connection: Connection,
1070    rx: Receiver<ReaderRequest>,
1071    live_workers: Arc<AtomicUsize>,
1072) {
1073    live_workers.fetch_add(1, Ordering::SeqCst);
1074    // Drop guard so the live counter decrements even on panic.
1075    struct LiveGuard(Arc<AtomicUsize>);
1076    impl Drop for LiveGuard {
1077        fn drop(&mut self) {
1078            self.0.fetch_sub(1, Ordering::SeqCst);
1079        }
1080    }
1081    let _guard = LiveGuard(live_workers);
1082
1083    while let Ok(request) = rx.recv() {
1084        match request {
1085            ReaderRequest::Shutdown => break,
1086            ReaderRequest::SearchProjectedText { query, name, filter, limit, view, respond } => {
1087                let result = read_projected_text_in_tx(
1088                    &mut connection,
1089                    &query,
1090                    &name,
1091                    filter.as_deref(),
1092                    limit,
1093                    view,
1094                );
1095                let _ = respond.send(result);
1096            }
1097            ReaderRequest::Search {
1098                compiled,
1099                query_vector,
1100                query_vector_bin,
1101                result_limit,
1102                candidate_limit,
1103                direct_text_candidate_limit,
1104                filter,
1105                recency_enabled,
1106                importance_enabled,
1107                vector_stage_only,
1108                raw_query,
1109                rerank_depth,
1110                use_graph_arm,
1111                alpha,
1112                pool_n,
1113                explain,
1114                view,
1115                respond,
1116            } => {
1117                let result = read_search_in_tx(
1118                    &mut connection,
1119                    &compiled,
1120                    query_vector.as_deref(),
1121                    query_vector_bin.as_deref(),
1122                    result_limit,
1123                    candidate_limit,
1124                    direct_text_candidate_limit,
1125                    filter.as_deref(),
1126                    recency_enabled,
1127                    importance_enabled,
1128                    vector_stage_only,
1129                    &raw_query,
1130                    rerank_depth,
1131                    use_graph_arm,
1132                    alpha,
1133                    pool_n,
1134                    explain,
1135                    view,
1136                );
1137                // Receiver may have been dropped if the caller went
1138                // away; nothing to do in that case.
1139                let _ = respond.send(result);
1140            }
1141            ReaderRequest::GetById { logical_ids, view, respond } => {
1142                let result = read_get_by_id_in_tx(&mut connection, &logical_ids, &view);
1143                let _ = respond.send(result);
1144            }
1145            ReaderRequest::ReadCollection { collection, after_id, limit, respond } => {
1146                let result = read_collection_in_tx(&mut connection, &collection, after_id, limit);
1147                let _ = respond.send(result);
1148            }
1149            ReaderRequest::ReadList { kind, predicates, limit, view, respond } => {
1150                let result = read_list_in_tx(&mut connection, &kind, &predicates, limit, &view);
1151                let _ = respond.send(result);
1152            }
1153            ReaderRequest::GraphNeighbors { root_logical_id, depth, direction, view, respond } => {
1154                let result = graph_neighbors_in_tx(
1155                    &mut connection,
1156                    &root_logical_id,
1157                    depth,
1158                    direction,
1159                    &view,
1160                );
1161                let _ = respond.send(result);
1162            }
1163            ReaderRequest::CrossedBoundarySince { since, view, respond } => {
1164                let result = crossed_boundary_since_in_tx(&mut connection, since, &view);
1165                let _ = respond.send(result);
1166            }
1167            ReaderRequest::SearchExpand { search_hits, depth, respond } => {
1168                let result = search_expand_in_tx(&mut connection, &search_hits, depth);
1169                let _ = respond.send(result);
1170            }
1171            ReaderRequest::ExplainGraphNeighbors { root_logical_id, depth, direction, respond } => {
1172                let result = explain_graph_neighbors_in_tx(
1173                    &mut connection,
1174                    &root_logical_id,
1175                    depth,
1176                    direction,
1177                );
1178                let _ = respond.send(result);
1179            }
1180            #[cfg(debug_assertions)]
1181            ReaderRequest::LookasideStatus { respond } => {
1182                let _ = respond.send(read_lookaside_used_hiwtr(&connection));
1183            }
1184            #[cfg(debug_assertions)]
1185            ReaderRequest::CacheStatus { snapshot_label, respond } => {
1186                let (hit, miss, used) = read_cache_status(&connection);
1187                let _ = respond.send((snapshot_label, hit, miss, used));
1188            }
1189            #[cfg(debug_assertions)]
1190            ReaderRequest::SecureDeleteStatus { respond } => {
1191                let value: i64 =
1192                    connection.query_row("PRAGMA secure_delete", [], |r| r.get(0)).unwrap_or(-1);
1193                let _ = respond.send(value);
1194            }
1195        }
1196    }
1197
1198    // Per `dev/design/engine.md` § Close path, uninstall the profile
1199    // callback before dropping the connection so SQLite cannot fire
1200    // one last callback against a `ProfileContext` whose Box is about
1201    // to free.
1202    uninstall_profile_callback(&connection);
1203    drop(connection);
1204}
1205
1206/// 0.8.20 keystone closeout fix-3 — a test-only rendezvous hook fired at the TOP
1207/// of [`read_search_in_tx`], BEFORE the reader opens its deferred transaction.
1208///
1209/// It exists ONLY to make the validate/execute TOCTOU race deterministic: a test
1210/// arms a closure that parks the reader worker here (after the caller-side search
1211/// setup, before the reader pins its snapshot), performs a concurrent
1212/// `configure_projections` DROP of a `filterable` attribute on the writer
1213/// connection, then releases the reader. The reader then pins a snapshot that
1214/// INCLUDES the drop — exactly the window that used to yield an opaque `no such
1215/// column` `Storage` error and now yields a typed `InvalidFilter`. Kept OFF the
1216/// governed surface (`_for_test`), mirroring the sanctioned
1217/// `set_vector_stage_only_for_test` seam pattern. Disarmed by default: a single
1218/// `Relaxed` atomic load per search (same class as the four hot-path atomics
1219/// already read here), fires at most once (the closure is `take`n), and is a
1220/// no-op in production because nothing ever arms it.
1221mod reader_search_hook {
1222    use std::sync::atomic::{AtomicBool, Ordering};
1223    use std::sync::Mutex;
1224
1225    static ARMED: AtomicBool = AtomicBool::new(false);
1226    #[allow(clippy::type_complexity)]
1227    static HOOK: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None);
1228
1229    pub(crate) fn arm(hook: Box<dyn Fn() + Send>) {
1230        *HOOK.lock().expect("reader-search hook mutex") = Some(hook);
1231        ARMED.store(true, Ordering::SeqCst);
1232    }
1233
1234    pub(crate) fn clear() {
1235        ARMED.store(false, Ordering::SeqCst);
1236        *HOOK.lock().expect("reader-search hook mutex") = None;
1237    }
1238
1239    /// Fire the armed hook exactly ONCE, then disarm. Cheap early-out when
1240    /// disarmed (the production and common-test path).
1241    pub(crate) fn fire() {
1242        if !ARMED.load(Ordering::SeqCst) {
1243            return;
1244        }
1245        // Disarm first so a re-entrant / second reader never re-fires.
1246        ARMED.store(false, Ordering::SeqCst);
1247        let hook = HOOK.lock().expect("reader-search hook mutex").take();
1248        if let Some(hook) = hook {
1249            hook();
1250        }
1251    }
1252}
1253
1254/// 0.8.20 keystone closeout fix-3 — arm the [`reader_search_hook`] (test-only).
1255/// See that module's docs. `#[doc(hidden)]`, `_for_test`; never re-exported from
1256/// the `fathomdb` facade.
1257#[doc(hidden)]
1258pub fn arm_reader_search_hook_for_test(hook: Box<dyn Fn() + Send>) {
1259    reader_search_hook::arm(hook);
1260}
1261
1262/// 0.8.20 keystone closeout fix-3 — disarm the [`reader_search_hook`] (test-only).
1263#[doc(hidden)]
1264pub fn clear_reader_search_hook_for_test() {
1265    reader_search_hook::clear();
1266}
1267
1268impl ProjectionRuntime {
1269    fn new(
1270        path: PathBuf,
1271        embedder: Option<Arc<dyn Embedder>>,
1272        embedder_identity: EmbedderIdentity,
1273        mean_already_pinned: bool,
1274        subscribers: Arc<lifecycle::SubscriberRegistry>,
1275    ) -> Self {
1276        // EU-5b/EU-5f — only allocate the streaming accumulator when the
1277        // workspace's identity is MC-required AND no mean has been pinned
1278        // yet on disk. Allocating it for an already-pinned workspace would
1279        // let a later 256-doc run RE-pin and overwrite the compute-once
1280        // mean (violating `dev/design/embedder.md` §0.3). Other identities
1281        // pay no memory cost (`Option::None`).
1282        let mc_required = identity_requires_mean_centering(&embedder_identity);
1283        let mean_accumulator = if mc_required && !mean_already_pinned {
1284            Some(MeanAccumulator::new(embedder_identity.dimension as usize))
1285        } else {
1286            None
1287        };
1288        let shared = Arc::new(ProjectionRuntimeShared {
1289            path,
1290            embedder,
1291            embedder_identity,
1292            subscribers,
1293            state: Mutex::new(ProjectionRuntimeState::default()),
1294            state_cvar: Condvar::new(),
1295            queue: Mutex::new(VecDeque::new()),
1296            queue_cvar: Condvar::new(),
1297            retry_delays_ms: Mutex::new(DEFAULT_PROJECTION_RETRY_DELAYS_MS.to_vec()),
1298            embed_timeout_ms: AtomicU64::new(DEFAULT_EMBED_TIMEOUT_MS),
1299            embed_serialize: Mutex::new(()),
1300            live_embed_threads: Arc::new(AtomicU64::new(0)),
1301            embed_circuit_open: AtomicBool::new(false),
1302            embed_circuit_threshold: AtomicU64::new(DEFAULT_EMBED_CIRCUIT_THRESHOLD),
1303            mean_accumulator: Mutex::new(mean_accumulator),
1304            pending_events: Mutex::new(Vec::new()),
1305            commit_gate: Mutex::new(()),
1306            search_limit_override: AtomicUsize::new(SEARCH_RERANK_LIMIT),
1307            recency_reweight_enabled: AtomicBool::new(false),
1308            importance_reweight_enabled: AtomicBool::new(false),
1309            vector_stage_only_for_test: AtomicBool::new(false),
1310            #[cfg(debug_assertions)]
1311            force_recompute_failure: AtomicBool::new(false),
1312            #[cfg(debug_assertions)]
1313            force_projection_commit_failure: AtomicUsize::new(0),
1314            #[cfg(debug_assertions)]
1315            projection_commit_failure_pause: Mutex::new(None),
1316            #[cfg(debug_assertions)]
1317            projection_stop_ack: Mutex::new(None),
1318        });
1319
1320        let dispatcher_shared = Arc::clone(&shared);
1321        let dispatcher = thread::spawn(move || projection_dispatcher_loop(dispatcher_shared));
1322
1323        let mut workers = Vec::with_capacity(PROJECTION_WORKERS);
1324        for _ in 0..PROJECTION_WORKERS {
1325            let worker_shared = Arc::clone(&shared);
1326            workers.push(thread::spawn(move || projection_worker_loop(worker_shared)));
1327        }
1328
1329        Self { shared, dispatcher: Mutex::new(Some(dispatcher)), workers: Mutex::new(workers) }
1330    }
1331
1332    fn notify_new_work(&self) {
1333        if let Ok(mut state) = self.shared.state.lock() {
1334            state.pending_scan = true;
1335            self.shared.state_cvar.notify_all();
1336        }
1337    }
1338
1339    fn set_frozen(&self, frozen: bool) {
1340        if let Ok(mut state) = self.shared.state.lock() {
1341            state.frozen = frozen;
1342            if !frozen {
1343                state.pending_scan = true;
1344            }
1345            self.shared.state_cvar.notify_all();
1346        }
1347    }
1348
1349    fn pending_scan_for_test(&self) -> bool {
1350        self.shared.state.lock().map(|state| state.pending_scan).unwrap_or(true)
1351    }
1352
1353    fn wait_for_idle(&self, timeout_ms: u64) -> bool {
1354        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
1355        let mut state = match self.shared.state.lock() {
1356            Ok(state) => state,
1357            Err(_) => return false,
1358        };
1359        loop {
1360            if state.active_jobs == 0 && state.queued_jobs == 0 {
1361                drop(state);
1362                if !database_has_pending_projection_work(&self.shared.path).unwrap_or(true) {
1363                    return true;
1364                }
1365                state = match self.shared.state.lock() {
1366                    Ok(state) => state,
1367                    Err(_) => return false,
1368                };
1369            }
1370            let now = Instant::now();
1371            if now >= deadline {
1372                return false;
1373            }
1374            let wait = deadline.saturating_duration_since(now);
1375            let Ok((next_state, _)) = self.shared.state_cvar.wait_timeout(state, wait) else {
1376                return false;
1377            };
1378            state = next_state;
1379        }
1380    }
1381
1382    fn set_retry_delays_for_test(&self, delays_ms: &[u64]) {
1383        if let Ok(mut delays) = self.shared.retry_delays_ms.lock() {
1384            *delays = delays_ms.to_vec();
1385        }
1386    }
1387
1388    #[cfg(debug_assertions)]
1389    fn force_next_projection_commit_failure_for_test(&self) {
1390        self.shared.force_projection_commit_failure.store(1, Ordering::SeqCst);
1391    }
1392
1393    #[cfg(debug_assertions)]
1394    fn force_next_projection_storage_failure_for_test(&self) {
1395        self.shared.force_projection_commit_failure.store(2, Ordering::SeqCst);
1396    }
1397
1398    #[cfg(debug_assertions)]
1399    fn pause_projection_commit_failure_cleanup_for_test(
1400        &self,
1401        reported: Arc<Barrier>,
1402        release: Arc<Barrier>,
1403    ) {
1404        *self
1405            .shared
1406            .projection_commit_failure_pause
1407            .lock()
1408            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((reported, release));
1409    }
1410
1411    #[cfg(debug_assertions)]
1412    fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
1413        *self.shared.projection_stop_ack.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) =
1414            Some(acknowledged);
1415    }
1416
1417    fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
1418        self.shared.embed_timeout_ms.store(timeout_ms, Ordering::Relaxed);
1419    }
1420
1421    fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
1422        self.shared.embed_circuit_threshold.store(threshold, Ordering::Relaxed);
1423    }
1424
1425    fn embed_circuit_open_for_test(&self) -> bool {
1426        self.shared.embed_circuit_open.load(Ordering::Relaxed)
1427    }
1428
1429    fn stop(&self) {
1430        if let Ok(mut state) = self.shared.state.lock() {
1431            if state.stopping {
1432                return;
1433            }
1434            state.stopping = true;
1435            state.pending_scan = false;
1436            self.shared.state_cvar.notify_all();
1437        }
1438        #[cfg(debug_assertions)]
1439        if let Some(acknowledged) = self
1440            .shared
1441            .projection_stop_ack
1442            .lock()
1443            .unwrap_or_else(|poisoned| poisoned.into_inner())
1444            .take()
1445        {
1446            acknowledged.wait();
1447        }
1448        if let Ok(mut queue) = self.shared.queue.lock() {
1449            queue.clear();
1450            self.shared.queue_cvar.notify_all();
1451        }
1452
1453        if let Ok(mut dispatcher) = self.dispatcher.lock() {
1454            if let Some(handle) = dispatcher.take() {
1455                let _ = handle.join();
1456            }
1457        }
1458        if let Ok(mut workers) = self.workers.lock() {
1459            for handle in workers.drain(..) {
1460                let _ = handle.join();
1461            }
1462        }
1463    }
1464}
1465
1466#[derive(Clone, Debug, Eq, PartialEq)]
1467pub struct OpenReport {
1468    pub schema_version_before: u32,
1469    pub schema_version_after: u32,
1470    pub migration_steps: Vec<MigrationStepReport>,
1471    pub embedder_warmup_ms: u64,
1472    pub query_backend: &'static str,
1473    pub default_embedder: EmbedderIdentity,
1474    /// Total wall time the loader spent materializing default-embedder
1475    /// weights — covers HF GETs, sha256 verification, atomic rename,
1476    /// parent-dir fsync (POSIX), and cache directory writes. This is
1477    /// the "engine open paid by the embedder" envelope, useful for SLA
1478    /// budgeting; it is intentionally wider than just the bytes-flowing
1479    /// time so callers see the full first-use cost.
1480    ///
1481    /// `Some(ms)` when network bytes flowed (`bytes_downloaded > 0`);
1482    /// `None` for caller-supplied embedders (loader bypassed) and on
1483    /// full cache hits (no bytes flowed). For pure per-file network
1484    /// analysis, use the `DefaultEmbedderDownload` events on
1485    /// [`embedder_events`](Self::embedder_events) — each event carries
1486    /// the file's bytes + sha256 + cache path.
1487    pub embedder_download_ms: Option<u64>,
1488    /// Structured loader events (`dev/design/embedder.md` §7). Empty for
1489    /// caller-supplied embedders; populated from `LoadedWeights.events`
1490    /// for the Default path.
1491    pub embedder_events: Vec<EmbedderEvent>,
1492    /// Static identity capability (`dev/design/embedder.md` §0.6). True
1493    /// iff the live embedder identity is the bge-small default, which is
1494    /// the only identity that ships with the EU-5a2 mean-centering apply
1495    /// paths. `false` for `fathomdb-noop` and for any other
1496    /// caller-supplied identity. EU-5b's identity flip makes the Default
1497    /// path return `true` here.
1498    pub embedder_mean_centering_required: bool,
1499    /// Dynamic workspace state (`dev/design/embedder.md` §0.6). True iff
1500    /// `_fathomdb_embedder_profiles.mean_vec IS NOT NULL` for the default
1501    /// profile. EU-5a2 reads from the schema column added in migration
1502    /// step 10; the value is dimension-validated (§0.2) at open time
1503    /// and fails closed via `EmbedderIdentityMismatch` on drift.
1504    pub embedder_mean_vec_pinned: bool,
1505    /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-6) — degraded-open
1506    /// observability. `true` iff the open-time #5 self-check re-embedded the 45
1507    /// committed probes and found a divergence beyond the frozen D4 floor (a
1508    /// Phase-1 mean-centered `embedding_bin` sign flip OR a Phase-2 un-centered
1509    /// L2 over `VECTOR_EQUIVALENCE_L2_EPSILON`). When `true`, `Engine::open`
1510    /// SUCCEEDED but every vector-dependent arm refuses at query time with
1511    /// `EngineError::VectorEquivalenceMismatch`; the text-only/FTS-only path stays
1512    /// serviceable. The state is RE-DERIVED at every open (the probe re-runs), so
1513    /// a reopen with a still-divergent backend stays degraded (never silently
1514    /// re-enables dense) and a reopen with a matching backend clears it.
1515    pub dense_disabled: bool,
1516    /// R-VEQ-6 — human-readable reason for `dense_disabled` (which representation
1517    /// tripped: P1 flip count or P2 L2). `None` when `dense_disabled == false`.
1518    pub dense_disabled_reason: Option<String>,
1519}
1520
1521#[derive(Debug)]
1522pub struct OpenedEngine {
1523    pub engine: Engine,
1524    pub report: OpenReport,
1525}
1526
1527/// EU-5b — loader-supplied open-time telemetry threaded into
1528/// `OpenReport.embedder_download_ms` and `OpenReport.embedder_events`.
1529#[derive(Clone, Debug)]
1530struct LoaderInfo {
1531    download_ms: Option<u64>,
1532    events: Vec<EmbedderEvent>,
1533}
1534
1535#[derive(Clone, Debug, Eq, PartialEq)]
1536pub struct WriteReceipt {
1537    /// The batch high-water cursor — the `write_cursor` of the last row written
1538    /// (also the engine's new `next_cursor`). Unchanged from 0.7.x.
1539    pub cursor: u64,
1540    /// G0 (Slice 15) — the per-row `write_cursor` of each row in the batch, 1:1
1541    /// with input order. This is the `write_cursor`-as-row-id identity carrier
1542    /// (HITL-accepted for 0.8.0; a dedicated `row_id` is deferred). For an
1543    /// N-row batch this is `[cursor-N+1, …, cursor]`.
1544    pub row_cursors: Vec<u64>,
1545    /// G8 (Slice 20 / F10) — count of edge endpoints in this batch that point at
1546    /// a non-existent **or superseded** canonical node. An endpoint is dangling
1547    /// when no **active** node (`superseded_at IS NULL`) carries its `logical_id`;
1548    /// `from_id` and `to_id` are probed independently, so one edge contributes 0,
1549    /// 1, or 2. This is **informational** (default FLAG-AND-COUNT: the batch
1550    /// commits regardless) and `0` whenever the batch committed no active edges.
1551    pub dangling_edge_endpoints: u64,
1552}
1553
1554/// Soft-fallback signal carried on hybrid `search` results.
1555///
1556/// Per `dev/design/retrieval.md` § Soft-fallback signal, this record is
1557/// present only when one non-essential branch could not contribute. Total
1558/// request failure is not expressed via this carrier.
1559#[derive(Clone, Debug, Eq, PartialEq)]
1560pub struct SoftFallback {
1561    pub branch: SoftFallbackBranch,
1562}
1563
1564/// Which retrieval branch produced a hit (or could not contribute).
1565///
1566/// `Vector` = ANN vector branch (node bodies); `Text` = node-body FTS branch;
1567/// `TextEdge` = edge-body hit (FTS via `search_index_edges` OR vector-projected
1568/// edge facts — both produce the same kind="edge_fact" row shape and share the
1569/// same downstream handling in `search_expand_in_tx`). `Vector`/`Text` also
1570/// used as soft-fallback signal when the respective branch is empty.
1571/// `GraphArm` = R3 (Slice 30) BFS-reachable node from the temporal fact-edge
1572/// graph arm. Owned by `dev/design/retrieval.md`.
1573#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1574pub enum SoftFallbackBranch {
1575    Vector,
1576    Text,
1577    /// G11 (Slice 15) — edge-body hit from `search_index_edges` FTS or from
1578    /// `vector_default` edge-fact projection. `kind = "edge_fact"` in both cases.
1579    TextEdge,
1580    /// R3 (Slice 30) — BFS-reachable node from the temporal fact-edge graph arm.
1581    /// Only present when `use_graph_arm = true`. Nodes in the graph arm were NOT
1582    /// in the initial vector/text fused result (newly-reached nodes only).
1583    GraphArm,
1584}
1585
1586/// A single structured search hit (G1 / AC-057a-clean).
1587///
1588/// Both retrieval branches emit this shape. `id` is a typed [`IdSpace`] — the
1589/// **permanent** caller-facing identity since C-2 (0.8.19 / TC-8), NOT the
1590/// interim `write_cursor: u64` the pre-0.8.19 releases carried and NOT an
1591/// interim carrier awaiting a later swap. The positional `write_cursor` field
1592/// below survives as engine-internal book-keeping and the SDK bindings do not
1593/// surface it. See the field docs on [`SearchHit::id`] / [`IdSpace`].
1594/// `score` is the **G9 RRF-fused** relevance (`Σ 1/(RRF_K + rank)` over the
1595/// branches that surfaced this body; higher = more relevant), optionally
1596/// recency-reweighted when the dedicated recency flag is on. Raw `vec_distance_l2`
1597/// and `bm25()` are fused on **rank**, never compared raw (they are not
1598/// comparable). `branch` tags which retrieval branch produced the representative
1599/// hit (vector-first when a body is surfaced by both).
1600///
1601/// `source_id` (G0 Phase-2 / BLOCK-2; generalised by TC-31 in 0.8.20 Slice 10a)
1602/// carries the source-document provenance of a hit — the identifier
1603/// [`Engine::erase_source`] consumes. It is populated on **every** hit path:
1604/// - **Node hits** (text/BM25F, vector, and the pre-step-12 legacy text
1605///   fallback) carry the **node's own** `canonical_nodes.source_id`.
1606/// - **Edge hits** (edge-FTS from `search_index_edges`, and edge-fact hits
1607///   hydrated by the vector arm) carry the **edge's own**
1608///   `canonical_edges.source_id`.
1609/// - **GraphArm** hits carry the **traversed edge's** `source_id` (the session
1610///   the fact-edge was extracted from) — unchanged by TC-31 — enabling
1611///   `doc_id_of` to resolve a graph-reached entity back to a gold session id.
1612///
1613/// Before TC-31 only the GraphArm branch populated this, which left
1614/// `erase_source` shipping with its argument unreachable from a text or vector
1615/// hit (0.8.19 also stopped surfacing `write_cursor` to the SDKs, removing the
1616/// only fallback route). It stays `Option<String>`: a row written before 0.8.20,
1617/// or a GOVERNED row deliberately spared by the step-21 backfill under the TC-11
1618/// pin, legitimately carries NULL at rest and must read back as `None` rather
1619/// than a fabricated value.
1620///
1621/// The field never participates in ranking, so result order and scores are
1622/// unaffected.
1623///
1624/// C-2 (0.8.19 / OPP-12 record-lifecycle Phase-1, TC-8) — the **id-space** of a
1625/// [`SearchHit::id`]. A closed, typed enum (NOT a magic-prefixed string) — the
1626/// C-2 binding ratified in the OPP-12 protocol:
1627/// - [`Logical`](IdSpaceKind::Logical) — `"l:"`, a governed/canonical node keyed
1628///   by its `logical_id` (the only lifecycle-addressable space).
1629/// - [`Content`](IdSpaceKind::Content) — `"h:"`, a doc-seeded/anonymous node
1630///   keyed by a content hash of its body (the dominant corpus hit class).
1631/// - [`Passage`](IdSpaceKind::Passage) — `"p:"`, a synthetic `rerank_passages`
1632///   hit keyed by the caller-supplied passage ordinal.
1633#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1634pub enum IdSpaceKind {
1635    /// `"l:"` — governed/canonical node (its `logical_id`).
1636    Logical,
1637    /// `"h:"` — doc-seeded/anonymous node (content hash of the body).
1638    Content,
1639    /// `"p:"` — synthetic rerank passage (caller-supplied ordinal).
1640    Passage,
1641}
1642
1643impl IdSpaceKind {
1644    /// The two-char id-space prefix (`"l:"` / `"h:"` / `"p:"`) used in the
1645    /// prefixed string form. Byte-identical to the pre-swap `derive_stable_id`
1646    /// tags so real-gold keying stays a no-op.
1647    #[must_use]
1648    pub fn prefix(self) -> &'static str {
1649        match self {
1650            Self::Logical => "l:",
1651            Self::Content => "h:",
1652            Self::Passage => "p:",
1653        }
1654    }
1655
1656    /// The lowercase discriminant (`"logical"` / `"content"` / `"passage"`)
1657    /// surfaced through the SDK bindings as the `IdSpace.space` field (mirrors
1658    /// how `SoftFallbackBranch` is surfaced as a `branch` string).
1659    #[must_use]
1660    pub fn as_str(self) -> &'static str {
1661        match self {
1662            Self::Logical => "logical",
1663            Self::Content => "content",
1664            Self::Passage => "passage",
1665        }
1666    }
1667}
1668
1669/// C-2 (0.8.19 / OPP-12 Phase-1, TC-8) — the typed, non-null, id-space-**total**
1670/// carrier for [`SearchHit::id`]. Subsumes the interim `write_cursor` id AND the
1671/// additive Cause-A `stable_id` field of prior releases: the `value` is the BARE
1672/// id (prefix stripped), and [`to_prefixed`](IdSpace::to_prefixed) reproduces the
1673/// pre-swap `stable_id` string byte-for-byte (`l:`/`h:` unchanged) so
1674/// cross-session real-gold keying continues on `id` as a true no-op.
1675///
1676/// Lifecycle-addressability is a type check consumed downstream by the
1677/// `transition`/`purge` verbs: only [`Logical`](IdSpaceKind::Logical) is
1678/// lifecycle-addressable; `Content`/`Passage` are total-but-not-addressable.
1679#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1680pub struct IdSpace {
1681    /// The typed id-space (`Logical`/`Content`/`Passage`).
1682    pub space: IdSpaceKind,
1683    /// The bare id value (id-space prefix stripped).
1684    pub value: String,
1685}
1686
1687impl IdSpace {
1688    /// A `Logical` (`"l:"`) id carrying `value` (a `logical_id`).
1689    pub fn logical(value: impl Into<String>) -> Self {
1690        Self { space: IdSpaceKind::Logical, value: value.into() }
1691    }
1692
1693    /// A `Content` (`"h:"`) id carrying `value` (a content hash).
1694    pub fn content(value: impl Into<String>) -> Self {
1695        Self { space: IdSpaceKind::Content, value: value.into() }
1696    }
1697
1698    /// A `Passage` (`"p:"`) id carrying `value` (a caller-supplied ordinal).
1699    pub fn passage(value: impl Into<String>) -> Self {
1700        Self { space: IdSpaceKind::Passage, value: value.into() }
1701    }
1702
1703    /// The prefixed string form (`{prefix}{value}`) — byte-identical to the
1704    /// pre-swap `derive_stable_id` output for `l:`/`h:`.
1705    #[must_use]
1706    pub fn to_prefixed(&self) -> String {
1707        format!("{}{}", self.space.prefix(), self.value)
1708    }
1709
1710    /// Parse the prefixed string form back into a typed `IdSpace`. Round-trip
1711    /// stable: `IdSpace::parse(&x.to_prefixed()) == Some(x)`. Only the FIRST
1712    /// two-char id-space prefix is stripped, so a value that itself contains
1713    /// `":"` round-trips unchanged. Returns `None` for an untagged string.
1714    #[must_use]
1715    pub fn parse(s: &str) -> Option<Self> {
1716        if let Some(v) = s.strip_prefix("l:") {
1717            Some(Self::logical(v))
1718        } else if let Some(v) = s.strip_prefix("h:") {
1719            Some(Self::content(v))
1720        } else {
1721            s.strip_prefix("p:").map(Self::passage)
1722        }
1723    }
1724}
1725
1726impl std::fmt::Display for IdSpace {
1727    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1728        write!(f, "{}{}", self.space.prefix(), self.value)
1729    }
1730}
1731
1732/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — `score: f64` forbids
1733/// total equality.
1734#[derive(Clone, Debug, PartialEq)]
1735pub struct SearchHit {
1736    /// C-2 (0.8.19 / TC-8) — the typed, non-null, id-space-total hit id
1737    /// ([`IdSpace`]). Was the interim `write_cursor: u64` in prior releases; now
1738    /// carries the cross-session-stable key: `value` is the BARE (prefix-stripped)
1739    /// id, and [`to_prefixed`](IdSpace::to_prefixed) (== `{prefix}{value}`)
1740    /// reproduces the pre-swap `stable_id` byte-for-byte (the real-gold-keying
1741    /// no-op). Governed hits are `l:`, doc-seeded hits `h:`, synthetic
1742    /// passages `p:`. This is the caller-facing identity; the positional
1743    /// `write_cursor` below is engine-internal book-keeping.
1744    pub id: IdSpace,
1745    /// Engine-internal positional cursor (the value `id` carried before the C-2
1746    /// swap). Reassigned on every re-projection/re-ingest — NOT cross-session
1747    /// stable, NOT the caller-facing id. Retained because the engine still needs
1748    /// a positional cursor for its own book-keeping (vector rowid mapping, the
1749    /// `state='active'` filter lookups, RRF recency/importance reweight keys,
1750    /// telemetry `result_ids` keying, `search_expand` re-resolution). The SDK
1751    /// bindings do NOT surface it.
1752    pub write_cursor: u64,
1753    pub kind: String,
1754    pub body: String,
1755    pub score: f64,
1756    pub branch: SoftFallbackBranch,
1757    pub source_id: Option<String>,
1758    /// 0.8.5 (EXP-0) — per-candidate cross-encoder score `ce_norm =
1759    /// sigmoid(ce_logit) ∈ [0,1]`. `Some` ONLY for hits inside the reranked pool
1760    /// (the top `pool_n` when the CE model is loaded); `None` for the unreranked
1761    /// remainder, the `rerank_depth == 0` identity path, an empty list, and the
1762    /// no-CE-model soft-fallback. Additive + nullable: it never participates in
1763    /// ranking, so default-path ordering/scores stay byte-stable.
1764    pub ce_score: Option<f64>,
1765}
1766
1767/// G0 Phase-2 (E0a / BLOCK-1) — graph-arm frontier instrumentation. A
1768/// **side-channel** meter (deliberately NOT a `SearchResult`/`SearchHit` field —
1769/// byte stability) that proves whether the graph arm seeds a non-empty frontier.
1770/// Under the current doc-seeded path the frontier is empty (doc nodes carry
1771/// `logical_id = NULL`), so `seeds_resolved == 0` and `resolved_seed_rate == 0.0`
1772/// — this meter is the measurement that proves it (and, post-C1, the 0→>0 flip).
1773///
1774/// `resolved_seed_rate = seeds_resolved / seeds_considered`, with `0/0 → 0.0`.
1775#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1776pub struct GraphFrontierStats {
1777    /// Hits inspected as seed candidates (the `take(SEED_N)` window, skipping TextEdge).
1778    pub seeds_considered: u32,
1779    /// Seed candidates that resolved to an active `logical_id` (pushed onto the frontier).
1780    pub seeds_resolved: u32,
1781    /// Whether the BFS frontier was non-empty after seeding.
1782    pub frontier_nonempty: bool,
1783    /// Number of graph-arm `SearchHit`s emitted (reachable, not already in the two-arm result).
1784    pub graph_candidates_emitted: u32,
1785}
1786
1787impl GraphFrontierStats {
1788    /// `seeds_resolved / seeds_considered`, defined as `0.0` when nothing was considered.
1789    pub fn resolved_seed_rate(&self) -> f64 {
1790        if self.seeds_considered == 0 {
1791            0.0
1792        } else {
1793            f64::from(self.seeds_resolved) / f64::from(self.seeds_considered)
1794        }
1795    }
1796}
1797
1798/// Slice 30 (G2) — an active canonical node row returned by `read.get` /
1799/// `read.get_many`.
1800///
1801/// `logical_id` is the queried stable identity (echoed). `write_cursor` is the
1802/// interim id carrier (same column `SearchHit.id` carries). Only ACTIVE rows
1803/// (`superseded_at IS NULL`) are ever materialised into this shape; a missing or
1804/// superseded `logical_id` is a normal absence (`None`), never an error.
1805#[derive(Clone, Debug, Eq, PartialEq)]
1806pub struct NodeRecord {
1807    pub logical_id: String,
1808    pub kind: String,
1809    pub body: String,
1810    pub write_cursor: u64,
1811}
1812
1813/// 0.8.20 Slice 10b (R-20-RV / R-20-NV) — the **read view**: the single knob
1814/// that decides which `canonical_nodes` rows a read verb may see.
1815///
1816/// Every field is a *relaxation*: `ReadView::default()` is the STRICT view and
1817/// compiles to exactly the predicates the five read verbs carried before this
1818/// slice (`superseded_at IS NULL AND state = 'active'`), so the default read
1819/// path is behaviourally unchanged. Flags compose INDEPENDENTLY — each one
1820/// drops exactly one conjunct and no other.
1821///
1822/// The view is applied UNIFORMLY by [`Engine::read_get`],
1823/// [`Engine::read_get_many`], [`Engine::read_list`],
1824/// [`Engine::read_list_filter`] and [`Engine::graph_neighbors`] — and, inside
1825/// `graph_neighbors`, at EVERY position of EVERY direction's recursive CTE
1826/// (anchor, recursive join, final projection), so a relaxation cannot silently
1827/// apply on one traversal position and not another.
1828///
1829/// # World-time only
1830///
1831/// `valid_as_of` selects along the **world-time** (validity) axis only.
1832/// Transaction-time / `history_as_of` is explicitly OUT OF SCOPE — this type
1833/// deliberately has no way to ask "what did the database believe at time T".
1834#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1835pub struct ReadView {
1836    /// Relax `superseded_at IS NULL` — include superseded (historical) versions
1837    /// of a row, not just the current one. `false` (default) keeps the shipped
1838    /// current-version-only behaviour.
1839    ///
1840    /// On the point-lookup verbs ([`Engine::read_get`] /
1841    /// [`Engine::read_get_many`]) a `logical_id` can now match several rows;
1842    /// the slot resolves DETERMINISTICALLY to the highest `write_cursor` (the
1843    /// most recent version). Use [`Engine::read_list`] to enumerate history.
1844    pub include_superseded: bool,
1845
1846    /// Relax `state = 'active'` — include rows in a non-`active` lifecycle
1847    /// state (`pending` / `deleted` / `purged`). `false` (default) keeps the
1848    /// shipped active-only behaviour.
1849    pub include_inactive: bool,
1850
1851    /// Relax the validity-window predicate ENTIRELY — return rows whatever
1852    /// their `[valid_from, valid_until)` window, ignoring `valid_as_of`.
1853    /// `false` (default) filters to rows valid at the selected instant.
1854    ///
1855    /// Note this is a NO-OP on any row with an unbounded (NULL/NULL) window,
1856    /// which is every row that predates schema step 22.
1857    pub include_out_of_window: bool,
1858
1859    /// The instant (INTEGER epoch SECONDS, UTC) at which validity is evaluated.
1860    /// `None` (default) resolves to *now* at query time.
1861    ///
1862    /// This is the **`:now` seam**: whichever way it resolves, the instant is
1863    /// compiled as a BOUND PARAMETER, never a `datetime('now')` SQL literal —
1864    /// which is what makes node validity deterministically testable without
1865    /// clock games. (The shipped EDGE temporal filter still inlines
1866    /// `datetime('now')`; that path is untouched by this slice.)
1867    pub valid_as_of: Option<i64>,
1868}
1869
1870impl ReadView {
1871    /// The instant to bind for the validity predicate, or `None` when the view
1872    /// relaxes validity entirely (in which case no `:now` parameter is emitted
1873    /// and none must be bound).
1874    fn now_param(&self) -> Option<i64> {
1875        if self.include_out_of_window {
1876            return None;
1877        }
1878        Some(self.valid_as_of.unwrap_or_else(current_epoch_seconds))
1879    }
1880
1881    /// The existence conjunct for node-table `alias`. Each flag drops exactly
1882    /// one conjunct; the strict view reproduces the pre-slice predicate pair
1883    /// verbatim. Always begins with ` AND ` (or is empty), so every call site
1884    /// must already have a preceding `WHERE` predicate.
1885    fn existence_sql(&self, alias: &str) -> String {
1886        let mut sql = String::new();
1887        if !self.include_superseded {
1888            sql.push_str(&format!(" AND {alias}.superseded_at IS NULL"));
1889        }
1890        if !self.include_inactive {
1891            sql.push_str(&format!(" AND {alias}.state = 'active'"));
1892        }
1893        sql
1894    }
1895
1896    /// The validity conjunct for node-table `alias`, bound to positional
1897    /// parameter `?{now_idx}`. Empty when validity is relaxed.
1898    ///
1899    /// Encodes the HALF-OPEN window `[valid_from, valid_until)` with NULL
1900    /// meaning unbounded on that side — so a NULL/NULL row is valid at every
1901    /// instant and this conjunct never changes its visibility.
1902    fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
1903        if self.include_out_of_window {
1904            return String::new();
1905        }
1906        format!(
1907            " AND ({alias}.valid_from IS NULL OR {alias}.valid_from <= ?{now_idx}) \
1908             AND ({alias}.valid_until IS NULL OR {alias}.valid_until > ?{now_idx})"
1909        )
1910    }
1911
1912    /// The full node predicate (existence + validity) for `alias`. This is the
1913    /// ONE function every read site calls, so no site can drift from another.
1914    fn node_sql(&self, alias: &str, now_idx: usize) -> String {
1915        format!("{}{}", self.existence_sql(alias), self.validity_sql(alias, now_idx))
1916    }
1917
1918    /// 0.8.20 Slice 15b fix-3 (F2) — resolve this view's validity instant ONCE
1919    /// and hand back a [`FrozenView`] that carries the resolved value.
1920    ///
1921    /// This is the ONLY constructor of a `FrozenView`, and therefore the only
1922    /// point on the search path where the wall clock is read.
1923    fn freeze(self) -> FrozenView {
1924        // TC-33: resolve the instant ONCE, unconditionally, and derive both
1925        // axes from it. `valid_as_of.unwrap_or_else(current_epoch_seconds)` is
1926        // exactly what `now_param()` computes, so the clock is read the same
1927        // number of times as before on every path that reads it at all.
1928        let resolved = self.valid_as_of.unwrap_or_else(current_epoch_seconds);
1929        let now = if self.include_out_of_window { None } else { Some(resolved) };
1930        FrozenView { view: self, now, edge_now: resolved }
1931    }
1932
1933    /// 0.8.20 Slice 15b fix-2 — the `search` path honours the VALIDITY axis of a
1934    /// `ReadView` and refuses the EXISTENCE axis. See [`Engine::search_view`] for
1935    /// why refusing beats silently ignoring.
1936    fn reject_existence_relaxation_on_search(&self) -> Result<(), EngineError> {
1937        let relaxed = match (self.include_superseded, self.include_inactive) {
1938            (true, true) => "include_superseded + include_inactive",
1939            (true, false) => "include_superseded",
1940            (false, true) => "include_inactive",
1941            (false, false) => return Ok(()),
1942        };
1943        Err(EngineError::InvalidArgument {
1944            msg: format!(
1945                "ReadView.{relaxed} is not supported on the search path; search hydrates from \
1946                 projection indexes that are not version-complete, so only the validity axis \
1947                 (valid_as_of / include_out_of_window) is honoured. Use read_list for history."
1948            ),
1949        })
1950    }
1951}
1952
1953/// 0.8.20 Slice 10b (R-20-NV) — one node that crossed a validity boundary
1954/// inside the interrogated interval, as reported by
1955/// [`Engine::crossed_boundary_since`].
1956///
1957/// A node can cross BOTH boundaries in the same interval (a window that opened
1958/// and closed inside it), so the two fields are independent `Option`s rather
1959/// than one enum.
1960#[derive(Clone, Debug, Eq, PartialEq)]
1961pub struct BoundaryCrossing {
1962    /// The node that crossed.
1963    pub node: NodeRecord,
1964    /// `Some(valid_from)` when the node BECAME VALID inside the interval.
1965    pub became_valid_at: Option<i64>,
1966    /// `Some(valid_until)` when the node BECAME INVALID inside the interval.
1967    pub became_invalid_at: Option<i64>,
1968}
1969
1970/// 0.8.20 Slice 15b fix-3 (F2) — a [`ReadView`] whose validity instant has
1971/// ALREADY been resolved, produced only by [`ReadView::freeze`].
1972///
1973/// R-20-NV requires `:now` to bind ONCE PER QUERY — not per row, and not per
1974/// ARM. The multi-arm search path made that easy to violate: each arm held a
1975/// `ReadView` and could call `now_param()`, which for the default view
1976/// (`valid_as_of == None`) reads the wall clock. Two arms, two instants, and a
1977/// query straddling a validity boundary gets nondeterministic membership.
1978///
1979/// The fix is TYPE-LEVEL rather than a comment asking future arms to behave:
1980/// the instant is resolved once at the top of `read_search_in_tx` and every arm
1981/// receives a `FrozenView`, which stores the resolved value in `now` and has NO
1982/// path back to the clock. An arm cannot re-resolve the instant because it
1983/// never holds anything that could — the failure mode is unreachable, not
1984/// merely discouraged.
1985#[derive(Clone, Copy, Debug)]
1986struct FrozenView {
1987    /// The underlying view — consulted for SQL SHAPE only (which conjuncts to
1988    /// emit), never to re-resolve the instant.
1989    view: ReadView,
1990    /// The instant resolved at freeze time. `None` ⇔ the view relaxes validity
1991    /// entirely, in which case no conjunct is emitted and nothing is bound.
1992    now: Option<i64>,
1993    /// TC-33 — the instant EDGE validity is evaluated at. Always present.
1994    ///
1995    /// The EXISTENCE-relaxation flag `include_out_of_window` belongs to the NODE
1996    /// validity axis and does NOT relax edge recency: an edge invalidated in the
1997    /// past stays excluded regardless. So this is the resolved instant even when
1998    /// `now` is `None`, and it is resolved from the SAME clock read.
1999    edge_now: i64,
2000}
2001
2002impl FrozenView {
2003    /// The instant to bind, resolved at freeze time. Unlike
2004    /// [`ReadView::now_param`] this is a stored value: calling it a second time
2005    /// cannot yield a different answer, and it never touches the clock.
2006    fn now_param(&self) -> Option<i64> {
2007        self.now
2008    }
2009
2010    /// TC-33 — the instant to bind for the EDGE-validity conjunct
2011    /// ([`edge_validity_sql`]). Frozen, like [`FrozenView::now_param`].
2012    ///
2013    /// Honouring `valid_as_of` here is what finally UNIFIES the node and edge
2014    /// temporal axes: step 22 recorded "the shipped EDGE path still inlines
2015    /// `datetime('now')`" as the reason they could not be unified. For the
2016    /// DEFAULT view (`valid_as_of == None`) this is the wall clock, i.e. exactly
2017    /// the pre-TC-33 behaviour.
2018    fn edge_now(&self) -> i64 {
2019        self.edge_now
2020    }
2021
2022    /// The validity conjunct — delegated to the one generator every read site
2023    /// shares, so the search arms cannot drift from the five read verbs.
2024    fn validity_sql(&self, alias: &str, now_idx: usize) -> String {
2025        self.view.validity_sql(alias, now_idx)
2026    }
2027}
2028
2029/// 0.8.20 Slice 15b fix-3 (F2) — how many times [`current_epoch_seconds`] has
2030/// been called in this process. Test-only observation; see
2031/// [`clock_reads_for_test`].
2032static CLOCK_READS: AtomicU64 = AtomicU64::new(0);
2033
2034/// Test seam — the process-wide count of wall-clock reads on the validity path.
2035/// Kept OFF the governed surface (`#[doc(hidden)]`, `_for_test`), mirroring the
2036/// sanctioned `set_vector_stage_only_for_test` / `vector_phase1_sql_for_test`
2037/// pattern; it is never re-exported from the `fathomdb` facade.
2038///
2039/// The counter is PROCESS-WIDE, so a test asserting on a delta must hold a
2040/// lock that excludes every other clock-reading test in its binary (test
2041/// binaries are separate processes, so only intra-binary contention matters).
2042/// `slice15b_search_validity_recall.rs` does this with a file-local mutex.
2043#[doc(hidden)]
2044#[must_use]
2045pub fn clock_reads_for_test() -> u64 {
2046    CLOCK_READS.load(Ordering::Relaxed)
2047}
2048
2049/// Wall-clock now as INTEGER epoch SECONDS (UTC), saturating at 0 before the
2050/// Unix epoch. The single place the node-validity path reads the clock — and it
2051/// is read in RUST, then BOUND, never inlined into SQL as `datetime('now')`.
2052fn current_epoch_seconds() -> i64 {
2053    // 0.8.20 Slice 15b fix-3 (F2) — meter every wall-clock read on the validity
2054    // path. R-20-NV requires `:now` to bind ONCE PER QUERY (not per row, not per
2055    // ARM): if two arms of one query each resolve *now*, a query that straddles
2056    // a validity boundary can have its arms disagree about which side they are
2057    // on. That is invisible to a result-shape assertion and unreachable by a
2058    // deterministic test — you cannot assert on a race. Counting the reads makes
2059    // the property testable WITHOUT racing the clock, and keeps failing for any
2060    // arm added later that re-reads it. `Relaxed` is sufficient: the counter is
2061    // an observation, never a synchronization point.
2062    CLOCK_READS.fetch_add(1, Ordering::Relaxed);
2063    std::time::SystemTime::now()
2064        .duration_since(std::time::UNIX_EPOCH)
2065        .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
2066        .unwrap_or(0)
2067}
2068
2069/// TC-33 — the edge-validity conjunct, bound to positional parameter
2070/// `?{now_idx}`. THE one generator for "is this edge valid at `:now`", so no
2071/// read site can drift from another (the same discipline
2072/// [`ReadView::validity_sql`] applies to node validity).
2073///
2074/// An edge is valid at `t` iff it has no invalid-time, or its invalid-time is
2075/// strictly in the future. `t_invalid` is INTEGER epoch seconds since step 23,
2076/// so this is a direct integer comparison — no `datetime()` conversion per row.
2077///
2078/// **`:now` is a BOUND PARAMETER, never `datetime('now')`.** Before TC-33 every
2079/// edge read site inlined `datetime('now')`, which made the predicate
2080/// non-deterministic, untestable, and re-evaluated per row; step 22's comment
2081/// flagged that as the reason node and edge validity could not be unified. They
2082/// are unified now.
2083///
2084/// Always begins with ` AND `, so every call site must already have a preceding
2085/// `WHERE` predicate.
2086fn edge_validity_sql(alias: &str, now_idx: usize) -> String {
2087    format!(" AND ({alias}.t_invalid IS NULL OR {alias}.t_invalid > ?{now_idx})")
2088}
2089
2090/// TC-33 — parse one ISO-8601 timestamp to INTEGER epoch seconds using SQLite's
2091/// own date parser, via a BOUND parameter.
2092///
2093/// Returns `None` when SQLite cannot resolve the value — `strftime` yields SQL
2094/// NULL for junk (`'not a date'`, `''`, `'2020-13-45T99:99:99Z'`, a bare epoch
2095/// string, whitespace-padded input, non-ASCII digits) and a digit string for
2096/// anything it understands.
2097///
2098/// **Why SQLite and not a date crate:** there is no `chrono`/`time` dependency
2099/// anywhere in the workspace, and HITL directed this spelling rather than adding
2100/// one. The value is BOUND, never interpolated.
2101///
2102/// **This does not violate the inline-clock rule.** That rule forbids
2103/// `datetime('now')` / `strftime('%s','now')` — an inline CLOCK. Parsing a bound
2104/// user value is deterministic and reads no clock. The current instant still
2105/// comes from the bound `:now` seam ([`current_epoch_seconds`]).
2106///
2107/// The `CAST` matters: `strftime('%s', ...)` returns TEXT (a digit string), not
2108/// an integer, and the column is `typeof(...) = 'integer'`-checked.
2109///
2110/// # TC-33 fix-5 — strict ISO-8601 SHAPE gate before delegating to SQLite
2111///
2112/// `strftime('%s', ?)` alone is NOT an ISO-8601 validator: SQLite's date parser
2113/// is MORE lenient than the declared wire contract. A bare number is read as a
2114/// **Julian day** (`strftime('%s','2451545.0')` → `946728000`, i.e. year 2000)
2115/// and `strftime('%s','0')` resolves to a pre-year-0000 epoch — so non-ISO input
2116/// was ACCEPTED and stored as an unrelated instant despite the "hard-reject
2117/// ISO-8601" contract HITL ratified (2026-07-21). [`is_iso8601_shape`] runs
2118/// FIRST and returns `None` for anything that is not a strict ISO-8601
2119/// date/datetime shape, so the existing hard-reject path fires. The shape gate
2120/// does NOT replace SQLite's calendar math — a shape-valid but impossible date
2121/// (`2025-13-45T00:00:00Z`) still `None`s out via `strftime` and hard-rejects.
2122///
2123/// # TC-47 — the calendar-DATE ROUND-TRIP backstop (keystone terminal codex P2)
2124///
2125/// The shape gate checks FORMAT, not CALENDAR VALIDITY, and `strftime('%s', ?)`
2126/// does NOT fully validate the calendar: it **rolls over an impossible DAY**
2127/// rather than returning NULL — `strftime('%s','2025-02-30T00:00:00Z')` yields
2128/// the epoch for `2025-03-02`, and `2025-04-31` yields `2025-05-01`. So a
2129/// shape-valid Feb-30 would parse to a DIFFERENT instant than the provider
2130/// supplied, bypassing the hard-reject contract. (An impossible MONTH like
2131/// `2025-13-01`, and impossible TIMES like `25:00:00` / `:60` / `:61`, already
2132/// NULL out; only impossible DAYS roll over — that is the sole residue.)
2133///
2134/// The `WHERE` clause is the round-trip: the literal calendar DATE component of
2135/// the input (`substr(?1, 1, 10)` — the `YYYY-MM-DD` the shape gate guarantees is
2136/// present) must survive SQLite's own calendar math UNCHANGED. If it rolled over,
2137/// `strftime('%Y-%m-%d', substr(?1,1,10))` differs from the literal substring and
2138/// the `WHERE` yields zero rows => `query_row` -> `QueryReturnedNoRows` -> `None`
2139/// => the existing hard-reject fires. This is a superset of the shape gate: it
2140/// also rejects the TC-44 Julian string `2451545.0` (its `substr(1,10)` renders
2141/// to `2000-01-01`, not itself).
2142///
2143/// **Why the DATE component and not the raw string or the UTC-rendered instant:**
2144/// a raw-string or `unixepoch`-rendered comparison would FALSE-REJECT valid
2145/// equivalent forms. `Z` vs `+00:00`, a non-UTC offset like `+05:00`, date-only,
2146/// and fractional seconds are all valid and store the correct (offset-shifted)
2147/// epoch — but a UTC re-render shifts the wall clock, so its date can differ from
2148/// the input's literal date. Comparing ONLY the literal DATE field is
2149/// tz-INVARIANT (the offset never alters the input's own `YYYY-MM-DD` text) while
2150/// still catching every DAY rollover, because the rollover happens in the
2151/// calendar math BEFORE any offset is applied. **Pure SQL — no date crate.**
2152fn iso8601_to_epoch_seconds(connection: &Connection, raw: &str) -> Option<i64> {
2153    if !is_iso8601_shape(raw) {
2154        return None;
2155    }
2156    connection
2157        .query_row(
2158            "SELECT CAST(strftime('%s', ?1) AS INTEGER) \
2159             WHERE strftime('%Y-%m-%d', substr(?1, 1, 10)) IS substr(?1, 1, 10)",
2160            params![raw],
2161            |r| r.get::<_, Option<i64>>(0),
2162        )
2163        .ok()
2164        .flatten()
2165}
2166
2167/// TC-33 fix-5 — strict ISO-8601 date/datetime SHAPE gate. Hand-rolled on ASCII
2168/// bytes (NO new dependency: the workspace has no `chrono`/`time`, and `regex`
2169/// is only a transitive dep of `jsonschema`, not a direct one — adding either as
2170/// a direct dep would violate the "no new dependency" constraint).
2171///
2172/// Accepts EXACTLY:
2173/// - `YYYY-MM-DD` (date only); optionally followed by
2174/// - a `T` **or** a single space separator, then `HH:MM:SS`; optionally followed
2175///   by `.fff` fractional seconds (one or more digits); optionally followed by
2176///   a zone: `Z`, or `±HH:MM`, or `±HHMM`.
2177///
2178/// Rejects bare numbers (`0`, `2451545.0`), partial junk, whitespace, non-ASCII
2179/// digits, and anything with trailing characters. All digit positions require
2180/// ASCII `0..=9` (`u8::is_ascii_digit`), so non-ASCII digit look-alikes cannot
2181/// slip through. This is a SHAPE check only — calendar validity (e.g. month 13,
2182/// day 45) is still enforced by SQLite's `strftime` after this gate passes.
2183fn is_iso8601_shape(s: &str) -> bool {
2184    let b = s.as_bytes();
2185    let d = |c: u8| c.is_ascii_digit();
2186
2187    // Date: YYYY-MM-DD (exactly 10 bytes).
2188    if b.len() < 10 {
2189        return false;
2190    }
2191    if !(d(b[0])
2192        && d(b[1])
2193        && d(b[2])
2194        && d(b[3])
2195        && b[4] == b'-'
2196        && d(b[5])
2197        && d(b[6])
2198        && b[7] == b'-'
2199        && d(b[8])
2200        && d(b[9]))
2201    {
2202        return false;
2203    }
2204    if b.len() == 10 {
2205        return true; // date-only
2206    }
2207
2208    // Separator (`T` or a single space) + time HH:MM:SS (indices 10..=18).
2209    if b[10] != b'T' && b[10] != b' ' {
2210        return false;
2211    }
2212    if b.len() < 19 {
2213        return false;
2214    }
2215    if !(d(b[11])
2216        && d(b[12])
2217        && b[13] == b':'
2218        && d(b[14])
2219        && d(b[15])
2220        && b[16] == b':'
2221        && d(b[17])
2222        && d(b[18]))
2223    {
2224        return false;
2225    }
2226
2227    let mut i = 19;
2228
2229    // Optional fractional seconds `.fff` (one or more digits).
2230    if i < b.len() && b[i] == b'.' {
2231        i += 1;
2232        let start = i;
2233        while i < b.len() && d(b[i]) {
2234            i += 1;
2235        }
2236        if i == start {
2237            return false; // `.` with no digits
2238        }
2239    }
2240
2241    // Optional zone.
2242    if i == b.len() {
2243        return true; // no zone
2244    }
2245    match b[i] {
2246        b'Z' => i += 1,
2247        b'+' | b'-' => {
2248            i += 1;
2249            // `HH`
2250            if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2251                return false;
2252            }
2253            i += 2;
2254            // `:MM` or `MM`
2255            if i < b.len() && b[i] == b':' {
2256                i += 1;
2257            }
2258            if i + 2 > b.len() || !d(b[i]) || !d(b[i + 1]) {
2259                return false;
2260            }
2261            i += 2;
2262        }
2263        _ => return false,
2264    }
2265
2266    i == b.len() // no trailing junk
2267}
2268
2269/// TC-33 — render INTEGER epoch seconds back to an ISO-8601 UTC string for the
2270/// BYO-LLM wire. The exact inverse of [`iso8601_to_epoch_seconds`].
2271///
2272/// Storage and the governed SDK are epoch seconds, but the harness protocols
2273/// (`fathomdb.extract.v1` and the consolidation harness) carry ISO-8601 — LLMs
2274/// reason about dates as text, and pushing epoch integers onto them would make
2275/// the wire hostile to the very providers it exists to serve. So the boundary
2276/// converts in BOTH directions and the representation split stays a boundary
2277/// concern rather than leaking into the protocol.
2278fn epoch_seconds_to_iso8601(connection: &Connection, epoch: i64) -> Option<String> {
2279    connection
2280        .query_row("SELECT strftime('%Y-%m-%dT%H:%M:%SZ', ?1, 'unixepoch')", params![epoch], |r| {
2281            r.get::<_, Option<String>>(0)
2282        })
2283        .ok()
2284        .flatten()
2285}
2286
2287/// TC-33 fix-1 — the inclusive epoch-seconds range SQLite's
2288/// `strftime(..., 'unixepoch')` can render back to ISO-8601. SQLite's date
2289/// functions cover years 0000..=9999 ONLY, so:
2290/// - `MIN` = `0000-01-01T00:00:00Z`
2291/// - `MAX` = `9999-12-31T23:59:59Z`
2292///
2293/// TC-33 fix-5 makes these the ACTUAL rejection predicate (a numeric
2294/// `[MIN, MAX]` bounds check), not just message text. Renderability was too
2295/// weak: `strftime(..., 'unixepoch')` renders a below-`MIN` value like
2296/// `-62_167_219_201` to `-001-12-31T23:59:59Z` (NON-NULL), so a pre-year-0000
2297/// epoch slipped the renderability guard even though it is outside the declared
2298/// years 0000..=9999. Both bounds are verified against SQLite to correspond
2299/// EXACTLY to the first/last renderable instant:
2300/// `strftime('%Y-%m-%dT%H:%M:%SZ', MIN, 'unixepoch') = 0000-01-01T00:00:00Z` and
2301/// `= 9999-12-31T23:59:59Z` for `MAX` (`MAX+1` and `MIN-1` are the first
2302/// out-of-range instants).
2303const MIN_RENDERABLE_EPOCH: i64 = -62_167_219_200; // 0000-01-01T00:00:00Z
2304const MAX_RENDERABLE_EPOCH: i64 = 253_402_300_799; // 9999-12-31T23:59:59Z
2305
2306/// TC-33 fix-1 — reject an edge epoch that SQLite cannot render back to
2307/// ISO-8601, at the governed write boundary, so it is UNSTORABLE.
2308///
2309/// # Why this is the primary layer
2310///
2311/// Storage and `PreparedWrite::Edge` carry INTEGER epoch seconds and accept an
2312/// arbitrary `i64`. The consolidation path renders each candidate's
2313/// `t_valid`/`t_invalid` to ISO-8601 for the LLM via `strftime(..., 'unixepoch')`,
2314/// which only spans years 0000..=9999. An epoch outside that range renders to
2315/// NULL, and the render site would then send a silent `null` for a timestamp
2316/// that is actually stored NON-NULL — the OUTBOUND twin of the fail-open TC-33
2317/// removes. A `null` `t_invalid` reads as "still valid", and the consolidation
2318/// reference stub echoes a winner's `t_valid` straight back as the verdict's
2319/// `t_invalid`, so the `null` round-trips through the inbound normaliser as
2320/// "still valid": an invalidated edge silently resurrected.
2321///
2322/// Inbound ISO normalisation can never MINT such an epoch (a 4-digit-year ISO
2323/// string maxes at 9999), so the governed integer surface is the only ingress —
2324/// which is exactly where this guard sits. Like the inbound
2325/// [`normalize_extractor_timestamp`] hard-reject, it is a typed
2326/// [`EngineError::InvalidArgument`] naming the offending value and the bound,
2327/// never a silent coercion.
2328///
2329/// ⚠ It no longer mirrors the `validate_write` Node branch's
2330/// `valid_from >= valid_until` refusal: decision #18 (0.8.20 Slice 22) moved
2331/// THAT refusal onto the message-less [`EngineError::WriteValidation`] unit
2332/// variant, which carries no value at all. The two refusals are deliberately in
2333/// different families now — a malformed submitted write SHAPE is
2334/// `WriteValidation`; an out-of-domain scalar on this render path stays
2335/// `InvalidArgument`. Do not restate them as one pattern.
2336fn reject_unrenderable_edge_epoch(field: &str, value: Option<i64>) -> Result<(), EngineError> {
2337    // TC-33 fix-5 — an explicit numeric MIN/MAX bounds check, NOT a renderability
2338    // test. `strftime(..., 'unixepoch')` renders a below-`MIN` epoch (e.g.
2339    // `-62_167_219_201`, year -0001) to a NON-NULL string, so a renderability
2340    // guard would let pre-year-0000 values through even though they are outside
2341    // the declared years 0000..=9999. No `Connection` is needed now that the
2342    // predicate is pure integer arithmetic.
2343    if let Some(ts) = value {
2344        if !(MIN_RENDERABLE_EPOCH..=MAX_RENDERABLE_EPOCH).contains(&ts) {
2345            return Err(EngineError::InvalidArgument {
2346                msg: format!(
2347                    "edge field `{field}` = {ts} is outside the epoch-seconds range SQLite can \
2348                     render to ISO-8601 ([{MIN_RENDERABLE_EPOCH}, {MAX_RENDERABLE_EPOCH}], i.e. \
2349                     years 0000..=9999). REJECTED rather than stored: such an epoch renders to a \
2350                     silent NULL (or a nonsensical out-of-range instant) on the consolidation \
2351                     wire, and a NULL `t_invalid` reads as \"still valid\" — resurrecting an \
2352                     invalidated edge."
2353                ),
2354            });
2355        }
2356    }
2357    Ok(())
2358}
2359
2360/// The JSON type name of `value`, for diagnosing a mistyped extractor field.
2361fn json_type_name(value: &Value) -> &'static str {
2362    match value {
2363        Value::Null => "null",
2364        Value::Bool(_) => "boolean",
2365        Value::Number(_) => "number",
2366        Value::String(_) => "string",
2367        Value::Array(_) => "array",
2368        Value::Object(_) => "object",
2369    }
2370}
2371
2372/// TC-33 — normalise one timestamp arriving on the **BYO-LLM extractor
2373/// boundary** (`fathomdb.extract.v1`) into the INTEGER epoch seconds the storage
2374/// and governed-SDK layers use. **HARD-REJECTS** anything it cannot normalise.
2375///
2376/// This is the layering boundary HITL ratified on 2026-07-21:
2377/// - the **extractor wire format stays ISO-8601 strings** — LLMs emit text, and
2378///   this function is the one place that changes;
2379/// - **storage and the governed SDK surface are INTEGER epoch seconds.**
2380///
2381/// # Why rejection, not coercion — fail-open is the defect
2382///
2383/// A NULL `t_invalid` means **"still valid"**. So any path that turns an
2384/// unparseable timestamp into NULL silently **resurrects an invalidated edge**.
2385/// Two distinct fail-opens are closed here:
2386///
2387/// 1. **Malformed strings.** Previously NOTHING parsed or validated these; junk
2388///    went verbatim into the INSERT. Under the old TEXT column it then failed
2389///    CLOSED by accident (`datetime('junk')` → NULL ⇒ the read disjunct is
2390///    falsy ⇒ the row vanished). Under INTEGER that polarity would INVERT.
2391/// 2. **Non-string JSON — a fail-open that PREDATES TC-33.** The old site read
2392///    `edge.get("t_invalid").and_then(|v| v.as_str())`, and `as_str()` returns
2393///    `None` for a JSON number/bool/object. So `"t_invalid": 1710000000` — a
2394///    plausible mistake, and exactly the epoch form storage now uses — had its
2395///    invalidation SILENTLY DISCARDED and the edge stored as "still valid".
2396///
2397/// `None`/JSON `null`/absent is the ONLY sanctioned way to say "unknown"; it
2398/// maps to `Ok(None)` and keeps the NULL-means-still-valid semantic.
2399///
2400/// Refuses with a typed [`EngineError::InvalidArgument`] CARRYING the offending
2401/// value, so a caller can see what was rejected.
2402///
2403/// ⚠ This is NOT the same pattern as the `validate_write` `Node` branch's
2404/// `valid_from >= valid_until` check, which that comment used to cite: decision
2405/// #18 (0.8.20 Slice 22) moved that refusal onto the message-less
2406/// [`EngineError::WriteValidation`] unit variant, which carries **no value at
2407/// all**. Both the family AND the carry-the-value property differ.
2408fn normalize_extractor_timestamp(
2409    connection: &Connection,
2410    field: &str,
2411    raw: Option<&Value>,
2412) -> Result<Option<i64>, EngineError> {
2413    match raw {
2414        None | Some(Value::Null) => Ok(None),
2415        Some(Value::String(text)) => match iso8601_to_epoch_seconds(connection, text) {
2416            Some(epoch) => Ok(Some(epoch)),
2417            None => Err(EngineError::InvalidArgument {
2418                msg: format!(
2419                    "extractor edge field `{field}` must be a valid, calendar-real ISO-8601 \
2420                     timestamp; got {text:?}, which either `strftime('%s', ?)` resolves to NULL \
2421                     or fails the calendar round-trip (a shape-valid but impossible DAY like \
2422                     `2025-02-30` that SQLite would silently ROLL OVER to a different instant). \
2423                     REJECTED rather than stored: a NULL `t_invalid` reads as \"still valid\" and \
2424                     a rolled-over date stores the WRONG instant — both breach the hard-reject \
2425                     contract. Use JSON null for \"unknown\"."
2426                ),
2427            }),
2428        },
2429        Some(other) => Err(EngineError::InvalidArgument {
2430            msg: format!(
2431                "extractor edge field `{field}` must be an ISO-8601 string or JSON null; got a \
2432                 JSON {kind} ({other}). The `fathomdb.extract.v1` wire format carries ISO-8601 at \
2433                 this boundary — INTEGER epoch seconds are the STORAGE representation, not the \
2434                 wire one. REJECTED rather than coerced to NULL, which reads as \"still valid\".",
2435                kind = json_type_name(other)
2436            ),
2437        }),
2438    }
2439}
2440
2441/// Slice 30 (G3) — one `operational_mutations` row returned by `read.collection`
2442/// / `read.mutations`. `id` is the autoincrement PK (the after-id cursor key).
2443#[derive(Clone, Debug, Eq, PartialEq)]
2444pub struct OpStoreRow {
2445    pub id: i64,
2446    pub collection: String,
2447    pub record_key: String,
2448    pub op_kind: String,
2449    pub payload: String,
2450    pub schema_id: Option<String>,
2451    pub write_cursor: u64,
2452}
2453
2454/// Hybrid `search` result. `results` carries structured [`SearchHit`]s in
2455/// vector-first, dedup-on-body order. Derives `Clone, Debug, PartialEq` but
2456/// **not `Eq`** — each hit carries a `score: f64`.
2457#[derive(Clone, Debug, PartialEq)]
2458// 0.8.8 EXP-OBS (field-set ratification): non_exhaustive so future additive fields
2459// (e.g. the deferred QueryTrace.timings_ms, Q3) are non-breaking. All construction
2460// is in-crate (engine + tests); external crates read fields only.
2461#[non_exhaustive]
2462pub struct SearchResult {
2463    pub projection_cursor: u64,
2464    pub soft_fallback: Option<SoftFallback>,
2465    pub results: Vec<SearchHit>,
2466    /// 0.8.8 EXP-OBS (Slice 5) — opt-in retrieval explanation **sidecar**.
2467    /// `Some` ONLY on the `search_explained` path; `None` for every default
2468    /// (`explain=false`) search, so `results` + `projection_cursor` stay
2469    /// byte-identical to the pre-0.8.8 shape (R-OBS-2 zero-cost contract,
2470    /// HITL-ratified sidecar carrier — see
2471    /// `dev/design/0.8.8-explain-and-telemetry-adr.md` §A.2). Field-set is
2472    /// PROPOSED/ratification-pending; additive inside `Explanation` so later
2473    /// amendments do not reshape `SearchResult`/`SearchHit`.
2474    pub explanation: Option<Explanation>,
2475}
2476
2477/// 0.8.8 EXP-OBS (Slice 5) — the opt-in retrieval explanation payload returned
2478/// behind `search_explained` (the `explain=true` surface). Built from the
2479/// engine's OWN fusion/rerank machinery (`fuse_three_arms` per-arm ranks,
2480/// `ce_rerank` blend components) — no parallel machinery (R-OBS-3). Carries a
2481/// query-level [`QueryTrace`] plus a per-hit breakdown parallel to (and in the
2482/// same order as) `SearchResult.results`.
2483///
2484/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2485#[derive(Clone, Debug, PartialEq)]
2486#[non_exhaustive] // 0.8.8 field-set ratification — additive-safe sidecar
2487pub struct Explanation {
2488    pub trace: QueryTrace,
2489    pub per_hit: Vec<PerHitExplain>,
2490}
2491
2492/// 0.8.8 EXP-OBS (Slice 5) — query-level retrieval trace. Reuses the existing
2493/// `search_reranked` knobs + the active embedder identity; timings are coarse
2494/// per-stage wall-clock (monotonic) captured only on the explain path.
2495#[derive(Clone, Debug, PartialEq)]
2496// 0.8.8 field-set ratification — HARD: leaf absorbs the deferred `timings_ms` (Q3)
2497// and any future trace field without a contract break.
2498#[non_exhaustive]
2499pub struct QueryTrace {
2500    /// Query LENGTH only (chars) — never the query text (privacy; ADR §C).
2501    pub query_chars: u32,
2502    /// Caller-requested final result limit (`final_limit`).
2503    pub k: u32,
2504    pub rerank_depth: u32,
2505    pub pool_n: u32,
2506    pub alpha: f64,
2507    pub use_graph_arm: bool,
2508    /// Recency reweight (the dedicated G12 flag) was applied.
2509    pub recency: bool,
2510    /// Active embedder identity `name@revision` (+ dim), or empty when none.
2511    pub embedder_id: String,
2512    /// The CE cross-encoder actually reranked the pool (model loaded + depth>0).
2513    pub ce_active: bool,
2514    /// Per-arm input hit counts (pre-fusion).
2515    pub vector_hits: u32,
2516    pub text_hits: u32,
2517    pub graph_hits: u32,
2518    /// Edge-FTS candidates rejected only because an attribute predicate is
2519    /// node-scoped. Present on the opt-in explanation so this deliberate
2520    /// filtering never looks like an absent corpus.
2521    pub dropped_edge_hits: u32,
2522}
2523
2524/// 0.8.8 EXP-OBS (Slice 5) — per-hit provenance + score breakdown. One entry per
2525/// returned `SearchHit`, same order. `*_rank` is the 0-based rank the hit's body
2526/// held in that arm's pre-fusion list (`None` = absent from that arm).
2527///
2528/// Derives `Clone, Debug, PartialEq` but **not `Eq`** — scores are `f64`.
2529#[derive(Clone, Debug, PartialEq)]
2530// 0.8.8 field-set ratification — HARD: leaf absorbs future arms / score components.
2531#[non_exhaustive]
2532pub struct PerHitExplain {
2533    /// The hit's engine-internal positional `write_cursor` (the pre-C-2
2534    /// `SearchHit.id`). Post-0.8.19 the caller-facing `SearchHit.id` is a typed
2535    /// [`IdSpace`]; this field keeps carrying the positional cursor so the explain
2536    /// sidecar cross-references the telemetry `result_ids` space. Correlate a
2537    /// `PerHitExplain` to its `SearchHit` by position (both lists are 1:1, same
2538    /// order).
2539    pub id: u64,
2540    /// Winning arm after RRF dedup (vector-first), == `SearchHit.branch`.
2541    pub arm: SoftFallbackBranch,
2542    pub vector_rank: Option<u32>,
2543    pub text_rank: Option<u32>,
2544    pub graph_rank: Option<u32>,
2545    /// Raw RRF fused score AFTER recency reweight, BEFORE CE blend (the value
2546    /// `ce_rerank` normalizes). Faithful to the engine computation — downstream
2547    /// may normalize. (ADR §A.4 Q1: raw exposed; normalization deferred.)
2548    pub fused_score: f64,
2549    /// In-pool cross-encoder score `sigmoid(ce_logit) ∈ [0,1]`, == the returned
2550    /// `SearchHit.ce_score`; `None` outside the reranked pool / no-CE path.
2551    pub ce_score: Option<f64>,
2552    /// Final blended score, == the returned `SearchHit.score`.
2553    pub blended: f64,
2554    /// 0.8.16 Slice 5 / F9 — the node `importance` scalar applied to this hit's
2555    /// fused contribution when the importance reweight is ON, else the raw stored
2556    /// value. `None` = never assigned (graceful-absent, ranks NEUTRAL). Additive
2557    /// (`#[non_exhaustive]` leaf absorbs the new score component).
2558    pub importance: Option<f64>,
2559    /// 0.8.16 Slice 5 / F9 — the edge `confidence` scalar applied to this hit's
2560    /// graph-arm contribution when the importance reweight is ON, else the raw
2561    /// stored value. `None` for node hits / edges without a confidence
2562    /// (graceful-absent, ranks NEUTRAL).
2563    pub confidence: Option<f64>,
2564}
2565
2566// ===== G4 filter grammar types (Slice 35) ===============================
2567
2568/// G4 (Slice 35) — scalar value for [`Predicate`] comparisons.
2569///
2570/// Shared vocabulary with G10 — defined once at the `fathomdb-engine` crate
2571/// root so reserved-gap 37 (full G4↔G10 unification) can import it without a
2572/// path change. Derives `Clone, Debug, PartialEq` per the ADR contract
2573/// (D-F1 exhaustiveness: exactly `{Text, Integer, Bool}`).
2574#[derive(Clone, Debug, PartialEq)]
2575pub enum ScalarValue {
2576    Text(String),
2577    Integer(i64),
2578    Bool(bool),
2579}
2580
2581/// G4 (Slice 35) — comparison operator for [`Predicate::JsonPathCompare`].
2582///
2583/// Shared vocabulary (same crate-root export as `ScalarValue`). Closed
2584/// enum: `{Gt, Gte, Lt, Lte}` per D-F1. Derives `Clone, Debug, PartialEq`.
2585#[derive(Clone, Debug, PartialEq)]
2586pub enum ComparisonOp {
2587    Gt,
2588    Gte,
2589    Lt,
2590    Lte,
2591}
2592
2593/// Allowed JSON paths for [`Predicate`] constructors. The SQL compilation in
2594/// [`Engine::read_list`] uses the **allowlist constant** (a server-side literal),
2595/// never the caller-supplied string, so only paths in this set reach
2596/// `json_extract`. Callers receive [`EngineError::InvalidFilter`] for any
2597/// non-allowlisted path — no passthrough, no panic.
2598///
2599/// To extend: add an entry here. No API change is needed; the constructor
2600/// accepts the new path string once it appears in this array.
2601const PREDICATE_PATH_ALLOWLIST: &[&str] =
2602    &["$.status", "$.priority", "$.tags", "$.kind", "$.created_at", "$.action_kind"];
2603
2604/// G4 (Slice 35) — closed typed predicate for [`Engine::read_list`] filter.
2605///
2606/// Exactly two variants per ADR D-F1 (`{JsonPathEq, JsonPathCompare}`).
2607/// The fused variants (`JsonPathFused*`) and all `*_unchecked` builders are
2608/// explicitly EXCLUDED (ADR D-F2). Use the validated constructors
2609/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`]; they
2610/// enforce the path allowlist at construction time.
2611///
2612/// Multiple predicates in [`Engine::read_list`] are combined by implicit AND
2613/// (D-F5). Compilation target: `json_extract(body, '$.field') <op> ?` with
2614/// a bound parameter (never interpolated — injection-safe per D-F4).
2615#[derive(Clone, Debug, PartialEq)]
2616pub enum Predicate {
2617    /// `json_extract(body, path) = ?` (equality).
2618    JsonPathEq { path: String, value: ScalarValue },
2619    /// `json_extract(body, path) <op> ?` (inequality).
2620    JsonPathCompare { path: String, op: ComparisonOp, value: ScalarValue },
2621}
2622
2623impl Predicate {
2624    /// Construct a `JsonPathEq` predicate with allowlist validation.
2625    ///
2626    /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2627    /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2628    pub fn json_path_eq(path: impl Into<String>, value: ScalarValue) -> Result<Self, EngineError> {
2629        let path = path.into();
2630        if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2631            return Err(EngineError::InvalidFilter {
2632                reason: format!("path '{path}' is not in the predicate path allowlist"),
2633            });
2634        }
2635        Ok(Self::JsonPathEq { path, value })
2636    }
2637
2638    /// Construct a `JsonPathCompare` predicate with allowlist validation.
2639    ///
2640    /// Returns [`EngineError::InvalidFilter`] if `path` is not in
2641    /// [`PREDICATE_PATH_ALLOWLIST`]; never panics on bad input.
2642    pub fn json_path_compare(
2643        path: impl Into<String>,
2644        op: ComparisonOp,
2645        value: ScalarValue,
2646    ) -> Result<Self, EngineError> {
2647        let path = path.into();
2648        if !PREDICATE_PATH_ALLOWLIST.contains(&path.as_str()) {
2649            return Err(EngineError::InvalidFilter {
2650                reason: format!("path '{path}' is not in the predicate path allowlist"),
2651            });
2652        }
2653        Ok(Self::JsonPathCompare { path, op, value })
2654    }
2655
2656    /// Return the validated path string for use in SQL compilation.
2657    /// This always returns a path that is in `PREDICATE_PATH_ALLOWLIST`.
2658    fn path(&self) -> &str {
2659        match self {
2660            Self::JsonPathEq { path, .. } => path.as_str(),
2661            Self::JsonPathCompare { path, .. } => path.as_str(),
2662        }
2663    }
2664
2665    /// Compile this predicate to a SQL WHERE clause fragment.
2666    /// The path is validated at construction time and is always an allowlist
2667    /// constant — never the raw caller-supplied string.
2668    fn to_sql_clause(&self, param_idx: usize) -> String {
2669        // The path is already validated against the allowlist at construction.
2670        // We use the allowlist entry (the stored path) directly as a SQL literal.
2671        // The VALUE is always a bound `?` parameter (injection-safe).
2672        //
2673        // Type guards prevent cross-type matches caused by SQLite's json_extract
2674        // coercing JSON booleans to integer 1/0:
2675        //   - Bool predicates: AND json_type IN ('true', 'false') — exclude integers
2676        //   - Integer predicates: AND json_type = 'integer' — exclude booleans
2677        // Text predicates need no guard: json_extract returns TEXT for strings and
2678        // the coercion never conflates TEXT with integer/bool.
2679        let path = self.path();
2680        match self {
2681            Self::JsonPathEq { value, .. } => match value {
2682                ScalarValue::Bool(_) => format!(
2683                    "json_extract(body, '{path}') = ?{param_idx} \
2684                     AND json_type(body, '{path}') IN ('true', 'false')"
2685                ),
2686                ScalarValue::Integer(_) => format!(
2687                    "json_extract(body, '{path}') = ?{param_idx} \
2688                     AND json_type(body, '{path}') = 'integer'"
2689                ),
2690                ScalarValue::Text(_) => {
2691                    format!("json_extract(body, '{path}') = ?{param_idx}")
2692                }
2693            },
2694            Self::JsonPathCompare { op, value, .. } => {
2695                let op_str = match op {
2696                    ComparisonOp::Gt => ">",
2697                    ComparisonOp::Gte => ">=",
2698                    ComparisonOp::Lt => "<",
2699                    ComparisonOp::Lte => "<=",
2700                };
2701                match value {
2702                    ScalarValue::Bool(_) => format!(
2703                        "json_extract(body, '{path}') {op_str} ?{param_idx} \
2704                         AND json_type(body, '{path}') IN ('true', 'false')"
2705                    ),
2706                    ScalarValue::Integer(_) => format!(
2707                        "json_extract(body, '{path}') {op_str} ?{param_idx} \
2708                         AND json_type(body, '{path}') = 'integer'"
2709                    ),
2710                    ScalarValue::Text(_) => format!(
2711                        "json_extract(body, '{path}') {op_str} ?{param_idx} \
2712                         AND json_type(body, '{path}') = 'text'"
2713                    ),
2714                }
2715            }
2716        }
2717    }
2718
2719    /// Bind the value of this predicate as a rusqlite parameter.
2720    fn bind_value(&self) -> rusqlite::types::Value {
2721        let value = match self {
2722            Self::JsonPathEq { value, .. } => value,
2723            Self::JsonPathCompare { value, .. } => value,
2724        };
2725        match value {
2726            ScalarValue::Text(s) => rusqlite::types::Value::Text(s.clone()),
2727            ScalarValue::Integer(i) => rusqlite::types::Value::Integer(*i),
2728            ScalarValue::Bool(b) => rusqlite::types::Value::Integer(i64::from(*b)),
2729        }
2730    }
2731}
2732
2733// ===== Slice 20 (G5/G6) — graph traversal types =========================
2734
2735/// Slice 20 (G5) — direction of graph traversal for
2736/// [`Engine::graph_neighbors`] / [`Engine::search_expand`].
2737///
2738/// `Outgoing` follows edges where the root is the `from_id` (source).
2739/// `Incoming` follows edges where the root is the `to_id` (target).
2740/// `Both` follows edges in either direction.
2741#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2742pub enum TraversalDirection {
2743    Outgoing,
2744    Incoming,
2745    Both,
2746}
2747
2748/// Slice 20 (G6) — result of [`Engine::search_expand`]: initial search hits
2749/// plus nodes reached by bounded BFS expansion that are not already in the
2750/// search hit set.
2751#[derive(Clone, Debug)]
2752pub struct SearchExpandResult {
2753    /// Original RRF-scored search results (G1+G9 hybrid).
2754    pub search_hits: Vec<SearchHit>,
2755    /// Nodes reached by graph traversal but NOT already in `search_hits`.
2756    /// Each entry is `(node, hop_count)` where `hop_count` is the BFS depth
2757    /// from the nearest search hit that reached this node.
2758    pub expanded: Vec<(NodeRecord, u32)>,
2759    /// Deduplicated union of all logical_ids (search hits first, then expanded).
2760    pub all_logical_ids: Vec<String>,
2761}
2762
2763/// G10 — closed metadata filter for [`Engine::search_filtered`] (Slice 10).
2764///
2765/// All fields are optional; a `None` field imposes no constraint, and an
2766/// all-`None` filter (or `None` filter) is the unfiltered path whose phase-1 SQL
2767/// is byte-identical to 0.7.2. This is a **closed struct**, not an open filter
2768/// DSL (ADR-0.8.0-agent-memory-retrieval-and-identity Q1); the filter-grammar /
2769/// `list` decision stays a later-slice concern.
2770///
2771/// `created_after` is a `created_at >= bound` lower bound in unix seconds.
2772/// `status` is wired through to the vec0 `status` metadata column. vec0 TEXT
2773/// metadata columns are **NOT NULL-able**, so the "no real population yet" state
2774/// is an **empty-string sentinel** `''` (a forced deviation from the planned
2775/// "NULL plumbing"; a real population source is reserved-gap candidate 13). A
2776/// `status = Some("open")`-style filter therefore prunes every row until that
2777/// population slice lands.
2778// 0.8.20 Slice 15e fix-2 (Finding 2) — `#[non_exhaustive]`: the `attributes`
2779// field was added additively in 0.8.20. Marking the struct non-exhaustive means
2780// EXTERNAL crates can no longer use a struct literal `SearchFilter { .. }` and
2781// must go through `..Default::default()` (or a constructor), so a FUTURE field
2782// add is not a source break for them. Internal (in-workspace) construction is
2783// unaffected — `#[non_exhaustive]` only constrains other crates — and every
2784// in-crate literal already spreads `..Default::default()`. Governed-surface
2785// status: PROPOSED / NOT SIGNED.
2786#[derive(Clone, Debug, Default, Eq, PartialEq)]
2787#[non_exhaustive]
2788pub struct SearchFilter {
2789    pub source_type: Option<String>,
2790    pub kind: Option<String>,
2791    pub created_after: Option<i64>,
2792    pub status: Option<String>,
2793    /// 0.8.20 Slice 15e (R-20-PR, ADR-0.8.11 D3) — declared-`filterable`-attribute
2794    /// equality predicates, each `(attribute_name, value)`. Lowered into the
2795    /// **indexed pre-KNN** vec0 metadata column `attr_<hex>` by
2796    /// [`vector_filter_clause`] (NOT a post-KNN `json_extract`). Empty ⇒ the
2797    /// byte-identical unfiltered path is preserved. `attribute_name` is the
2798    /// registry projection name; the encoded column is derived by
2799    /// [`attr_vec0_column`].
2800    pub attributes: Vec<(String, String)>,
2801}
2802
2803impl SearchFilter {
2804    /// True when no field constrains the search — equivalent to `None`. Used to
2805    /// keep the unfiltered code path (and its byte-identical SQL) on the
2806    /// all-`None` struct.
2807    fn is_unfiltered(&self) -> bool {
2808        self.source_type.is_none()
2809            && self.kind.is_none()
2810            && self.created_after.is_none()
2811            && self.status.is_none()
2812            && self.attributes.is_empty()
2813    }
2814}
2815
2816// ===== 0.8.11 Slice 40 (#17) — unified filter grammar (G4 + G10) =========
2817
2818/// 0.8.11 Slice 40 (#17) — a single closed `FilterTerm` of the **unified**
2819/// filter grammar (ADR-0.8.11-filter-grammar-unification, Option A; closes
2820/// reserved-gap 37). Exactly **five** variants: the four G10 shorthand metadata
2821/// fields (`SourceType`/`Kind`/`CreatedAfter`/`Status`) plus the general G4
2822/// json-path [`Predicate`] (`Json`). The shorthand fields are dedicated typed
2823/// variants — NOT `Json(Predicate)` over `$.source_type` etc. — precisely so the
2824/// vec0 search backend can lower them to the *indexed* pre-KNN metadata columns
2825/// while typed-rejecting an arbitrary `Json` term (D3: no demotion to post-KNN
2826/// `json_extract`).
2827///
2828/// The grammar stays **closed** (inherits ADR-0.8.0 D-F1/D-F2/D-F4/D-F5): no
2829/// DSL, no caller SQL, no `JsonPathFused*`, no `*_unchecked`, no OR/nesting
2830/// (implicit AND only); `Json` terms are built ONLY via the validated
2831/// [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`] constructors
2832/// (path allowlist enforced at construction). The shipped `ScalarValue` /
2833/// `ComparisonOp` / `Predicate` vocabulary is reused verbatim — no new grammar.
2834#[derive(Clone, Debug, PartialEq)]
2835pub enum FilterTerm {
2836    /// vec0 partition-key metadata column `source_type` (pre-KNN). On
2837    /// `read.list` it **constant-folds** against `resolve_source_type(kind)`
2838    /// (the column does not exist in `canonical_nodes`).
2839    SourceType(String),
2840    /// `kind` — the vec0 metadata column (pre-KNN). On `read.list` it
2841    /// constant-folds against the partition `kind` argument (D1 impl decision:
2842    /// constant-fold, the simpler total option vs a redundant column clause).
2843    Kind(String),
2844    /// `created_at >= bound` (unix seconds). vec0 metadata column (pre-KNN);
2845    /// lowers to `json_extract(body,'$.created_at') >= ?` on `read.list`.
2846    CreatedAfter(i64),
2847    /// vec0 metadata column `status` (pre-KNN); lowers to
2848    /// `json_extract(body,'$.status') = ?` on `read.list`.
2849    Status(String),
2850    /// The general G4 json-path predicate (unchanged shipped grammar). Resolves
2851    /// **only** on the `read.list` (canonical_nodes) backend; **typed-rejected**
2852    /// on `search_filtered` because it would require a post-KNN `json_extract`
2853    /// that defeats the indexed pre-KNN filter (D3 no-demotion guarantee).
2854    Json(Predicate),
2855}
2856
2857/// 0.8.11 Slice 40 (#17) — the unified closed `Filter` contract. ONE superset
2858/// type with implicit-AND [`FilterTerm`]s, dispatched to one of **two** internal
2859/// compilation backends (Option A — the TYPE unifies, the COMPILATION
2860/// dispatches): the vec0-metadata indexed pre-KNN `WHERE` for `search_filtered`,
2861/// and `json_extract` over `canonical_nodes.body` for `read.list`. The shipped
2862/// `SearchFilter` (G10) and `Predicate` lists (G4) re-express as sugar that
2863/// lowers into this type (D4); the `filter=None` byte-identical-0.7.2-SQL pin is
2864/// preserved because the vec0 lowering routes back through the shipped
2865/// `vector_filter_clause` compilation verbatim.
2866#[derive(Clone, Debug, Default, PartialEq)]
2867pub struct Filter {
2868    /// AND-combined terms (implicit AND, inherits D-F5). Empty = unfiltered.
2869    pub terms: Vec<FilterTerm>,
2870}
2871
2872impl TryFrom<&SearchFilter> for Filter {
2873    type Error = EngineError;
2874
2875    /// D4 sugar lowering — the shipped G10 [`SearchFilter`] re-expressed as the
2876    /// unified [`Filter`]. Attribute equality is intentionally not part of the
2877    /// unified grammar, so it is refused rather than silently discarded. Field
2878    /// → term uses canonical order (`source_type`, `kind`, `created_after`,
2879    /// `status`) so an attribute-free round-trip stays byte-identical.
2880    fn try_from(sf: &SearchFilter) -> Result<Self, Self::Error> {
2881        if !sf.attributes.is_empty() {
2882            return Err(EngineError::InvalidFilter {
2883                reason:
2884                    "projected attribute predicates are not supported by the unified Filter grammar"
2885                        .to_string(),
2886            });
2887        }
2888        let mut terms = Vec::new();
2889        if let Some(s) = &sf.source_type {
2890            terms.push(FilterTerm::SourceType(s.clone()));
2891        }
2892        if let Some(k) = &sf.kind {
2893            terms.push(FilterTerm::Kind(k.clone()));
2894        }
2895        if let Some(c) = sf.created_after {
2896            terms.push(FilterTerm::CreatedAfter(c));
2897        }
2898        if let Some(s) = &sf.status {
2899            terms.push(FilterTerm::Status(s.clone()));
2900        }
2901        Ok(Filter { terms })
2902    }
2903}
2904
2905impl Filter {
2906    /// Backend dispatch for `search_filtered` (vec0 — indexed pre-KNN). Lowers
2907    /// the metadata subset `{SourceType, Kind, CreatedAfter, Status}` back into a
2908    /// [`SearchFilter`] (which the shipped `vector_filter_clause` compiles to the
2909    /// pre-KNN `WHERE`), and **typed-rejects** a [`FilterTerm::Json`] term with
2910    /// [`EngineError::InvalidFilter`] — the explicit no-demotion guarantee (D3).
2911    /// Field-by-variant assignment makes the output canonical-order-independent
2912    /// of `terms` ordering (hand-built router filters included). A later
2913    /// duplicate metadata term overwrites the earlier (last-wins).
2914    pub fn to_search_filter(&self) -> Result<SearchFilter, EngineError> {
2915        let mut sf = SearchFilter::default();
2916        for term in &self.terms {
2917            match term {
2918                FilterTerm::SourceType(s) => sf.source_type = Some(s.clone()),
2919                FilterTerm::Kind(k) => sf.kind = Some(k.clone()),
2920                FilterTerm::CreatedAfter(c) => sf.created_after = Some(*c),
2921                FilterTerm::Status(s) => sf.status = Some(s.clone()),
2922                FilterTerm::Json(_) => {
2923                    return Err(EngineError::InvalidFilter {
2924                        reason: "arbitrary json-path predicate not supported on search_filtered; \
2925                                 it would require a post-KNN json_extract that defeats the \
2926                                 indexed pre-KNN filter (ADR-0.8.11 D3 no-demotion guarantee)"
2927                            .to_string(),
2928                    });
2929                }
2930            }
2931        }
2932        Ok(sf)
2933    }
2934
2935    /// Backend dispatch for `read.list` (canonical_nodes — `json_extract`). The
2936    /// full set resolves here. Returns:
2937    /// - `Ok(Some(preds))` — the implicit-AND [`Predicate`] list to run; or
2938    /// - `Ok(None)` — a constant-folded **guaranteed-empty** result (a `Kind` or
2939    ///   `SourceType` term that cannot match this partition), so the caller
2940    ///   returns an empty `Vec` without touching SQL; or
2941    /// - `Err(InvalidFilter)` — a non-allowlisted path (defense-in-depth; the
2942    ///   shorthand lowerings only ever use allowlisted paths).
2943    ///
2944    /// Lowering (D3): `Json(p)` → `p`; `Status(s)` →
2945    /// `json_path_eq("$.status", Text(s))`; `CreatedAfter(b)` →
2946    /// `json_path_compare("$.created_at", Gte, Integer(b))`; `Kind(k)` →
2947    /// constant-fold vs the partition `kind` arg (no-op if equal, empty if not);
2948    /// `SourceType(s)` → constant-fold vs `resolve_source_type(kind)` (no-op if
2949    /// equal, empty otherwise — the column does not exist in `body`).
2950    fn lower_for_read_list(&self, kind: &str) -> Result<Option<Vec<Predicate>>, EngineError> {
2951        let mut preds = Vec::new();
2952        for term in &self.terms {
2953            match term {
2954                FilterTerm::Json(p) => preds.push(p.clone()),
2955                FilterTerm::Status(s) => {
2956                    preds.push(Predicate::json_path_eq("$.status", ScalarValue::Text(s.clone()))?);
2957                }
2958                FilterTerm::CreatedAfter(b) => {
2959                    preds.push(Predicate::json_path_compare(
2960                        "$.created_at",
2961                        ComparisonOp::Gte,
2962                        ScalarValue::Integer(*b),
2963                    )?);
2964                }
2965                FilterTerm::Kind(k) => {
2966                    // Constant-fold vs the partition argument (D1 impl decision).
2967                    if k != kind {
2968                        return Ok(None);
2969                    }
2970                }
2971                FilterTerm::SourceType(s) => {
2972                    // source_type is NOT a canonical_nodes column; it is a pure
2973                    // function of `kind`. Constant-fold (D2/D3).
2974                    match resolve_source_type(kind) {
2975                        Ok(resolved) if resolved == s.as_str() => {}
2976                        _ => return Ok(None),
2977                    }
2978                }
2979            }
2980        }
2981        Ok(Some(preds))
2982    }
2983
2984    /// 0.8.11 Slice 40 — test seam: expose the vec0 backend dispatch so the
2985    /// unification suite can pin the typed-rejection (RED→GREEN) and that a
2986    /// metadata-only Filter lowers losslessly. Returns the lowered
2987    /// [`SearchFilter`] (or `InvalidFilter` for a `Json` term).
2988    #[doc(hidden)]
2989    pub fn to_search_filter_for_test(&self) -> Result<SearchFilter, EngineError> {
2990        self.to_search_filter()
2991    }
2992
2993    /// 0.8.11 Slice 40 — test seam: expose the `read.list` backend lowering so
2994    /// the unification suite can pin total dispatch incl. the `SourceType`/`Kind`
2995    /// constant-folds. `Ok(None)` == constant-folded-empty.
2996    #[doc(hidden)]
2997    pub fn lower_for_read_list_for_test(
2998        &self,
2999        kind: &str,
3000    ) -> Result<Option<Vec<Predicate>>, EngineError> {
3001        self.lower_for_read_list(kind)
3002    }
3003}
3004
3005/// G11 (Slice 15) — a document sent to a BYO-LLM extraction harness via
3006/// [`Engine::ingest_with_extractor`].
3007#[derive(Clone, Debug)]
3008pub struct ExtractDocument {
3009    /// Stable opaque identifier for this document. Used as `source_id` on
3010    /// ingested edges and for provenance tracking.
3011    pub source_doc_id: String,
3012    /// Full text body of the document to extract entities and relationships from.
3013    pub body: String,
3014}
3015
3016/// G11 (Slice 15) — receipt returned by [`Engine::ingest_with_extractor`].
3017#[derive(Clone, Debug, Default)]
3018pub struct IngestWithExtractorReceipt {
3019    /// Number of `canonical_nodes` rows written (new entity insertions; skipped
3020    /// for entities that already have a matching active logical_id).
3021    pub nodes_written: u64,
3022    /// Number of `canonical_edges` rows written (new fact-edge insertions;
3023    /// superseded prior edges are ALSO counted as rows written).
3024    pub edges_written: u64,
3025    /// Number of documents processed (including no-facts documents).
3026    pub docs_processed: u64,
3027}
3028
3029/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one (subject-entity, relation) axis to
3030/// consolidate via [`Engine::consolidate_with_provider`]. FathomDB assembles the
3031/// competing fact-edge cluster for this axis DETERMINISTICALLY (CPU-only, no
3032/// LLM) by querying active `canonical_edges` where `from_id = subject_logical_id`
3033/// AND `kind = relation`.
3034#[derive(Clone, Debug)]
3035pub struct ConsolidateAxis {
3036    /// Stable `logical_id` of the subject entity (edge `from_id`).
3037    pub subject_logical_id: String,
3038    /// The relation/edge `kind` whose competing fact-edges form the cluster.
3039    pub relation: String,
3040}
3041
3042/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — one competing fact-edge in a candidate
3043/// cluster sent to the consolidation harness. Assembled deterministically from
3044/// `canonical_edges`; sent to the harness as the request payload; the harness's
3045/// verdict references edges back by `edge_ref` (the edge's stable `logical_id`).
3046#[derive(Clone, Debug)]
3047pub struct ConsolidateCandidateEdge {
3048    /// The edge's stable `logical_id` — the ref the harness uses in its verdict.
3049    pub edge_ref: String,
3050    /// The fact/relationship text (never rewritten by consolidation — §2.1).
3051    pub body: Option<String>,
3052    /// Event valid-time as INTEGER epoch seconds (UTC), if known.
3053    ///
3054    /// TC-33: epoch seconds, NOT ISO-8601. ISO-8601 lives only on the BYO-LLM
3055    /// extractor wire; `normalize_extractor_timestamp` is the one boundary.
3056    pub t_valid: Option<i64>,
3057    /// Event invalid-time as INTEGER epoch seconds (UTC), if already
3058    /// invalidated. `None` = still valid.
3059    pub t_invalid: Option<i64>,
3060    /// Extraction confidence ∈ [0.0, 1.0], if known.
3061    pub confidence: Option<f64>,
3062    /// Provenance: originating document id.
3063    pub source_doc_id: Option<String>,
3064    /// Provenance: extractor model id from the original BYO-LLM ingest.
3065    pub extractor_model_id: Option<String>,
3066}
3067
3068/// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — receipt returned by
3069/// [`Engine::consolidate_with_provider`]. Consolidation records supersession /
3070/// recency METADATA only (§2.1): edge bodies are never rewritten and no row is
3071/// ever deleted, so these counts describe metadata transitions, not content
3072/// changes.
3073#[derive(Clone, Debug, Default)]
3074pub struct ConsolidateReceipt {
3075    /// Number of (subject, relation) axes with a non-empty cluster that were
3076    /// dispatched to the harness.
3077    pub clusters_processed: u64,
3078    /// Number of candidate edges presented across all clusters.
3079    pub edges_examined: u64,
3080    /// Number of edges the harness ruled `keep` (no metadata change).
3081    pub edges_kept: u64,
3082    /// Number of edges the harness ruled `invalidate` (t_invalid set; row + body
3083    /// preserved).
3084    pub edges_invalidated: u64,
3085    /// Number of edges the harness ruled `supersede`/`merge` (marked superseded
3086    /// via the existing G0 tombstone column; row + body preserved).
3087    pub edges_superseded: u64,
3088}
3089
3090/// 0.8.20 Slice 5c (R-20-E3) — the provenance of a canonical row: which source
3091/// document it is attributable to, and therefore what `excise_source` must erase
3092/// when that source is withdrawn.
3093///
3094/// **Why a newtype and not `Option<String>`.** Erasure runs through provenance:
3095/// a row whose `source_id` is NULL is reachable by NO `excise_source` call and
3096/// is therefore **un-erasable**. Before 0.8.20 the public `PreparedWrite`
3097/// carried `source_id: Option<String>`, so a caller could express "no
3098/// provenance" and silently create such a row. A *runtime* rejection would not
3099/// have closed this: the facade crate re-exports `PreparedWrite` and
3100/// `Engine::write` is `pub`, so a caller can build the value directly and skip
3101/// any validation the engine performs. Replacing the field's type is what makes
3102/// the absence of provenance **inexpressible** rather than merely rejected —
3103/// the guarantee is enforced by `rustc`, not by a branch. `tests/ui/` in the
3104/// facade crate holds the compile-fail witness.
3105///
3106/// **This is a BREAKING change**, shipped ON by default as part of the 0.8.20
3107/// coordinated breaking-pair release. There is deliberately no compatibility
3108/// shim and no deprecation window: a shim would re-open the hole it closes.
3109///
3110/// **Reserved namespace.** Ids beginning with `_` belong to the engine and are
3111/// rejected by [`SourceId::new`]. Two are currently minted internally:
3112///
3113/// * [`SourceId::ENGINE_PREFIX`] (`_engine:`) — rows the engine derives for
3114///   itself (EXP-S coverage/graph substrate rows), which never pass through
3115///   `PreparedWrite` (design §4 item 6).
3116/// * [`SourceId::LEGACY_PRE_0_8_20`] (`_legacy:pre-0.8.20`) — stamped by schema
3117///   migration step 21 onto pre-0.8.20 rows that were stored with NULL
3118///   provenance, so they become erasable (R-20-E8). **Gated to UNGOVERNED rows
3119///   only** (`logical_id IS NULL`); a governed row keeps NULL `source_id` and
3120///   stays `purge`-addressable by its `logical_id` (TC-11 pin).
3121///
3122/// **`source_id` must not be PII.** It survives the erasure it authorises: the
3123/// `excise_source` audit row in `operational_mutations` records it verbatim, and
3124/// while 0.8.20 makes that audit row durable (design §2 defect D-A) the rule was
3125/// always that the handle you erase BY must not itself be the thing needing
3126/// erasure. Use an opaque document id, not an email address.
3127#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
3128pub struct SourceId(String);
3129
3130impl SourceId {
3131    /// Reserved prefix for engine-derived rows (design §4 item 6).
3132    pub const ENGINE_PREFIX: &'static str = "_engine:";
3133
3134    /// Reserved provenance stamped by schema migration step 21 onto pre-0.8.20
3135    /// UNGOVERNED rows that were stored with NULL provenance (R-20-E8).
3136    pub const LEGACY_PRE_0_8_20: &'static str = "_legacy:pre-0.8.20";
3137
3138    /// The single public constructor. Rejects the two ways a caller could
3139    /// express "effectively no provenance":
3140    ///
3141    /// * an empty or whitespace-only id — it names no source, and
3142    ///   `excise_source` already refuses the empty string, so such a row would
3143    ///   be un-erasable in practice;
3144    /// * an id in the engine's reserved `_`-prefixed namespace — a caller who
3145    ///   could mint `_legacy:pre-0.8.20` could hide rows among the migration's
3146    ///   back-filled ones, or mint `_engine:` rows that read as engine
3147    ///   substrate.
3148    ///
3149    /// # Errors
3150    ///
3151    /// [`EngineError::WriteValidation`] for either rejection above.
3152    pub fn new(id: impl Into<String>) -> Result<Self, EngineError> {
3153        let id = id.into();
3154        if id.trim().is_empty() || id.starts_with('_') {
3155            return Err(EngineError::WriteValidation);
3156        }
3157        Ok(Self(id))
3158    }
3159
3160    /// Mint a reserved `_engine:*` provenance for an engine-derived row. Crate
3161    /// -internal by construction: the reserved namespace is exactly what
3162    /// [`SourceId::new`] refuses, so a caller cannot reach this spelling.
3163    pub(crate) fn engine_derived(role: &str) -> Self {
3164        Self(format!("{}{role}", Self::ENGINE_PREFIX))
3165    }
3166
3167    /// The on-disk `source_id` text.
3168    #[must_use]
3169    pub fn as_str(&self) -> &str {
3170        &self.0
3171    }
3172
3173    /// Consume into the owned on-disk text.
3174    #[must_use]
3175    pub fn into_string(self) -> String {
3176        self.0
3177    }
3178}
3179
3180impl AsRef<str> for SourceId {
3181    fn as_ref(&self) -> &str {
3182        &self.0
3183    }
3184}
3185
3186impl Display for SourceId {
3187    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3188        f.write_str(&self.0)
3189    }
3190}
3191
3192impl TryFrom<String> for SourceId {
3193    type Error = EngineError;
3194
3195    fn try_from(value: String) -> Result<Self, Self::Error> {
3196        Self::new(value)
3197    }
3198}
3199
3200impl TryFrom<&str> for SourceId {
3201    type Error = EngineError;
3202
3203    fn try_from(value: &str) -> Result<Self, Self::Error> {
3204        Self::new(value)
3205    }
3206}
3207
3208/// Batch input shape for [`Engine::write`].
3209///
3210/// Marked `#[non_exhaustive]` per ADR-0.6.0-prepared-write-shape; new
3211/// entity variants land in 0.6.x without a major bump. Adding fields to
3212/// existing variants remains a binding-coordination change.
3213#[non_exhaustive]
3214#[derive(Clone, Debug, PartialEq)]
3215pub enum PreparedWrite {
3216    Node {
3217        kind: String,
3218        body: String,
3219        /// REQ-026 / AC-028 / AC-042 recovery seam, made **structurally
3220        /// mandatory** in 0.8.20 (R-20-E3). Was `Option<String>`; a `None`
3221        /// landed NULL on disk and produced a row no `excise_source` call could
3222        /// reach. See [`SourceId`] for why the fix is a type change rather than
3223        /// a validation check.
3224        source_id: SourceId,
3225        /// G0 (Slice 15) — stable cross-re-ingestion identity. `Some(id)`
3226        /// makes this write a transaction-time supersession of the prior
3227        /// active version of `(logical_id, kind)` (tombstone-then-insert).
3228        /// `None` is the legacy/own-identity default: a plain insert with a
3229        /// NULL `logical_id` (NULL-safe — never collides with other NULLs).
3230        logical_id: Option<String>,
3231        /// OPP-12 Phase-1 (0.8.19 Slice 5) — the create-time existence state.
3232        /// `InitialState::Active` (the [`Default`]) is the back-compat default and
3233        /// lands `state = 'active'` on disk (value-identical to the migration
3234        /// step-20 column DEFAULT). `InitialState::Pending` creates a quarantined
3235        /// node excluded from default retrieval. A `deleted`/`purged` node is
3236        /// UNREPRESENTABLE at create time (the [`InitialState`] type is the typed
3237        /// rejection) — those states are reachable only via the Slice-10
3238        /// `transition`/`purge` verbs.
3239        state: InitialState,
3240        /// OPP-12 Phase-1 (0.8.19 Slice 5) — advisory cause for the create-time
3241        /// `state` (e.g. the quarantine cause for a `pending` node), stored
3242        /// verbatim in `canonical_nodes.reason`. Engine never interprets it. `None`
3243        /// lands NULL (the back-compat default).
3244        reason: Option<String>,
3245        /// 0.8.20 Slice 15b (TC-34) — world-time validity window, INCLUSIVE lower
3246        /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded below.
3247        ///
3248        /// Slice 10b added the `valid_from`/`valid_until` columns, the [`ReadView`]
3249        /// validity predicate and [`Engine::crossed_boundary_since`] but NO writer,
3250        /// so a window could only be authored with raw SQL. These two fields are
3251        /// that writer. They are deliberately FIELDS rather than a new verb,
3252        /// exactly as [`PreparedWrite::Edge`] already carries `t_valid`/`t_invalid`:
3253        /// the governed command surface is unchanged.
3254        ///
3255        /// The pair is validated together — see `valid_until`.
3256        valid_from: Option<i64>,
3257        /// 0.8.20 Slice 15b (TC-34) — world-time validity window, EXCLUSIVE upper
3258        /// bound, INTEGER epoch SECONDS UTC. `None` lands NULL = unbounded above.
3259        ///
3260        /// The window is half-open `[valid_from, valid_until)`, matching the read
3261        /// predicate in `ReadView::validity_sql` exactly. Because it is half-open,
3262        /// a pair with `valid_from >= valid_until` describes an EMPTY window that no
3263        /// instant can ever satisfy — so [`Engine::write`] refuses it with
3264        /// [`EngineError::WriteValidation`] rather than storing a row that no
3265        /// default read could ever return. A ONE-SIDED window is never empty and is
3266        /// never refused, however extreme its single bound.
3267        ///
3268        /// **BREAKING (0.8.20 Slice 22, decision #18).** This refusal used to be
3269        /// [`EngineError::InvalidArgument`] NAMING both bounds. It is now the
3270        /// message-less `WriteValidation` unit variant — the one family the
3271        /// taxonomy of record assigns to a malformed submitted write SHAPE — so
3272        /// **the offending bounds are no longer carried in the error**. A caller
3273        /// that parsed them out must validate the pair before calling.
3274        valid_until: Option<i64>,
3275    },
3276    Edge {
3277        kind: String,
3278        from: String,
3279        to: String,
3280        /// REQ-026 / AC-028 / AC-042 recovery seam — see Node. Structurally
3281        /// mandatory since 0.8.20 (R-20-E3).
3282        source_id: SourceId,
3283        /// G0 (Slice 15) — see Node. Supersession semantics are identical on
3284        /// edges (keyed by `(logical_id, kind)`).
3285        logical_id: Option<String>,
3286        /// G11 (Slice 15) — the fact/relationship text. When `Some`, triggers
3287        /// FTS projection into `search_index_edges` and vector projection via
3288        /// the projection scheduler (kind `"edge_fact"`). Also triggers
3289        /// invalidate-not-accumulate on `(from_id, to_id, kind)`.
3290        body: Option<String>,
3291        /// G11 (Slice 15) — event valid-time. NULL = unknown / still valid.
3292        ///
3293        /// **TC-33 (HITL-RATIFIED 2026-07-21): INTEGER epoch seconds (UTC), not
3294        /// ISO-8601.** This is the GOVERNED SDK WRITE SURFACE, which carries the
3295        /// same representation as storage. ISO-8601 survives ONLY on the BYO-LLM
3296        /// extractor wire (`fathomdb.extract.v1`), where
3297        /// `normalize_extractor_timestamp` converts it with hard rejection.
3298        t_valid: Option<i64>,
3299        /// G11 (Slice 15) — event invalid-time. NULL = still valid.
3300        ///
3301        /// **TC-33: INTEGER epoch seconds (UTC)** — see `t_valid`. The
3302        /// NULL-means-still-valid semantic is load-bearing and unchanged, which
3303        /// is why the schema pins the type with a `typeof` CHECK rather than
3304        /// `NOT NULL`.
3305        t_invalid: Option<i64>,
3306        /// G11 (Slice 15) — extraction confidence ∈ [0.0, 1.0]. NULL for
3307        /// non-BYO-LLM-ingested edges.
3308        confidence: Option<f64>,
3309        /// G11 (Slice 15) — opaque model/provider id from the BYO-LLM harness
3310        /// `ready.model` field. NULL for non-BYO-LLM edges.
3311        extractor_model_id: Option<String>,
3312        /// R3 (Slice 30, SCHEMA-GATE-1, HITL-SIGNED 2026-06-13) — set when the
3313        /// ELPS extractor defaulted this edge's `t_valid` to `created_at` rather
3314        /// than deriving it from the document text. Such edges have untrustworthy
3315        /// event times and are excluded from graph-arm BFS temporal queries.
3316        /// `None`/`false` = not a fallback; `Some(true)` = fallback.
3317        temporal_fallback: Option<bool>,
3318    },
3319    OpStore {
3320        collection: String,
3321        record_key: String,
3322        schema_id: Option<String>,
3323        body: String,
3324    },
3325    AdminSchema {
3326        name: String,
3327        kind: String,
3328        schema_json: String,
3329        retention_json: String,
3330    },
3331}
3332
3333/// EXP-S (0.8.14 Slice 5, D1) — structural-role tag for a canonical row.
3334///
3335/// A SEPARATE axis from the doc-type `kind` (email/article/paper/meeting/
3336/// note/todo/doc/edge_fact): `row_kind` describes *what structural role* a row
3337/// plays in the "one store, many indexes" substrate, not what document type it
3338/// carries. Stored in `canonical_nodes.row_kind` (schema migration step 16).
3339///
3340/// `Leaf` is the default (a normal record; every existing/normal write is a
3341/// leaf — back-compat preserving). `Coverage` = coverage/summary rows;
3342/// `Graph` = graph structural rows. Engine-internal in 0.8.14 — there is NO
3343/// public Py/TS SDK surface for `row_kind` this release (`Leaf` for all normal
3344/// writes; `Coverage`/`Graph` are set only by internal paths). Cross-binding
3345/// parity (X1) is a Slice-40 concern.
3346#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3347pub enum RowKind {
3348    Leaf,
3349    Coverage,
3350    Graph,
3351}
3352
3353impl RowKind {
3354    /// On-disk `canonical_nodes.row_kind` spelling. Must match the migration
3355    /// step-16 `DEFAULT 'leaf'` and the schema vocabulary (D1).
3356    #[must_use]
3357    pub fn as_str(self) -> &'static str {
3358        match self {
3359            RowKind::Leaf => "leaf",
3360            RowKind::Coverage => "coverage",
3361            RowKind::Graph => "graph",
3362        }
3363    }
3364}
3365
3366/// OPP-12 record-lifecycle Phase-1 (0.8.19 Slice 5) — the existence axis.
3367///
3368/// One mutually-exclusive typed enum stored as TEXT in the `canonical_nodes.state`
3369/// column (schema migration step-20). Semantics (design §2 / plan §1):
3370///   `Pending` = present + versioned but NOT admitted to default retrieval
3371///               (quarantine / promotion gate);
3372///   `Active`  = admitted to default retrieval (the shipped-corpus default);
3373///   `Deleted` = soft-deleted, retained + recoverable, excluded from default
3374///               reads, stays indexed behind the flag;
3375///   `Purged`  = terminal, physically erased.
3376/// `Deleted`/`Purged` are reachable only through the Phase-2/Slice-10
3377/// `transition`/`purge` verbs — they can NEVER be a create-time state (see
3378/// [`InitialState`]).
3379#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3380pub enum LifecycleState {
3381    Pending,
3382    Active,
3383    Deleted,
3384    Purged,
3385}
3386
3387impl LifecycleState {
3388    /// On-disk `canonical_nodes.state` spelling. Must match the migration step-20
3389    /// `DEFAULT 'active'` and the `state = 'active'` default-read exclusion.
3390    #[must_use]
3391    pub fn as_str(self) -> &'static str {
3392        match self {
3393            LifecycleState::Pending => "pending",
3394            LifecycleState::Active => "active",
3395            LifecycleState::Deleted => "deleted",
3396            LifecycleState::Purged => "purged",
3397        }
3398    }
3399
3400    /// Parse the on-disk spelling back into the typed enum. `None` for any value
3401    /// outside the closed vocabulary (a corrupt/foreign `state`).
3402    #[must_use]
3403    pub fn from_str_opt(value: &str) -> Option<Self> {
3404        match value {
3405            "pending" => Some(LifecycleState::Pending),
3406            "active" => Some(LifecycleState::Active),
3407            "deleted" => Some(LifecycleState::Deleted),
3408            "purged" => Some(LifecycleState::Purged),
3409            _ => None,
3410        }
3411    }
3412
3413    /// OPP-12 Phase-1 (0.8.19 Slice 10) — the target states legally reachable from
3414    /// `self` via the `transition` VERB (design §2 legal-transition table). This
3415    /// is the verb-specific enumeration reported by `IllegalTransitionError.legal`:
3416    ///   `Pending` → `[Active, Deleted]`   (promote / reject)
3417    ///   `Active`  → `[Deleted]`           (soft-delete)
3418    ///   `Deleted` → `[Active]`            (undelete)
3419    ///   `Purged`  → `[]`                  (terminal; nothing is reachable)
3420    /// `Purged` is DELIBERATELY excluded even from `Deleted`: reaching `purged` is
3421    /// the `purge` verb's job (see [`Engine::purge`]), NOT a legal `transition`
3422    /// target, so reporting it here would mislead a caller into thinking
3423    /// `transition(deleted → purged)` is legal when it is not. Likewise `Pending`
3424    /// is create-time-only and is never a `transition` target. Derived directly
3425    /// from [`is_legal_transition_move`] so this can never drift from the table.
3426    #[must_use]
3427    pub fn legal_next_states(self) -> Vec<LifecycleState> {
3428        [
3429            LifecycleState::Pending,
3430            LifecycleState::Active,
3431            LifecycleState::Deleted,
3432            LifecycleState::Purged,
3433        ]
3434        .into_iter()
3435        .filter(|&to| is_legal_transition_move(self, to))
3436        .collect()
3437    }
3438}
3439
3440/// OPP-12 Phase-1 (0.8.19 Slice 10) — whether `(from, to)` is one of the four
3441/// legal `transition`-verb moves (design §2 table): `pending→active` (promote),
3442/// `pending→deleted` (reject), `active→deleted` (soft-delete), `deleted→active`
3443/// (undelete). Every other pair — self-loops, any move to `Purged` (purge-only)
3444/// or `Pending` (create-only), or from `Purged` — is illegal via `transition`.
3445#[must_use]
3446fn is_legal_transition_move(from: LifecycleState, to: LifecycleState) -> bool {
3447    matches!(
3448        (from, to),
3449        (LifecycleState::Pending, LifecycleState::Active)
3450            | (LifecycleState::Pending, LifecycleState::Deleted)
3451            | (LifecycleState::Active, LifecycleState::Deleted)
3452            | (LifecycleState::Deleted, LifecycleState::Active)
3453    )
3454}
3455
3456/// OPP-12 Phase-1 (0.8.19 Slice 5) — the CREATE-TIME subset of [`LifecycleState`].
3457///
3458/// A write can only bring a node into existence as `Pending` or `Active` (design
3459/// §2 / gap-6). You CANNOT create a `Deleted`/`Purged` node — those states are
3460/// reachable only via the `transition`/`purge` verbs (Slice 10). Making the
3461/// create-time surface a separate two-variant type is the TYPED rejection: a
3462/// `deleted`/`purged` create is simply unrepresentable in the Rust API (the SDK
3463/// bindings map an out-of-subset string to a typed write-validation error).
3464#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
3465pub enum InitialState {
3466    Pending,
3467    /// The back-compat default: every pre-lifecycle write lands `Active`, matching
3468    /// the migration step-20 `DEFAULT 'active'`.
3469    #[default]
3470    Active,
3471}
3472
3473impl InitialState {
3474    /// On-disk `canonical_nodes.state` spelling for a create-time state.
3475    #[must_use]
3476    pub fn as_str(self) -> &'static str {
3477        match self {
3478            InitialState::Pending => "pending",
3479            InitialState::Active => "active",
3480        }
3481    }
3482
3483    /// The full [`LifecycleState`] this create-time state corresponds to.
3484    #[must_use]
3485    pub fn to_lifecycle_state(self) -> LifecycleState {
3486        match self {
3487            InitialState::Pending => LifecycleState::Pending,
3488            InitialState::Active => LifecycleState::Active,
3489        }
3490    }
3491
3492    /// Parse a caller-supplied create-time `state` string into the create-time
3493    /// subset. `Some(state)` for `"pending"`/`"active"`; `None` for `"deleted"`,
3494    /// `"purged"`, or any unknown value — the SDK bindings turn `None` into a
3495    /// typed write-validation rejection (you cannot CREATE a deleted/purged node).
3496    #[must_use]
3497    pub fn from_create_str(value: &str) -> Option<Self> {
3498        match value {
3499            "pending" => Some(InitialState::Pending),
3500            "active" => Some(InitialState::Active),
3501            _ => None,
3502        }
3503    }
3504}
3505
3506/// F5 (0.8.14 Slice 10) — per-field BM25F weights for the `search_index_v2`
3507/// multi-column FTS index. One weight per indexed field
3508/// (`kind`/`body`/`status`), applied as the field's contribution multiplier in
3509/// the BM25F weighted-term-frequency accumulation.
3510///
3511/// The default is uniform (`1.0` each) — the "unweighted" baseline the R-F5-1
3512/// acceptance test contrasts against. Boosting a field (e.g. `kind`) makes a
3513/// match in that field outrank a same-strength match in a lower-weighted field.
3514/// Engine-internal for 0.8.14: there is NO public Py/TS SDK surface for these
3515/// tunables this release (cross-binding parity is a Slice-40/X1 concern).
3516#[derive(Clone, Copy, Debug, PartialEq)]
3517pub struct Bm25fFieldWeights {
3518    pub kind: f64,
3519    pub body: f64,
3520    pub status: f64,
3521}
3522
3523impl Default for Bm25fFieldWeights {
3524    fn default() -> Self {
3525        Self { kind: 1.0, body: 1.0, status: 1.0 }
3526    }
3527}
3528
3529/// F5 (0.8.14 Slice 10) — the compiled BM25F query plan for the fielded lexical
3530/// arm (`ADR-0.8.1` §3.2 `BM25fQueryPlan`). Carries the tunable per-field
3531/// `weights` and the tunable length-normalization `b` (and the term-saturation
3532/// `k1`).
3533///
3534/// NOTE on `b`: SQLite FTS5's built-in `bm25()` auxiliary function pins its
3535/// internal `k1`/`b` and exposes ONLY per-column weights — it cannot express a
3536/// tunable `b`. So the score is computed in-engine (a textbook BM25F over the
3537/// FTS5-recalled candidates) rather than delegated to the built-in `bm25()`:
3538/// that is what makes `b` (and `k1`) genuinely tunable here, not a dead
3539/// parameter. The `search_index_v2` FTS5 index is still load-bearing — it does
3540/// the candidate recall (`MATCH`) that the scorer then ranks.
3541///
3542/// Defaults match Robertson/SQLite BM25 (`b = 0.75`, `k1 = 1.2`) with uniform
3543/// field weights. Engine-internal for 0.8.14 (no SDK surface).
3544#[derive(Clone, Copy, Debug, PartialEq)]
3545pub struct Bm25fQueryPlan {
3546    pub weights: Bm25fFieldWeights,
3547    pub b: f64,
3548    pub k1: f64,
3549}
3550
3551impl Default for Bm25fQueryPlan {
3552    fn default() -> Self {
3553        Self { weights: Bm25fFieldWeights::default(), b: 0.75, k1: 1.2 }
3554    }
3555}
3556
3557/// Snapshot of engine-internal counters returned by [`Engine::counters`].
3558///
3559/// Public key set is owned by `dev/design/lifecycle.md` § Public key set
3560/// and locked by AC-004a. Reading a snapshot is non-perturbing per
3561/// AC-004c. The 0.6.0 surface exposes exactly these seven fields.
3562#[derive(Clone, Debug, Default, Eq, PartialEq)]
3563pub struct CounterSnapshot {
3564    pub queries: u64,
3565    pub writes: u64,
3566    pub write_rows: u64,
3567    pub errors_by_code: BTreeMap<String, u64>,
3568    pub admin_ops: u64,
3569    pub cache_hit: u64,
3570    pub cache_miss: u64,
3571}
3572
3573pub use lifecycle::Subscription;
3574
3575/// Stable corruption-on-open detail carried by
3576/// [`EngineOpenError::Corruption`].
3577///
3578/// Layout owned by `dev/design/errors.md` § Corruption detail owner.
3579#[derive(Clone, Debug, Eq, PartialEq)]
3580pub struct CorruptionDetail {
3581    pub kind: CorruptionKind,
3582    pub stage: OpenStage,
3583    pub locator: CorruptionLocator,
3584    pub recovery_hint: RecoveryHint,
3585}
3586
3587/// Open-path corruption category.
3588///
3589/// 0.6.0 emits exactly the four members below; per
3590/// `dev/design/errors.md` § Engine.open corruption table, doctor-only
3591/// finding codes are not represented here.
3592#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3593pub enum CorruptionKind {
3594    WalReplayFailure,
3595    HeaderMalformed,
3596    SchemaInconsistent,
3597    EmbedderIdentityDrift,
3598}
3599
3600/// `Engine.open` stage at which corruption was detected.
3601///
3602/// Per ADR-0.6.0-corruption-open-behavior, `LockAcquisition` is intentionally
3603/// not a member here; lock contention is surfaced via
3604/// [`EngineOpenError::DatabaseLocked`].
3605#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3606pub enum OpenStage {
3607    WalReplay,
3608    HeaderProbe,
3609    SchemaProbe,
3610    EmbedderIdentity,
3611}
3612
3613/// Locator pointing at the corrupted region of the database file.
3614///
3615/// Variant set owned by `dev/design/errors.md` § CorruptionLocator
3616/// ownership. `OpaqueSqliteError` is the required fallback when SQLite
3617/// surfaces corruption without a usable structured locator.
3618#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3619pub enum CorruptionLocator {
3620    FileOffset { offset: u64 },
3621    PageId { page: u32 },
3622    TableRow { table: &'static str, rowid: i64 },
3623    Vec0ShadowRow { partition: &'static str, rowid: i64 },
3624    MigrationStep { from: u32, to: u32 },
3625    OpaqueSqliteError { sqlite_extended_code: i32 },
3626}
3627
3628/// Recovery dispatch surface attached to a corruption detail.
3629///
3630/// `code` is the stable dispatch key used by bindings and doctor output;
3631/// `doc_anchor` points at the documentation section that explains the
3632/// remediation path.
3633#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3634pub struct RecoveryHint {
3635    pub code: &'static str,
3636    pub doc_anchor: &'static str,
3637}
3638
3639#[derive(Clone, Debug, Eq, PartialEq)]
3640pub enum EngineOpenError {
3641    DatabaseLocked {
3642        holder_pid: Option<u32>,
3643    },
3644    Corruption(CorruptionDetail),
3645    IncompatibleSchemaVersion {
3646        seen: u32,
3647        supported: u32,
3648    },
3649    MigrationError {
3650        schema_version_before: u32,
3651        schema_version_current: u32,
3652        step_id: u32,
3653    },
3654    EmbedderIdentityMismatch {
3655        stored: EmbedderIdentity,
3656        supplied: EmbedderIdentity,
3657    },
3658    EmbedderDimensionMismatch {
3659        stored: u32,
3660        supplied: u32,
3661    },
3662    /// Embedder runtime returned a typed error during `Engine::open`.
3663    Embedder(RuntimeEmbedderError),
3664    Io {
3665        message: String,
3666    },
3667}
3668
3669/// Caller-facing selector for the embedder used by an opened engine
3670/// (`dev/design/embedder.md` §0).
3671#[derive(Clone)]
3672pub enum EmbedderChoice {
3673    /// Use the engine's default embedder. With the `default-embedder`
3674    /// Cargo feature enabled, this materializes a `CandleBgeEmbedder`
3675    /// via the EU-3 loader at `Engine::open`; on first use the loader
3676    /// downloads pinned bge-small-en-v1.5 weights from HuggingFace per
3677    /// `ADR-0.7.1-default-embedder-weight-fetch`. Without the feature,
3678    /// this returns `EmbedderError::Failed` directing the caller to
3679    /// rebuild with `--features default-embedder` or supply
3680    /// `EmbedderChoice::Caller`.
3681    Default,
3682    /// Caller supplies the embedder instance. The supplied embedder's
3683    /// `identity()` becomes the workspace's default-profile identity.
3684    Caller(Arc<dyn Embedder>),
3685    /// No embedder configured. Engine opens; subsequent vector writes
3686    /// fail with `EngineError::EmbedderNotConfigured`. Useful for
3687    /// read-only or canonical-only flows.
3688    None,
3689}
3690
3691impl Display for EngineOpenError {
3692    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3693        match self {
3694            Self::DatabaseLocked { holder_pid } => match holder_pid {
3695                Some(pid) => write!(f, "database is locked by process {pid}"),
3696                None => write!(f, "database is locked by another engine instance"),
3697            },
3698            Self::Corruption(detail) => {
3699                write!(
3700                    f,
3701                    "engine corruption at {:?} stage: {}",
3702                    detail.stage, detail.recovery_hint.code
3703                )
3704            }
3705            Self::IncompatibleSchemaVersion { seen, supported } => write!(
3706                f,
3707                "database schema version {seen} is incompatible with supported version {supported}"
3708            ),
3709            Self::MigrationError {
3710                schema_version_before,
3711                schema_version_current,
3712                step_id,
3713            } => write!(
3714                f,
3715                "schema migration failed at step {step_id}; schema version remained between {schema_version_before} and {schema_version_current}"
3716            ),
3717            Self::EmbedderIdentityMismatch { stored, supplied } => write!(
3718                f,
3719                "embedder identity mismatch: stored {}@{}, supplied {}@{}",
3720                stored.name, stored.revision, supplied.name, supplied.revision,
3721            ),
3722            Self::EmbedderDimensionMismatch { stored, supplied } => write!(
3723                f,
3724                "embedder vector dimension mismatch: stored {stored}, supplied {supplied}",
3725            ),
3726            Self::Embedder(err) => match err {
3727                RuntimeEmbedderError::Timeout => write!(f, "embedder timeout during open"),
3728                RuntimeEmbedderError::Failed { message } => {
3729                    write!(f, "embedder failure during open: {message}")
3730                }
3731            },
3732            Self::Io { message } => write!(f, "database I/O error: {message}"),
3733        }
3734    }
3735}
3736
3737impl Error for EngineOpenError {}
3738
3739#[derive(Clone, Debug, Eq, PartialEq)]
3740pub enum EngineError {
3741    Storage,
3742    Projection,
3743    Vector,
3744    Embedder,
3745    EmbedderNotConfigured,
3746    KindNotVectorIndexed,
3747    EmbedderDimensionMismatch {
3748        expected: u32,
3749        actual: u32,
3750    },
3751    Scheduler,
3752    OpStore,
3753    WriteValidation,
3754    SchemaValidation,
3755    Overloaded,
3756    Closing,
3757    /// G11 (Slice 15) — BYO-LLM extractor subprocess error (protocol mismatch,
3758    /// spawn failure, or harness-returned error code).
3759    Extractor,
3760    /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM consolidation provider
3761    /// error (protocol mismatch, spawn/handshake failure, task not advertised in
3762    /// `supported_tasks`, or a malformed/out-of-cluster verdict). Rides the SAME
3763    /// `provider_session` transport as `Extractor`; this is the task-specific leaf.
3764    Consolidator,
3765    /// G4 (Slice 35) — filter predicate construction error: non-allowlisted
3766    /// path or invalid filter argument. NOT a panic — returned as a typed error
3767    /// from [`Predicate::json_path_eq`] / [`Predicate::json_path_compare`].
3768    InvalidFilter {
3769        reason: String,
3770    },
3771    /// Slice 20 (G5/G6) — an argument is out of the accepted range (e.g.
3772    /// `depth > 3` for graph traversal). The `msg` field carries a
3773    /// human-readable explanation; it is intentionally non-exhaustive so the
3774    /// binding layer can forward it as a `ValueError` / `TypeError`.
3775    InvalidArgument {
3776        msg: String,
3777    },
3778    /// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
3779    /// self-check re-embedded the 45 committed probes with the live backend and
3780    /// found a divergence beyond the frozen D4 floor (a Phase-1 mean-centered
3781    /// `embedding_bin` sign flip, OR a Phase-2 un-centered L2 distance over
3782    /// `VECTOR_EQUIVALENCE_L2_EPSILON`). `Engine::open` succeeded into a degraded
3783    /// state (`dense_disabled = true`); this query-time error is raised at the
3784    /// single choke point [`Engine::search_inner_with_stats`] BEFORE any embedding
3785    /// / vector SQL / graph seeding / CE rerank, refusing EVERY vector-dependent
3786    /// arm (`search`, `search_expand`, explain/rerank, graph-arm). The explicit
3787    /// text-only/FTS-only path ([`Engine::search_text_only`]) stays serviceable.
3788    /// Sibling of the open-time `EngineOpenError::EmbedderIdentityMismatch`; per
3789    /// ADR-0.8.18 codex R2 U1-1 the refusal surfaces as an `EngineError` (queries
3790    /// never surface `EngineOpenError`). `reason` carries a human-readable summary.
3791    VectorEquivalenceMismatch {
3792        reason: String,
3793    },
3794    /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle `transition`/`purge` move
3795    /// that the engine-enforced legal-transition table (design §2) forbids.
3796    /// Raised for an illegal `transition` target (`purged`/`pending` are never
3797    /// `transition` targets; self-loops; a from→to pair not in the table) AND for
3798    /// a `purge` precondition failure (purge is legal only from `deleted`).
3799    /// `from_state`/`to_state` use the FULL, parity-safe field names (S7 — `from`
3800    /// is a Python reserved word); `legal` enumerates the target states reachable
3801    /// from `from_state` in the full state machine.
3802    IllegalTransition {
3803        from_state: LifecycleState,
3804        to_state: LifecycleState,
3805        legal: Vec<LifecycleState>,
3806    },
3807    /// OPP-12 Phase-1 (0.8.19 Slice 10) — a lifecycle verb (`transition`/`purge`)
3808    /// was addressed with a non-`Logical` id space (a `Content`/`h:` doc-seeded or
3809    /// `Passage`/`p:` synthetic id). Only the `Logical` (`l:`) space is
3810    /// lifecycle-addressable (design §3); this is a typed refusal, never a panic
3811    /// or a silent no-op. `id_space` carries the offending [`IdSpaceKind`].
3812    NotLifecycleAddressable {
3813        id_space: IdSpaceKind,
3814    },
3815    /// 0.8.20 Slice 5b (R-20-E5, design `0.8.20-slice0-erasure-design.md` §4
3816    /// item 4) — an erasure verb (`purge` / `excise_source` /
3817    /// `excise_collection_record`) deleted its rows but could NOT complete the
3818    /// erasure **at rest**, so it refuses to report success.
3819    ///
3820    /// The motivating case is the write-ahead log. `PRAGMA secure_delete=ON`
3821    /// zeroes pages freed inside the database file, but the erased content also
3822    /// sits in the WAL as committed frames from the ORIGINAL insert: an erasure
3823    /// DELETE appends new frames, it never rewrites old ones. Only a
3824    /// `wal_checkpoint(TRUNCATE)` removes them, and a concurrent reader pinning a
3825    /// WAL snapshot makes that checkpoint return `busy`. After a bounded retry
3826    /// the verb raises THIS error rather than returning `Ok` over erased bytes
3827    /// that are still `grep`-able on disk.
3828    ///
3829    /// **Contract: an erasure verb must never report success on an incomplete
3830    /// erasure.** The row deletions are committed and durable when this is
3831    /// raised; what failed is the at-rest scrub. The remedy is to retry the verb
3832    /// (or `recover --truncate-wal`) once the blocking reader has finished.
3833    /// `stage` names the uncompleted step (e.g. `"wal_checkpoint"`,
3834    /// `"telemetry_redaction"`); `detail` is a human-readable summary.
3835    ErasureIncomplete {
3836        stage: String,
3837        detail: String,
3838    },
3839    /// 0.8.20 Slice 15d (R-20-PR) — `configure_projections` refused an
3840    /// incompatible/DESTRUCTIVE change to an existing projection `name` that was
3841    /// NOT accompanied by an explicit `drop`. Omission from the spec never drops
3842    /// (C3, `api-surface.md:27`); a role REMOVAL or a tokenizer/embedder change
3843    /// on a live projection would silently discard an expensive-to-rebuild
3844    /// resource, so it is refused with the destructive `delta` surfaced. The
3845    /// caller re-issues with `drop: [name]` to consciously rebuild.
3846    ProjectionDestructive {
3847        name: String,
3848        delta: String,
3849    },
3850}
3851
3852impl Display for EngineError {
3853    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
3854        match self {
3855            Self::Storage => write!(f, "storage error"),
3856            Self::Projection => write!(f, "projection error"),
3857            Self::Vector => write!(f, "vector error"),
3858            Self::Embedder => write!(f, "embedder error"),
3859            Self::EmbedderNotConfigured => write!(f, "embedder is not configured"),
3860            Self::KindNotVectorIndexed => write!(f, "kind is not configured for vector indexing"),
3861            Self::EmbedderDimensionMismatch { expected, actual } => {
3862                write!(f, "embedder dimension mismatch: expected {expected}, actual {actual}")
3863            }
3864            Self::Scheduler => write!(f, "scheduler error"),
3865            Self::OpStore => write!(f, "op-store error"),
3866            Self::WriteValidation => write!(f, "write validation error"),
3867            Self::SchemaValidation => write!(f, "schema validation error"),
3868            Self::Overloaded => write!(f, "engine overloaded"),
3869            Self::Closing => write!(f, "engine is closing"),
3870            Self::Extractor => write!(f, "extractor error"),
3871            Self::Consolidator => write!(f, "consolidator error"),
3872            Self::InvalidFilter { reason } => write!(f, "invalid filter: {reason}"),
3873            Self::InvalidArgument { msg } => write!(f, "invalid argument: {msg}"),
3874            Self::VectorEquivalenceMismatch { reason } => {
3875                write!(f, "vector-equivalence self-check failed; dense retrieval refused: {reason}")
3876            }
3877            Self::IllegalTransition { from_state, to_state, legal } => {
3878                let legal_list = legal.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
3879                write!(
3880                    f,
3881                    "illegal lifecycle transition {} -> {}; legal targets from {}: [{}]",
3882                    from_state.as_str(),
3883                    to_state.as_str(),
3884                    from_state.as_str(),
3885                    legal_list,
3886                )
3887            }
3888            Self::NotLifecycleAddressable { id_space } => write!(
3889                f,
3890                "id space {:?} ({}) is not lifecycle-addressable; only the logical (l:) space is",
3891                id_space,
3892                id_space.prefix(),
3893            ),
3894            Self::ErasureIncomplete { stage, detail } => write!(
3895                f,
3896                "erasure incomplete at stage '{stage}': the rows were deleted but the erasure \
3897                 could not be completed at rest ({detail})",
3898            ),
3899            Self::ProjectionDestructive { name, delta } => write!(
3900                f,
3901                "configure_projections refused a destructive change to projection '{name}' \
3902                 without an explicit drop ({delta}); re-issue with drop: [\"{name}\"] to rebuild",
3903            ),
3904        }
3905    }
3906}
3907
3908impl EngineError {
3909    /// Stable machine-readable code for `errors_by_code` keys.
3910    ///
3911    /// Names match the binding-facing class stems in
3912    /// `dev/design/errors.md` § Binding-facing class matrix.
3913    fn stable_code(&self) -> &'static str {
3914        match self {
3915            Self::Storage => "StorageError",
3916            Self::Projection => "ProjectionError",
3917            Self::Vector => "VectorError",
3918            Self::Embedder => "EmbedderError",
3919            Self::EmbedderNotConfigured => "EmbedderNotConfiguredError",
3920            Self::KindNotVectorIndexed => "KindNotVectorIndexedError",
3921            Self::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
3922            Self::Scheduler => "SchedulerError",
3923            Self::OpStore => "OpStoreError",
3924            Self::WriteValidation => "WriteValidationError",
3925            Self::SchemaValidation => "SchemaValidationError",
3926            Self::Overloaded => "OverloadedError",
3927            Self::Closing => "ClosingError",
3928            Self::Extractor => "ExtractorError",
3929            Self::Consolidator => "ConsolidatorError",
3930            Self::InvalidFilter { .. } => "InvalidFilterError",
3931            Self::InvalidArgument { .. } => "InvalidArgumentError",
3932            Self::VectorEquivalenceMismatch { .. } => "VectorEquivalenceMismatchError",
3933            Self::IllegalTransition { .. } => "IllegalTransitionError",
3934            Self::NotLifecycleAddressable { .. } => "NotLifecycleAddressableError",
3935            Self::ErasureIncomplete { .. } => "ErasureIncompleteError",
3936            Self::ProjectionDestructive { .. } => "ProjectionDestructiveError",
3937        }
3938    }
3939}
3940
3941impl Error for EngineError {}
3942
3943/// Doctor `check-integrity` invocation flags. `quick` and `round_trip`
3944/// are accepted in 0.6.0 but treated as default; only `full` activates
3945/// `PRAGMA integrity_check`. Per `dev/design/recovery.md` § Doctor-only
3946/// flags.
3947#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
3948pub struct CheckIntegrityOpts {
3949    pub quick: bool,
3950    pub full: bool,
3951    pub round_trip: bool,
3952}
3953
3954/// One section of an [`IntegrityReport`]. Either every check in the
3955/// section was clean, or one or more typed [`Finding`]s describe the
3956/// detected issue. Per AC-043b.
3957#[derive(Clone, Debug, Eq, PartialEq)]
3958pub enum Section {
3959    Clean,
3960    Findings(Vec<Finding>),
3961}
3962
3963/// Single doctor finding record. Stable report-shape per AC-043c. The
3964/// `code` and `doc_anchor` strings are stable dispatch keys owned by
3965/// `dev/design/recovery.md` § Code-to-operator-action cross-reference.
3966#[derive(Clone, Debug, Eq, PartialEq)]
3967pub struct Finding {
3968    pub code: &'static str,
3969    pub stage: &'static str,
3970    pub locator: CorruptionLocator,
3971    pub doc_anchor: &'static str,
3972    pub detail: String,
3973}
3974
3975/// Three-section integrity report. AC-043a pins exactly these three
3976/// keys.
3977#[derive(Clone, Debug, Eq, PartialEq)]
3978pub struct IntegrityReport {
3979    pub physical: Section,
3980    pub logical: Section,
3981    pub semantic: Section,
3982}
3983
3984/// Result of a successful [`Engine::safe_export`] call. The returned
3985/// `manifest_sha256` equals the SHA-256 of the export file bytes (per
3986/// AC-039a) and matches the `sha256` field written into the manifest
3987/// JSON.
3988#[derive(Clone, Debug, Eq, PartialEq)]
3989pub struct SafeExportArtifact {
3990    pub export_path: PathBuf,
3991    pub manifest_path: PathBuf,
3992    pub manifest_sha256: String,
3993}
3994
3995/// Phase 9 Pack B trace report (AC-042). One event per canonical row
3996/// attributable to the requested `source_id`, ordered by `write_cursor`
3997/// ascending.
3998#[derive(Clone, Debug, Eq, PartialEq)]
3999pub struct TraceReport {
4000    pub source_ref: String,
4001    pub events: Vec<TraceEvent>,
4002}
4003
4004/// Single canonical-row tracing record. `table` is one of
4005/// `"canonical_nodes"` or `"canonical_edges"`.
4006#[derive(Clone, Debug, Eq, PartialEq)]
4007pub struct TraceEvent {
4008    pub write_cursor: u64,
4009    pub kind: String,
4010    pub table: &'static str,
4011}
4012
4013/// Which shadow-state surface a [`RebuildReport`] describes.
4014/// `Projections` covers the full FTS5 + vec0 + projection-terminal
4015/// rebuild emitted by [`Engine::rebuild_projections`]. `Vec0` covers
4016/// the vec0-only path emitted by [`Engine::rebuild_vec0`].
4017#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4018pub enum RebuildKind {
4019    Projections,
4020    Vec0,
4021}
4022
4023/// Structured result of a rebuild operation. `rows_invalidated` is the
4024/// total shadow-state rows truncated before re-derivation; `rows_rebuilt`
4025/// is the count of rows the synchronous rebuild loop re-materialised
4026/// (asynchronous re-enqueue work performed by the projection scheduler is
4027/// not counted here). `projection_cursor_after` is the post-rebuild value
4028/// of the projection cursor.
4029#[derive(Clone, Debug, Eq, PartialEq)]
4030pub struct RebuildReport {
4031    pub kind: RebuildKind,
4032    pub rows_invalidated: u64,
4033    pub rows_rebuilt: u64,
4034    pub projection_cursor_after: u64,
4035}
4036
4037/// Phase 9 Pack B excise report (AC-028a/b/c). Counts are post-excise
4038/// totals; `projections_invalidated` reports the shadow-row invalidation
4039/// total (FTS5 + vec0 + projection terminal) for the excised source.
4040#[derive(Clone, Debug, Eq, PartialEq)]
4041pub struct ExciseReport {
4042    pub source_ref: String,
4043    pub nodes_excised: u64,
4044    pub edges_excised: u64,
4045    pub projections_invalidated: u64,
4046}
4047
4048/// 0.8.20 Slice 15d (R-20-PR, C-1) — one member of a [`ProjectionSpec`]'s role
4049/// set. **Exactly three members** (HITL-ratified S8, `api-surface.md:87`):
4050/// `searchable→FTS` and `searchable→vector` are NOT roles — they are tier labels
4051/// carried by the `fts`/`vector` sub-objects of the spec, so an attribute is
4052/// `Searchable` once and the sub-objects select FTS-only / vector-only / both.
4053#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4054pub enum ProjectionRole {
4055    /// Projects into the EAV store + its `(attr_name, attr_value)` composite
4056    /// index — cheap equality/range, built same-transaction.
4057    Filterable,
4058    /// The F9 importance/recency signal. **Graceful-absent (Q6a):** declaring
4059    /// it is legal and never errors, but the engine DEFERS the build until F9
4060    /// exists and grafts it on the next idempotent `configure_projections`.
4061    Rankable,
4062    /// Full-text / dense recall of the meaning text. The `fts`/`vector`
4063    /// sub-objects select the sub-target.
4064    Searchable,
4065}
4066
4067impl ProjectionRole {
4068    #[must_use]
4069    pub fn as_str(self) -> &'static str {
4070        match self {
4071            ProjectionRole::Filterable => "filterable",
4072            ProjectionRole::Rankable => "rankable",
4073            ProjectionRole::Searchable => "searchable",
4074        }
4075    }
4076
4077    #[must_use]
4078    pub fn from_str_opt(value: &str) -> Option<Self> {
4079        match value {
4080            "filterable" => Some(ProjectionRole::Filterable),
4081            "rankable" => Some(ProjectionRole::Rankable),
4082            "searchable" => Some(ProjectionRole::Searchable),
4083            _ => None,
4084        }
4085    }
4086}
4087
4088/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→FTS` sub-target selector.
4089#[derive(Clone, Debug, Default, Eq, PartialEq)]
4090pub struct ProjectionFts {
4091    /// Optional tokenizer override; `None` ⇒ the engine default FTS5 tokenizer
4092    /// (`body`-FTS's `porter unicode61 remove_diacritics 2`). A custom
4093    /// per-attr tokenizer is the ≥0.9.x multi-field FTS work — recorded but
4094    /// not honoured here (graceful-graft later, same as `rankable`).
4095    pub tokenizer: Option<String>,
4096}
4097
4098/// 0.8.20 Slice 20 (R-20-DR) — the ENGINE-SET readiness of the
4099/// `searchable→vector` projection, per
4100/// `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
4101/// §3.
4102///
4103/// **Exactly three members.** `filterable` and `searchable→FTS` are
4104/// same-transaction (non-stale on commit) so they need no readiness axis at all;
4105/// `searchable→vector` is **async, rebuild-durable**, so it carries one.
4106///
4107/// **Naming discipline (load-bearing).** The token **`pending` is RESERVED for
4108/// the admission axis** (quarantine/trust — an app judgment). Index-readiness is
4109/// a DIFFERENT, orthogonal dimension (a record can be
4110/// `active ∧ is_latest ∧ admissible` yet `dense_readiness = embedding`), so this
4111/// enum deliberately does **not** reuse that word: the non-ready member is
4112/// `Embedding`, never `Pending`.
4113#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
4114pub enum DenseReadiness {
4115    /// Engine-selected state for a session with no usable dense runtime (an
4116    /// absent embedder or refused vector equivalence). Caller input remains
4117    /// accept-inert; reads select this through the shared runtime predicate.
4118    Unavailable,
4119    /// At least one row in the vector projection's row set has not yet reached a
4120    /// projection terminal — embedding is outstanding. This is the ONLY
4121    /// tolerable torn state: readiness `embedding` with the vector absent (the
4122    /// dense arm reads as partial and RRF under-ranks; it does not hide).
4123    Embedding,
4124    /// Every row in the vector projection's row set has reached a projection
4125    /// terminal — the dense arm is caught up. Because the vector INSERT and the
4126    /// terminal record are written in ONE transaction
4127    /// ([`commit_projection_outcomes`]), `Ready` can never be observed with the
4128    /// vector row absent (design §4.1 invariant 1).
4129    Ready,
4130}
4131
4132impl DenseReadiness {
4133    #[must_use]
4134    pub fn as_str(self) -> &'static str {
4135        match self {
4136            DenseReadiness::Unavailable => "unavailable",
4137            DenseReadiness::Embedding => "embedding",
4138            DenseReadiness::Ready => "ready",
4139        }
4140    }
4141
4142    /// The three accepted spellings. `"pending"` is DELIBERATELY not one of them
4143    /// (reserved for the admission axis) and so parses to `None`.
4144    #[must_use]
4145    pub fn from_str_opt(value: &str) -> Option<Self> {
4146        match value {
4147            "unavailable" => Some(DenseReadiness::Unavailable),
4148            "embedding" => Some(DenseReadiness::Embedding),
4149            "ready" => Some(DenseReadiness::Ready),
4150            _ => None,
4151        }
4152    }
4153}
4154
4155/// The reason [`ProjectionRuntimeStatus::runtime_embedder_available`] is false.
4156///
4157/// This facade is deliberately distinct from the internal lifecycle
4158/// `ProjectionStatus`: it describes this open engine session's ability to run
4159/// the shared dense pipeline, not the terminal state of a canonical row.
4160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4161pub enum ProjectionRuntimeUnavailabilityReason {
4162    /// A usable dense runtime is attached, so there is no unavailability.
4163    None,
4164    /// This engine session was opened without an attached embedder.
4165    NoRuntime,
4166    /// The attached embedder failed the existing vector-equivalence guard.
4167    VectorEquivalenceDisabled,
4168}
4169
4170impl ProjectionRuntimeUnavailabilityReason {
4171    #[must_use]
4172    pub fn as_str(self) -> &'static str {
4173        match self {
4174            Self::None => "none",
4175            Self::NoRuntime => "no_runtime",
4176            Self::VectorEquivalenceDisabled => "vector_equivalence_disabled",
4177        }
4178    }
4179}
4180
4181/// The dense-readiness projection of [`ProjectionRuntimeStatusEntry`].
4182///
4183/// `NotDeclared` means that the declaration has no *effective* vector arm.
4184/// The remaining states reuse the shared runtime/readiness facts, which are
4185/// corpus-wide until the engine gains per-projection dense work tracking.
4186#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4187pub enum ProjectionStatusDenseReadiness {
4188    /// The declaration has no `searchable` + vector sub-object pair.
4189    NotDeclared,
4190    /// An effective vector arm exists but this session has no usable runtime.
4191    Unavailable,
4192    /// An effective vector arm has eligible outstanding shared dense work.
4193    Embedding,
4194    /// An effective vector arm's shared dense work is quiescent.
4195    Ready,
4196}
4197
4198impl ProjectionStatusDenseReadiness {
4199    #[must_use]
4200    pub fn as_str(self) -> &'static str {
4201        match self {
4202            Self::NotDeclared => "not_declared",
4203            Self::Unavailable => "unavailable",
4204            Self::Embedding => "embedding",
4205            Self::Ready => "ready",
4206        }
4207    }
4208}
4209
4210/// One declaration's current dense status in [`ProjectionRuntimeStatus`].
4211#[derive(Clone, Debug, Eq, PartialEq)]
4212pub struct ProjectionRuntimeStatusEntry {
4213    /// Declared projection name. Entries are returned in ascending name order.
4214    pub name: String,
4215    /// Current dense state for this declaration's effective vector arm.
4216    pub dense_readiness: ProjectionStatusDenseReadiness,
4217}
4218
4219/// A pure, current view of projection-runtime facts for one open engine session.
4220///
4221/// `runtime_embedder_available` and its reason describe the dense runtime, not
4222/// whether any projection is declared. `projections` contains one entry per
4223/// durable declaration, sorted by name. `vector_unsupported_kinds` is current
4224/// and declaration-scoped: it is empty unless at least one declaration has an
4225/// effective (`searchable` + vector) arm.
4226#[derive(Clone, Debug, Eq, PartialEq)]
4227pub struct ProjectionRuntimeStatus {
4228    /// Whether an attached embedder passed the identity/equivalence safeguards.
4229    pub runtime_embedder_available: bool,
4230    /// `None` exactly when `runtime_embedder_available` is true.
4231    pub runtime_unavailability_reason: ProjectionRuntimeUnavailabilityReason,
4232    /// One sorted entry for every durable declaration.
4233    pub projections: Vec<ProjectionRuntimeStatusEntry>,
4234    /// Sorted, deduplicated permanently non-committable kinds for an effective arm.
4235    pub vector_unsupported_kinds: Vec<String>,
4236}
4237
4238/// 0.8.20 Slice 15d (R-20-PR) — the `searchable→vector` sub-target selector.
4239///
4240/// **Slice 20 (R-20-DR) attached `dense_readiness` HERE, additively:** this
4241/// sub-object is STORED by 15d (so the shape exists and a caller can declare a
4242/// vector projection); Slice 20 hangs the READ-METADATA readiness flag off it.
4243/// Nothing in 15d's persisted shape changed (the registry columns
4244/// `vector_embedder` + `vector_declared` still round-trip the declaration) —
4245/// **readiness is DERIVED, never stored**, so there is no schema step and no
4246/// separate flag that could tear (see [`derive_dense_readiness`]).
4247#[derive(Clone, Debug, Default, Eq, PartialEq)]
4248pub struct ProjectionVector {
4249    /// Optional embedder override; `None` ⇒ the engine's shipped default.
4250    pub embedder: Option<String>,
4251    /// 0.8.20 Slice 20 (R-20-DR) — **READ METADATA, engine-set.** Populated by
4252    /// [`Engine::read_projections`]; `None` on every caller-authored spec.
4253    ///
4254    /// It is **not part of the declaration**: `configure_projections` neither
4255    /// stores nor honours it (see [`StoredProjection::from_spec`], which reads
4256    /// only `embedder`), so a value supplied here is INERT — the engine always
4257    /// reports the derived truth. This is deliberately accept-inert rather than
4258    /// hard-reject so `read.projections` output stays feedable straight back
4259    /// into `configure_projections` (the fix-4 read→configure round-trip, which
4260    /// both bindings pin with a test).
4261    ///
4262    /// **0.8.20 Slice 23 (`R-20-SV`) correction (TC-39 class).** This doc used to
4263    /// justify accept-inert by analogy with "the already-audited accept-inert
4264    /// ruling on an `fts`/`vector` sub-object declared without the `searchable`
4265    /// role". **That ruling is OVERRULED** — the HITL ruled the shape an INVALID
4266    /// SPEC on 2026-07-24 and [`apply_projection_config`] now rejects it with
4267    /// [`EngineError::WriteValidation`]. `dense_readiness` accept-inert is
4268    /// UNCHANGED and stands on its own footing: it is engine-set READ METADATA,
4269    /// never part of the declaration, so there is nothing about it to reject.
4270    ///
4271    /// The bindings still HARD-REJECT the shapes that could
4272    /// not round-trip: a readiness supplied with `vector = false`, and any
4273    /// spelling outside `{unavailable, embedding, ready}`.
4274    pub dense_readiness: Option<DenseReadiness>,
4275}
4276
4277/// 0.8.20 Slice 15d (R-20-PR / C-1) — a single declarative projection
4278/// declaration. HITL-ratified shape (`api-surface.md:85-89`):
4279/// `{ name, roles: Set<ProjectionRole>, fts?, vector? }`. `roles` carries SET
4280/// semantics (dedup + membership; an attribute can be `Filterable` AND
4281/// `Searchable`) — encoded here as a sorted, de-duplicated `BTreeSet`. Named
4282/// `roles`, not `kind` (`kind` is the node/edge type discriminator).
4283#[derive(Clone, Debug, Eq, PartialEq)]
4284pub struct ProjectionSpec {
4285    pub name: String,
4286    pub roles: BTreeSet<ProjectionRole>,
4287    pub fts: Option<ProjectionFts>,
4288    pub vector: Option<ProjectionVector>,
4289    /// Optional ordered literal object-member path in the canonical node body.
4290    /// `None` preserves the legacy direct top-level lookup by `name`.
4291    pub source: Option<Vec<String>>,
4292}
4293
4294/// 0.8.20 Slice 15d (R-20-PR) — the diff [`Engine::configure_projections`]
4295/// applied. Idempotent re-registration yields `unchanged == true` with all
4296/// vecs empty (the "re-registration is a no-op" acceptance signal). A
4297/// destructive change without an explicit `drop` is an `Err`, not a delta.
4298#[derive(Clone, Debug, Default, Eq, PartialEq)]
4299pub struct ProjectionDelta {
4300    /// Attribute names whose same-transaction projections (EAV / property-FTS)
4301    /// were (re)built by this apply.
4302    pub built: Vec<String>,
4303    /// Attribute names dropped (explicit `drop` list) — their EAV + property-FTS
4304    /// rows and registry row removed.
4305    pub dropped: Vec<String>,
4306    /// Attribute names whose declared roles were persisted but NOT built:
4307    /// `rankable` (F9 not yet live) and the `searchable→vector` sub-target
4308    /// (Slice 20). These graft on a future idempotent apply. No error.
4309    pub deferred: Vec<String>,
4310    /// True iff nothing was built, dropped, or newly deferred — the whole apply
4311    /// diffed to a no-op.
4312    pub unchanged: bool,
4313    /// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **node KINDS, not attribute
4314    /// names.** The vector-eligible node kinds present in the corpus that the
4315    /// vector writer can NEVER commit, so no `searchable→vector` declaration
4316    /// will ever produce an embedding for them.
4317    ///
4318    /// # Why this field exists — the silence it replaces
4319    ///
4320    /// [`kind_is_vector_committable`] (Slice 20c fix-2) restricted enrolment to
4321    /// the kinds [`resolve_source_type`] maps, because enrolling any other kind
4322    /// is a permanent liveness wedge. That fix was correct and is unchanged —
4323    /// but it made the exclusion **silent**: the declaration persists, its name
4324    /// is pushed onto [`ProjectionDelta::deferred`], and the caller cannot tell
4325    /// "waiting on the embedder" (transient) from "this kind will never be
4326    /// embedded" (permanent). Per the HITL ruling on TC-67 the remedy is
4327    /// option **(c) REPORT** — the vocabulary is NOT grown and the Pack-1 D3
4328    /// partition-key lock is NOT touched (`dev/design/0.7.0-vector-quant-pack1.md`).
4329    ///
4330    /// # Axis, and why the name is what it is
4331    ///
4332    /// `built` / `dropped` / `deferred` are all lists of **projection attribute
4333    /// names**. This one is a list of **node kinds** — a different axis entirely,
4334    /// so the name says `kinds` explicitly and is prefixed `vector_` to bind it
4335    /// to the dense arm (an unsupported kind is still fully FTS/lexically
4336    /// searchable). Sorted and de-duplicated (`SELECT DISTINCT … ORDER BY kind`).
4337    ///
4338    /// # It is a STATE report, not a diff
4339    ///
4340    /// Unlike the other three vectors it does not describe what this call
4341    /// changed; it describes the corpus as it stands. So it is populated on an
4342    /// idempotent re-apply too (where `unchanged == true` and the other three
4343    /// are empty), and it deliberately does NOT feed [`ProjectionDelta::unchanged`].
4344    /// That is what makes the declare-time residual cheap to live with: to
4345    /// refresh the report after writing new kinds, re-apply the same spec — a
4346    /// no-op that still returns a current report.
4347    ///
4348    /// # Independent of the embedder
4349    ///
4350    /// Computed whenever a `searchable→vector` projection is declared, whether
4351    /// or not this session has a usable dense runtime. The vocabulary is static, so
4352    /// "this kind can never be embedded" is true in a no-embedder session too —
4353    /// and must not be conflated with the Q6a graceful-absent deferral, which is
4354    /// transient and is reported through `deferred`.
4355    ///
4356    /// Empty (never absent) when there is nothing to report.
4357    pub vector_unsupported_kinds: Vec<String>,
4358}
4359
4360/// 0.8.20 Slice 5b (R-20-E7) — outcome of
4361/// [`Engine::excise_collection_record`]. `records_excised` counts the erased
4362/// `operational_mutations` versions (an append-only-log collection keeps every
4363/// version of a key); `state_rows_excised` counts the erased
4364/// `operational_state` row (0 or 1).
4365///
4366/// `record_digest` is `SHA-256(collection + 0x1F + record_key)` — the audit
4367/// handle. The raw `record_key` is deliberately NOT carried: it is arbitrary
4368/// caller-supplied text and may itself be the identifier being erased, so
4369/// echoing it into a durable audit row would defeat the erasure.
4370#[derive(Clone, Debug, Eq, PartialEq)]
4371pub struct ExciseRecordReport {
4372    pub collection: String,
4373    pub record_digest: String,
4374    pub records_excised: u64,
4375    pub state_rows_excised: u64,
4376}
4377
4378/// Typed outcome of [`Engine::verify_embedder`]. Mismatches do not raise
4379/// `EngineError`; the operator workflow needs to see the stored vs.
4380/// supplied pair to decide on next action.
4381#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4382pub enum VerifyEmbedderStatus {
4383    Match,
4384    IdentityMismatch,
4385    DimensionMismatch,
4386    BothMismatch,
4387}
4388
4389/// Result of [`Engine::verify_embedder`]. `stored_identity` is the
4390/// `name:revision` pair persisted in `_fathomdb_embedder_profiles`;
4391/// `supplied_identity` echoes the operator's input verbatim.
4392#[derive(Clone, Debug, Eq, PartialEq)]
4393pub struct VerifyEmbedderReport {
4394    pub stored_identity: String,
4395    pub stored_dimension: u32,
4396    pub supplied_identity: String,
4397    pub supplied_dimension: u32,
4398    pub status: VerifyEmbedderStatus,
4399}
4400
4401/// Single table or index entry emitted by [`Engine::dump_schema`].
4402#[derive(Clone, Debug, Eq, PartialEq)]
4403pub struct SchemaObject {
4404    pub name: String,
4405    pub sql: String,
4406}
4407
4408/// Result of [`Engine::dump_schema`]. `user_version` is the
4409/// `PRAGMA user_version` sentinel. Canonical tables appear first per
4410/// [`fathomdb_schema::CANONICAL_TABLES`], then remaining non-`sqlite_*`
4411/// tables alphabetically. Indexes follow the same alphabetical rule.
4412#[derive(Clone, Debug, Eq, PartialEq)]
4413pub struct DumpSchemaReport {
4414    pub user_version: u32,
4415    pub tables: Vec<SchemaObject>,
4416    pub indexes: Vec<SchemaObject>,
4417}
4418
4419/// Single canonical-table row count emitted by [`Engine::dump_row_counts`].
4420#[derive(Clone, Debug, Eq, PartialEq)]
4421pub struct TableRowCount {
4422    pub name: String,
4423    pub rows: u64,
4424}
4425
4426/// Result of [`Engine::dump_row_counts`]. Canonical tables only;
4427/// projection / FTS / vec0 shadow tables are excluded. Order matches
4428/// [`fathomdb_schema::CANONICAL_TABLES`].
4429#[derive(Clone, Debug, Eq, PartialEq)]
4430pub struct DumpRowCountsReport {
4431    pub counts: Vec<TableRowCount>,
4432}
4433
4434/// 0.8.20 Slice 5d (R-20-E8) — one `source_id` bucket in an
4435/// [`OrphanProvenanceReport`]. `source_id` is `None` for the NULL-provenance
4436/// bucket, which after migration step 21 should contain ONLY governed NODES.
4437#[derive(Clone, Debug, Eq, PartialEq)]
4438pub struct OrphanProvenanceSource {
4439    /// `None` = the NULL-`source_id` bucket.
4440    pub source_id: Option<String>,
4441    /// Canonical rows (nodes + edges) carrying this provenance.
4442    pub rows: u64,
4443    /// How many of `rows` carry a `logical_id`.
4444    ///
4445    /// NOT the same thing as "purge-addressable": only a NODE's `logical_id`
4446    /// confers purge-addressability. An EDGE's `logical_id` is a supersession
4447    /// identity and reaches no erasure verb (see
4448    /// [`Engine::orphan_provenance`]), so governed edges are counted here but
4449    /// are NOT subtracted from
4450    /// [`OrphanProvenanceReport::unerasable_rows`].
4451    pub governed_rows: u64,
4452    /// True for the engine's reserved `_`-prefixed namespace (`_engine:*`,
4453    /// `_legacy:pre-0.8.20`). Reserved buckets are reachable only through the
4454    /// operator seam `excise_source`, never through the governed
4455    /// [`Engine::erase_source`].
4456    pub reserved: bool,
4457}
4458
4459/// Result of [`Engine::orphan_provenance`] — the per-`source_id` census behind
4460/// `fathomdb doctor orphan-provenance` (design §4 item 11).
4461///
4462/// `unerasable_rows` is the load-bearing field: canonical rows carrying
4463/// NEITHER a `source_id` NOR a `logical_id`. Such a row is reachable by no
4464/// erasure verb at all — `purge` keys on `logical_id`, `erase_source` keys on
4465/// `source_id` — so it can never be deleted on request. Slice 5c made that
4466/// state unwritable and migration step 21 back-filled the historical cases, so
4467/// a non-zero count means the invariant has been violated and the verb exits
4468/// `DOCTOR_FOUND_ISSUES`.
4469#[derive(Clone, Debug, Eq, PartialEq)]
4470pub struct OrphanProvenanceReport {
4471    /// Per-`source_id` buckets, ordered by descending `rows` then `source_id`
4472    /// so the output is deterministic (a diagnostic that reorders between runs
4473    /// cannot be diffed).
4474    pub sources: Vec<OrphanProvenanceSource>,
4475    /// Total canonical rows surveyed.
4476    pub total_rows: u64,
4477    /// Rows with NO `source_id` AND NO `logical_id` — un-erasable by any verb.
4478    pub unerasable_rows: u64,
4479}
4480
4481/// Result of [`Engine::dump_profile`]. Mirrors the open-time embedder
4482/// posture + the per-kind vector configuration registered in
4483/// `_fathomdb_vector_kinds`.
4484#[derive(Clone, Debug, Eq, PartialEq)]
4485pub struct DumpProfileReport {
4486    pub embedder_identity: String,
4487    pub embedder_dimension: u32,
4488    pub vectorized_kinds: Vec<String>,
4489}
4490
4491/// 0.7.2 PR-2b — result of [`Engine::recompute_mean`] (the manual
4492/// `doctor recompute-mean` path) and of the shared in-transaction
4493/// recompute core. `drift_cos_before` is the cosine between the freshly
4494/// derived corpus mean and the previously-pinned mean (1.0 when nothing
4495/// was pinned yet, i.e. a first pin). `mean_was_pinned` distinguishes a
4496/// refresh of an existing mean from an initial pin. See
4497/// `dev/design/embedder.md` §0.3.
4498#[derive(Clone, Debug, PartialEq)]
4499pub struct MeanRecomputeReport {
4500    pub dim: u32,
4501    pub old_doc_count: u64,
4502    pub doc_count_requantized: u64,
4503    pub drift_cos_before: f32,
4504    pub mean_was_pinned: bool,
4505    pub elapsed_ms: u64,
4506}
4507
4508/// Typed outcome of [`Engine::truncate_wal`]. `Done` matches SQLite's
4509/// `busy = 0` return from `PRAGMA wal_checkpoint(TRUNCATE)`; any other
4510/// value surfaces as `Busy`.
4511#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4512pub enum TruncateWalStatus {
4513    Done,
4514    Busy,
4515}
4516
4517/// Result of [`Engine::truncate_wal`]. Carries the three counters
4518/// returned by `PRAGMA wal_checkpoint(TRUNCATE)`: `busy`, `log_frames`,
4519/// `checkpointed_frames`.
4520#[derive(Clone, Debug, Eq, PartialEq)]
4521pub struct TruncateWalReport {
4522    pub status: TruncateWalStatus,
4523    pub busy: u32,
4524    pub log_frames: u32,
4525    pub checkpointed_frames: u32,
4526}
4527
4528impl Drop for Engine {
4529    fn drop(&mut self) {
4530        let _ = self.close();
4531    }
4532}
4533
4534impl Engine {
4535    fn usable_dense_runtime(&self) -> bool {
4536        usable_dense_runtime(
4537            self.runtime_embedder.as_deref(),
4538            self.dense_disabled.load(Ordering::Acquire),
4539        )
4540    }
4541
4542    pub fn open(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4543        Self::open_with_embedder_and_subscriber(
4544            path,
4545            default_embedder_identity(),
4546            None,
4547            None,
4548            None,
4549            &mut |_| {},
4550        )
4551    }
4552
4553    /// Open an engine with an explicit [`EmbedderChoice`].
4554    ///
4555    /// Per `dev/design/embedder.md` §0 + the 0.7.1 EU-5 campaign, this is
4556    /// the canonical entry point for selecting how the workspace's
4557    /// default embedder is supplied. See [`EmbedderChoice`] for the
4558    /// semantics of each variant; in particular `Default` materializes
4559    /// the pinned BGE embedder via the loader when the `default-embedder`
4560    /// feature is enabled.
4561    pub fn open_with_choice(
4562        path: impl Into<PathBuf>,
4563        choice: EmbedderChoice,
4564    ) -> Result<OpenedEngine, EngineOpenError> {
4565        match choice {
4566            EmbedderChoice::Default => Self::open_default_embedder(path),
4567            EmbedderChoice::Caller(embedder) => {
4568                let identity = embedder.identity();
4569                Self::open_with_embedder_and_subscriber(
4570                    path,
4571                    identity,
4572                    Some(embedder),
4573                    None,
4574                    None,
4575                    &mut |_| {},
4576                )
4577            }
4578            EmbedderChoice::None => Self::open_with_embedder_and_subscriber(
4579                path,
4580                default_embedder_identity(),
4581                None,
4582                None,
4583                None,
4584                &mut |_| {},
4585            ),
4586        }
4587    }
4588
4589    /// EU-5b: materialize the engine's pinned default embedder
4590    /// (`CandleBgeEmbedder` backed by the EU-3 loader) and open the
4591    /// workspace with it. Without the `default-embedder` feature, fails
4592    /// with a typed `Embedder` error rather than touching the network.
4593    #[cfg(feature = "default-embedder")]
4594    fn open_default_embedder(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4595        use std::time::Instant as DownloadInstant;
4596        let download_start = DownloadInstant::now();
4597        let weights = fathomdb_embedder::loader::load_pinned_default_embedder().map_err(|err| {
4598            EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4599                message: format!("default embedder loader: {err}"),
4600            })
4601        })?;
4602        let events = weights.events.clone();
4603        let download_ms = if weights.bytes_downloaded > 0 {
4604            Some(u64::try_from(download_start.elapsed().as_millis()).unwrap_or(u64::MAX))
4605        } else {
4606            None
4607        };
4608        let embedder =
4609            fathomdb_embedder::CandleBgeEmbedder::new_from_weights(weights).map_err(|err| {
4610                EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4611                    message: format!("default embedder construct: {err}"),
4612                })
4613            })?;
4614        let embedder: Arc<dyn Embedder> = Arc::new(embedder);
4615        let identity = embedder.identity();
4616        let loader_info = LoaderInfo { download_ms, events };
4617        Self::open_with_embedder_and_subscriber(
4618            path,
4619            identity,
4620            Some(embedder),
4621            Some(loader_info),
4622            None,
4623            &mut |_| {},
4624        )
4625    }
4626
4627    #[cfg(not(feature = "default-embedder"))]
4628    fn open_default_embedder(_path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
4629        Err(EngineOpenError::Embedder(RuntimeEmbedderError::Failed {
4630            message: "EmbedderChoice::Default requires the `default-embedder` Cargo feature"
4631                .to_string(),
4632        }))
4633    }
4634
4635    pub fn open_with_migration_event_sink(
4636        path: impl Into<PathBuf>,
4637        mut emit_migration_event: impl FnMut(&MigrationStepReport),
4638    ) -> Result<OpenedEngine, EngineOpenError> {
4639        Self::open_with_embedder_and_subscriber(
4640            path,
4641            default_embedder_identity(),
4642            None,
4643            None,
4644            None,
4645            &mut emit_migration_event,
4646        )
4647    }
4648
4649    #[cfg(debug_assertions)]
4650    #[doc(hidden)]
4651    pub fn open_with_migrations_for_test(
4652        path: impl Into<PathBuf>,
4653        migrations: &'static [fathomdb_schema::Migration],
4654        mut emit_migration_event: impl FnMut(&MigrationStepReport),
4655    ) -> Result<OpenedEngine, EngineOpenError> {
4656        Self::open_with_migrations(
4657            path,
4658            migrations,
4659            default_embedder_identity(),
4660            None,
4661            None,
4662            &mut emit_migration_event,
4663            None,
4664        )
4665    }
4666
4667    #[doc(hidden)]
4668    pub fn open_with_subscriber_for_test(
4669        path: impl Into<PathBuf>,
4670        subscriber: Arc<dyn lifecycle::Subscriber>,
4671    ) -> Result<OpenedEngine, EngineOpenError> {
4672        Self::open_with_embedder_and_subscriber(
4673            path,
4674            default_embedder_identity(),
4675            None,
4676            None,
4677            Some(subscriber),
4678            &mut |_| {},
4679        )
4680    }
4681
4682    #[doc(hidden)]
4683    pub fn open_without_embedder_for_test(
4684        path: impl Into<PathBuf>,
4685    ) -> Result<OpenedEngine, EngineOpenError> {
4686        Self::open_with_embedder_and_subscriber(
4687            path,
4688            default_embedder_identity(),
4689            None,
4690            None,
4691            None,
4692            &mut |_| {},
4693        )
4694    }
4695
4696    #[doc(hidden)]
4697    pub fn open_with_embedder_for_test(
4698        path: impl Into<PathBuf>,
4699        embedder: Arc<dyn Embedder>,
4700    ) -> Result<OpenedEngine, EngineOpenError> {
4701        let identity = embedder.identity();
4702        Self::open_with_embedder_and_subscriber(
4703            path,
4704            identity,
4705            Some(embedder),
4706            None,
4707            None,
4708            &mut |_| {},
4709        )
4710    }
4711
4712    fn open_with_embedder_and_subscriber(
4713        path: impl Into<PathBuf>,
4714        embedder_identity: EmbedderIdentity,
4715        runtime_embedder: Option<Arc<dyn Embedder>>,
4716        loader_info: Option<LoaderInfo>,
4717        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4718        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4719    ) -> Result<OpenedEngine, EngineOpenError> {
4720        Self::open_with_migrations(
4721            path,
4722            MIGRATIONS,
4723            embedder_identity,
4724            runtime_embedder,
4725            loader_info,
4726            emit_migration_event,
4727            initial_subscriber,
4728        )
4729    }
4730
4731    fn open_with_migrations(
4732        path: impl Into<PathBuf>,
4733        migrations: &'static [fathomdb_schema::Migration],
4734        embedder_identity: EmbedderIdentity,
4735        runtime_embedder: Option<Arc<dyn Embedder>>,
4736        loader_info: Option<LoaderInfo>,
4737        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4738        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
4739    ) -> Result<OpenedEngine, EngineOpenError> {
4740        let canonical_path = canonical_database_path(&path.into())?;
4741        let lock = acquire_lock(&canonical_path)?;
4742        let open_result = Self::open_locked(
4743            canonical_path.clone(),
4744            migrations,
4745            &embedder_identity,
4746            emit_migration_event,
4747        );
4748
4749        match open_result {
4750            Ok((connection, readers, mut report, reader_lookaside_rcs)) => {
4751                // EU-5b — splice the loader's measurements + structured
4752                // events into the report. The loader path is the only
4753                // surface that produces these today; caller-supplied
4754                // embedders and EmbedderChoice::None leave them as the
4755                // open_locked defaults (None / empty).
4756                if let Some(info) = loader_info {
4757                    if info.download_ms.is_some() {
4758                        report.embedder_download_ms = info.download_ms;
4759                    }
4760                    if !info.events.is_empty() {
4761                        report.embedder_events = info.events;
4762                    }
4763                }
4764
4765                // 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — run the
4766                // open-time self-check on the FINAL post-recovery connection (the
4767                // mean is already pinned/recovered inside open_locked, U1-b). First
4768                // registration persists the 45 UN-centered f32 references; a
4769                // subsequent open re-embeds + asserts P1 (mean-centered flip count,
4770                // floor 0) and P2 (un-centered L2 ε). Divergence ⇒ degraded-open
4771                // (`dense_disabled=true`), surfaced on the OpenReport (R-VEQ-6); the
4772                // query-time refusal fires later at `search_inner_with_stats`.
4773                // A durable declaration is a prospective dense arm even before
4774                // it has enrolled a kind. Check it before the boot graft below:
4775                // a refused backend may leave the declaration at rest, but must
4776                // not enrol, requeue, dispatch, or write any dense work.
4777                let prospective_dense_arm =
4778                    vector_projection_declared(&connection).map_err(|_| EngineOpenError::Io {
4779                        message: "could not inspect declared vector projection on open".to_string(),
4780                    })?;
4781                let veq = run_vector_equivalence_probe(
4782                    &connection,
4783                    runtime_embedder.as_deref(),
4784                    &embedder_identity,
4785                    report.embedder_mean_vec_pinned,
4786                    prospective_dense_arm,
4787                );
4788                report.dense_disabled = veq.dense_disabled;
4789                report.dense_disabled_reason = veq.reason.clone();
4790
4791                let dense_runtime_usable =
4792                    usable_dense_runtime(runtime_embedder.as_deref(), veq.dense_disabled);
4793                let boot_graft_enqueued = if dense_runtime_usable {
4794                    boot_graft_declared_vector_backfill(&connection).map_err(|_| {
4795                        EngineOpenError::Io {
4796                            message: "could not graft declared vector projection on boot"
4797                                .to_string(),
4798                        }
4799                    })?
4800                } else {
4801                    false
4802                };
4803
4804                let next_cursor = load_next_cursor(&connection);
4805                let subscribers = Arc::new(lifecycle::SubscriberRegistry::new());
4806                let profiling_enabled = Arc::new(AtomicBool::new(false));
4807                let slow_threshold_ms = Arc::new(AtomicU64::new(DEFAULT_SLOW_THRESHOLD_MS));
4808                let mut profile_contexts: Vec<Box<ProfileContext>> = Vec::new();
4809                let scheduler_embedder =
4810                    if dense_runtime_usable { runtime_embedder.clone() } else { None };
4811                let projection_runtime = ProjectionRuntime::new(
4812                    canonical_path.clone(),
4813                    scheduler_embedder,
4814                    embedder_identity.clone(),
4815                    report.embedder_mean_vec_pinned,
4816                    Arc::clone(&subscribers),
4817                );
4818
4819                install_profile_callback(
4820                    &connection,
4821                    &subscribers,
4822                    &profiling_enabled,
4823                    &slow_threshold_ms,
4824                    &mut profile_contexts,
4825                );
4826                for reader in &readers {
4827                    install_profile_callback(
4828                        reader,
4829                        &subscribers,
4830                        &profiling_enabled,
4831                        &slow_threshold_ms,
4832                        &mut profile_contexts,
4833                    );
4834                }
4835
4836                let opened = OpenedEngine {
4837                    engine: Self {
4838                        path: canonical_path.clone(),
4839                        next_cursor: AtomicU64::new(next_cursor),
4840                        closed: AtomicBool::new(false),
4841                        lock: Mutex::new(Some(lock)),
4842                        connection: Mutex::new(Some(connection)),
4843                        reader_pool: ReaderWorkerPool::new(readers),
4844                        counters: lifecycle::Counters::new(),
4845                        subscribers,
4846                        profiling_enabled,
4847                        slow_threshold_ms,
4848                        runtime_embedder,
4849                        runtime_embedder_identity: embedder_identity,
4850                        projection_runtime,
4851                        provenance_row_cap: AtomicU64::new(DEFAULT_PROVENANCE_ROW_CAP),
4852                        profile_contexts: Mutex::new(profile_contexts),
4853                        reader_lookaside_rcs,
4854                        telemetry: Mutex::new(None),
4855                        telemetry_enabled: AtomicBool::new(false),
4856                        dense_disabled: AtomicBool::new(veq.dense_disabled),
4857                        dense_disabled_reason: Mutex::new(veq.reason),
4858                        vector_equivalence_refusals: AtomicU64::new(0),
4859                        #[cfg(debug_assertions)]
4860                        force_next_commit_failure: AtomicBool::new(false),
4861                    },
4862                    report,
4863                };
4864                if let Some(subscriber) = initial_subscriber {
4865                    opened.engine.subscribers.attach_persistent(subscriber);
4866                }
4867                if dense_runtime_usable
4868                    && (boot_graft_enqueued
4869                        || database_has_pending_projection_work(&canonical_path).unwrap_or(false))
4870                {
4871                    opened.engine.projection_runtime.notify_new_work();
4872                }
4873                Ok(opened)
4874            }
4875            Err(err) => {
4876                if let Some(subscriber) = initial_subscriber {
4877                    emit_open_error_event(&subscriber, &err);
4878                }
4879                drop(lock);
4880                Err(err)
4881            }
4882        }
4883    }
4884
4885    fn open_locked(
4886        path: PathBuf,
4887        migrations: &'static [fathomdb_schema::Migration],
4888        embedder_identity: &EmbedderIdentity,
4889        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
4890    ) -> Result<(Connection, Vec<Connection>, OpenReport, Vec<i32>), EngineOpenError> {
4891        init_perf_experiments_runtime();
4892        register_sqlite_vec_extension();
4893        let mut connection = Connection::open(&path)
4894            .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
4895        // Order pinned by `dev/design/errors.md` § OpenStage matrix: each
4896        // step routes its own SQLite-level error to a distinct
4897        // `CorruptionKind` (Header → WalReplay → Schema → EmbedderIdentity).
4898        // The schema and WAL probes both happen BEFORE `pragma WAL`
4899        // because that pragma also reads page 1 — letting it run first
4900        // would reclassify schema-side corruption as a WAL replay
4901        // failure, breaking the AC-035b stable-code contract.
4902        probe_database_header(&connection)?;
4903        probe_open_integrity(&connection)?;
4904        probe_wal_sidecar(&path)?;
4905        // 0.7.0 perf-experiments: apply writer-side experiment PRAGMAs
4906        // (page_size, etc.) BEFORE journal_mode + migrations. page_size
4907        // is silently ignored once any table exists; this is the only
4908        // legal window to set it on a fresh DB. Gated on
4909        // FATHOMDB_PERF_EXPERIMENTS=1; no-op in production.
4910        apply_perf_experiment_writer_pragmas(&connection);
4911        // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — standing
4912        // `secure_delete=ON` on the writer, applied at EVERY open (fresh + migrated).
4913        // It zeroes every page freed by a future DELETE, so the Slice-10 `purge`
4914        // hard-erase is complete WITHOUT a per-purge `VACUUM`. It is a connection
4915        // PRAGMA (not schema DDL), so it belongs here, not in the 19→20 migration.
4916        // RESIDUAL (documented, not forced): pages freed on a pre-20 DB BEFORE this
4917        // was enabled are not retroactively scrubbed; there is no migration-time
4918        // full `VACUUM` (O(db-size)). NOTE: this is a standing pragma set at EVERY
4919        // connection open (writer here, plus the reader-pool and
4920        // `open_runtime_connection`), NOT the writer alone — non-writer connections
4921        // also free pages (projection / vector-rewrite DELETEs), so a writer-only
4922        // `secure_delete` would leak freed content on disk. See the matching
4923        // reader/runtime open comment (~lines 3335-3336).
4924        connection
4925            .pragma_update(None, "secure_delete", "ON")
4926            .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4927        connection
4928            .pragma_update(None, "journal_mode", "WAL")
4929            .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
4930
4931        reject_legacy_shape(&connection)?;
4932        let migration = migrate_with_event_sink(&connection, migrations, emit_migration_event)
4933            .map_err(map_migration_error)?;
4934        // 0.8.0 Slice 5 (G1) — global FTS5 tokenizer-default upgrade. Step 11
4935        // drops + recreates `search_index` with the new tokenizer, leaving it
4936        // EMPTY on a migrated DB. The projection scheduler will NOT
4937        // repopulate it (`database_has_pending_projection_work` keys "pending"
4938        // off `_fathomdb_projection_terminal`, which the migration does not
4939        // clear). Re-tokenize from the canonical source rows here, on the
4940        // writer connection, single-threaded, before readers spawn —
4941        // projection-only, no source-record migration.
4942        //
4943        // Crash-retryable (fix-1): step 11 commits `user_version = 11` with an
4944        // empty index in its OWN transaction; this reproject commits in a
4945        // LATER transaction. A crash in that window leaves a durable v11 + empty
4946        // index, on which a boundary-crossing guard (`before < 11`) is FALSE,
4947        // skipping repair forever. So gate on the completion marker's ABSENCE
4948        // (written atomically with the reindex) instead: idempotent, and a
4949        // crash before the reindex commit simply re-runs on the next open.
4950        if migration.schema_version_after >= SEARCH_INDEX_TOKENIZER_SCHEMA_VERSION
4951            && !search_index_tokenizer_reproject_complete(&connection).map_err(|_| {
4952                EngineOpenError::Io {
4953                    message: "could not read search_index tokenizer reproject marker".to_string(),
4954                }
4955            })?
4956        {
4957            reproject_search_index_after_tokenizer_upgrade(&connection).map_err(|_| {
4958                EngineOpenError::Io {
4959                    message: "could not re-tokenize search_index after tokenizer upgrade"
4960                        .to_string(),
4961                }
4962            })?;
4963        }
4964        let mut embedder_mean_vec_pinned = check_embedder_profile(&connection, embedder_identity)?;
4965        ensure_vector_partition(&mut connection, embedder_identity.dimension).map_err(|_| {
4966            EngineOpenError::Io { message: "could not initialize vector partition".to_string() }
4967        })?;
4968
4969        // 0.8.20 Slice 15c (TC-33) fix-6 [codex §9 P1] — the step-23
4970        // `canonical_edges` recreate drops every edge row (NO DATA MIGRATION) and
4971        // removes their `_fathomdb_vector_rows` sidecar rows, but the vec0
4972        // `vector_default` shadow those mirror is engine-created + dim-aware, so
4973        // the migration cannot delete its rows. Left behind, an orphaned edge vec0
4974        // row (whose `canonical_edges` row is gone) still occupies a top-K KNN
4975        // candidate slot — `build_vector_phase1_sql` reads candidates DIRECTLY
4976        // from `vector_default` before hydrating them through the canonical tables
4977        // — and is then discarded at hydration, so an upgraded DB silently returns
4978        // too few / no vector results. Prune the orphans now that
4979        // `ensure_vector_partition` guarantees `vector_default` exists, BEFORE the
4980        // mean-vec row-count recovery below (so the count excludes them). One-time
4981        // and crash-retryable via the durable completion marker; a no-op on any
4982        // healthy corpus (every vec0 row has a sidecar entry), so recall / eu7
4983        // fidelity are unchanged on a DB that never dropped edges.
4984        if migration.schema_version_after >= EDGE_TEMPORAL_EPOCH_SCHEMA_VERSION
4985            && !edge_vector_prune_complete(&connection).map_err(|_| EngineOpenError::Io {
4986                message: "could not read edge-vector prune marker".to_string(),
4987            })?
4988        {
4989            prune_orphaned_edge_vectors(&connection).map_err(|_| EngineOpenError::Io {
4990                message: "could not prune orphaned edge vector rows".to_string(),
4991            })?;
4992        }
4993
4994        // 0.8.20 Slice 15d (R-20-PR, Q5) — boot re-derive the projection registry
4995        // (the engine `ProjectionSpec` is a derived cache). For every persisted
4996        // declaration, clear + backfill its EAV / property-FTS rows from the
4997        // canonical nodes so a crash window (registry row survives, projection
4998        // rows partial) self-heals idempotently. A no-op single empty-table read
4999        // on every DB that has not declared a projection. On the writer
5000        // connection, single-threaded, before readers spawn — like the tokenizer
5001        // reproject above. Runs after the fix-6 edge-vector prune above; the two
5002        // are independent boot reconciliations.
5003        rederive_projections_on_boot(&connection).map_err(|_| EngineOpenError::Io {
5004            message: "could not re-derive projection registry on boot".to_string(),
5005        })?;
5006
5007        // 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — bring an
5008        // ALREADY-ENROLLED inert vector kind into agreement with the role-aware
5009        // decision. Slice 21c closed the three forward doors, but a database that
5010        // already ran the old code under `{roles:[filterable], vector:{}}` keeps
5011        // its `_fathomdb_vector_kinds` rows — `vector_kind_needs_enrolment`
5012        // short-circuits on `kind_is_vector_indexed` and never reaches the new
5013        // predicate, and `project_canonical_node_row` reads only the registry
5014        // membership — so upgrading did not actually stop the unwanted embeddings.
5015        // Narrowly authorised (registry EXISTS, declares a `vector` sub-object,
5016        // and declares no `searchable→vector` projection) so a LEGACY workspace
5017        // with a working dense arm is never touched; see
5018        // [`registry_governs_an_inert_dense_arm`]. Deletes no embedding. Runs
5019        // BEFORE `run_vector_equivalence_probe` (which fires after `open_locked`
5020        // returns), so a database whose only enrolment was the inert one pays no
5021        // probe embeds on the healing open. Another boot reconciliation on the
5022        // writer connection, single-threaded, before readers spawn.
5023        reconcile_inert_vector_enrolments_on_boot(&connection).map_err(|_| {
5024            EngineOpenError::Io {
5025                message: "could not reconcile inert vector kind enrolments on boot".to_string(),
5026            }
5027        })?;
5028
5029        // 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns
5030        // with the registry's `filterable` set. On a DB whose vec0 shape already
5031        // matches the registry (the common case, incl. every reopen of a DB that
5032        // declared filterable projections in a prior session) this is a pure
5033        // no-op: the diff is empty, so boot never re-inserts and NEVER silently
5034        // wipes the corpus. It converges only a shape that drifted from the
5035        // registry (e.g. a restored registry row). A no-op when the table is
5036        // absent (no embedder). Runs on the writer connection, single-threaded,
5037        // before readers spawn — like the boot re-derive above.
5038        {
5039            let tx = connection.transaction().map_err(|_| EngineOpenError::Io {
5040                message: "could not begin vector-attr reconcile on boot".to_string(),
5041            })?;
5042            reconcile_vector_attr_columns(&tx, embedder_identity.dimension).map_err(|_| {
5043                EngineOpenError::Io {
5044                    message: "could not reconcile vector attribute columns on boot".to_string(),
5045                }
5046            })?;
5047            tx.commit().map_err(|_| EngineOpenError::Io {
5048                message: "could not commit vector-attr reconcile on boot".to_string(),
5049            })?;
5050        }
5051
5052        // EU-5f — recovery pin (`dev/design/embedder.md` §0.3, Hazard 4). If
5053        // the identity is MC-required, no mean is pinned, yet the workspace
5054        // already holds >= MEAN_VEC_PIN_THRESHOLD vector rows (e.g. a crash
5055        // between the threshold-crossing write and its pin commit), derive
5056        // the mean from the existing un-centered rows and pin+re-quantize
5057        // now, single-threaded, before the projection workers spawn. The
5058        // NULL guard makes this idempotent on subsequent opens.
5059        if identity_requires_mean_centering(embedder_identity) && !embedder_mean_vec_pinned {
5060            let row_count: u64 = connection
5061                .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get(0))
5062                .unwrap_or(0);
5063            if row_count >= MEAN_VEC_PIN_THRESHOLD {
5064                recover_mean_vec_pin(&mut connection, embedder_identity).map_err(|_| {
5065                    EngineOpenError::Io {
5066                        message: "could not recover mean-centering pin".to_string(),
5067                    }
5068                })?;
5069                embedder_mean_vec_pinned = true;
5070            }
5071        }
5072
5073        let warmup_started = Instant::now();
5074        // Static identity capability — see `dev/design/embedder.md`
5075        // §0.6. Today only the bge-small identity reports `true`; the
5076        // noop scaffolding identity is `false`. EU-5b's identity flip
5077        // makes the Default path return `true` here automatically.
5078        let embedder_mean_centering_required = embedder_identity.name == BGE_SMALL_EMBEDDER_NAME;
5079        // EU-5a2 — populated from `_fathomdb_embedder_profiles.mean_vec`
5080        // by `check_embedder_profile` above (was hard-coded `false` in
5081        // EU-5a1). Dimension invariant (§0.2) enforced by that check.
5082        let report = OpenReport {
5083            schema_version_before: migration.schema_version_before,
5084            schema_version_after: migration.schema_version_after,
5085            migration_steps: migration.migration_steps,
5086            embedder_warmup_ms: u64::try_from(warmup_started.elapsed().as_millis())
5087                .unwrap_or(u64::MAX),
5088            query_backend: "fathomdb-query + sqlite-vec",
5089            default_embedder: embedder_identity.clone(),
5090            // TODO(EU-5b): surface `LoadedWeights.download_ms` from the
5091            // loader once the Default path materializes through it.
5092            embedder_download_ms: None,
5093            // TODO(EU-5b): surface `LoadedWeights.events` from the loader.
5094            embedder_events: Vec::new(),
5095            embedder_mean_centering_required,
5096            embedder_mean_vec_pinned,
5097            // 0.8.18 Slice 5 — set by the #5 self-check in `open_with_migrations`
5098            // (which has the runtime embedder in scope). `open_locked` returns the
5099            // non-degraded default; the probe runs after this returns.
5100            dense_disabled: false,
5101            dense_disabled_reason: None,
5102        };
5103
5104        let mut readers = Vec::with_capacity(READER_POOL_SIZE);
5105        let mut lookaside_rcs: Vec<i32> = Vec::with_capacity(READER_POOL_SIZE);
5106        for _ in 0..READER_POOL_SIZE {
5107            let reader = Connection::open(&path)
5108                .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
5109            // Pack 6.G G.1: configure per-connection lookaside BEFORE
5110            // any PRAGMA / prepare runs on this reader. Reordering this
5111            // after the journal-mode / query_only PRAGMAs would let
5112            // SQLite silently ignore the lookaside setting.
5113            let rc: i32 = configure_reader_lookaside(&reader);
5114            debug_assert_eq!(
5115                rc,
5116                rusqlite::ffi::SQLITE_OK,
5117                "sqlite3_db_config(LOOKASIDE) must return SQLITE_OK on a freshly opened reader",
5118            );
5119            lookaside_rcs.push(rc);
5120            reader
5121                .pragma_update(None, "journal_mode", "WAL")
5122                .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
5123            // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON`
5124            // at EVERY connection open, not just the writer. `secure_delete` is a
5125            // per-connection pager flag, so a reader-pool connection that frees a
5126            // page (vector-rewrite / projection DELETEs run off non-writer
5127            // connections) would otherwise leave that freed content on disk,
5128            // defeating GDPR erasure. Set BEFORE `query_only=ON` so the ordering is
5129            // unambiguous (the flag is a pager setting, not a DB write).
5130            reader
5131                .pragma_update(None, "secure_delete", "ON")
5132                .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
5133            reader
5134                .pragma_update(None, "query_only", "ON")
5135                .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))?;
5136            apply_perf_experiment_reader_pragmas(&reader);
5137            readers.push(reader);
5138        }
5139
5140        Ok((connection, readers, report, lookaside_rcs))
5141    }
5142
5143    #[must_use]
5144    pub fn path(&self) -> &Path {
5145        &self.path
5146    }
5147
5148    pub fn write(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
5149        let category = if batch_is_admin(batch) {
5150            lifecycle::EventCategory::Admin
5151        } else {
5152            lifecycle::EventCategory::Writer
5153        };
5154        self.emit_event(lifecycle::Phase::Started, category, None);
5155        let started = Instant::now();
5156        let outcome = self.write_inner(batch);
5157        self.detect_slow(started, category);
5158        match outcome {
5159            Ok(receipt) => {
5160                let rows = u64::try_from(batch.len()).unwrap_or(u64::MAX);
5161                if batch_is_admin(batch) {
5162                    self.counters.record_admin();
5163                } else {
5164                    self.counters.record_write(rows);
5165                }
5166                self.emit_event(lifecycle::Phase::Finished, category, None);
5167                Ok(receipt)
5168            }
5169            Err(err) => {
5170                let code = err.stable_code();
5171                self.counters.record_error(code);
5172                // AC-003d: capture-ordinal < raise-ordinal — Failed and Error
5173                // events both fire before the EngineError returns to the caller.
5174                self.emit_event(lifecycle::Phase::Failed, category, Some(code));
5175                self.emit_event(
5176                    lifecycle::Phase::Failed,
5177                    lifecycle::EventCategory::Error,
5178                    Some(code),
5179                );
5180                Err(err)
5181            }
5182        }
5183    }
5184
5185    /// 0.8.20 Slice 20c (R-20-DR remainder) — **late enrolment**, the write-path
5186    /// half of the C4 rider.
5187    ///
5188    /// [`enqueue_declared_vector_backfill`] enrols the kinds the corpus held AT
5189    /// DECLARATION TIME. A kind first written AFTERWARDS would otherwise fall
5190    /// through [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate
5191    /// straight onto a permanent `'up_to_date'` terminal and be silently,
5192    /// irrecoverably un-embedded — the identical false-ready barrier, reached by
5193    /// writing second instead of declaring second.
5194    ///
5195    /// **Gated on a usable dense runtime**, which is exactly why this lives on
5196    /// `Engine` and not inside the projector: with `EmbedderChoice::None` or a
5197    /// refused vector-equivalence guard there is no dense arm at all, so
5198    /// enrolling a kind would only queue embeds that cannot safely run. The
5199    /// declaration still PERSISTS without a usable runtime — it defers until a
5200    /// later safe open grafts it, or until an idempotent apply in an approved
5201    /// session. This mirrors the `run_vector_equivalence_probe` gate.
5202    ///
5203    /// Enrolment is an idempotent `INSERT OR IGNORE`, so running it on the writer
5204    /// connection just OUTSIDE the batch transaction is safe: if the batch then
5205    /// fails, the workspace is left having enrolled a kind for which no row
5206    /// exists — inert.
5207    ///
5208    /// fix-1 (codex §9 [P2]) — the `vector_projection_declared` probe below is
5209    /// what stops this path re-enrolling immediately after
5210    /// [`unenrol_registry_vector_node_kinds`] has run: the inverse removes the
5211    /// registry row, and with no active declaration this returns without
5212    /// re-adding it. (The kind registry DOES now have delete paths: that one, plus
5213    /// the Slice-21 fix-1 reconciliation
5214    /// [`reconcile_inert_vector_enrolments_on_boot`]. Both are gated on the SAME
5215    /// predicate this probe reads, so neither can be undone by a later write.)
5216    ///
5217    /// Cost: the registry probe is skipped entirely once the kind is enrolled, so
5218    /// a workspace with a live dense arm pays nothing; a workspace that never
5219    /// declared a vector projection pays two `prepare_cached` `EXISTS` probes per
5220    /// batch-row of an unenrolled kind. (Slice 21c / `TC-71` made that probe
5221    /// require the `searchable` ROLE, and deliberately kept the second `EXISTS`
5222    /// as a fast negative so this cost is unchanged — see
5223    /// [`vector_projection_declared`].)
5224    ///
5225    /// fix-2 (codex §9 [P2]) — a late enrolment now runs the SAME stranded-row
5226    /// treatment the declare-time door runs ([`reenqueue_stranded_vector_rows`]),
5227    /// and returns `true` iff that re-enqueued anything. Enrolling a kind while
5228    /// enqueueing ONLY the batch's own row left every earlier row of that kind
5229    /// holding its permanent `'up_to_date'` terminal with no vector, so once the
5230    /// new row drained readiness reported `ready` with pre-existing vector-eligible
5231    /// rows unembedded — a FALSE READY. Reached, for instance, by a database that
5232    /// persisted the declaration while opened WITHOUT an embedder and then reopened
5233    /// WITH one and wrote before re-applying the projection.
5234    ///
5235    /// fix-5 (codex §9 round 4 [P2]) — the registry INSERT and the un-stranding
5236    /// commit as ONE `BEGIN IMMEDIATE`…`COMMIT` (the shape
5237    /// [`rederive_projections_on_boot`] and
5238    /// [`reproject_search_index_after_tokenizer_upgrade`] already use). fix-2 ran
5239    /// them as two, and that window is not benign: a crash or a failed repair in
5240    /// between leaves the kind REGISTERED with the older rows still holding their
5241    /// `'up_to_date'` terminals and no vectors — and that state is SELF-SEALING,
5242    /// because `kind_is_vector_indexed` is then true, so every later write skips
5243    /// this path and therefore skips the repair, while readiness reads `ready` for
5244    /// rows nothing will ever embed. Only a manual re-apply of the projection
5245    /// recovers it. No marker table and no new recovery path: the two statements
5246    /// simply share a transaction.
5247    ///
5248    /// That transaction is opened on the writer connection just OUTSIDE the batch
5249    /// transaction: if the batch then fails, the workspace is left having enrolled
5250    /// a kind whose rows are correctly queued for the dense arm the registry does
5251    /// declare — inert, and self-healing on the next write or apply.
5252    fn enrol_batch_vector_kinds(
5253        &self,
5254        connection: &Connection,
5255        batch: &[PreparedWrite],
5256    ) -> Result<bool, EngineError> {
5257        if !self.usable_dense_runtime() {
5258            return Ok(false);
5259        }
5260        // READ-ONLY pre-pass. Nothing is written here, so the overwhelmingly
5261        // common case — every kind in the batch already enrolled, or no vector
5262        // projection declared at all — still pays only the probes it paid before
5263        // and never takes a write lock.
5264        let mut to_enrol: Vec<&str> = Vec::new();
5265        for write in batch {
5266            // Only `Node` writes: edge bodies enrol `'edge_fact'` themselves in
5267            // `project_canonical_edge_row` (G11), unconditionally and already.
5268            let PreparedWrite::Node { kind, .. } = write else { continue };
5269            if to_enrol.contains(&kind.as_str()) {
5270                continue;
5271            }
5272            if self.vector_kind_needs_enrolment(connection, kind, RowKind::Leaf)? {
5273                to_enrol.push(kind);
5274            }
5275        }
5276        if to_enrol.is_empty() {
5277            return Ok(false);
5278        }
5279        self.enrol_and_unstrand(connection, &to_enrol)
5280    }
5281
5282    /// 0.8.20 Slice 20c — would enrolling `kind` be correct here? The READ-ONLY
5283    /// half of a late enrolment; [`Engine::enrol_and_unstrand`] is the write half.
5284    /// The live-embedder precondition is the CALLER's (see
5285    /// [`Engine::enrol_batch_vector_kinds`]).
5286    fn vector_kind_needs_enrolment(
5287        &self,
5288        connection: &Connection,
5289        kind: &str,
5290        row_kind: RowKind,
5291    ) -> Result<bool, EngineError> {
5292        // `graph` rows are lexically searchable but NEVER embedded
5293        // (`index_targets_for_row_kind`), so they must not drag their kind into
5294        // the vector registry — that would start embedding every other row of
5295        // that kind.
5296        if !index_targets_for_row_kind(row_kind).vector {
5297            return Ok(false);
5298        }
5299        // fix-2 (codex §9 [P1]) — the SAME restriction the declare-time door
5300        // applies, from the SAME predicate, so the two cannot drift: a kind the
5301        // vector writer cannot commit must never be enrolled, or the projection
5302        // worker wedges on it forever. See [`kind_is_vector_committable`].
5303        if !kind_is_vector_committable(kind) {
5304            return Ok(false);
5305        }
5306        if kind_is_vector_indexed(connection, kind)? {
5307            return Ok(false);
5308        }
5309        if !vector_projection_declared(connection).map_err(|_| EngineError::Storage)? {
5310            return Ok(false);
5311        }
5312        Ok(true)
5313    }
5314
5315    /// 0.8.20 Slice 20c fix-5 (codex §9 round 4 [P2]) — the WRITE half of a LATE
5316    /// enrolment: register the kinds AND repair the rows they strand, in ONE
5317    /// transaction. Returns `true` iff the repair re-enqueued anything (the caller
5318    /// must then `notify_new_work()`, since those rows are outside its batch).
5319    ///
5320    /// Split out so both write-path doors ([`Engine::enrol_batch_vector_kinds`]
5321    /// and the `#[doc(hidden)]` `write_canonical_row_with_kind_for_test`) share it
5322    /// verbatim, and so neither can register a kind without owing the repair.
5323    ///
5324    /// `register_vector_kind` is `INSERT OR IGNORE` and
5325    /// [`reenqueue_stranded_vector_rows`] is idempotent, so the read-only pre-pass
5326    /// that chose `kinds` does not need re-validating under the write lock: the
5327    /// worst a stale decision costs is one no-op `MIN` probe.
5328    fn enrol_and_unstrand(
5329        &self,
5330        connection: &Connection,
5331        kinds: &[&str],
5332    ) -> Result<bool, EngineError> {
5333        connection.execute_batch("BEGIN IMMEDIATE").map_err(|_| EngineError::Storage)?;
5334        let result = (|| -> rusqlite::Result<bool> {
5335            for kind in kinds {
5336                register_vector_kind(connection, kind)?;
5337            }
5338            reenqueue_stranded_vector_rows(connection)
5339        })();
5340        match result {
5341            Ok(enqueued) => {
5342                connection.execute_batch("COMMIT").map_err(|_| EngineError::Storage)?;
5343                Ok(enqueued)
5344            }
5345            Err(_) => {
5346                let _ = connection.execute_batch("ROLLBACK");
5347                Err(EngineError::Storage)
5348            }
5349        }
5350    }
5351
5352    fn write_inner(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
5353        self.ensure_open()?;
5354
5355        if batch.is_empty() {
5356            return Err(EngineError::WriteValidation);
5357        }
5358
5359        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5360        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
5361        let plans = validate_batch(connection, batch)?;
5362        validate_nested_projection_sources_for_write(connection, batch)?;
5363        // 0.8.20 Slice 20c (R-20-DR remainder) — LATE ENROLMENT, before
5364        // `collect_projection_jobs` reads the vector-kind registry to decide
5365        // whether the dispatcher needs waking. See
5366        // `Engine::enrol_batch_vector_kinds`.
5367        //
5368        // fix-2 (codex §9 [P2]) — the flag says the enrolment ALSO un-stranded
5369        // rows outside this batch. Those rows are not in `projection_jobs` (that
5370        // only walks the batch), so it is OR-ed into `pending_projection` below:
5371        // `drain` is a passive barrier and never a trigger (C4 rider), so work
5372        // enqueued without a wake would sit until the next unrelated write.
5373        let unstranded = self.enrol_batch_vector_kinds(connection, batch)?;
5374        let projection_jobs = collect_projection_jobs(connection, batch)?;
5375        #[cfg(debug_assertions)]
5376        if self.force_next_commit_failure.swap(false, Ordering::SeqCst) {
5377            return Err(EngineError::Storage);
5378        }
5379        // One cursor per row. `base_cursor` is the last committed cursor;
5380        // row i in the batch gets cursor `base_cursor + i + 1`, and the
5381        // batch's final cursor (returned in WriteReceipt and stored as
5382        // the new `next_cursor`) is `base_cursor + batch.len()`. Sharing
5383        // one cursor across the batch previously collapsed every vec0
5384        // INSERT onto the same rowid via `INSERT OR IGNORE` — see
5385        // `dev/notes/0.7.0-engine-batch-vec0-collapse.md`.
5386        let base_cursor = self.next_cursor.load(Ordering::SeqCst);
5387        let increment = u64::try_from(batch.len()).unwrap_or(u64::MAX);
5388        let last_cursor = base_cursor.saturating_add(increment);
5389        // G11 (Slice 15) — edge bodies also need projection-runtime notification.
5390        // `collect_projection_jobs` only tracks Node items (pre-fetched for
5391        // cursor assignment); edge bodies update `_fathomdb_projection_state` in
5392        // `commit_batch` but need the scanner to wake up via `notify_new_work`.
5393        let has_edge_body_work =
5394            batch.iter().any(|w| matches!(w, PreparedWrite::Edge { body: Some(_), .. }));
5395        let pending_projection = !projection_jobs.is_empty() || has_edge_body_work || unstranded;
5396
5397        let dangling_edge_endpoints = match commit_batch(
5398            connection,
5399            batch,
5400            &plans,
5401            base_cursor,
5402            self.provenance_row_cap.load(Ordering::Relaxed),
5403        ) {
5404            Ok(count) => count,
5405            Err(err) => {
5406                self.emit_sqlite_internal_error(&err);
5407                return Err(EngineError::Storage);
5408            }
5409        };
5410        self.next_cursor.store(last_cursor, Ordering::SeqCst);
5411        if pending_projection {
5412            self.projection_runtime.notify_new_work();
5413        }
5414
5415        // G0 — surface the per-row cursors (1:1 with input order). Row i got
5416        // `base_cursor + i + 1`, matching the allocation in `commit_batch`.
5417        let row_cursors = (0..batch.len())
5418            .map(|i| base_cursor.saturating_add((i as u64).saturating_add(1)))
5419            .collect();
5420        Ok(WriteReceipt { cursor: last_cursor, row_cursors, dangling_edge_endpoints })
5421    }
5422
5423    /// G11 (Slice 15) — BYO-LLM ingest: spawn an external extraction harness
5424    /// speaking the `fathomdb.extract.v1` NDJSON-over-stdio protocol, send
5425    /// documents for extraction, and write the resulting entities
5426    /// (→ `canonical_nodes`) and fact-edges (→ `canonical_edges` with G11
5427    /// enrichment columns) to the store.
5428    ///
5429    /// `cmd` is argv (first element = program, rest = args). Documents are
5430    /// batched per the harness's `max_docs_per_request`. Entity `logical_id`
5431    /// is derived as `sha256("<type>:<name>")` (lowercase, hex-encoded) for
5432    /// stable cross-re-ingestion identity. Edge `logical_id` is derived as
5433    /// `sha256("<from_lid>:<to_lid>:<relation>")`. Both are consistent with
5434    /// G0 supersession: re-ingesting the same document yields the same ids,
5435    /// triggering tombstone-then-insert rather than accumulation.
5436    ///
5437    /// Returns [`EngineError::Extractor`] on protocol errors (bad handshake,
5438    /// subprocess spawn failure, JSON decode error). `no_facts` warnings from
5439    /// the harness are not errors and do not affect the receipt counts.
5440    pub fn ingest_with_extractor(
5441        &self,
5442        cmd: &[&str],
5443        documents: &[ExtractDocument],
5444    ) -> Result<IngestWithExtractorReceipt, EngineError> {
5445        // 0.8.6 Slice 5 (ADR-0.8.6): the spawn + hello/ready handshake +
5446        // request_id framing + error mapping now live in the reusable
5447        // `provider_session` transport seam, parameterized by `ProviderTask`.
5448        // `ingest_with_extractor` is the thin extract caller: it opens a session
5449        // for `ProviderTask::Extract` then runs the extract-specific payload
5450        // build + DB writes. The session owns child reaping via Drop.
5451        let mut session = self.provider_session(ProviderTask::Extract, cmd)?;
5452        self.run_extract_session(&mut session, documents)
5453    }
5454
5455    /// 0.8.6 Slice 5 (ADR-0.8.6) — open a provider session: spawn the caller
5456    /// subprocess, run the `hello`/`ready` handshake for `task`, and negotiate
5457    /// `supported_tasks`. The transport (NDJSON over stdio, the detached stdout
5458    /// drainer, the bounded-recv timeout, the `request_id` framing, and the
5459    /// catch-all `EngineError::Extractor` mapping) is identical across tasks;
5460    /// only the protocol string (`fathomdb.<task>.v1`) and the negotiated task
5461    /// name differ. For `ProviderTask::Extract` the wire is byte-identical to the
5462    /// pre-0.8.6 `fathomdb.extract.v1` path.
5463    fn provider_session(
5464        &self,
5465        task: ProviderTask,
5466        cmd: &[&str],
5467    ) -> Result<ProviderSession, EngineError> {
5468        let (program, args) = cmd.split_first().ok_or(EngineError::Extractor)?;
5469        let mut child = Command::new(program)
5470            .args(args)
5471            .stdin(Stdio::piped())
5472            .stdout(Stdio::piped())
5473            .stderr(Stdio::inherit())
5474            .spawn()
5475            .map_err(|_| EngineError::Extractor)?;
5476
5477        let child_stdin = match child.stdin.take() {
5478            Some(s) => s,
5479            None => {
5480                let _ = child.kill();
5481                let _ = child.wait();
5482                return Err(EngineError::Extractor);
5483            }
5484        };
5485        let child_stdout = match child.stdout.take() {
5486            Some(s) => s,
5487            None => {
5488                let _ = child.kill();
5489                let _ = child.wait();
5490                return Err(EngineError::Extractor);
5491            }
5492        };
5493
5494        // fix-35 [P1/P2]: drain stdout on a dedicated thread so (a) every read can
5495        // be bounded with a timeout — a hung harness can no longer block ingest
5496        // forever — and (b) the child's stdout pipe is drained continuously,
5497        // preventing a large-request deadlock (parent blocked writing stdin while
5498        // the child blocks writing a full stdout pipe). The handle is detached:
5499        // joining could hang if a misbehaving child holds stdout open past its
5500        // stdin EOF, so the session's `Drop` (child.kill()) is what guarantees
5501        // thread exit.
5502        let io_timeout = extractor_io_timeout();
5503        let (line_tx, line_rx) = mpsc::channel::<std::io::Result<String>>();
5504        thread::spawn(move || {
5505            let mut reader = BufReader::new(child_stdout);
5506            loop {
5507                let mut buf = String::new();
5508                match reader.read_line(&mut buf) {
5509                    Ok(0) => break,
5510                    Ok(_) => {
5511                        if line_tx.send(Ok(buf)).is_err() {
5512                            break;
5513                        }
5514                    }
5515                    Err(e) => {
5516                        let _ = line_tx.send(Err(e));
5517                        break;
5518                    }
5519                }
5520            }
5521        });
5522
5523        let mut session = ProviderSession {
5524            task,
5525            child,
5526            writer: std::io::BufWriter::new(child_stdin),
5527            line_rx,
5528            io_timeout,
5529            model: None,
5530            max_docs_per_request: 8,
5531        };
5532        // On any handshake/negotiation error the session is dropped here, which
5533        // reaps the child (Drop) — matching the prior outer kill/wait semantics.
5534        session.handshake()?;
5535        Ok(session)
5536    }
5537
5538    /// 0.8.6 Slice 5 — extract-specific driver over a `ProviderSession`. The
5539    /// payload build (documents → entities/edges) and DB writes are byte-identical
5540    /// to the pre-0.8.6 inner loop; only the spawn/handshake/framing moved into
5541    /// the shared session.
5542    fn run_extract_session(
5543        &self,
5544        session: &mut ProviderSession,
5545        documents: &[ExtractDocument],
5546    ) -> Result<IngestWithExtractorReceipt, EngineError> {
5547        let extractor_model_id = session.model.clone();
5548        let max_docs = session.max_docs_per_request;
5549
5550        // --- per-batch extract → write loop ---
5551        let mut nodes_written: u64 = 0;
5552        let mut edges_written: u64 = 0;
5553        let docs_processed = documents.len() as u64;
5554
5555        for (batch_idx, batch) in documents.chunks(max_docs).enumerate() {
5556            let request_id = format!("req-{batch_idx}");
5557            let docs_json: Vec<Value> = batch
5558                .iter()
5559                .map(|d| {
5560                    serde_json::json!({
5561                        "source_doc_id": d.source_doc_id,
5562                        "body": d.body,
5563                    })
5564                })
5565                .collect();
5566
5567            // Send the framed extract request and receive its matching `result`.
5568            // The session adds protocol/type/request_id and validates the
5569            // type=="result" + matching request_id envelope (fix-24 [P2]).
5570            let result = session
5571                .request(&request_id, vec![("documents".to_string(), Value::Array(docs_json))])?;
5572
5573            // R-20-E2 (0.8.20 Slice 5c, design §4 item 10) — every row this batch
5574            // produces takes its provenance from the CALLER's
5575            // `ExtractDocument.source_doc_id`, NEVER from the model's echo of that
5576            // field. The echo is attacker-/error-controlled: a harness that omits
5577            // it used to yield rows with NULL `source_id`, which no
5578            // `excise_source` call can reach — the model could make a row
5579            // permanently un-erasable simply by dropping a key.
5580            //
5581            // `resolve_provenance` therefore admits the echo only as a SELECTOR
5582            // among ids the caller already supplied in THIS batch, and never as a
5583            // value:
5584            //
5585            //   * single-document batch — attribution is unambiguous, so the
5586            //     caller's id is used and the echo is ignored outright;
5587            //   * multi-document batch — the echo must name one of the batch's
5588            //     caller-supplied ids (the caller's own copy of the string is
5589            //     then stored). An absent or unrecognised echo is a protocol
5590            //     violation and fails the ingest LOUDLY with
5591            //     `EngineError::Extractor`, because the alternative — guessing an
5592            //     attribution — would silently mis-file the row under a document
5593            //     whose erasure would then not remove it.
5594            let batch_provenance = batch
5595                .iter()
5596                .map(|d| SourceId::new(d.source_doc_id.clone()))
5597                .collect::<Result<Vec<_>, _>>()?;
5598            let resolve_provenance = |echo: Option<&str>| -> Result<SourceId, EngineError> {
5599                if let [only] = batch_provenance.as_slice() {
5600                    return Ok(only.clone());
5601                }
5602                let echo = echo.ok_or(EngineError::Extractor)?;
5603                batch_provenance
5604                    .iter()
5605                    .find(|caller_id| caller_id.as_str() == echo)
5606                    .cloned()
5607                    .ok_or(EngineError::Extractor)
5608            };
5609
5610            // --- map entities → PreparedWrite::Node with stable logical_id ---
5611            let entities =
5612                result.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5613            let raw_edges =
5614                result.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
5615
5616            // R3 (SCHEMA-GATE-1): collect substituted_t_valid values from
5617            // temporal_fallback warnings. An edge whose t_valid matches one of
5618            // these values had its event time defaulted to created_at (not
5619            // text-grounded) and must be flagged so BFS can exclude it.
5620            //
5621            // TC-33: kept as RAW `Value`s here and normalised below, together
5622            // with the edge side, through the SAME function. See the
5623            // normalisation block for why that is load-bearing.
5624            let raw_fallback_dates: Vec<&Value> = result
5625                .get("warnings")
5626                .and_then(|v| v.as_array())
5627                .map(|ws| {
5628                    ws.iter()
5629                        .filter(|w| {
5630                            w.get("kind").and_then(|k| k.as_str()) == Some("temporal_fallback")
5631                        })
5632                        .filter_map(|w| w.get("substituted_t_valid"))
5633                        .collect()
5634                })
5635                .unwrap_or_default();
5636
5637            if !entities.is_empty() {
5638                let node_batch: Vec<PreparedWrite> = entities
5639                    .iter()
5640                    .map(|entity| -> Result<PreparedWrite, EngineError> {
5641                        let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5642                        let kind = entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity");
5643                        // R-20-E2: caller-grounded, echo used only as a selector.
5644                        let source_doc_id = resolve_provenance(
5645                            entity.get("source_doc_id").and_then(|v| v.as_str()),
5646                        )?;
5647                        // fix-34 [P1]: derive_logical_id now rejects an empty name
5648                        // or a ':' in kind — inputs that would collide distinct
5649                        // entities onto one identity and silently drop one.
5650                        let logical_id = derive_logical_id(kind, name)?;
5651                        Ok(PreparedWrite::Node {
5652                            kind: kind.to_string(),
5653                            body: name.to_string(),
5654                            source_id: source_doc_id,
5655                            logical_id: Some(logical_id),
5656                            state: InitialState::Active,
5657                            reason: None,
5658                            valid_from: None,
5659                            valid_until: None,
5660                        })
5661                    })
5662                    .collect::<Result<Vec<_>, _>>()?;
5663
5664                // fix-29/fix-34 [P2]: deduplicate within the batch by logical_id so
5665                // a harness that returns the same entity twice does not write a row
5666                // that immediately supersedes its sibling (shared with the edge arm).
5667                let node_batch = dedup_prepared_by_logical_id(node_batch);
5668
5669                // fix-23 [P2]: skip entities whose logical_id is already active
5670                // to avoid needless supersede churn on re-ingest.
5671                let ids: Vec<String> = node_batch
5672                    .iter()
5673                    .filter_map(|w| {
5674                        if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5675                            Some(id.clone())
5676                        } else {
5677                            None
5678                        }
5679                    })
5680                    .collect();
5681                let existing: std::collections::HashSet<String> = self
5682                    // Internal existence probe: STRICT view — this must see
5683                    // exactly the rows the pre-slice code saw.
5684                    .read_get_many(&ids, &ReadView::default())?
5685                    .into_iter()
5686                    .zip(ids)
5687                    .filter_map(|(opt, id)| opt.map(|_| id))
5688                    .collect();
5689                let new_nodes: Vec<PreparedWrite> = node_batch
5690                    .into_iter()
5691                    .filter(|w| {
5692                        if let PreparedWrite::Node { logical_id: Some(id), .. } = w {
5693                            !existing.contains(id)
5694                        } else {
5695                            true
5696                        }
5697                    })
5698                    .collect();
5699                if !new_nodes.is_empty() {
5700                    let n = new_nodes.len() as u64;
5701                    self.write(&new_nodes)?;
5702                    nodes_written = nodes_written.saturating_add(n);
5703                }
5704            }
5705
5706            // --- map edges → PreparedWrite::Edge with G11 columns ---
5707            if !raw_edges.is_empty() {
5708                // fix-33 [P1]: the protocol gives edges NO endpoint types —
5709                // `from_entity`/`to_entity` reference entities BY NAME (or alias).
5710                // Build a name+alias → (canonical name, type) index from the same
5711                // result's `entities[]` so each endpoint's logical_id matches the
5712                // node's. (Nodes derive id from the entity's real type; defaulting
5713                // the edge endpoint kind to "entity" orphaned every contract-faithful
5714                // edge from its nodes and tripped the G8 dangling probe.)
5715                //
5716                // Two passes so a canonical NAME always wins over a (different
5717                // entity's) ALIAS regardless of `entities[]` order: pass 1 inserts
5718                // all canonical names, pass 2 fills aliases only where no name
5719                // already claims that key. (Name↔name clashes remain first-wins —
5720                // contradictory input; no principled resolution exists.)
5721                let mut entity_index: std::collections::HashMap<String, (String, String)> =
5722                    std::collections::HashMap::new();
5723                for entity in &entities {
5724                    let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5725                    if name.is_empty() {
5726                        continue;
5727                    }
5728                    let kind =
5729                        entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5730                    entity_index
5731                        .entry(name.to_lowercase())
5732                        .or_insert_with(|| (name.to_string(), kind));
5733                }
5734                for entity in &entities {
5735                    let name = entity.get("name").and_then(|v| v.as_str()).unwrap_or("");
5736                    if name.is_empty() {
5737                        continue;
5738                    }
5739                    let kind =
5740                        entity.get("type").and_then(|v| v.as_str()).unwrap_or("entity").to_string();
5741                    if let Some(aliases) = entity.get("aliases").and_then(|v| v.as_array()) {
5742                        for alias in aliases.iter().filter_map(|a| a.as_str()) {
5743                            if !alias.is_empty() {
5744                                entity_index
5745                                    .entry(alias.to_lowercase())
5746                                    .or_insert_with(|| (name.to_string(), kind.clone()));
5747                            }
5748                        }
5749                    }
5750                }
5751
5752                // TC-33 — normalise EVERY extractor timestamp here, in ONE pass,
5753                // under ONE connection lock, BEFORE any edge is built. Both the
5754                // edge side (`t_valid`/`t_invalid`) and the temporal_fallback
5755                // warning side (`substituted_t_valid`) go through the SAME
5756                // function, and any value that cannot be normalised HARD-REJECTS
5757                // the whole ingest.
5758                //
5759                // **Normalising both sides is load-bearing, and nothing would
5760                // have caught it.** `temporal_fallback` is decided by comparing
5761                // the edge's t_valid against the warnings' substituted_t_valid.
5762                // That was a RAW BYTE-FOR-BYTE STRING MATCH with
5763                // `.unwrap_or(false)` on the miss path, and `substituted_t_valid`
5764                // is a FREE-FORM JSON key on the ELPS warnings envelope, not a
5765                // Rust struct field. So normalising only the edge side would
5766                // leave the set never matching, `.unwrap_or(false)` firing, and
5767                // EVERY fallback edge silently becoming a TRUSTED edge — with no
5768                // compile error anywhere. That flag is the only thing excluding
5769                // untrustworthy-time edges from graph BFS and graph seeding.
5770                //
5771                // Normalising both sides also FIXES a pre-existing brittleness:
5772                // `2025-03-20T09:30:00Z` and `2025-03-20T09:30:00+00:00` are the
5773                // same instant but MISS each other under a byte comparison. They
5774                // now compare equal as epochs.
5775                //
5776                // A malformed `substituted_t_valid` rejects rather than being
5777                // skipped: skipping it would leave the edge unflagged, i.e.
5778                // treated as TRUSTED — the same fail-open in a different place.
5779                //
5780                // The lock is taken and released HERE; `self.write(...)` below
5781                // re-acquires it, so no lock is held across the write.
5782                // (t_valid, t_invalid) epoch pair per edge, in `raw_edges` order.
5783                type EdgeTimes = Vec<(Option<i64>, Option<i64>)>;
5784                let (edge_times, fallback_epochs): (EdgeTimes, std::collections::HashSet<i64>) = {
5785                    let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5786                    let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5787
5788                    let mut times = Vec::with_capacity(raw_edges.len());
5789                    for edge in &raw_edges {
5790                        times.push((
5791                            normalize_extractor_timestamp(
5792                                connection,
5793                                "t_valid",
5794                                edge.get("t_valid"),
5795                            )?,
5796                            normalize_extractor_timestamp(
5797                                connection,
5798                                "t_invalid",
5799                                edge.get("t_invalid"),
5800                            )?,
5801                        ));
5802                    }
5803
5804                    let mut epochs = std::collections::HashSet::new();
5805                    for raw in &raw_fallback_dates {
5806                        if let Some(epoch) = normalize_extractor_timestamp(
5807                            connection,
5808                            "substituted_t_valid",
5809                            Some(raw),
5810                        )? {
5811                            epochs.insert(epoch);
5812                        }
5813                    }
5814                    (times, epochs)
5815                };
5816
5817                let edge_batch: Vec<PreparedWrite> = raw_edges
5818                    .iter()
5819                    .zip(&edge_times)
5820                    .map(|(edge, &(t_valid, t_invalid))| -> Result<PreparedWrite, EngineError> {
5821                        let from_entity =
5822                            edge.get("from_entity").and_then(|v| v.as_str()).unwrap_or("");
5823                        let to_entity =
5824                            edge.get("to_entity").and_then(|v| v.as_str()).unwrap_or("");
5825                        let relation =
5826                            edge.get("relation").and_then(|v| v.as_str()).unwrap_or("related_to");
5827                        let body = edge.get("body").and_then(|v| v.as_str()).map(str::to_string);
5828                        // TC-33: `t_valid`/`t_invalid` were normalised (and any
5829                        // malformed or non-string value hard-rejected) in the
5830                        // pass above; they arrive here as epoch seconds.
5831                        // fix-26 [P2]: validate confidence is in [0.0, 1.0] at the
5832                        // protocol boundary; reject out-of-range values.
5833                        let confidence = match edge.get("confidence").and_then(|v| v.as_f64()) {
5834                            Some(c) if !(0.0..=1.0).contains(&c) => {
5835                                return Err(EngineError::Extractor);
5836                            }
5837                            c => c,
5838                        };
5839                        // R-20-E2: caller-grounded, echo used only as a selector.
5840                        let source_doc_id =
5841                            resolve_provenance(edge.get("source_doc_id").and_then(|v| v.as_str()))?;
5842
5843                        // fix-33 [P1]: resolve each endpoint via the entities[]
5844                        // index (by name or alias) → the entity's canonical
5845                        // (name, type); fall back to kind "entity" only for a truly
5846                        // unlisted name (synthesized dangling endpoints ARE listed,
5847                        // so this is the defensive path). derive_logical_id (fix-34)
5848                        // still rejects an empty name / ':' in kind.
5849                        let (from_name, from_kind) = entity_index
5850                            .get(&from_entity.to_lowercase())
5851                            .cloned()
5852                            .unwrap_or_else(|| (from_entity.to_string(), "entity".to_string()));
5853                        let (to_name, to_kind) = entity_index
5854                            .get(&to_entity.to_lowercase())
5855                            .cloned()
5856                            .unwrap_or_else(|| (to_entity.to_string(), "entity".to_string()));
5857                        let from_lid = derive_logical_id(&from_kind, &from_name)?;
5858                        let to_lid = derive_logical_id(&to_kind, &to_name)?;
5859                        let edge_key = format!("{from_lid}:{to_lid}:{relation}");
5860                        let edge_lid = derive_logical_id("edge", &edge_key)?;
5861
5862                        // TC-33: BOTH sides are now epochs from the SAME
5863                        // normalisation, so this compares instants rather than
5864                        // byte strings.
5865                        let is_temporal_fallback =
5866                            t_valid.is_some_and(|tv| fallback_epochs.contains(&tv));
5867                        Ok(PreparedWrite::Edge {
5868                            kind: relation.to_string(),
5869                            from: from_lid,
5870                            to: to_lid,
5871                            source_id: source_doc_id,
5872                            logical_id: Some(edge_lid),
5873                            body,
5874                            t_valid,
5875                            t_invalid,
5876                            confidence,
5877                            extractor_model_id: extractor_model_id.clone(),
5878                            temporal_fallback: if is_temporal_fallback { Some(true) } else { None },
5879                        })
5880                    })
5881                    .collect::<Result<Vec<_>, _>>()?;
5882                // fix-34 [P2]: dedup edges by logical_id, mirroring the node arm
5883                // (fix-29) — a duplicate edge in one harness response would
5884                // otherwise write a row that immediately supersedes its sibling.
5885                let edge_batch = dedup_prepared_by_logical_id(edge_batch);
5886                let n = edge_batch.len() as u64;
5887                self.write(&edge_batch)?;
5888                edges_written = edges_written.saturating_add(n);
5889            }
5890        }
5891
5892        // The `ProviderSession` (and its writer/child) is dropped by the caller
5893        // when `ingest_with_extractor` returns: Drop sends stdin EOF and reaps
5894        // the child, matching the prior explicit drop(writer)+kill/wait.
5895        Ok(IngestWithExtractorReceipt { nodes_written, edges_written, docs_processed })
5896    }
5897
5898    /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — BYO-LLM CONSOLIDATION / RECENCY.
5899    ///
5900    /// The SECOND consumer of the one `provider_session` transport (ADR-0.8.6):
5901    /// consolidation reuses the exact NDJSON-over-stdio transport, hello/ready
5902    /// handshake, `supported_tasks` negotiation, `request_id` framing, and
5903    /// bounded-recv timeout — only the protocol string
5904    /// (`fathomdb.consolidate.v1`) and the task-specific payload differ. There is
5905    /// NO second transport and NO second handshake.
5906    ///
5907    /// For each `(subject, relation)` axis, FathomDB assembles a candidate
5908    /// cluster of competing active fact-edges DETERMINISTICALLY (CPU-only, no
5909    /// LLM), sends it to the caller-supplied harness, and applies the returned
5910    /// verdicts. **CALLER-SIDE BYO-LLM**: the harness is the caller's subprocess;
5911    /// the library never embeds or calls an LLM and makes NO network egress.
5912    ///
5913    /// **Load-bearing semantic (ADR-0.8.12 §2.1):** consolidation records
5914    /// supersession / recency METADATA only — `invalidate` sets `t_invalid`,
5915    /// `supersede`/`merge` marks the row superseded via the existing G0 tombstone
5916    /// column. Edge BODIES are NEVER rewritten and NO row is ever deleted (the
5917    /// 0.8.3 lesson: blind content-merge HURT accuracy). The original rows
5918    /// survive; the engine stays deterministic.
5919    ///
5920    /// Returns [`EngineError::Consolidator`] on any transport/handshake/protocol
5921    /// fault or a malformed / out-of-cluster verdict.
5922    pub fn consolidate_with_provider(
5923        &self,
5924        cmd: &[&str],
5925        axes: &[ConsolidateAxis],
5926    ) -> Result<ConsolidateReceipt, EngineError> {
5927        // Reuse the shared transport verbatim; remap its (Extractor-flavoured)
5928        // transport error to the task-specific Consolidator leaf.
5929        let mut session = self
5930            .provider_session(ProviderTask::Consolidate, cmd)
5931            .map_err(|_| EngineError::Consolidator)?;
5932        self.run_consolidate_session(&mut session, axes)
5933    }
5934
5935    /// 0.8.12 Slice 15 — consolidate-specific driver over a `ProviderSession`.
5936    /// Mirrors [`run_extract_session`][Engine::run_extract_session]: assemble the
5937    /// task payload, run the framed request over the shared session, apply the
5938    /// task-specific DB effect. The cluster assembly + verdict application are
5939    /// CPU-only/deterministic.
5940    fn run_consolidate_session(
5941        &self,
5942        session: &mut ProviderSession,
5943        axes: &[ConsolidateAxis],
5944    ) -> Result<ConsolidateReceipt, EngineError> {
5945        let mut receipt = ConsolidateReceipt::default();
5946
5947        for (i, axis) in axes.iter().enumerate() {
5948            // 1. Deterministically assemble the candidate cluster (CPU-only, no LLM).
5949            let cluster = self.assemble_consolidate_cluster(axis)?;
5950            if cluster.is_empty() {
5951                continue;
5952            }
5953            receipt.clusters_processed = receipt.clusters_processed.saturating_add(1);
5954            receipt.edges_examined = receipt.edges_examined.saturating_add(cluster.len() as u64);
5955
5956            // 2. Send the cluster; receive the verdict envelope. The session adds
5957            //    protocol/type/request_id and validates type=="result" + matching
5958            //    request_id. Any transport/protocol fault → Consolidator.
5959            let request_id = format!("req-{i}");
5960            // TC-33: storage and `ConsolidateCandidateEdge` are INTEGER epoch
5961            // seconds, but the harness WIRE is ISO-8601 — the same split as the
5962            // extractor boundary. Render on the way out; the verdict's
5963            // `t_invalid` is normalised back on the way in. Without this the
5964            // harness would receive epoch integers and (since the reference stub
5965            // echoes the winner's `t_valid` straight back as `t_invalid`) its
5966            // reply would be rejected by our own inbound normaliser.
5967            let edges_json: Vec<Value> = {
5968                let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
5969                let connection = connection.as_ref().ok_or(EngineError::Closing)?;
5970                // TC-33 fix-1 backstop [DEFENSIVE — unreachable]. A stored
5971                // `Some(ts)` that fails to render must NOT become a silent
5972                // `null`: that is exactly the "still valid" resurrection vector.
5973                // With `reject_unrenderable_edge_epoch` guarding the write
5974                // boundary, no unrenderable epoch can reach storage — so this is
5975                // a hard-assert upholding that invariant STRUCTURALLY, not the
5976                // primary defence. `None` (unknown) still renders to JSON null;
5977                // only a NON-NULL stored epoch that fails to render is an error.
5978                let render = |field: &str, value: Option<i64>| -> Result<Value, EngineError> {
5979                    match value {
5980                        None => Ok(Value::Null),
5981                        Some(ts) => match epoch_seconds_to_iso8601(connection, ts) {
5982                            Some(iso) => Ok(Value::from(iso)),
5983                            None => Err(EngineError::InvalidArgument {
5984                                msg: format!(
5985                                    "INVARIANT VIOLATION (TC-33 fix-1): stored edge `{field}` = \
5986                                     {ts} is unrenderable to ISO-8601 and would have gone to the \
5987                                     consolidation wire as a silent null (\"still valid\"). The \
5988                                     write boundary should have made this unstorable."
5989                                ),
5990                            }),
5991                        },
5992                    }
5993                };
5994                cluster
5995                    .iter()
5996                    .map(|e| {
5997                        Ok::<Value, EngineError>(serde_json::json!({
5998                            "edge_ref": e.edge_ref,
5999                            "body": e.body,
6000                            "t_valid": render("t_valid", e.t_valid)?,
6001                            "t_invalid": render("t_invalid", e.t_invalid)?,
6002                            "confidence": e.confidence,
6003                            "source_doc_id": e.source_doc_id,
6004                            "extractor_model_id": e.extractor_model_id,
6005                        }))
6006                    })
6007                    .collect::<Result<Vec<Value>, EngineError>>()?
6008            };
6009            let cluster_json = serde_json::json!({
6010                "subject": axis.subject_logical_id,
6011                "relation": axis.relation,
6012                "edges": edges_json,
6013            });
6014            let result = session
6015                .request(&request_id, vec![("cluster".to_string(), cluster_json)])
6016                .map_err(|_| EngineError::Consolidator)?;
6017
6018            // 3. Apply the verdicts (metadata-only; original rows + bodies survive).
6019            let verdicts = result
6020                .get("verdicts")
6021                .and_then(|v| v.as_array())
6022                .ok_or(EngineError::Consolidator)?
6023                .clone();
6024            self.apply_consolidate_verdicts(&cluster, &verdicts, &mut receipt)?;
6025        }
6026
6027        Ok(receipt)
6028    }
6029
6030    /// 0.8.12 Slice 15 — assemble the competing fact-edge cluster for one
6031    /// `(subject, relation)` axis, deterministically, from active `canonical_edges`
6032    /// (`from_id = subject AND kind = relation AND superseded_at IS NULL`), ordered
6033    /// by `write_cursor` (stable insertion order). CPU-only; no network, no LLM.
6034    fn assemble_consolidate_cluster(
6035        &self,
6036        axis: &ConsolidateAxis,
6037    ) -> Result<Vec<ConsolidateCandidateEdge>, EngineError> {
6038        self.ensure_open()?;
6039        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
6040        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
6041        let mut stmt = connection
6042            .prepare(
6043                "SELECT logical_id, body, t_valid, t_invalid, confidence, source_id, \
6044                        extractor_model_id \
6045                 FROM canonical_edges \
6046                 WHERE from_id = ?1 AND kind = ?2 AND superseded_at IS NULL \
6047                 ORDER BY write_cursor",
6048            )
6049            .map_err(|_| EngineError::Storage)?;
6050        let rows = stmt
6051            .query_map(params![axis.subject_logical_id, axis.relation], |r| {
6052                Ok(ConsolidateCandidateEdge {
6053                    edge_ref: r.get::<_, Option<String>>(0)?.unwrap_or_default(),
6054                    body: r.get(1)?,
6055                    t_valid: r.get(2)?,
6056                    t_invalid: r.get(3)?,
6057                    confidence: r.get(4)?,
6058                    source_doc_id: r.get(5)?,
6059                    extractor_model_id: r.get(6)?,
6060                })
6061            })
6062            .map_err(|_| EngineError::Storage)?;
6063        let out: rusqlite::Result<Vec<ConsolidateCandidateEdge>> = rows.collect();
6064        // Skip any edge with a NULL/empty logical_id (no stable ref to round-trip).
6065        Ok(out
6066            .map_err(|_| EngineError::Storage)?
6067            .into_iter()
6068            .filter(|e| !e.edge_ref.is_empty())
6069            .collect())
6070    }
6071
6072    /// 0.8.12 Slice 15 — apply the harness verdicts as METADATA-ONLY transitions
6073    /// (ADR-0.8.12 §2.1). NEVER rewrites a body, NEVER deletes a row. A verdict
6074    /// referencing an edge not in the presented cluster, or an unknown verdict
6075    /// kind, is a protocol fault → [`EngineError::Consolidator`].
6076    fn apply_consolidate_verdicts(
6077        &self,
6078        cluster: &[ConsolidateCandidateEdge],
6079        verdicts: &[Value],
6080        receipt: &mut ConsolidateReceipt,
6081    ) -> Result<(), EngineError> {
6082        let known: std::collections::HashSet<&str> =
6083            cluster.iter().map(|e| e.edge_ref.as_str()).collect();
6084        // fix-1 [P2] bijection: the verdict set must cover the presented cluster
6085        // EXACTLY — every presented edge ruled on, none ruled on twice.
6086        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
6087
6088        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
6089        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
6090        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
6091
6092        for v in verdicts {
6093            let edge_ref =
6094                v.get("edge_ref").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
6095            // The harness may only rule on edges FathomDB presented in the cluster.
6096            if !known.contains(edge_ref) {
6097                return Err(EngineError::Consolidator);
6098            }
6099            // fix-1 [P2]: a repeated edge_ref is a protocol fault (not a bijection).
6100            if !seen.insert(edge_ref) {
6101                return Err(EngineError::Consolidator);
6102            }
6103            let verdict =
6104                v.get("verdict").and_then(|x| x.as_str()).ok_or(EngineError::Consolidator)?;
6105            // Look up the active edge's projection cursor BEFORE any UPDATE so a
6106            // supersede (which clears `superseded_at IS NULL`) can still find it.
6107            let active_cursor = Self::active_edge_write_cursor(&tx, edge_ref)?;
6108            match verdict {
6109                "keep" => {
6110                    receipt.edges_kept = receipt.edges_kept.saturating_add(1);
6111                }
6112                "invalidate" => {
6113                    // Recency metadata: set t_invalid; the row and its body are
6114                    // left intact (this is NOT a destructive content rewrite).
6115                    //
6116                    // TC-33: the CONSOLIDATION harness is the same class of
6117                    // BYO-LLM boundary as the extractor, so it carries ISO-8601
6118                    // on the wire and is normalised here with the SAME hard
6119                    // rejection. Previously the raw string went straight into the
6120                    // UPDATE with no validation whatsoever.
6121                    // fix-3 [P2]: consolidation is a BYO-LLM PROVIDER boundary, so
6122                    // a malformed / non-string `t_invalid` is a PROVIDER protocol
6123                    // fault → `Consolidator`, NOT the extractor/user `InvalidArgument`
6124                    // that `normalize_extractor_timestamp` emits. Remap it to match
6125                    // the two sibling failure modes on this same value (missing key
6126                    // and null/unparseable-to-None, both `Consolidator`). Consistent,
6127                    // not a diagnostic loss: `Consolidator` is a unit variant and the
6128                    // adjacent `.ok_or(EngineError::Consolidator)` cases already
6129                    // discard any message.
6130                    let ts = normalize_extractor_timestamp(
6131                        &tx,
6132                        "t_invalid",
6133                        Some(v.get("t_invalid").ok_or(EngineError::Consolidator)?),
6134                    )
6135                    .map_err(|_| EngineError::Consolidator)?
6136                    .ok_or(EngineError::Consolidator)?;
6137                    tx.execute(
6138                        "UPDATE canonical_edges SET t_invalid = ?1 \
6139                         WHERE logical_id = ?2 AND superseded_at IS NULL",
6140                        params![ts, edge_ref],
6141                    )
6142                    .map_err(|_| EngineError::Storage)?;
6143                    // fix-1 [P1]: prune the STATIC projection shadow rows so the
6144                    // consolidated-away edge stops surfacing in FTS/vector — but
6145                    // ONLY when the edge is ended as of the engine's "now",
6146                    // mirroring the graph-traversal filter `edge_validity_sql`.
6147                    // A future-dated t_invalid keeps the edge valid ⇒ keep the
6148                    // projection. NON-DESTRUCTIVE: the canonical_edges row + body
6149                    // survive (ADR-0.8.12 §2.1).
6150                    //
6151                    // TC-33: this used to be `SELECT datetime(?1) <= datetime('now')`
6152                    // — an inline clock, AND a misleading error class: junk made
6153                    // the SELECT yield SQL NULL, so `r.get::<bool>` failed as
6154                    // `EngineError::Storage`. Both timestamps are integers now, so
6155                    // the comparison is plain Rust against the bound `:now` seam.
6156                    if let Some(cursor) = active_cursor {
6157                        let ended = ts <= current_epoch_seconds();
6158                        if ended {
6159                            // fix-2 [P2]: KEEP the projection terminal row. The
6160                            // canonical_edges row stays NON-superseded (invalidate
6161                            // is metadata-only), and `database_has_pending_projection_work`
6162                            // flags any non-superseded edge that has a body but no
6163                            // terminal as pending; since `next_pending_projection_jobs`
6164                            // only scans cursors ABOVE the stored projection cursor, an
6165                            // already-projected invalidated edge would never be requeued
6166                            // and `drain()`/`wait_for_idle` would hang forever. Dropping
6167                            // the FTS/vec shadows (below) hides it from active retrieval;
6168                            // retaining the terminal keeps the scheduler idle.
6169                            Self::prune_edge_projection_shadows(&tx, cursor, true)?;
6170                        }
6171                    }
6172                    receipt.edges_invalidated = receipt.edges_invalidated.saturating_add(1);
6173                }
6174                // `merge` maps cleanly to supersede + metadata (ADR-0.8.12 §3):
6175                // the loser is marked superseded; the winner ("by"/"into") is the
6176                // surviving active row. No body is merged.
6177                "supersede" | "merge" => {
6178                    // Mark superseded via the existing G0 tombstone column; the row
6179                    // survives (invalidate-not-delete). Use a fresh monotonic cursor.
6180                    let cursor = self.next_cursor.fetch_add(1, Ordering::SeqCst).saturating_add(1);
6181                    tx.execute(
6182                        "UPDATE canonical_edges SET superseded_at = ?1 \
6183                         WHERE logical_id = ?2 AND superseded_at IS NULL",
6184                        params![cursor, edge_ref],
6185                    )
6186                    .map_err(|_| EngineError::Storage)?;
6187                    // fix-1 [P1]: a superseded edge is unconditionally out of the
6188                    // active set (graph traversal filters `superseded_at IS NULL`),
6189                    // so prune its FTS/vector projection shadow rows to match.
6190                    if let Some(active_cursor) = active_cursor {
6191                        // A superseded row is excluded from the pending-work check
6192                        // (`superseded_at IS NOT NULL`), so dropping its terminal too
6193                        // is safe (matches the excise pattern) and cannot phantom-pend.
6194                        Self::prune_edge_projection_shadows(&tx, active_cursor, false)?;
6195                    }
6196                    receipt.edges_superseded = receipt.edges_superseded.saturating_add(1);
6197                }
6198                _ => return Err(EngineError::Consolidator),
6199            }
6200        }
6201
6202        // fix-1 [P2]: bijection completeness — every presented cluster edge must
6203        // have received exactly one verdict.
6204        if seen.len() != known.len() {
6205            return Err(EngineError::Consolidator);
6206        }
6207
6208        tx.commit().map_err(|_| EngineError::Storage)?;
6209        Ok(())
6210    }
6211
6212    /// fix-1 [P1] — the active (non-superseded) row's projection `write_cursor`
6213    /// for a fact-edge `logical_id`, or `None` if there is no active row. The
6214    /// cursor keys the STATIC projection shadow rows (FTS `search_index_edges`,
6215    /// vec0 `vector_default` by rowid, `_fathomdb_vector_rows`,
6216    /// `_fathomdb_projection_terminal`).
6217    fn active_edge_write_cursor(
6218        tx: &rusqlite::Transaction<'_>,
6219        edge_ref: &str,
6220    ) -> Result<Option<i64>, EngineError> {
6221        tx.query_row(
6222            "SELECT write_cursor FROM canonical_edges \
6223             WHERE logical_id = ?1 AND superseded_at IS NULL",
6224            params![edge_ref],
6225            |r| r.get::<_, i64>(0),
6226        )
6227        .optional()
6228        .map_err(|_| EngineError::Storage)
6229    }
6230
6231    /// fix-1 [P1] — prune the STATIC projection shadow rows for a canonical
6232    /// row's `write_cursor` so a consolidated-away edge stops surfacing in
6233    /// FTS/vector retrieval. Mirrors the excision pattern at
6234    /// `excise_source_inner` (invalidate-not-delete: the canonical row + body
6235    /// are NEVER touched here).
6236    ///
6237    /// `keep_terminal` retains the `_fathomdb_projection_terminal` marker — set it
6238    /// when the canonical row stays NON-superseded (an `invalidate` verdict), so the
6239    /// projection scheduler still treats the cursor as done (fix-2 [P2]); clear it
6240    /// when the row is superseded (excluded from the pending-work scan anyway).
6241    ///
6242    /// FIXED (0.8.12 Slice A, R-CON-2 named default-ON blocker; Slice-20 codex
6243    /// §9 [P2]): a full `rebuild_projections` re-projects every non-superseded
6244    /// edge with a body from `canonical_edges` — this used to re-materialise an
6245    /// invalidated edge's FTS/vec shadows even though graph traversal excludes
6246    /// it via the `t_invalid > now` filter. The FTS rebuild SELECT
6247    /// (`rebuild_shadow_state`), the vec projection queue
6248    /// (`next_pending_projection_jobs`), and the pending-work probe
6249    /// (`database_has_pending_projection_work`) now all carry the same
6250    /// `edge_validity_sql` filter as graph traversal (TC-33: INTEGER compare
6251    /// against the bound `:now`, formerly `datetime(t_invalid) > datetime('now')`),
6252    /// so a rebuild is durable across the recency exclusion.
6253    fn prune_edge_projection_shadows(
6254        tx: &rusqlite::Transaction<'_>,
6255        cursor: i64,
6256        keep_terminal: bool,
6257    ) -> Result<(), EngineError> {
6258        tx.execute("DELETE FROM search_index_edges WHERE write_cursor = ?1", [cursor])
6259            .map_err(|_| EngineError::Storage)?;
6260        // vec0 rowid is the canonical row's write_cursor. TC-76: via the one
6261        // vec0-delete primitive ([`delete_vector_partition_row`]).
6262        delete_vector_partition_row(tx, cursor).map_err(|_| EngineError::Storage)?;
6263        tx.execute("DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1", [cursor])
6264            .map_err(|_| EngineError::Storage)?;
6265        if !keep_terminal {
6266            tx.execute(
6267                "DELETE FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
6268                [cursor],
6269            )
6270            .map_err(|_| EngineError::Storage)?;
6271        }
6272        Ok(())
6273    }
6274
6275    pub fn search(&self, query: &str) -> Result<SearchResult, EngineError> {
6276        self.search_with_limit(query, DEFAULT_SEARCH_RESULT_LIMIT)
6277    }
6278
6279    /// Hybrid search with an explicit ranked-result limit in `1..=100`.
6280    pub fn search_with_limit(
6281        &self,
6282        query: &str,
6283        limit: usize,
6284    ) -> Result<SearchResult, EngineError> {
6285        self.search_filtered_with_limit(query, None, limit)
6286    }
6287
6288    /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — `search` under an explicit
6289    /// [`ReadView`], the escape hatch matching the one the five read verbs got in
6290    /// Slice 10b. `search(query)` is exactly `search_view(query, &ReadView::default())`.
6291    ///
6292    /// **Scope: the VALIDITY axis only.** `include_out_of_window` and
6293    /// `valid_as_of` are honoured; the EXISTENCE flags (`include_superseded`,
6294    /// `include_inactive`) are **refused** with
6295    /// [`EngineError::InvalidArgument`] rather than silently ignored. Relaxing
6296    /// `superseded_at IS NULL` on a retrieval path would resurrect the stale-body
6297    /// leak the Slice-15 fix-1 review closed, and search hydrates from projection
6298    /// indexes (`search_index`, `vector_default`) that are not version-complete —
6299    /// so "include superseded" has no truthful answer here. Refusing says that;
6300    /// ignoring would be the dead surface this fix exists to remove.
6301    ///
6302    /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6303    pub fn search_view(&self, query: &str, view: &ReadView) -> Result<SearchResult, EngineError> {
6304        self.search_view_with_limit(query, view, DEFAULT_SEARCH_RESULT_LIMIT)
6305    }
6306
6307    /// Hybrid search under a [`ReadView`] with an explicit ranked-result limit.
6308    pub fn search_view_with_limit(
6309        &self,
6310        query: &str,
6311        view: &ReadView,
6312        limit: usize,
6313    ) -> Result<SearchResult, EngineError> {
6314        self.search_reranked_view_with_limit(query, None, 0, false, 0.3, 0, false, view, limit)
6315    }
6316
6317    /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — the FULL-arity view entry
6318    /// point: [`search_reranked`][Engine::search_reranked] /
6319    /// [`search_explained`][Engine::search_explained] under an explicit
6320    /// [`ReadView`]. This is what the Python and TypeScript `search(..., view=)`
6321    /// bindings call, so a caller can combine a content filter, the CE knobs and
6322    /// a validity view in one query — passing `view` must not silently disable
6323    /// the filter, and passing a filter must not silently disable `view`.
6324    ///
6325    /// `search_reranked(q, f, d, g, a, p)` is exactly
6326    /// `search_reranked_view(q, f, d, g, a, p, false, &ReadView::default())`.
6327    ///
6328    /// Validity axis only; existence flags are refused. See
6329    /// [`search_view`][Engine::search_view].
6330    ///
6331    /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6332    #[allow(clippy::too_many_arguments)] // mirrors search_explained + the view
6333    pub fn search_reranked_view(
6334        &self,
6335        query: &str,
6336        filter: Option<SearchFilter>,
6337        rerank_depth: usize,
6338        use_graph_arm: bool,
6339        alpha: f64,
6340        pool_n: usize,
6341        explain: bool,
6342        view: &ReadView,
6343    ) -> Result<SearchResult, EngineError> {
6344        self.search_reranked_view_with_limit(
6345            query,
6346            filter,
6347            rerank_depth,
6348            use_graph_arm,
6349            alpha,
6350            pool_n,
6351            explain,
6352            view,
6353            DEFAULT_SEARCH_RESULT_LIMIT,
6354        )
6355    }
6356
6357    /// Full-arity hybrid search under a [`ReadView`] with an explicit ranked-result limit.
6358    #[allow(clippy::too_many_arguments)]
6359    pub fn search_reranked_view_with_limit(
6360        &self,
6361        query: &str,
6362        filter: Option<SearchFilter>,
6363        rerank_depth: usize,
6364        use_graph_arm: bool,
6365        alpha: f64,
6366        pool_n: usize,
6367        explain: bool,
6368        view: &ReadView,
6369        limit: usize,
6370    ) -> Result<SearchResult, EngineError> {
6371        let limit = validate_search_result_limit(limit)?;
6372        self.search_reranked_with_explain(
6373            query,
6374            filter,
6375            rerank_depth,
6376            use_graph_arm,
6377            alpha,
6378            pool_n,
6379            explain,
6380            *view,
6381            limit,
6382        )
6383    }
6384
6385    /// G10 — hybrid `search` with an optional closed [`SearchFilter`]. `None`
6386    /// (or an all-`None` filter) is the unfiltered path whose phase-1 SQL is
6387    /// byte-identical to 0.7.2. The filter prunes the vector branch in the
6388    /// single phase-1 candidates statement and constrains the text branch by the
6389    /// same metadata. Ranking is the unconditional G9 RRF fusion.
6390    pub fn search_filtered(
6391        &self,
6392        query: &str,
6393        filter: Option<SearchFilter>,
6394    ) -> Result<SearchResult, EngineError> {
6395        self.search_filtered_with_limit(query, filter, DEFAULT_SEARCH_RESULT_LIMIT)
6396    }
6397
6398    /// Hybrid search with an optional [`SearchFilter`] and explicit ranked-result limit.
6399    pub fn search_filtered_with_limit(
6400        &self,
6401        query: &str,
6402        filter: Option<SearchFilter>,
6403        limit: usize,
6404    ) -> Result<SearchResult, EngineError> {
6405        // 0.8.11 Slice 40 (R-FIL-2): re-express the shipped G10 `SearchFilter`
6406        // sugar through the unified `Filter` type, then lower back to the vec0
6407        // backend's `SearchFilter` (D4). The round-trip is lossless +
6408        // canonical-order-preserving, so the produced phase-1 SQL stays
6409        // byte-identical to 0.7.2 on the `None`/all-`None` path. `SearchFilter`
6410        // never carries a `Json` term, so `to_search_filter` never rejects here.
6411        //
6412        // 0.8.20 Slice 15e — the unified `Filter`/`FilterTerm` grammar does not yet
6413        // carry `filterable`-attribute terms (a later slice adds that surface), so
6414        // the round-trip would drop `SearchFilter.attributes`. Carry them across
6415        // explicitly: they already route pre-KNN through `vector_filter_clause`.
6416        let lowered = filter
6417            .map(|mut sf| {
6418                let attributes = sf.attributes.clone();
6419                // Attribute predicates are intentionally absent from the unified
6420                // grammar, but this legacy/hybrid entry point owns their existing
6421                // pre-KNN lowering. Remove them only for the metadata round-trip,
6422                // then restore them on its `SearchFilter` output.
6423                sf.attributes.clear();
6424                Filter::try_from(&sf).and_then(|filter| {
6425                    filter.to_search_filter().map(|mut lo| {
6426                        lo.attributes = attributes;
6427                        lo
6428                    })
6429                })
6430            })
6431            .transpose()?;
6432        // FIX-6: delegate to search_reranked(depth=0, use_graph_arm=false) to eliminate the
6433        // ~26-line duplicate body that would otherwise drift with search_reranked.
6434        // 0.8.5: depth=0 is inert, so the α/pool_n defaults (0.3, 0) never reach the blend.
6435        self.search_reranked_with_limit(query, lowered, 0, false, 0.3, 0, limit)
6436    }
6437
6438    /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the vec0 search
6439    /// backend. Lowers the metadata subset to the indexed pre-KNN `WHERE` and
6440    /// **typed-rejects** a [`FilterTerm::Json`] term with
6441    /// [`EngineError::InvalidFilter`] (D3 no-demotion guarantee). This is the
6442    /// unified surface the 0.8.15 router `constraints` block reasons over; the
6443    /// shipped [`Engine::search_filtered`]`(query, Option<SearchFilter>)` stays
6444    /// as sugar over the same path.
6445    pub fn search_filter(&self, query: &str, filter: &Filter) -> Result<SearchResult, EngineError> {
6446        self.search_filter_with_limit(query, filter, DEFAULT_SEARCH_RESULT_LIMIT)
6447    }
6448
6449    /// Unified-filter hybrid search with an explicit ranked-result limit.
6450    pub fn search_filter_with_limit(
6451        &self,
6452        query: &str,
6453        filter: &Filter,
6454        limit: usize,
6455    ) -> Result<SearchResult, EngineError> {
6456        let sf = filter.to_search_filter()?;
6457        self.search_reranked_with_limit(query, Some(sf), 0, false, 0.3, 0, limit)
6458    }
6459
6460    /// 0.8.1 Slice 10 (R1) / Slice 30 (R3) — `search_reranked`: hybrid search
6461    /// with optional CE reranking and optional graph-BFS third arm. `rerank_depth
6462    /// = 0` is the identity (soft-fallback) path, byte-identical to
6463    /// [`search_filtered`][Engine::search_filtered]. `rerank_depth = N > 0`
6464    /// applies the cross-encoder over the top-N fused hits (when the
6465    /// `default-reranker` feature is enabled and the model is loaded); without the
6466    /// model, the call falls back to the fused order.
6467    ///
6468    /// `use_graph_arm = false` (the default) produces byte-identical results to
6469    /// the pre-Slice-30 two-arm pipeline. `use_graph_arm = true` seeds a BFS over
6470    /// temporal fact-edges from the top-10 fused hits and fuses the reachable
6471    /// nodes as a third RRF arm.
6472    ///
6473    /// Governed surface: re-exported from `fathomdb` facade.
6474    pub fn search_reranked(
6475        &self,
6476        query: &str,
6477        filter: Option<SearchFilter>,
6478        rerank_depth: usize,
6479        use_graph_arm: bool,
6480        alpha: f64,
6481        pool_n: usize,
6482    ) -> Result<SearchResult, EngineError> {
6483        self.search_reranked_with_limit(
6484            query,
6485            filter,
6486            rerank_depth,
6487            use_graph_arm,
6488            alpha,
6489            pool_n,
6490            DEFAULT_SEARCH_RESULT_LIMIT,
6491        )
6492    }
6493
6494    /// Hybrid search with optional reranking and an explicit ranked-result limit.
6495    #[allow(clippy::too_many_arguments)]
6496    pub fn search_reranked_with_limit(
6497        &self,
6498        query: &str,
6499        filter: Option<SearchFilter>,
6500        rerank_depth: usize,
6501        use_graph_arm: bool,
6502        alpha: f64,
6503        pool_n: usize,
6504        limit: usize,
6505    ) -> Result<SearchResult, EngineError> {
6506        let limit = validate_search_result_limit(limit)?;
6507        // explain=false → `SearchResult.explanation == None`, byte-identical results.
6508        self.search_reranked_with_explain(
6509            query,
6510            filter,
6511            rerank_depth,
6512            use_graph_arm,
6513            alpha,
6514            pool_n,
6515            false,
6516            ReadView::default(),
6517            limit,
6518        )
6519    }
6520
6521    /// 0.8.8 EXP-OBS (Slice 5) — `search_explained`: the opt-in `explain=true`
6522    /// surface. Identical retrieval to [`search_reranked`][Engine::search_reranked]
6523    /// (same fused/CE ranking, same `results`), additionally returning a
6524    /// [`Explanation`] sidecar on `SearchResult.explanation` with per-hit arm
6525    /// provenance + score breakdown + a query-level [`QueryTrace`]. The default
6526    /// `search`/`search_filtered`/`search_reranked` paths are unaffected and stay
6527    /// byte-identical (R-OBS-2).
6528    ///
6529    /// Governed surface: re-exported from `fathomdb` facade.
6530    pub fn search_explained(
6531        &self,
6532        query: &str,
6533        filter: Option<SearchFilter>,
6534        rerank_depth: usize,
6535        use_graph_arm: bool,
6536        alpha: f64,
6537        pool_n: usize,
6538    ) -> Result<SearchResult, EngineError> {
6539        self.search_explained_with_limit(
6540            query,
6541            filter,
6542            rerank_depth,
6543            use_graph_arm,
6544            alpha,
6545            pool_n,
6546            DEFAULT_SEARCH_RESULT_LIMIT,
6547        )
6548    }
6549
6550    /// Explained hybrid search with an explicit ranked-result limit.
6551    #[allow(clippy::too_many_arguments)]
6552    pub fn search_explained_with_limit(
6553        &self,
6554        query: &str,
6555        filter: Option<SearchFilter>,
6556        rerank_depth: usize,
6557        use_graph_arm: bool,
6558        alpha: f64,
6559        pool_n: usize,
6560        limit: usize,
6561    ) -> Result<SearchResult, EngineError> {
6562        let limit = validate_search_result_limit(limit)?;
6563        self.search_reranked_with_explain(
6564            query,
6565            filter,
6566            rerank_depth,
6567            use_graph_arm,
6568            alpha,
6569            pool_n,
6570            true,
6571            ReadView::default(),
6572            limit,
6573        )
6574    }
6575
6576    /// 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the explicit
6577    /// **text-only / FTS-only** search path. It does NOT embed the query and does
6578    /// NOT route through the vector-dependent choke point
6579    /// [`search_inner_with_stats`][Engine::search_inner_with_stats], so it NEVER
6580    /// raises [`EngineError::VectorEquivalenceMismatch`] and stays serviceable when
6581    /// the engine opened in the degraded `dense_disabled` state (the D2 "keep FTS
6582    /// servable" contract; codex R2 U1-2). Results come from the node- and
6583    /// edge-body FTS branches only — no vector recall, no CE rerank, no graph arm.
6584    /// Available regardless of degraded state; when dense is healthy it is simply a
6585    /// text-only view of the same corpus. Matching node- and edge-body
6586    /// candidates are body-deduplicated and deterministically ranked before
6587    /// the requested result limit is applied.
6588    ///
6589    /// Governed surface: re-exported from the `fathomdb` facade + Py/TS bindings.
6590    pub fn search_text_only(&self, query: &str) -> Result<SearchResult, EngineError> {
6591        self.search_text_only_with_limit(query, DEFAULT_SEARCH_RESULT_LIMIT)
6592    }
6593
6594    /// Text-only search with an explicit ranked-result limit in `1..=100`.
6595    ///
6596    /// For the same immutable selection and effective validity time, a smaller
6597    /// limit's ordered results are the prefix of a larger limit's results.
6598    pub fn search_text_only_with_limit(
6599        &self,
6600        query: &str,
6601        limit: usize,
6602    ) -> Result<SearchResult, EngineError> {
6603        self.search_text_only_view_with_limit(query, &ReadView::default(), limit)
6604    }
6605
6606    /// 0.8.20 Slice 15b fix-2 (R-20-NV / R-20-RV) — [`search_text_only`][Engine::search_text_only]
6607    /// under an explicit [`ReadView`]. Same validity-axis-only scope, and the same
6608    /// typed refusal of the existence flags, as [`search_view`][Engine::search_view].
6609    ///
6610    /// Governed surface: PROPOSED / NOT SIGNED (0.8.20 Slice 15b fix-2).
6611    pub fn search_text_only_view(
6612        &self,
6613        query: &str,
6614        view: &ReadView,
6615    ) -> Result<SearchResult, EngineError> {
6616        self.search_text_only_view_with_limit(query, view, DEFAULT_SEARCH_RESULT_LIMIT)
6617    }
6618
6619    /// Text-only search under a [`ReadView`] with an explicit ranked-result limit.
6620    ///
6621    /// For the same immutable selection and explicit effective validity time, a
6622    /// smaller limit's ordered results are the prefix of a larger limit's
6623    /// results. `ReadView::valid_as_of = None` resolves independently per call,
6624    /// so callers comparing calls must provide a fixed value.
6625    pub fn search_text_only_view_with_limit(
6626        &self,
6627        query: &str,
6628        view: &ReadView,
6629        limit: usize,
6630    ) -> Result<SearchResult, EngineError> {
6631        let limit = validate_search_result_limit(limit)?;
6632        self.ensure_open()?;
6633        view.reject_existence_relaxation_on_search()?;
6634        if query.trim().is_empty() {
6635            return Err(EngineError::WriteValidation);
6636        }
6637        let compiled = compile_text_query(query);
6638        let candidate_limit =
6639            self.projection_runtime.shared.search_limit_override.load(Ordering::SeqCst).max(limit);
6640        let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
6641        // This explicit marker distinguishes direct text-only search from a hybrid
6642        // request whose embedder yields no vector. Only the direct path gets the
6643        // fixed node candidate bound before node/edge body deduplication and RRF.
6644        let request = ReaderRequest::Search {
6645            compiled,
6646            query_vector: None,
6647            query_vector_bin: None,
6648            result_limit: limit,
6649            candidate_limit,
6650            direct_text_candidate_limit: Some(MAX_SEARCH_RESULT_LIMIT),
6651            filter: None,
6652            recency_enabled: false,
6653            importance_enabled: false,
6654            vector_stage_only: false,
6655            raw_query: Box::from(query),
6656            rerank_depth: 0,
6657            use_graph_arm: false,
6658            alpha: 0.3,
6659            pool_n: 0,
6660            explain: false,
6661            view: *view,
6662            respond: response_tx,
6663        };
6664        if self.reader_pool.dispatch(request).is_err() {
6665            return Err(EngineError::Closing);
6666        }
6667        let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
6668        let (cursor, soft_fallback, results, _graph_stats, explanation) = match search_result {
6669            Ok(result) => result,
6670            // fix-3 (codex §9 [P2]) — carry the reader-snapshot validation verdict
6671            // through: an undeclared `filterable` attribute is the EXISTING typed
6672            // `InvalidFilter`, never collapsed to `Storage`. (This path takes
6673            // `filter = None`, so it never fires here, but the match stays total.)
6674            Err(SearchReaderError::InvalidFilter(reason)) => {
6675                return Err(EngineError::InvalidFilter { reason });
6676            }
6677            Err(SearchReaderError::Sqlite(err)) => {
6678                self.emit_sqlite_internal_error(&err);
6679                return Err(EngineError::Storage);
6680            }
6681        };
6682        Ok(SearchResult { projection_cursor: cursor, soft_fallback, results, explanation })
6683    }
6684
6685    /// Search one declared `searchable→FTS` projection without invoking body
6686    /// search, vector search, score fusion, or a fallback arm. Results carry the
6687    /// ordinary text branch shape and are ordered by property-FTS bm25 ascending
6688    /// then write cursor ascending.
6689    pub fn search_projected_text(
6690        &self,
6691        query: &str,
6692        name: &str,
6693        filter: Option<SearchFilter>,
6694        view: &ReadView,
6695    ) -> Result<SearchResult, EngineError> {
6696        self.search_projected_text_with_limit(
6697            query,
6698            name,
6699            filter,
6700            view,
6701            DEFAULT_SEARCH_RESULT_LIMIT,
6702        )
6703    }
6704
6705    /// Search one declared property-FTS projection with an explicit ranked-result limit.
6706    pub fn search_projected_text_with_limit(
6707        &self,
6708        query: &str,
6709        name: &str,
6710        filter: Option<SearchFilter>,
6711        view: &ReadView,
6712        limit: usize,
6713    ) -> Result<SearchResult, EngineError> {
6714        let limit = validate_search_result_limit(limit)?;
6715        self.ensure_open()?;
6716        view.reject_existence_relaxation_on_search()?;
6717        if query.trim().is_empty() {
6718            return Err(EngineError::WriteValidation);
6719        }
6720
6721        let (response_tx, response_rx) = mpsc::sync_channel(1);
6722        let request = ReaderRequest::SearchProjectedText {
6723            query: query.to_string(),
6724            name: name.to_string(),
6725            filter: filter.map(Box::new),
6726            limit,
6727            view: *view,
6728            respond: response_tx,
6729        };
6730        if self.reader_pool.dispatch(request).is_err() {
6731            return Err(EngineError::Closing);
6732        }
6733        match response_rx.recv().map_err(|_| EngineError::Storage)? {
6734            Ok(result) => Ok(result),
6735            Err(SearchReaderError::InvalidFilter(reason)) => {
6736                Err(EngineError::InvalidFilter { reason })
6737            }
6738            Err(SearchReaderError::Sqlite(err)) => {
6739                self.emit_sqlite_internal_error(&err);
6740                Err(EngineError::Storage)
6741            }
6742        }
6743    }
6744
6745    /// 0.8.18 Slice 5 (R-VEQ-6) — degraded-open observability accessor. `true` iff
6746    /// the open-time #5 self-check found a vector-equivalence divergence and every
6747    /// vector-dependent arm is refusing. Mirrors `OpenReport.dense_disabled`; read
6748    /// lock-free.
6749    #[must_use]
6750    pub fn dense_disabled(&self) -> bool {
6751        self.dense_disabled.load(Ordering::Acquire)
6752    }
6753
6754    /// 0.8.18 Slice 5 (R-VEQ-6) — the human-readable reason for the degraded state
6755    /// (which representation tripped), or `None` when dense is healthy.
6756    #[must_use]
6757    pub fn dense_disabled_reason(&self) -> Option<String> {
6758        self.dense_disabled_reason.lock().ok().and_then(|g| g.clone())
6759    }
6760
6761    /// 0.8.18 Slice 5 (R-VEQ-6) — telemetry counter: number of query-time
6762    /// vector-dependent-arm refusals raised because the engine opened degraded.
6763    /// Observable pre/post-query.
6764    #[must_use]
6765    pub fn vector_equivalence_refusal_count(&self) -> u64 {
6766        self.vector_equivalence_refusals.load(Ordering::Relaxed)
6767    }
6768
6769    /// Shared event-wrapped body for [`search_reranked`][Engine::search_reranked]
6770    /// (`explain=false`) and [`search_explained`][Engine::search_explained]
6771    /// (`explain=true`). Keeps the Started/Finished/Failed lifecycle emissions +
6772    /// slow detection in one place.
6773    #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
6774    fn search_reranked_with_explain(
6775        &self,
6776        query: &str,
6777        filter: Option<SearchFilter>,
6778        rerank_depth: usize,
6779        use_graph_arm: bool,
6780        alpha: f64,
6781        pool_n: usize,
6782        explain: bool,
6783        view: ReadView,
6784        limit: usize,
6785    ) -> Result<SearchResult, EngineError> {
6786        // fix-2: refuse an existence-relaxing view BEFORE any work (and before the
6787        // Started event), so the refusal is a pure argument error rather than a
6788        // half-emitted query lifecycle.
6789        view.reject_existence_relaxation_on_search()?;
6790        self.emit_event(lifecycle::Phase::Started, lifecycle::EventCategory::Search, None);
6791        let started = Instant::now();
6792        let outcome = self.search_inner(
6793            query,
6794            filter,
6795            rerank_depth,
6796            use_graph_arm,
6797            alpha,
6798            pool_n,
6799            explain,
6800            view,
6801            limit,
6802        );
6803        self.detect_slow(started, lifecycle::EventCategory::Search);
6804        match outcome {
6805            Ok(result) => {
6806                self.counters.record_query();
6807                // 0.8.8 Slice 15 (OPP-9) — opt-in telemetry capture. No-op + no
6808                // allocation when telemetry is OFF (the default).
6809                self.capture_telemetry(query, &result);
6810                self.emit_event(lifecycle::Phase::Finished, lifecycle::EventCategory::Search, None);
6811                Ok(result)
6812            }
6813            Err(err) => {
6814                let code = err.stable_code();
6815                self.counters.record_error(code);
6816                self.emit_event(
6817                    lifecycle::Phase::Failed,
6818                    lifecycle::EventCategory::Search,
6819                    Some(code),
6820                );
6821                self.emit_event(
6822                    lifecycle::Phase::Failed,
6823                    lifecycle::EventCategory::Error,
6824                    Some(code),
6825                );
6826                Err(err)
6827            }
6828        }
6829    }
6830
6831    /// 0.8.8 Slice 15 (OPP-9) — enable opt-in telemetry capture to a local JSONL
6832    /// `sink_path` (append-only). Off by default; once enabled, each `search`
6833    /// records a query→result event and `record_feedback` appends agent labels.
6834    /// Local file only — no network/egress. `query_id` + `ts_monotonic_ms` are
6835    /// reset deterministically on enable. Idempotent re-enable resets the seq.
6836    pub fn enable_telemetry(&self, sink_path: &str) -> Result<(), EngineError> {
6837        // Touch the sink (create + validate writable) before arming capture, so a
6838        // bad path fails loudly here rather than silently dropping events.
6839        std::fs::OpenOptions::new()
6840            .create(true)
6841            .append(true)
6842            .open(sink_path)
6843            .map_err(|_| EngineError::Storage)?;
6844        let mut guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6845        *guard = Some(TelemetrySink {
6846            path: PathBuf::from(sink_path),
6847            base: Instant::now(),
6848            nonce: 0,
6849            seq: 0,
6850            last_query_id: None,
6851        });
6852        // Arm the fast OFF-path guard LAST (after the sink is installed) so a
6853        // concurrent search either sees telemetry fully off or fully on.
6854        self.telemetry_enabled.store(true, Ordering::Release);
6855        Ok(())
6856    }
6857
6858    /// 0.8.8 Slice 15 — the most-recent captured `query_id` (for `record_feedback`).
6859    /// `None` when telemetry is off or no query has been captured yet.
6860    pub fn last_telemetry_query_id(&self) -> Option<String> {
6861        self.telemetry.lock().ok()?.as_ref().and_then(|s| s.last_query_id.clone())
6862    }
6863
6864    /// 0.8.8 Slice 15 — capture a query→result telemetry event. No-op (no alloc,
6865    /// no I/O) when telemetry is off (the default). Best-effort: a sink write error
6866    /// never fails the search. Captures ONLY ids, arms, and the query LENGTH —
6867    /// never the query text or `source_id` (privacy, ADR §C).
6868    ///
6869    /// ID-SPACES (Cause-A, 0.8.11.2 — honest record). `result_ids` is the interim
6870    /// `SearchHit.id` == `write_cursor`: within-session consistent but NOT
6871    /// cross-session-stable (reassigned on re-projection/re-ingest). `arm_of` is
6872    /// keyed by that same `write_cursor`. Cause-A adds a NEW PARALLEL field
6873    /// `result_stable_ids` carrying the cross-session-stable id
6874    /// ([`SearchHit::stable_id`], `logical_id` / content-hash) in the SAME order as
6875    /// `result_ids`; the existing `write_cursor` keys are RETAINED unchanged so
6876    /// pre-Cause-A gold and sink byte-output stay valid (the F-8a `id_space` flip
6877    /// is a separate, conscious step — see
6878    /// `dev/plans/runs/NOTE-0.8.8-to-steward-id-contract.md`).
6879    fn capture_telemetry(&self, query: &str, result: &SearchResult) {
6880        // Fast OFF path (codex §9 P2): a single atomic load when telemetry has
6881        // never been enabled — NO mutex acquisition, NO contention with the search
6882        // hot path.
6883        if !self.telemetry_enabled.load(Ordering::Acquire) {
6884            return;
6885        }
6886        let Ok(mut guard) = self.telemetry.lock() else { return };
6887        let Some(sink) = guard.as_mut() else { return };
6888        let query_id = format!("q{}-{}", sink.nonce, sink.seq);
6889        let ts_monotonic_ms = sink.base.elapsed().as_millis() as u64;
6890        let mut arm_of = serde_json::Map::new();
6891        for h in &result.results {
6892            // Keyed on the engine-internal positional cursor (the pre-C-2
6893            // `SearchHit.id` == `write_cursor`), byte-unchanged so `record_feedback`
6894            // + the gold pipeline keep keying on the same `result_ids` space.
6895            arm_of
6896                .insert(h.write_cursor.to_string(), serde_json::Value::from(branch_str(h.branch)));
6897        }
6898        let event = serde_json::json!({
6899            "type": "event",
6900            "schema_version": 1,
6901            "ts_monotonic_ms": ts_monotonic_ms,
6902            "query_id": query_id,
6903            "query_chars": query.chars().count() as u64,
6904            "result_ids": result.results.iter().map(|h| h.write_cursor).collect::<Vec<u64>>(),
6905            // Cause-A / C-2: parallel cross-session-stable ids, SAME order as
6906            // result_ids. Post-C-2 the stable id lives on `SearchHit.id` (its
6907            // prefixed form == the pre-swap `stable_id` value byte-for-byte), so
6908            // the emitted bytes are unchanged and the `write_cursor` result_ids
6909            // keys are retained unchanged (pre-Cause-A gold stays valid).
6910            "result_stable_ids": result
6911                .results
6912                .iter()
6913                .map(|h| h.id.to_prefixed())
6914                .collect::<Vec<String>>(),
6915            "arm_of": arm_of,
6916        });
6917        let _ = append_jsonl(&sink.path, &event);
6918        sink.seq += 1;
6919        sink.last_query_id = Some(query_id);
6920    }
6921
6922    /// 0.8.8 Slice 15 — append an agent-supplied relevance-label record for a
6923    /// previously-captured `query_id`. `label_source` is the only exogenous string
6924    /// (caller-declared, e.g. `"agent:hermes"`).
6925    ///
6926    /// ID-SPACE (Cause-A, 0.8.11.2 — honest record). `relevant_ids` /
6927    /// `irrelevant_ids` are the interim `SearchHit.id` == `write_cursor` (the same
6928    /// space as the captured event's `result_ids`), NOT `logical_id`. The
6929    /// signature is left byte-stable: the gold pipeline maps these `write_cursor`
6930    /// keys to the cross-session-stable id via the capture event's parallel
6931    /// `result_ids` ↔ `result_stable_ids` arrays (`eval/gold_capture.py`), so no
6932    /// new feedback parameter — and no binding-signature churn — is required.
6933    /// Errors if telemetry is off.
6934    pub fn record_feedback(
6935        &self,
6936        query_id: &str,
6937        relevant_ids: &[u64],
6938        irrelevant_ids: &[u64],
6939        label_source: &str,
6940    ) -> Result<(), EngineError> {
6941        let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
6942        let sink = guard
6943            .as_ref()
6944            .ok_or(EngineError::InvalidArgument { msg: "telemetry is not enabled".to_string() })?;
6945        // codex §9 [P1] (privacy): `query_id` is an exogenous caller string. Only a
6946        // deterministic id that `capture_telemetry` has ALREADY emitted may be
6947        // persisted — otherwise a caller could smuggle query text / a `source_id`
6948        // into the sink under the `query_id` key. Require the canonical
6949        // `q{nonce}-{seq}` form with `nonce == sink.nonce` AND `seq < sink.seq`
6950        // (a seq the capture path has issued). Reject (writing nothing) otherwise.
6951        let is_issued_id = query_id
6952            .strip_prefix('q')
6953            .and_then(|rest| rest.split_once('-'))
6954            .and_then(|(nonce, seq)| Some((nonce.parse::<u64>().ok()?, seq.parse::<u64>().ok()?)))
6955            .is_some_and(|(nonce, seq)| nonce == sink.nonce && seq < sink.seq);
6956        if !is_issued_id {
6957            return Err(EngineError::InvalidArgument { msg: "unknown query_id".to_string() });
6958        }
6959        let record = serde_json::json!({
6960            "type": "feedback",
6961            "schema_version": 1,
6962            "query_id": query_id,
6963            "relevant_ids": relevant_ids,
6964            "irrelevant_ids": irrelevant_ids,
6965            "label_source": label_source,
6966        });
6967        append_jsonl(&sink.path, &record).map_err(|_| EngineError::Storage)
6968    }
6969
6970    fn detect_slow(&self, started: Instant, category: lifecycle::EventCategory) {
6971        let elapsed = started.elapsed();
6972        let threshold = self.slow_threshold_ms.load(Ordering::Relaxed);
6973        let threshold_duration = std::time::Duration::from_millis(threshold);
6974        if elapsed > threshold_duration {
6975            // `dev/design/lifecycle.md` § Slow and heartbeat policy: a slow
6976            // operation produces TWO correlated facts. The
6977            // statement-level slow-statement signal is dispatched by the
6978            // sqlite3_profile callback (`profile_callback_trampoline`).
6979            // This site emits the lifecycle `Phase::Slow` event for the
6980            // outer operation envelope (AC-008).
6981            self.emit_event(lifecycle::Phase::Slow, category, None);
6982        }
6983    }
6984
6985    fn emit_event(
6986        &self,
6987        phase: lifecycle::Phase,
6988        category: lifecycle::EventCategory,
6989        code: Option<&'static str>,
6990    ) {
6991        let event =
6992            lifecycle::Event { phase, source: lifecycle::EventSource::Engine, category, code };
6993        self.subscribers.dispatch(&event);
6994    }
6995
6996    /// Emit a `(SqliteInternal, Error, code: <SQLITE_*>)` lifecycle
6997    /// event for a rusqlite error. Per `dev/design/lifecycle.md`
6998    /// § Diagnostic source and category, SQLite-originated diagnostics
6999    /// route through the same host subscriber as engine-originated
7000    /// events with `source` preserved. AC-021 dispatches on
7001    /// `code == "SQLITE_SCHEMA"`.
7002    fn emit_sqlite_internal_error(&self, err: &rusqlite::Error) {
7003        if let Some(code) = sqlite_extended_code_name(err) {
7004            let event = lifecycle::Event {
7005                phase: lifecycle::Phase::Failed,
7006                source: lifecycle::EventSource::SqliteInternal,
7007                category: lifecycle::EventCategory::Error,
7008                code: Some(code),
7009            };
7010            self.subscribers.dispatch(&event);
7011        }
7012    }
7013
7014    /// Thin wrapper: the production search path that discards the G0 Phase-2
7015    /// frontier meter (it never reaches `SearchResult` / the governed surface).
7016    #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
7017    fn search_inner(
7018        &self,
7019        query: &str,
7020        filter: Option<SearchFilter>,
7021        rerank_depth: usize,
7022        use_graph_arm: bool,
7023        alpha: f64,
7024        pool_n: usize,
7025        explain: bool,
7026        view: ReadView,
7027        result_limit: usize,
7028    ) -> Result<SearchResult, EngineError> {
7029        self.search_inner_with_stats(
7030            query,
7031            filter,
7032            rerank_depth,
7033            use_graph_arm,
7034            alpha,
7035            pool_n,
7036            explain,
7037            view,
7038            result_limit,
7039        )
7040        .map(|(result, _stats)| result)
7041    }
7042
7043    /// G0 Phase-2: the search body, additionally returning the graph-arm frontier
7044    /// meter. Only the `_graph_frontier_stats_for_test` seam consumes the stats;
7045    /// `search_inner` (and thus `search_reranked` / `search`) drops them.
7046    #[allow(clippy::too_many_arguments)] // mirrors search_reranked + the explain flag
7047    fn search_inner_with_stats(
7048        &self,
7049        query: &str,
7050        filter: Option<SearchFilter>,
7051        rerank_depth: usize,
7052        use_graph_arm: bool,
7053        alpha: f64,
7054        pool_n: usize,
7055        explain: bool,
7056        view: ReadView,
7057        result_limit: usize,
7058    ) -> Result<(SearchResult, GraphFrontierStats), EngineError> {
7059        self.ensure_open()?;
7060        // 0.8.18 Slice 5 (#5 vector-equivalence probe, R-VEQ-4) — the SINGLE
7061        // vector-dependent choke point. If the open-time self-check found a
7062        // divergence beyond the D4 floor, refuse EVERY vector-dependent arm
7063        // (search / search_expand / explain-rerank / graph-arm all funnel here)
7064        // BEFORE any embedding / vector SQL / graph seeding / CE rerank — no
7065        // silent partial results. The text-only/FTS-only path
7066        // (`search_text_only`) does NOT route through here, so FTS stays
7067        // serviceable in degraded mode.
7068        if self.dense_disabled.load(Ordering::Acquire) {
7069            self.vector_equivalence_refusals.fetch_add(1, Ordering::Relaxed);
7070            let reason =
7071                self.dense_disabled_reason.lock().ok().and_then(|g| g.clone()).unwrap_or_else(
7072                    || "open-time #5 vector-equivalence self-check failed".to_string(),
7073                );
7074            return Err(EngineError::VectorEquivalenceMismatch { reason });
7075        }
7076        if query.trim().is_empty() {
7077            return Err(EngineError::WriteValidation);
7078        }
7079
7080        // 0.8.20 Slice 15e fix-2 finding 1 [P2] — every filter attribute name is
7081        // validated against the declared `filterable` registry set BEFORE any arm
7082        // runs, so an UNDECLARED name is a typed `InvalidFilter` rejection instead
7083        // of an opaque `no such column` `Storage` crash (vector arm) or a silent
7084        // no-match (FTS arm). ADR-0.8.11 D3: every filter term has a DEFINED outcome
7085        // ("compiles" or "typed rejection") IDENTICAL across arms.
7086        //
7087        // keystone closeout fix-3 (codex §9 [P2], TOCTOU): that validation is NO
7088        // LONGER performed here on `self.connection` before dispatch. fix-2 checked
7089        // the registry on the WRITER connection and then let the reader prepare the
7090        // vec0 query on a DIFFERENT connection/snapshot — a `configure_projections`
7091        // DROP landing in the window between the check and the reader snapshot could
7092        // still make the `attr_<hex>` column vanish AFTER validation passed, i.e. the
7093        // exact untyped `Storage` failure fix-2 meant to prevent. The check now runs
7094        // INSIDE the reader's deferred transaction (see
7095        // `validate_filter_attributes_on_snapshot`, called from `read_search_in_tx`),
7096        // so the registry it reads and the vec0 columns the query compiles against are
7097        // ONE snapshot — the race is closed and BOTH arms still see the same typed
7098        // `InvalidFilter`. Moving it there also removes a per-filtered-search writer
7099        // lock and the fix-2 concurrent-ADD false-reject (the reader snapshot sees a
7100        // freshly-added declaration and accepts).
7101        let compiled = compile_text_query(query);
7102        // REQ-013 / AC-059b / REQ-055: the cursor returned with a search
7103        // MUST be derived from the same WAL snapshot the data was read
7104        // from. Loading `next_cursor` from the writer-side atomic before
7105        // the reader transaction acquires its snapshot races against
7106        // concurrent writers — see `dev/design/engine.md` § Cursor
7107        // contract. Run cursor probe + body query inside one read tx
7108        // (BEGIN DEFERRED on a `query_only=ON` connection in WAL mode is
7109        // a snapshot-stable read).
7110        // EU-5a2 mean-centering apply path (query side). `query_vector`
7111        // is ALWAYS un-centered (used by the f32 vec_distance_l2 rerank
7112        // in phase 2). `query_vector_bin` is the (possibly centered) f32
7113        // fed to `vec_quantize_binary` in phase 1. The centering decision
7114        // mirrors the write path: identity must be MC-required AND a
7115        // mean_vec must be pinned. NoopEmbedder collapses to
7116        // `query_vector_bin == query_vector` until EU-5b.
7117        let raw_query_vector =
7118            self.runtime_embedder.as_ref().and_then(|embedder| embedder.embed(query).ok());
7119        let query_vector_bin = match raw_query_vector.as_ref() {
7120            Some(vector) if identity_requires_mean_centering(&self.runtime_embedder_identity) => {
7121                let pinned = {
7122                    let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7123                    let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7124                    read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)?
7125                };
7126                match pinned {
7127                    Some(mean) => serde_json::to_string(&subtract_mean(vector, &mean)).ok(),
7128                    None => serde_json::to_string(vector).ok(),
7129                }
7130            }
7131            Some(vector) => serde_json::to_string(vector).ok(),
7132            None => None,
7133        };
7134        let query_vector = raw_query_vector.and_then(|vector| serde_json::to_string(&vector).ok());
7135        // The public result limit is independent of the test-only vector
7136        // candidate fanout. The seam may raise this fanout for recall tests,
7137        // but the reader still truncates visible results to `result_limit`.
7138        let candidate_limit = self
7139            .projection_runtime
7140            .shared
7141            .search_limit_override
7142            .load(Ordering::SeqCst)
7143            .max(result_limit);
7144        let recency_enabled =
7145            self.projection_runtime.shared.recency_reweight_enabled.load(Ordering::SeqCst);
7146        let importance_enabled =
7147            self.projection_runtime.shared.importance_reweight_enabled.load(Ordering::SeqCst);
7148        let vector_stage_only =
7149            self.projection_runtime.shared.vector_stage_only_for_test.load(Ordering::SeqCst);
7150        let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
7151        let request = ReaderRequest::Search {
7152            compiled,
7153            query_vector,
7154            query_vector_bin,
7155            result_limit,
7156            candidate_limit,
7157            direct_text_candidate_limit: None,
7158            filter: filter.map(Box::new),
7159            recency_enabled,
7160            importance_enabled,
7161            vector_stage_only,
7162            raw_query: Box::from(query), // FIX-4: Box<str> (16B) not String (24B)
7163            rerank_depth,
7164            use_graph_arm,
7165            alpha,
7166            pool_n,
7167            explain,
7168            view,
7169            respond: response_tx,
7170        };
7171        if self.reader_pool.dispatch(request).is_err() {
7172            return Err(EngineError::Closing);
7173        }
7174        let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
7175        let (cursor, soft_fallback, results, graph_stats, explanation) = match search_result {
7176            Ok(result) => result,
7177            // fix-3 (codex §9 [P2]) — an undeclared `filterable` attribute is caught
7178            // on the reader's OWN snapshot (validate + vec0 query = one transaction),
7179            // so it surfaces as the EXISTING typed `InvalidFilter` and can never be
7180            // the opaque `no such column` `Storage` error the TOCTOU race produced.
7181            Err(SearchReaderError::InvalidFilter(reason)) => {
7182                return Err(EngineError::InvalidFilter { reason });
7183            }
7184            Err(SearchReaderError::Sqlite(err)) => {
7185                self.emit_sqlite_internal_error(&err);
7186                return Err(EngineError::Storage);
7187            }
7188        };
7189
7190        // The worker (`read_search_in_tx`) has no embedder identity; fill the
7191        // trace's `embedder_id` here, where `self.runtime_embedder_identity` is in
7192        // scope. Only on the explain path (`explanation` is `Some`).
7193        let explanation = explanation.map(|mut exp| {
7194            let id = &self.runtime_embedder_identity;
7195            exp.trace.embedder_id = format!("{}@{} (dim={})", id.name, id.revision, id.dimension);
7196            exp
7197        });
7198
7199        Ok((
7200            SearchResult { projection_cursor: cursor, soft_fallback, results, explanation },
7201            graph_stats,
7202        ))
7203    }
7204
7205    /// G0 Phase-2 (BLOCK-1) test seam — runs the graph-arm retrieval path and
7206    /// returns the frontier meter (`GraphFrontierStats`) for `query`. Mirrors the
7207    /// sanctioned `set_vector_stage_only_for_test` / `_configure_vector_kind_for_test`
7208    /// pattern: kept OFF the governed surface (test/eval-only), so the meter never
7209    /// appears on `SearchResult`. Used by the recall harness to prove the
7210    /// doc-seeded frontier is empty (`resolved_seed_rate == 0.0`) and, post-C1, the
7211    /// 0→>0 flip.
7212    pub fn _graph_frontier_stats_for_test(
7213        &self,
7214        query: &str,
7215    ) -> Result<GraphFrontierStats, EngineError> {
7216        self.search_inner_with_stats(
7217            query,
7218            None,
7219            0,
7220            true,
7221            0.3,
7222            0,
7223            false,
7224            ReadView::default(),
7225            DEFAULT_SEARCH_RESULT_LIMIT,
7226        )
7227        .map(|(_result, stats)| stats)
7228    }
7229
7230    /// Slice 30 (G2) — `read.get`: active-only point lookup by `logical_id`.
7231    /// Delegates to [`Engine::read_get_many`]; returns the single slot. A
7232    /// missing/superseded id is `None` (a normal absence, not an error). Reads
7233    /// ride the ReaderWorkerPool DEFERRED-tx path (never the writer lock).
7234    pub fn read_get(
7235        &self,
7236        logical_id: &str,
7237        view: &ReadView,
7238    ) -> Result<Option<NodeRecord>, EngineError> {
7239        let ids = [logical_id.to_string()];
7240        let rows = self.read_get_many(&ids, view)?;
7241        Ok(rows.into_iter().next().flatten())
7242    }
7243
7244    /// Slice 30 (G2) — `read.get_many`: active-only point lookup over many
7245    /// `logical_id`s. Returns one slot per requested id in REQUEST ORDER, `None`
7246    /// where no active row carries that id (partial, never all-or-nothing).
7247    pub fn read_get_many(
7248        &self,
7249        logical_ids: &[String],
7250        view: &ReadView,
7251    ) -> Result<Vec<Option<NodeRecord>>, EngineError> {
7252        self.ensure_open()?;
7253        if logical_ids.is_empty() {
7254            return Ok(Vec::new());
7255        }
7256        let (response_tx, response_rx) = mpsc::sync_channel(1);
7257        let request = ReaderRequest::GetById {
7258            logical_ids: logical_ids.to_vec(),
7259            view: *view,
7260            respond: response_tx,
7261        };
7262        if self.reader_pool.dispatch(request).is_err() {
7263            return Err(EngineError::Closing);
7264        }
7265        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7266            Ok(rows) => Ok(rows),
7267            Err(err) => {
7268                self.emit_sqlite_internal_error(&err);
7269                Err(EngineError::Storage)
7270            }
7271        }
7272    }
7273
7274    /// Slice 20 (G5) — `read.neighbors`: bounded BFS from `root_logical_id`
7275    /// over `canonical_edges`. Returns nodes reachable within `depth` hops
7276    /// (`1..=3`) in the given `direction`, excluding the root itself.
7277    ///
7278    /// Hard cap: 50 results (engine-enforced `LIMIT 50`).
7279    /// Traversal filter: `superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now)`.
7280    ///
7281    /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
7282    /// Returns `Ok(vec![])` for an unknown/superseded root.
7283    /// Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
7284    pub fn graph_neighbors(
7285        &self,
7286        root_logical_id: &str,
7287        depth: u32,
7288        direction: TraversalDirection,
7289        view: &ReadView,
7290    ) -> Result<Vec<NodeRecord>, EngineError> {
7291        self.ensure_open()?;
7292        if depth == 0 || depth > 3 {
7293            return Err(EngineError::InvalidArgument {
7294                msg: format!("traversal depth {depth} is out of range; must be 1, 2, or 3"),
7295            });
7296        }
7297        let (response_tx, response_rx) = mpsc::sync_channel(1);
7298        let request = ReaderRequest::GraphNeighbors {
7299            root_logical_id: root_logical_id.to_string(),
7300            depth,
7301            direction,
7302            view: *view,
7303            respond: response_tx,
7304        };
7305        if self.reader_pool.dispatch(request).is_err() {
7306            return Err(EngineError::Closing);
7307        }
7308        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7309            Ok(nodes) => Ok(nodes),
7310            Err(err) => {
7311                self.emit_sqlite_internal_error(&err);
7312                Err(EngineError::Storage)
7313            }
7314        }
7315    }
7316
7317    /// Slice 20 (G6) — `search_expand`: hybrid search (`G1+G9`) followed by
7318    /// bounded BFS expansion (`G5`) of each search hit. Returns the original
7319    /// search hits (with RRF scores) plus nodes reachable from any hit via
7320    /// up to `depth` hops that are NOT already in the search hit set.
7321    ///
7322    /// Returns `Err(EngineError::InvalidArgument)` for `depth > 3`.
7323    /// A `depth = 0` call returns search hits with their logical_ids resolved
7324    /// but no BFS expansion. Reads ride the `ReaderWorkerPool` DEFERRED-tx path.
7325    ///
7326    /// **Snapshot note:** the search phase (`search_inner`) and the expansion
7327    /// phase (`SearchExpand` reader request) run in separate DEFERRED reader
7328    /// transactions; a write that lands between them is visible to expansion
7329    /// but not search (or vice-versa). In practice the window is negligible for
7330    /// single-process embedded use. The expansion phase mitigates drift by
7331    /// filtering `search_hits` to only include hits whose `write_cursor` is
7332    /// still active in the expansion snapshot (superseded hits are dropped from
7333    /// the result rather than surfaced with stale data).
7334    pub fn search_expand(
7335        &self,
7336        query: &str,
7337        filter: Option<SearchFilter>,
7338        depth: u32,
7339    ) -> Result<SearchExpandResult, EngineError> {
7340        self.search_expand_with_limit(query, filter, depth, DEFAULT_SEARCH_RESULT_LIMIT)
7341    }
7342
7343    /// Hybrid search followed by graph expansion, with an explicit limit for
7344    /// the initial ranked `search_hits` set.
7345    pub fn search_expand_with_limit(
7346        &self,
7347        query: &str,
7348        filter: Option<SearchFilter>,
7349        depth: u32,
7350        limit: usize,
7351    ) -> Result<SearchExpandResult, EngineError> {
7352        let limit = validate_search_result_limit(limit)?;
7353        self.ensure_open()?;
7354        if depth > 3 {
7355            return Err(EngineError::InvalidArgument {
7356                msg: format!("traversal depth {depth} exceeds the SDK ceiling of 3"),
7357            });
7358        }
7359        // Step 1: run the hybrid search to get initial hits (no CE reranking in expand).
7360        // 0.8.5: depth=0 → no rerank, so α/pool_n (0.3, 0) are inert here.
7361        let search_result =
7362            self.search_inner(query, filter, 0, false, 0.3, 0, false, ReadView::default(), limit)?;
7363        if search_result.results.is_empty() {
7364            return Ok(SearchExpandResult {
7365                search_hits: Vec::new(),
7366                expanded: Vec::new(),
7367                all_logical_ids: Vec::new(),
7368            });
7369        }
7370        // Step 2: dispatch to the reader pool to resolve logical_ids and run BFS.
7371        // depth=0 is forwarded to the reader so it can populate all_logical_ids
7372        // (the union of search-hit logical_ids), even with no expansion.
7373        let (response_tx, response_rx) = mpsc::sync_channel(1);
7374        let request = ReaderRequest::SearchExpand {
7375            search_hits: search_result.results,
7376            depth,
7377            respond: response_tx,
7378        };
7379        if self.reader_pool.dispatch(request).is_err() {
7380            return Err(EngineError::Closing);
7381        }
7382        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7383            Ok(result) => Ok(result),
7384            Err(err) => {
7385                self.emit_sqlite_internal_error(&err);
7386                Err(EngineError::Storage)
7387            }
7388        }
7389    }
7390
7391    /// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and
7392    /// return the plan detail lines. Used by `explain_plan_uses_indexes`.
7393    #[doc(hidden)]
7394    pub fn explain_graph_neighbors_for_test(
7395        &self,
7396        root_logical_id: &str,
7397        depth: u32,
7398        direction: TraversalDirection,
7399    ) -> Result<Vec<String>, EngineError> {
7400        self.ensure_open()?;
7401        let (response_tx, response_rx) = mpsc::sync_channel(1);
7402        let request = ReaderRequest::ExplainGraphNeighbors {
7403            root_logical_id: root_logical_id.to_string(),
7404            depth,
7405            direction,
7406            respond: response_tx,
7407        };
7408        if self.reader_pool.dispatch(request).is_err() {
7409            return Err(EngineError::Closing);
7410        }
7411        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7412            Ok(plan) => Ok(plan),
7413            Err(err) => {
7414                self.emit_sqlite_internal_error(&err);
7415                Err(EngineError::Storage)
7416            }
7417        }
7418    }
7419
7420    /// Slice 30 (G3) — `read.collection`: paginated op-store read-back over
7421    /// `operational_mutations` for `collection`, `ORDER BY id`. `limit` is
7422    /// MANDATORY (clamped to the ~1M cap); `after_id` is the exclusive cursor.
7423    /// Reads ride the ReaderWorkerPool DEFERRED-tx path.
7424    pub fn read_collection(
7425        &self,
7426        collection: &str,
7427        after_id: Option<i64>,
7428        limit: usize,
7429    ) -> Result<Vec<OpStoreRow>, EngineError> {
7430        self.read_collection_dispatch(collection, after_id, limit)
7431    }
7432
7433    /// Slice 30 (G3) — `read.mutations`: the mutation-log-oriented alias surface
7434    /// over the SAME op-store read-back as [`Engine::read_collection`].
7435    pub fn read_mutations(
7436        &self,
7437        collection: &str,
7438        after_id: Option<i64>,
7439        limit: usize,
7440    ) -> Result<Vec<OpStoreRow>, EngineError> {
7441        self.read_collection_dispatch(collection, after_id, limit)
7442    }
7443
7444    fn read_collection_dispatch(
7445        &self,
7446        collection: &str,
7447        after_id: Option<i64>,
7448        limit: usize,
7449    ) -> Result<Vec<OpStoreRow>, EngineError> {
7450        self.ensure_open()?;
7451        let (response_tx, response_rx) = mpsc::sync_channel(1);
7452        let request = ReaderRequest::ReadCollection {
7453            collection: collection.to_string(),
7454            after_id,
7455            limit,
7456            respond: response_tx,
7457        };
7458        if self.reader_pool.dispatch(request).is_err() {
7459            return Err(EngineError::Closing);
7460        }
7461        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7462            Ok(rows) => Ok(rows),
7463            Err(err) => {
7464                self.emit_sqlite_internal_error(&err);
7465                Err(EngineError::Storage)
7466            }
7467        }
7468    }
7469
7470    /// Slice 35 (G4) — `read.list`: list active `canonical_nodes` of a given
7471    /// `kind`, optionally filtered by a closed [`Predicate`] set, up to `limit`
7472    /// rows. Returns `Vec<NodeRecord>` (active only; `superseded_at IS NULL`).
7473    ///
7474    /// Multiple predicates are combined as AND (D-F5). An empty predicate slice
7475    /// returns all active nodes of the given kind up to `limit` (unfiltered path).
7476    /// Compilation target: `json_extract(body, '$.field') <op> ?` with bound
7477    /// parameters (injection-safe per D-F4). See `dev/adr/ADR-0.8.0-filter-grammar.md`.
7478    ///
7479    /// Path validation happens at [`Predicate`] construction time; `read_list`
7480    /// revalidates as defense-in-depth (enum variants are `pub`, so direct
7481    /// struct-literal construction could bypass the constructors).
7482    pub fn read_list(
7483        &self,
7484        kind: &str,
7485        predicates: &[Predicate],
7486        limit: usize,
7487        view: &ReadView,
7488    ) -> Result<Vec<NodeRecord>, EngineError> {
7489        self.ensure_open()?;
7490        // Defense-in-depth: revalidate paths even if the caller bypassed the
7491        // validated constructors by constructing enum variants directly.
7492        for pred in predicates {
7493            let path = pred.path();
7494            if !PREDICATE_PATH_ALLOWLIST.contains(&path) {
7495                return Err(EngineError::InvalidFilter {
7496                    reason: format!("path '{path}' is not in the predicate path allowlist"),
7497                });
7498            }
7499        }
7500        let (response_tx, response_rx) = mpsc::sync_channel(1);
7501        let request = ReaderRequest::ReadList {
7502            kind: kind.to_string(),
7503            predicates: predicates.to_vec(),
7504            limit,
7505            view: *view,
7506            respond: response_tx,
7507        };
7508        if self.reader_pool.dispatch(request).is_err() {
7509            return Err(EngineError::Closing);
7510        }
7511        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7512            Ok(rows) => Ok(rows),
7513            Err(err) => {
7514                self.emit_sqlite_internal_error(&err);
7515                Err(EngineError::Storage)
7516            }
7517        }
7518    }
7519
7520    /// 0.8.11 Slice 40 (#17) — unified-`Filter` entry point for the
7521    /// canonical_nodes `read.list` backend. Accepts the **full** [`FilterTerm`]
7522    /// set (D3): `Json` runs the shipped allowlisted `json_extract` path;
7523    /// `Status`/`CreatedAfter` lower to allowlisted json-paths; `Kind`/`SourceType`
7524    /// **constant-fold** against the partition `kind` (a guaranteed-empty fold
7525    /// returns an empty `Vec` without touching SQL). Dispatches to the same
7526    /// [`Engine::read_list`] machinery the shipped `Predicate` surface uses, so
7527    /// every inherited invariant (`superseded_at IS NULL`, `json_valid(body)`,
7528    /// the `canonical_nodes(kind)` index, parameterized binds) is preserved.
7529    pub fn read_list_filter(
7530        &self,
7531        kind: &str,
7532        filter: &Filter,
7533        limit: usize,
7534        view: &ReadView,
7535    ) -> Result<Vec<NodeRecord>, EngineError> {
7536        self.ensure_open()?;
7537        match filter.lower_for_read_list(kind)? {
7538            None => Ok(Vec::new()),
7539            Some(preds) => self.read_list(kind, &preds, limit, view),
7540        }
7541    }
7542
7543    /// 0.8.20 Slice 10b (R-20-NV) — the **validity-boundary hook**: which nodes
7544    /// crossed a `[valid_from, valid_until)` boundary in the half-open interval
7545    /// `(since, as_of]`?
7546    ///
7547    /// `since` and the resolved upper bound are INTEGER epoch SECONDS. The upper
7548    /// bound is the view's own instant (`view.valid_as_of`, defaulting to now),
7549    /// so one instant governs both the boundary interval and the view — and, as
7550    /// everywhere else on this path, it is BOUND, never a `datetime('now')`
7551    /// literal, so the answer is deterministic for a fixed `(since, as_of)`.
7552    ///
7553    /// A node appears once, carrying whichever of the two boundaries it crossed;
7554    /// a window that both opened AND closed inside the interval reports both.
7555    /// Rows with an unbounded window on a side cannot cross that side, so a
7556    /// NULL/NULL row (every row predating schema step 22) never appears.
7557    ///
7558    /// The view's EXISTENCE flags still apply (so by default only current,
7559    /// active rows are considered), but its validity predicate does NOT: the
7560    /// question is about boundary crossings, not about being valid right now.
7561    ///
7562    /// When the view relaxes validity entirely (`include_out_of_window`), the
7563    /// interval is unbounded above.
7564    ///
7565    /// This is world-time only. There is deliberately no transaction-time
7566    /// (`history_as_of`) counterpart.
7567    pub fn crossed_boundary_since(
7568        &self,
7569        since: i64,
7570        view: &ReadView,
7571    ) -> Result<Vec<BoundaryCrossing>, EngineError> {
7572        self.ensure_open()?;
7573        let (response_tx, response_rx) = mpsc::sync_channel(1);
7574        let request =
7575            ReaderRequest::CrossedBoundarySince { since, view: *view, respond: response_tx };
7576        if self.reader_pool.dispatch(request).is_err() {
7577            return Err(EngineError::Closing);
7578        }
7579        match response_rx.recv().map_err(|_| EngineError::Storage)? {
7580            Ok(rows) => Ok(rows),
7581            Err(err) => {
7582                self.emit_sqlite_internal_error(&err);
7583                Err(EngineError::Storage)
7584            }
7585        }
7586    }
7587
7588    pub fn close(&self) -> Result<(), EngineError> {
7589        self.closed.store(true, Ordering::SeqCst);
7590        self.projection_runtime.stop();
7591        // Uninstall profile callbacks before dropping the connections so
7592        // SQLite cannot fire one last callback against a profile context
7593        // whose Box is about to free. Per `dev/design/engine.md` § Close
7594        // path step 6, readers drain before the writer connection so
7595        // SQLite's last-handle checkpointer runs on the writer. Each
7596        // reader worker uninstalls its own callback inside
7597        // `reader_worker_loop` before dropping its connection, then
7598        // exits — `shutdown` joins those threads here.
7599        self.reader_pool.shutdown();
7600        if let Ok(mut connection) = self.connection.lock() {
7601            if let Some(conn) = connection.as_ref() {
7602                uninstall_profile_callback(conn);
7603            }
7604            connection.take();
7605        }
7606        if let Ok(mut contexts) = self.profile_contexts.lock() {
7607            contexts.clear();
7608        }
7609        if let Ok(mut lock) = self.lock.lock() {
7610            lock.take();
7611        }
7612        Ok(())
7613    }
7614
7615    /// Block until in-flight writes drain or `timeout_ms` elapses.
7616    ///
7617    /// Surface owned by `dev/interfaces/rust.md` § Engine-attached
7618    /// instrumentation; semantics are owned by `dev/design/lifecycle.md`.
7619    pub fn drain(&self, timeout_ms: u64) -> Result<(), EngineError> {
7620        self.ensure_open()?;
7621        if self.projection_runtime.wait_for_idle(timeout_ms) {
7622            Ok(())
7623        } else {
7624            Err(EngineError::Scheduler)
7625        }
7626    }
7627
7628    /// Snapshot of engine-internal counters.
7629    ///
7630    /// Field set owned by `dev/design/lifecycle.md`.
7631    #[must_use]
7632    pub fn counters(&self) -> CounterSnapshot {
7633        self.counters.snapshot()
7634    }
7635
7636    /// Toggle response-cycle profiling.
7637    ///
7638    /// Per `dev/design/lifecycle.md` § Per-statement profiling, profiling
7639    /// is an opt-in surface that is independently toggleable on a running
7640    /// engine without restart. AC-005a locks runtime toggleability.
7641    pub fn set_profiling(&self, enabled: bool) -> Result<(), EngineError> {
7642        self.profiling_enabled.store(enabled, Ordering::Relaxed);
7643        Ok(())
7644    }
7645
7646    /// Set the threshold above which an operation is reported as slow.
7647    ///
7648    /// Per `dev/design/lifecycle.md` § Slow and heartbeat policy, the
7649    /// threshold is runtime-configurable; mutating it changes detection
7650    /// behavior on subsequent statements without restart (AC-007b).
7651    pub fn set_slow_threshold_ms(&self, value: u64) -> Result<(), EngineError> {
7652        self.slow_threshold_ms.store(value, Ordering::Relaxed);
7653        Ok(())
7654    }
7655
7656    /// Attach a host subscriber to engine events.
7657    ///
7658    /// Dropping the returned [`Subscription`] detaches the subscriber.
7659    /// Payload shape owned by `dev/design/lifecycle.md` and
7660    /// `dev/design/migrations.md`.
7661    #[must_use]
7662    pub fn subscribe(&self, subscriber: Arc<dyn lifecycle::Subscriber>) -> Subscription {
7663        self.subscribers.attach(subscriber)
7664    }
7665
7666    #[cfg(debug_assertions)]
7667    #[doc(hidden)]
7668    pub fn reader_worker_count_for_test(&self) -> usize {
7669        self.reader_pool.worker_count()
7670    }
7671
7672    #[cfg(debug_assertions)]
7673    #[doc(hidden)]
7674    pub fn live_reader_worker_count_for_test(&self) -> usize {
7675        self.reader_pool.live_count()
7676    }
7677
7678    /// Pack 6.G G.1 — return the `sqlite3_db_config(LOOKASIDE)` rc
7679    /// captured for each reader worker at open time, in worker index
7680    /// order. SQLITE_OK (= 0) means the lookaside was configured
7681    /// before any allocation happened on the connection.
7682    #[cfg(debug_assertions)]
7683    #[doc(hidden)]
7684    pub fn reader_lookaside_config_rcs_for_test(&self) -> Vec<i32> {
7685        self.reader_lookaside_rcs.clone()
7686    }
7687
7688    /// Pack 6.G G.1 — query each reader worker's
7689    /// `SQLITE_DBSTATUS_LOOKASIDE_USED` counter. A value > 0 means at
7690    /// least one allocation was satisfied from the per-connection
7691    /// lookaside arena (proof the configuration was honored before the
7692    /// first prepare).
7693    #[cfg(debug_assertions)]
7694    #[doc(hidden)]
7695    pub fn reader_lookaside_used_per_worker_for_test(&self) -> Vec<i32> {
7696        self.reader_pool.lookaside_used_per_worker()
7697    }
7698
7699    /// Pack 6.G G.3.5 — broadcast a debug-only `CacheStatus` request to
7700    /// every reader worker and collect per-worker
7701    /// `SQLITE_DBSTATUS_CACHE_HIT` / `_CACHE_MISS` / `_CACHE_USED`
7702    /// values. Counters are monotonic (reset flag = 0); callers compute
7703    /// pre/post deltas explicitly.
7704    #[cfg(debug_assertions)]
7705    #[doc(hidden)]
7706    pub fn cache_status_per_worker_for_test(&self, label: &str) -> Vec<CacheStatusReply> {
7707        self.reader_pool.cache_status_per_worker(label)
7708    }
7709
7710    #[cfg(debug_assertions)]
7711    #[doc(hidden)]
7712    pub fn force_next_commit_failure_for_test(&self) {
7713        self.force_next_commit_failure.store(true, Ordering::SeqCst);
7714    }
7715
7716    /// Force the next background projection terminal commit to fail with a
7717    /// synthetic SQLite busy error. Test-only seam for TC-91 rollback and
7718    /// redispatch coverage; it does not affect the caller's write transaction.
7719    #[cfg(debug_assertions)]
7720    #[doc(hidden)]
7721    pub fn force_next_projection_commit_failure_for_test(&self) {
7722        self.projection_runtime.force_next_projection_commit_failure_for_test();
7723    }
7724
7725    /// Force the next background projection terminal commit to fail with a
7726    /// rusqlite-layer storage error. Test-only TC-91 diagnostic classifier seam.
7727    #[cfg(debug_assertions)]
7728    #[doc(hidden)]
7729    pub fn force_next_projection_storage_failure_for_test(&self) {
7730        self.projection_runtime.force_next_projection_storage_failure_for_test();
7731    }
7732
7733    /// Pause a worker after a forced projection-commit error was reported and
7734    /// before its state cleanup. TC-91 test-only shutdown/reopen rendezvous.
7735    #[cfg(debug_assertions)]
7736    #[doc(hidden)]
7737    pub fn pause_projection_commit_failure_cleanup_for_test(
7738        &self,
7739        reported: Arc<Barrier>,
7740        release: Arc<Barrier>,
7741    ) {
7742        self.projection_runtime.pause_projection_commit_failure_cleanup_for_test(reported, release);
7743    }
7744
7745    /// Acknowledge after `Engine::close` marks the projection runtime stopping
7746    /// and before it joins workers. TC-91 test-only shutdown rendezvous.
7747    #[cfg(debug_assertions)]
7748    #[doc(hidden)]
7749    pub fn acknowledge_projection_stop_for_test(&self, acknowledged: Arc<Barrier>) {
7750        self.projection_runtime.acknowledge_projection_stop_for_test(acknowledged);
7751    }
7752
7753    /// Execute an arbitrary SQL statement on the writer connection through
7754    /// the same wall-clock + slow-detect path as `write` / `search`.
7755    ///
7756    /// Test-only helper for the deterministic-slow-cte fixture used by
7757    /// AC-007a / AC-007b. Not part of the public 0.6.0 surface; gated on
7758    /// `debug_assertions` so release builds do not expose it.
7759    #[cfg(debug_assertions)]
7760    #[doc(hidden)]
7761    pub fn execute_for_test(&self, sql: &str) -> Result<(), EngineError> {
7762        self.ensure_open()?;
7763        let started = Instant::now();
7764        {
7765            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7766            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7767            connection.execute_batch(sql).map_err(|_| EngineError::Storage)?;
7768        }
7769        self.detect_slow(started, lifecycle::EventCategory::Search);
7770        Ok(())
7771    }
7772
7773    /// One-thread-poison robustness fixture (AC-009).
7774    ///
7775    /// Spawns four reader threads + one writer thread that all make
7776    /// forward progress (single canonical write + repeated searches),
7777    /// plus one designated poison thread that runs an empty-batch write
7778    /// — a deterministic `EngineError::WriteValidation`. The captured
7779    /// poison failure is dispatched as a `StressFailureContext` whose
7780    /// `last_error_chain` is `[EngineError::stable_code(),
7781    /// engine_error.to_string()]` per the lifecycle § Stress-failure
7782    /// context payload contract.
7783    #[doc(hidden)]
7784    #[cfg(debug_assertions)]
7785    pub fn run_one_thread_poison_for_test(&self) -> Result<(), EngineError> {
7786        self.ensure_open()?;
7787
7788        // Forward-progress writer seeds a row so readers + the poison
7789        // thread share a non-trivial canonical state.
7790        self.write(&[PreparedWrite::Node {
7791            kind: "doc".to_string(),
7792            body: "poison-fixture-seed".to_string(),
7793            source_id: SourceId::engine_derived("poison-fixture"),
7794            logical_id: None,
7795            state: InitialState::Active,
7796            reason: None,
7797            valid_from: None,
7798            valid_until: None,
7799        }])?;
7800
7801        let poison_outcome: Mutex<Option<EngineError>> = Mutex::new(None);
7802        let poison_thread_id: AtomicU64 = AtomicU64::new(0);
7803
7804        thread::scope(|scope| {
7805            // N=4 reader threads make forward progress.
7806            for _ in 0..4 {
7807                scope.spawn(|| {
7808                    for _ in 0..4 {
7809                        let _ = self.search("poison-fixture-seed");
7810                    }
7811                });
7812            }
7813            // One forward-progress writer thread.
7814            scope.spawn(|| {
7815                let _ = self.write(&[PreparedWrite::Node {
7816                    kind: "doc".to_string(),
7817                    body: "writer-progress".to_string(),
7818                    source_id: SourceId::engine_derived("poison-fixture"),
7819                    logical_id: None,
7820                    state: InitialState::Active,
7821                    reason: None,
7822                    valid_from: None,
7823                    valid_until: None,
7824                }]);
7825            });
7826            // One poison thread — empty batch is a deterministic
7827            // WriteValidation failure.
7828            scope.spawn(|| {
7829                // Use a non-zero, deterministic group id so subscribers
7830                // see a stable identifier across runs of the fixture.
7831                poison_thread_id.store(1, Ordering::SeqCst);
7832                if let Err(err) = self.write(&[]) {
7833                    *poison_outcome.lock().expect("poison_outcome lock") = Some(err);
7834                }
7835            });
7836        });
7837
7838        let err = poison_outcome
7839            .into_inner()
7840            .expect("poison_outcome lock")
7841            .expect("poison thread must produce a deterministic error");
7842
7843        let projection_state = match self.projection_status_for_test("doc") {
7844            Ok(lifecycle::ProjectionStatus::Pending) => "Pending",
7845            Ok(lifecycle::ProjectionStatus::Failed) => "Failed",
7846            Ok(lifecycle::ProjectionStatus::UpToDate) => "UpToDate",
7847            // Default to UpToDate when projection status is unobservable
7848            // (e.g. embedder not configured for the seed kind). The
7849            // value is still one of the documented enum stringifications
7850            // per AC-010.
7851            Err(_) => "UpToDate",
7852        };
7853
7854        let context = lifecycle::StressFailureContext {
7855            thread_group_id: poison_thread_id.load(Ordering::SeqCst),
7856            op_kind: "write".to_string(),
7857            last_error_chain: vec![err.stable_code().to_string(), err.to_string()],
7858            projection_state: projection_state.to_string(),
7859        };
7860        self.subscribers.dispatch_stress_failure(&context);
7861        Ok(())
7862    }
7863
7864    #[doc(hidden)]
7865    pub fn set_projection_scheduler_frozen_for_test(&self, frozen: bool) {
7866        self.projection_runtime.set_frozen(frozen);
7867    }
7868
7869    /// Test-only snapshot of whether the dispatcher has a scan wake pending.
7870    ///
7871    /// This exists to prove pure observers do not notify the scheduler. It is
7872    /// deliberately narrower than a scheduler control or diagnostic surface.
7873    #[doc(hidden)]
7874    pub fn projection_scheduler_pending_scan_for_test(&self) -> bool {
7875        self.projection_runtime.pending_scan_for_test()
7876    }
7877
7878    #[doc(hidden)]
7879    pub fn set_projection_retry_delays_for_test(&self, delays_ms: &[u64]) {
7880        self.projection_runtime.set_retry_delays_for_test(delays_ms);
7881    }
7882
7883    /// PR-9 — lower the ADR-0.6.0 Invariant 5 per-`embed()` watchdog deadline
7884    /// for tests (production default is `DEFAULT_EMBED_TIMEOUT_MS` = 30s).
7885    #[doc(hidden)]
7886    pub fn set_embed_timeout_ms_for_test(&self, timeout_ms: u64) {
7887        self.projection_runtime.set_embed_timeout_ms_for_test(timeout_ms);
7888    }
7889
7890    /// PR-9 — lower the embed circuit-breaker threshold for tests (production
7891    /// default `DEFAULT_EMBED_CIRCUIT_THRESHOLD`); 0 disables the breaker.
7892    #[doc(hidden)]
7893    pub fn set_embed_circuit_threshold_for_test(&self, threshold: u64) {
7894        self.projection_runtime.set_embed_circuit_threshold_for_test(threshold);
7895    }
7896
7897    /// PR-9 — whether the embed circuit breaker has latched open.
7898    #[doc(hidden)]
7899    pub fn embed_circuit_open_for_test(&self) -> bool {
7900        self.projection_runtime.embed_circuit_open_for_test()
7901    }
7902
7903    #[doc(hidden)]
7904    pub fn projection_status_for_test(
7905        &self,
7906        kind: &str,
7907    ) -> Result<lifecycle::ProjectionStatus, EngineError> {
7908        self.ensure_open()?;
7909        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7910        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7911        projection_status(connection, kind)
7912    }
7913
7914    #[doc(hidden)]
7915    pub fn has_vector_for_cursor_for_test(&self, cursor: u64) -> Result<bool, EngineError> {
7916        self.ensure_open()?;
7917        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7918        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7919        terminal_state_for_cursor(connection, cursor)
7920            .map(|state| matches!(state.as_deref(), Some("up_to_date")))
7921            .map_err(|_| EngineError::Storage)
7922    }
7923
7924    #[doc(hidden)]
7925    pub fn projection_failure_count_for_test(&self, cursor: u64) -> Result<u64, EngineError> {
7926        self.ensure_open()?;
7927        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7928        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7929        connection
7930            .query_row(
7931                "SELECT COUNT(*) FROM operational_mutations
7932                 WHERE collection_name = 'projection_failures'
7933                   AND record_key = ?1",
7934                [cursor.to_string()],
7935                |row| row.get::<_, u64>(0),
7936            )
7937            .map_err(|_| EngineError::Storage)
7938    }
7939
7940    #[doc(hidden)]
7941    pub fn set_provenance_row_cap_for_test(&self, cap: Option<u64>) {
7942        self.provenance_row_cap.store(cap.unwrap_or(0), Ordering::Relaxed);
7943    }
7944
7945    #[doc(hidden)]
7946    pub fn provenance_row_count_for_test(&self) -> Result<u64, EngineError> {
7947        self.ensure_open()?;
7948        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7949        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7950        connection
7951            .query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get::<_, u64>(0))
7952            .map_err(|_| EngineError::Storage)
7953    }
7954
7955    #[doc(hidden)]
7956    pub fn oldest_provenance_record_key_for_test(
7957        &self,
7958        collection: &str,
7959    ) -> Result<Option<String>, EngineError> {
7960        self.ensure_open()?;
7961        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7962        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
7963        connection
7964            .query_row(
7965                "SELECT record_key FROM operational_mutations
7966                 WHERE collection_name = ?1
7967                 ORDER BY id
7968                 LIMIT 1",
7969                [collection],
7970                |row| row.get::<_, String>(0),
7971            )
7972            .map(Some)
7973            .or_else(|err| match err {
7974                rusqlite::Error::QueryReturnedNoRows => Ok(None),
7975                _ => Err(EngineError::Storage),
7976            })
7977    }
7978
7979    #[doc(hidden)]
7980    pub fn configure_vector_kind_for_test(&self, kind: &str) -> Result<(), EngineError> {
7981        self.ensure_open()?;
7982        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
7983        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
7984        connection
7985            .execute(
7986                "INSERT OR REPLACE INTO _fathomdb_vector_kinds(kind, profile, created_at)
7987                 VALUES(?1, ?2, 0)",
7988                params![kind, DEFAULT_VECTOR_PROFILE],
7989            )
7990            .map_err(|_| EngineError::Storage)?;
7991        Ok(())
7992    }
7993
7994    /// OPP-12 Phase-1 (0.8.19 Slice 10) — read the writer connection's
7995    /// `PRAGMA secure_delete` (design §3 gap-4). `true` iff the standing
7996    /// connection-open PRAGMA is in effect, so `purge` freelist erasure is
7997    /// complete without a per-purge `VACUUM`.
7998    #[doc(hidden)]
7999    pub fn secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
8000        self.ensure_open()?;
8001        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8002        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8003        let value: i64 = connection
8004            .query_row("PRAGMA secure_delete", [], |r| r.get(0))
8005            .map_err(|_| EngineError::Storage)?;
8006        Ok(value != 0)
8007    }
8008
8009    /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff EVERY
8010    /// reader-pool connection reports `PRAGMA secure_delete = ON`. Broadcasts a
8011    /// per-worker probe; proves the standing flag is set on the non-writer
8012    /// connections (which perform projection/vector-rewrite DELETEs), closing
8013    /// the GDPR-erasure leak codex flagged.
8014    #[cfg(debug_assertions)]
8015    #[doc(hidden)]
8016    pub fn reader_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
8017        self.ensure_open()?;
8018        let per_worker = self.reader_pool.secure_delete_per_worker();
8019        if per_worker.is_empty() {
8020            return Err(EngineError::Storage);
8021        }
8022        Ok(per_worker.iter().all(|&v| v == 1))
8023    }
8024
8025    /// OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `true` iff a freshly
8026    /// opened projection/runtime connection (`open_runtime_connection`) reports
8027    /// `PRAGMA secure_delete = ON`. The runtime connection performs the
8028    /// vector-rewrite/projection DELETEs, so its freed pages must be scrubbed too.
8029    #[doc(hidden)]
8030    pub fn runtime_secure_delete_enabled_for_test(&self) -> Result<bool, EngineError> {
8031        self.ensure_open()?;
8032        let connection = open_runtime_connection(&self.path).map_err(|_| EngineError::Storage)?;
8033        let value: i64 = connection
8034            .query_row("PRAGMA secure_delete", [], |r| r.get(0))
8035            .map_err(|_| EngineError::Storage)?;
8036        Ok(value != 0)
8037    }
8038
8039    /// EXP-S (0.8.14 Slice 5, D1) — write one canonical node row carrying an
8040    /// explicit structural `row_kind` (leaf/coverage/graph), routing the index
8041    /// projection through the SAME `row_kind -> index-target` dispatch seam
8042    /// (`project_canonical_node_row`) as the production `leaf` write path.
8043    ///
8044    /// This is the internal-only writer for `coverage`/`graph` rows (there is no
8045    /// public SDK surface for `row_kind` in 0.8.14). Cursor assignment preserves
8046    /// the `rowid == write_cursor == cursor` determinism identity. When the row
8047    /// projects into an async vector index, the worker pool is notified so the
8048    /// embed is scheduled exactly as for a normal write.
8049    #[doc(hidden)]
8050    pub fn write_canonical_row_with_kind_for_test(
8051        &self,
8052        kind: &str,
8053        body: &str,
8054        row_kind: RowKind,
8055    ) -> Result<WriteReceipt, EngineError> {
8056        self.ensure_open()?;
8057        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8058        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8059
8060        // R-20-E3 / design §4 item 6 — this writer BYPASSES `PreparedWrite`, so
8061        // the `SourceId` newtype cannot reach it; before 0.8.20 it inserted a
8062        // literal NULL `source_id` and produced a row that no `excise_source`
8063        // call could reach. Engine-derived rows instead take a reserved
8064        // `_engine:*` provenance, keyed by the structural role that produced
8065        // them, so they are both erasable and distinguishable from caller data.
8066        let engine_provenance = SourceId::engine_derived(row_kind.as_str());
8067
8068        // 0.8.20 Slice 20c — same late enrolment the governed write path takes
8069        // (`Engine::enrol_batch_vector_kinds`), so this internal writer does not
8070        // silently diverge into the false-ready barrier for `coverage` rows. The
8071        // live-embedder precondition is checked here, as that caller does; the
8072        // `row_kind` gate keeps `graph` rows out of the vector registry.
8073        //
8074        // fix-2 (codex §9 [P2]) — including the un-stranding half, so this door
8075        // cannot diverge from the other one either. fix-5 (codex §9 round 4 [P2])
8076        // — and both halves commit as ONE transaction, via the same shared
8077        // `enrol_and_unstrand`.
8078        let unstranded = if self.usable_dense_runtime()
8079            && self.vector_kind_needs_enrolment(connection, kind, row_kind)?
8080        {
8081            self.enrol_and_unstrand(connection, &[kind])?
8082        } else {
8083            false
8084        };
8085
8086        let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
8087        let enqueued = {
8088            let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8089            // 0.8.20 Slice 15b (TC-34) — this writer takes NO validity window, and
8090            // that is deliberate rather than an oversight. It is a `#[doc(hidden)]`
8091            // test-only writer for the internal `coverage`/`graph` row kinds, which
8092            // have no public SDK surface at all (see the doc comment above); the
8093            // caller-facing authoring path is `PreparedWrite::Node`, handled in
8094            // `commit_batch`. Omitting the columns binds NULL — the migration
8095            // step-22 default and the UNBOUNDED reading — so engine-derived rows
8096            // stay valid at every instant, which is the only correct answer for a
8097            // structural row that no caller can address a window to.
8098            tx.execute(
8099                "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind)
8100                 VALUES(?1, ?2, ?3, ?4, NULL, ?5)",
8101                params![cursor, kind, body, engine_provenance.as_str(), row_kind.as_str()],
8102            )
8103            .map_err(|_| EngineError::Storage)?;
8104            let enqueued = project_canonical_node_row(
8105                &tx,
8106                cursor,
8107                kind,
8108                body,
8109                row_kind,
8110                ProjectionPass::Write,
8111                // This #[doc(hidden)] writer inserts with the column DEFAULT
8112                // `state = 'active'` (no state column in its INSERT), so the row
8113                // is always active and its attributes project.
8114                true,
8115            )
8116            .map_err(|_| EngineError::Storage)?;
8117            advance_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
8118            tx.commit().map_err(|_| EngineError::Storage)?;
8119            enqueued
8120        };
8121        self.next_cursor.store(cursor, Ordering::SeqCst);
8122        if enqueued || unstranded {
8123            self.projection_runtime.notify_new_work();
8124        }
8125        Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
8126    }
8127
8128    /// EXP-S (0.8.14 Slice 5, D1) — select the active canonical rows carrying a
8129    /// given `row_kind`, returning their `write_cursor`s in cursor order. Proves
8130    /// the engine can query/select rows by the structural `row_kind` axis.
8131    #[doc(hidden)]
8132    pub fn canonical_rows_with_row_kind_for_test(
8133        &self,
8134        row_kind: RowKind,
8135    ) -> Result<Vec<u64>, EngineError> {
8136        self.ensure_open()?;
8137        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8138        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8139        let mut stmt = connection
8140            .prepare(
8141                "SELECT write_cursor FROM canonical_nodes
8142                 WHERE row_kind = ?1 AND superseded_at IS NULL
8143                 ORDER BY write_cursor",
8144            )
8145            .map_err(|_| EngineError::Storage)?;
8146        let cursors = stmt
8147            .query_map(params![row_kind.as_str()], |row| row.get::<_, u64>(0))
8148            .map_err(|_| EngineError::Storage)?
8149            .collect::<rusqlite::Result<Vec<u64>>>()
8150            .map_err(|_| EngineError::Storage)?;
8151        Ok(cursors)
8152    }
8153
8154    /// F5 (0.8.14 Slice 10) — the fielded BM25F lexical arm over
8155    /// `search_index_v2`. Recalls candidate rows through the FTS5 index
8156    /// (`search_index_v2 MATCH`) and scores them with a textbook BM25F using the
8157    /// plan's tunable per-field `weights` and tunable `b`/`k1`, returning
8158    /// `(write_cursor, score)` in descending score order (write_cursor asc as the
8159    /// deterministic tiebreak). Superseded node versions are excluded (join to
8160    /// `canonical_nodes WHERE superseded_at IS NULL`).
8161    ///
8162    /// This is the engine-internal `BM25fQueryPlan` compiler path (`ADR-0.8.1`
8163    /// §3.2); there is no public Py/TS SDK surface this release. The score is
8164    /// computed in-engine (not via SQLite's `bm25()`, which cannot express a
8165    /// tunable `b`); the FTS5 index remains load-bearing for candidate recall.
8166    #[doc(hidden)]
8167    pub fn bm25f_search(
8168        &self,
8169        query: &str,
8170        plan: &Bm25fQueryPlan,
8171    ) -> Result<Vec<(u64, f64)>, EngineError> {
8172        self.ensure_open()?;
8173        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8174        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8175        bm25f_search_inner(connection, query, plan).map_err(|_| EngineError::Storage)
8176    }
8177
8178    /// Embed arbitrary text with the engine's configured runtime embedder,
8179    /// returning the raw (un-centered) vector.
8180    ///
8181    /// This is the read-path embed primitive: it mirrors the search
8182    /// query-embedding path — a single, direct [`Embedder::embed`] call. The
8183    /// per-`embed()` watchdog/circuit-breaker guards only the bulk
8184    /// projection/write path (many embeds, fault isolation), not single
8185    /// read-side embeds, so a direct call is consistent with how a query is
8186    /// embedded. Callers get vectors under the engine's *pinned* embedder
8187    /// identity (`fathomdb-bge-small-en-v1.5` by default) rather than a
8188    /// parallel, possibly-divergent embedder.
8189    ///
8190    /// Returns [`EngineError::EmbedderNotConfigured`] if the engine was opened
8191    /// without an embedder (`use_default_embedder = false`).
8192    pub fn embed_text(&self, text: &str) -> Result<Vec<f32>, EngineError> {
8193        self.ensure_open()?;
8194        let embedder =
8195            self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
8196        embedder.embed(text).map_err(map_runtime_embedder_error)
8197    }
8198
8199    #[doc(hidden)]
8200    pub fn write_vector_for_test(
8201        &self,
8202        kind: &str,
8203        text: &str,
8204    ) -> Result<WriteReceipt, EngineError> {
8205        self.ensure_open()?;
8206        let embedder =
8207            self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
8208
8209        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8210        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8211        if !kind_is_vector_indexed(connection, kind)? {
8212            return Err(EngineError::KindNotVectorIndexed);
8213        }
8214
8215        let expected = default_profile_dimension(connection)?;
8216        ensure_vector_partition(connection, expected).map_err(|_| EngineError::Storage)?;
8217        let vector = embedder.embed(text).map_err(map_runtime_embedder_error)?;
8218        let actual = u32::try_from(vector.len()).unwrap_or(u32::MAX);
8219        if actual != expected {
8220            return Err(EngineError::EmbedderDimensionMismatch { expected, actual });
8221        }
8222
8223        let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
8224        // EU-5a2 mean-centering apply path (write side). f32 BLOB stored
8225        // is ALWAYS un-centered; the sign-quant input is the centered
8226        // vector iff the identity is MC-required AND a `mean_vec` is
8227        // pinned. NoopEmbedder identity (the only EU-5a2 live one) is
8228        // NOT MC-required, so this is a no-op until EU-5b's flip.
8229        let blob = encode_vector_blob(&vector);
8230        let bin_blob = if identity_requires_mean_centering(&self.runtime_embedder_identity) {
8231            match read_pinned_mean_vec(connection, self.runtime_embedder_identity.dimension)? {
8232                Some(mean) => encode_vector_blob(&subtract_mean(&vector, &mean)),
8233                None => blob.clone(),
8234            }
8235        } else {
8236            blob.clone()
8237        };
8238        let source_type = resolve_source_type(kind)?;
8239        let now_unix =
8240            SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
8241
8242        // EU-5b — feed the streaming mean accumulator (if live) and detect
8243        // a threshold-crossing pin. The mean materialization, pre-pin
8244        // re-quantize, and `MeanVecPinned` event emission all happen in
8245        // the SAME SQLite transaction as the row INSERT.
8246        let pin_event = {
8247            let runtime = &self.projection_runtime.shared;
8248            let mut accumulator =
8249                runtime.mean_accumulator.lock().map_err(|_| EngineError::Storage)?;
8250            if let Some(acc) = accumulator.as_mut() {
8251                acc.add(&vector);
8252                if acc.count() >= MEAN_VEC_PIN_THRESHOLD {
8253                    let mean = acc.materialize();
8254                    *accumulator = None;
8255                    Some(mean)
8256                } else {
8257                    None
8258                }
8259            } else {
8260                None
8261            }
8262        };
8263
8264        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8265        tx.execute(
8266            "INSERT INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
8267            params![cursor, kind, cursor],
8268        )
8269        .map_err(|_| EngineError::Storage)?;
8270        // Slice 10 / G10 — `status` ships an empty-string sentinel only: vec0 TEXT
8271        // metadata columns are NOT NULL-able ("Expected text for TEXT metadata
8272        // column"), so the "no real population yet" state is `''`, not NULL.
8273        //
8274        // 0.8.20 Slice 15e — this test helper carries no JSON body, so every live
8275        // `filterable` `attr_<hex>` column binds the `''` sentinel (an empty body
8276        // extracts nothing). When the table has no attr columns the statement is
8277        // byte-identical to the shipped form.
8278        let (cols_sql, ph_sql, attr_vals) =
8279            vector_attr_insert_fragments(&tx, "", 7).map_err(|_| EngineError::Storage)?;
8280        let sql = format!(
8281            "INSERT INTO vector_default(
8282                rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
8283             ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
8284        );
8285        let mut pv: Vec<rusqlite::types::Value> = vec![
8286            rusqlite::types::Value::Integer(cursor as i64),
8287            rusqlite::types::Value::Blob(blob.clone()),
8288            rusqlite::types::Value::Blob(bin_blob.clone()),
8289            rusqlite::types::Value::Text(source_type.to_string()),
8290            rusqlite::types::Value::Text(kind.to_string()),
8291            rusqlite::types::Value::Integer(now_unix),
8292        ];
8293        pv.extend(attr_vals);
8294        tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
8295            .map_err(|_| EngineError::Storage)?;
8296
8297        let mut emitted_event: Option<EmbedderEvent> = None;
8298        if let Some(mean_vec) = pin_event {
8299            let mean_bytes = encode_vector_blob(&mean_vec);
8300            tx.execute(
8301                "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
8302                params![mean_bytes],
8303            )
8304            .map_err(|_| EngineError::Storage)?;
8305            // Read all pre-pin (rowid, embedding) and re-quantize within
8306            // the same tx. The just-inserted row above is also covered.
8307            let rows: Vec<(i64, Vec<u8>)> = {
8308                let mut statement = tx
8309                    .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
8310                    .map_err(|_| EngineError::Storage)?;
8311                let mapped = statement
8312                    .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
8313                    .map_err(|_| EngineError::Storage)?;
8314                let mut out = Vec::new();
8315                for r in mapped {
8316                    out.push(r.map_err(|_| EngineError::Storage)?);
8317                }
8318                out
8319            };
8320            let (doc_count, _) = run_pin_and_requantize_pass(&tx, &rows, &mean_vec)?;
8321            emitted_event = Some(EmbedderEvent::MeanVecPinned {
8322                dim: u32::try_from(mean_vec.len()).unwrap_or(u32::MAX),
8323                doc_count,
8324            });
8325        }
8326
8327        tx.commit().map_err(|_| EngineError::Storage)?;
8328
8329        if let Some(ev) = emitted_event {
8330            if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
8331                events.push(ev);
8332            }
8333        }
8334
8335        self.next_cursor.store(cursor, Ordering::SeqCst);
8336        // G8 — this path (embedder-profile pin) commits no canonical edges, so
8337        // no endpoint can dangle.
8338        Ok(WriteReceipt { cursor, row_cursors: vec![cursor], dangling_edge_endpoints: 0 })
8339    }
8340
8341    /// EU-5b test seam — drain MeanVecPinned events queued by the
8342    /// projection-commit pin transaction since the last drain. Production
8343    /// callers consume these via `OpenReport.embedder_events`; this seam
8344    /// exists so the EU-5b RED test can observe the live emission.
8345    #[doc(hidden)]
8346    pub fn drain_mean_centering_events_for_test(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
8347        self.ensure_open()?;
8348        let mut events = self
8349            .projection_runtime
8350            .shared
8351            .pending_events
8352            .lock()
8353            .map_err(|_| EngineError::Storage)?;
8354        let out = std::mem::take(&mut *events);
8355        Ok(out)
8356    }
8357
8358    /// 0.7.2 PR-2b — NON-test observation seam. Drains and returns every
8359    /// `EmbedderEvent` queued since the last drain (mean pin, manual mean
8360    /// recompute). Production callers use
8361    /// this to observe the synchronous recompute work; events are queued
8362    /// only AFTER the recompute transaction is durable, so a rolled-back
8363    /// recompute never surfaces. Mirrors the at-open
8364    /// `OpenReport.embedder_events` channel for the steady-state path.
8365    pub fn drain_embedder_events(&self) -> Result<Vec<EmbedderEvent>, EngineError> {
8366        self.ensure_open()?;
8367        let mut events = self
8368            .projection_runtime
8369            .shared
8370            .pending_events
8371            .lock()
8372            .map_err(|_| EngineError::Storage)?;
8373        Ok(std::mem::take(&mut *events))
8374    }
8375
8376    /// 0.7.2 PR-2b — explicit `doctor recompute-mean` path. Re-derives the
8377    /// pinned corpus mean from the current `vector_default` rows and
8378    /// re-quantizes every row, SYNCHRONOUSLY in one transaction. ALWAYS
8379    /// allowed at any corpus size — this is the ONLY mean-refresh path as of
8380    /// 0.7.2 (the automatic in-ingest drift detector was carved out / deferred
8381    /// to 0.8.x; see `dev/design/embedder.md` §0.3).
8382    ///
8383    /// Serializes against the projection workers via `commit_gate` so the
8384    /// re-quantize sees a totally-ordered history, exactly like the at-pin
8385    /// commit. Publishes a `MeanVecRecomputed { trigger: Manual }` event
8386    /// only after the transaction is durable. No-op-safe on a non-MC
8387    /// identity (returns `EmbedderNotConfigured` rather than corrupting an
8388    /// un-centered workspace).
8389    #[cfg(feature = "operator")]
8390    pub fn recompute_mean(&self) -> Result<MeanRecomputeReport, EngineError> {
8391        self.ensure_open()?;
8392        let identity = self.runtime_embedder_identity.clone();
8393        if !identity_requires_mean_centering(&identity) {
8394            return Err(EngineError::EmbedderNotConfigured);
8395        }
8396        let report = {
8397            // Hold the commit gate for the whole recompute so no projection
8398            // worker commit interleaves with the re-quantize.
8399            let _gate = self
8400                .projection_runtime
8401                .shared
8402                .commit_gate
8403                .lock()
8404                .unwrap_or_else(|p| p.into_inner());
8405            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8406            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8407            let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8408            #[cfg(debug_assertions)]
8409            let fail = self
8410                .projection_runtime
8411                .shared
8412                .force_recompute_failure
8413                .swap(false, Ordering::SeqCst);
8414            #[cfg(not(debug_assertions))]
8415            let fail = false;
8416            let report = recompute_mean_in_tx_inner(&tx, &identity, fail)?;
8417            tx.commit().map_err(|_| EngineError::Storage)?;
8418            report
8419        };
8420        // Post-durable-commit publish.
8421        if let Ok(mut events) = self.projection_runtime.shared.pending_events.lock() {
8422            events.push(EmbedderEvent::MeanVecRecomputed {
8423                dim: report.dim,
8424                doc_count: report.doc_count_requantized,
8425                trigger: MeanRecomputeTrigger::Manual,
8426            });
8427        }
8428        Ok(report)
8429    }
8430
8431    /// Test seam that raises vector-candidate fanout for recall tests.
8432    ///
8433    /// This does not alter the caller-requested final result limit or the
8434    /// caller-visible result cardinality. Production uses the default and
8435    /// never consults an environment variable.
8436    #[doc(hidden)]
8437    pub fn set_search_limit_for_test(&self, limit: usize) {
8438        self.projection_runtime.shared.search_limit_override.store(limit, Ordering::SeqCst);
8439    }
8440
8441    /// Slice 10 / G12-recency test seam — flip the dedicated recency-reweight
8442    /// flag (off by default). The reweight runs AFTER bit-KNN on the fused hits;
8443    /// it is never a vec0 predicate and is NOT `fusion_mode`.
8444    #[doc(hidden)]
8445    pub fn set_recency_reweight_enabled_for_test(&self, enabled: bool) {
8446        self.projection_runtime.shared.recency_reweight_enabled.store(enabled, Ordering::SeqCst);
8447    }
8448
8449    /// 0.8.16 Slice 5 / F9 test seam — flip the dedicated importance/confidence
8450    /// reweight flag (off by default). The reweight runs AFTER bit-KNN + RRF on
8451    /// the fused hits (multiplicative-on-fused, `NULL ⇒ neutral`); it is never a
8452    /// vec0 predicate and is NOT `fusion_mode`. Mirrors
8453    /// `set_recency_reweight_enabled_for_test`.
8454    #[doc(hidden)]
8455    pub fn set_importance_reweight_enabled_for_test(&self, enabled: bool) {
8456        self.projection_runtime.shared.importance_reweight_enabled.store(enabled, Ordering::SeqCst);
8457    }
8458
8459    /// 0.8.16 Slice 5 / F9 (R-F9-1) — set the caller-supplied `importance` ranking
8460    /// scalar on the `canonical_nodes` row identified by `write_cursor` (the
8461    /// interim id `SearchHit.id` carries). Validates `importance ∈ [0.0, 1.0]`,
8462    /// mirroring the existing `canonical_edges.confidence` write-path check —
8463    /// an out-of-range value is a deterministic [`EngineError::WriteValidation`].
8464    ///
8465    /// The 3-way sentinel: NOT calling this leaves the column `NULL` (never
8466    /// assigned = graceful-absent, ranks NEUTRAL); `0.0` is the explicit floor;
8467    /// `(0.0, 1.0]` is an explicit importance. Importance is a caller-supplied
8468    /// scalar — the engine does NOT compute graph-centrality importance (ADR §4
8469    /// non-goal). Engine-internal minimal surface for this keystone; SDK (Py/TS)
8470    /// exposure is a Slice-40 concern.
8471    pub fn write_node_importance(
8472        &self,
8473        write_cursor: u64,
8474        importance: f64,
8475    ) -> Result<(), EngineError> {
8476        if !importance.is_finite() || !(0.0..=1.0).contains(&importance) {
8477            return Err(EngineError::WriteValidation);
8478        }
8479        self.ensure_open()?;
8480        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8481        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8482        connection
8483            .execute(
8484                "UPDATE canonical_nodes SET importance = ?1 WHERE write_cursor = ?2",
8485                params![importance, write_cursor],
8486            )
8487            .map_err(|_| EngineError::Storage)?;
8488        Ok(())
8489    }
8490
8491    /// 0.8.16 Slice 5 / F9 (R-F9-1) — read back the `importance` scalar for the
8492    /// `canonical_nodes` row identified by `write_cursor`. `None` = SQL `NULL` =
8493    /// never assigned (graceful-absent). The reciprocal read for
8494    /// [`Engine::write_node_importance`].
8495    pub fn node_importance(&self, write_cursor: u64) -> Result<Option<f64>, EngineError> {
8496        self.ensure_open()?;
8497        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8498        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8499        connection
8500            .query_row(
8501                "SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
8502                params![write_cursor],
8503                |r| r.get::<_, Option<f64>>(0),
8504            )
8505            .map_err(|_| EngineError::Storage)
8506    }
8507
8508    /// GA-2 / Slice-40 (◆ B-1) measurement seam — make `search()` return the
8509    /// pre-fusion VECTOR-branch ranking (the ANN+ bit-KNN K=192 + f32 rerank
8510    /// signal) instead of the unconditional RRF-fused result, so the eu7 recall
8511    /// gate (AC-075) can measure ANN-quantization FIDELITY — vector top-10 vs
8512    /// the exact-f32 VECTOR top-10 ground truth — in isolation. Off by default;
8513    /// never set on any production path. This is NOT a `fusion_mode` knob:
8514    /// production RRF fusion stays unconditional and `fuse_rrf`/`rerank_fused`/
8515    /// recency are unchanged. Mirrors `set_recency_reweight_enabled_for_test`
8516    /// (release-available, since eu7 runs in `--release`).
8517    #[doc(hidden)]
8518    pub fn set_vector_stage_only_for_test(&self, enabled: bool) {
8519        self.projection_runtime.shared.vector_stage_only_for_test.store(enabled, Ordering::SeqCst);
8520    }
8521
8522    /// 0.7.2 PR-2b test seam — arm a one-shot fault inside the NEXT
8523    /// `recompute_mean` so it errors after the `mean_vec` UPDATE but before
8524    /// the re-quantize completes. Proves the recompute tx rolls back whole.
8525    #[doc(hidden)]
8526    #[cfg(debug_assertions)]
8527    pub fn force_next_recompute_failure_for_test(&self) {
8528        self.projection_runtime.shared.force_recompute_failure.store(true, Ordering::SeqCst);
8529    }
8530
8531    #[doc(hidden)]
8532    pub fn vector_row_count_for_test(&self) -> Result<u64, EngineError> {
8533        self.ensure_open()?;
8534        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8535        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8536        connection
8537            .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get::<_, u64>(0))
8538            .map_err(|_| EngineError::Storage)
8539    }
8540
8541    #[doc(hidden)]
8542    pub fn read_vector_blob_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8543        self.ensure_open()?;
8544        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8545        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8546        connection
8547            .query_row("SELECT embedding FROM vector_default WHERE rowid = ?1", [rowid], |row| {
8548                row.get::<_, Vec<u8>>(0)
8549            })
8550            .map_err(|_| EngineError::Storage)
8551    }
8552
8553    /// 0.8.20 Slice 15e — read a row's raw `embedding_bin` blob bytes (the
8554    /// sign-quantized vector). Used to prove the non-destructive reshape copies the
8555    /// bits VERBATIM (condition #4): the pre-reshape and post-reshape bytes must be
8556    /// byte-identical.
8557    #[doc(hidden)]
8558    pub fn read_vector_bin_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
8559        self.ensure_open()?;
8560        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8561        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8562        connection
8563            .query_row(
8564                "SELECT embedding_bin FROM vector_default WHERE rowid = ?1",
8565                [rowid],
8566                |row| row.get::<_, Vec<u8>>(0),
8567            )
8568            .map_err(|_| EngineError::Storage)
8569    }
8570
8571    /// 0.8.20 Slice 15e — run an arbitrary read-only SELECT on the ENGINE
8572    /// connection (which has the vec0 extension loaded, unlike a bare
8573    /// `Connection::open`) and collect column 0 as `i64`. Lets a test run a
8574    /// phase-1-style KNN `MATCH ... {attr clause}` and observe which `rowid`s
8575    /// survive the pre-KNN filter.
8576    #[doc(hidden)]
8577    pub fn query_i64_col_for_test(&self, sql: &str) -> Result<Vec<i64>, EngineError> {
8578        self.ensure_open()?;
8579        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8580        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8581        let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8582        let rows =
8583            stmt.query_map([], |row| row.get::<_, i64>(0)).map_err(|_| EngineError::Storage)?;
8584        rows.collect::<rusqlite::Result<Vec<i64>>>().map_err(|_| EngineError::Storage)
8585    }
8586
8587    /// 0.8.20 Slice 15e — as [`query_i64_col_for_test`] but collects column 0 as
8588    /// `String` (e.g. an `attr_<hex>` metadata column's stored value).
8589    #[doc(hidden)]
8590    pub fn query_text_col_for_test(&self, sql: &str) -> Result<Vec<String>, EngineError> {
8591        self.ensure_open()?;
8592        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8593        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8594        let mut stmt = connection.prepare(sql).map_err(|_| EngineError::Storage)?;
8595        let rows =
8596            stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
8597        rows.collect::<rusqlite::Result<Vec<String>>>().map_err(|_| EngineError::Storage)
8598    }
8599
8600    #[doc(hidden)]
8601    pub fn default_embedder_profile_for_test(&self) -> Result<EmbedderIdentity, EngineError> {
8602        self.ensure_open()?;
8603        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8604        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8605        load_default_profile(connection).map_err(|_| EngineError::Storage)
8606    }
8607
8608    /// Doctor read-only integrity report. Three-section output per
8609    /// AC-043a/b. `opts.full` adds `PRAGMA integrity_check`. `quick` and
8610    /// `round_trip` are accepted but treated as default for 0.6.0.
8611    #[cfg(feature = "operator")]
8612    pub fn check_integrity(
8613        &self,
8614        opts: CheckIntegrityOpts,
8615    ) -> Result<IntegrityReport, EngineError> {
8616        self.ensure_open()?;
8617        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8618        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8619        Ok(IntegrityReport {
8620            physical: physical_section(connection, opts.full),
8621            logical: logical_section(connection),
8622            semantic: semantic_section(connection),
8623        })
8624    }
8625
8626    /// Doctor bit-preserving export. Runs `VACUUM INTO` to produce a
8627    /// self-contained SQLite file at `out`, computes SHA-256 of the
8628    /// resulting bytes, and writes a JSON manifest at `manifest`. Per
8629    /// AC-039a/b.
8630    #[cfg(feature = "operator")]
8631    pub fn safe_export(
8632        &self,
8633        out: &Path,
8634        manifest: &Path,
8635    ) -> Result<SafeExportArtifact, EngineError> {
8636        self.ensure_open()?;
8637        {
8638            let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8639            let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8640            let target = out.to_string_lossy().to_string();
8641            connection
8642                .execute("VACUUM INTO ?1", params![target])
8643                .map_err(|_| EngineError::Storage)?;
8644        }
8645        let bytes = std::fs::read(out).map_err(|_| EngineError::Storage)?;
8646        let digest = sha2::Sha256::digest(&bytes);
8647        let sha256_hex = hex_encode(digest.as_slice());
8648        let export_abs = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());
8649        let manifest_json = serde_json::json!({
8650            "export_path": export_abs.to_string_lossy(),
8651            "sha256": sha256_hex,
8652            "byte_count": bytes.len() as u64,
8653        });
8654        let manifest_bytes =
8655            serde_json::to_vec_pretty(&manifest_json).map_err(|_| EngineError::Storage)?;
8656        std::fs::write(manifest, &manifest_bytes).map_err(|_| EngineError::Storage)?;
8657        Ok(SafeExportArtifact {
8658            export_path: out.to_path_buf(),
8659            manifest_path: manifest.to_path_buf(),
8660            manifest_sha256: sha256_hex,
8661        })
8662    }
8663
8664    /// Operator regenerate workflow per `dev/design/projections.md`
8665    /// § Regenerate workflow. Drains in-flight projection work, then
8666    /// truncates FTS5 + vec0 shadow rows, resets the projection cursor,
8667    /// and lets the scheduler re-enqueue every canonical row. Durable
8668    /// `projection_failures` audit rows are preserved per design. AC-044
8669    /// + AC-063c.
8670    #[cfg(feature = "operator")]
8671    pub fn rebuild_projections(&self) -> Result<RebuildReport, EngineError> {
8672        self.ensure_open()?;
8673        self.run_rebuild(true, RebuildKind::Projections)
8674    }
8675
8676    /// Vec0-only variant of [`Engine::rebuild_projections`]. Leaves
8677    /// FTS5 shadow content untouched; per recovery design,
8678    /// `recover --rebuild-vec0` is the surface for vec0-only repair.
8679    #[cfg(feature = "operator")]
8680    pub fn rebuild_vec0(&self) -> Result<RebuildReport, EngineError> {
8681        self.ensure_open()?;
8682        self.run_rebuild(false, RebuildKind::Vec0)
8683    }
8684
8685    /// Phase 9 Pack B / AC-042 source trace. Returns the canonical-row
8686    /// id set produced by `source_id`, ordered by `write_cursor`. Empty
8687    /// string is not a valid `source_id`; rows with NULL `source_id`
8688    /// are excluded from every result.
8689    #[cfg(feature = "operator")]
8690    pub fn trace_source_ref(&self, source_id: &str) -> Result<TraceReport, EngineError> {
8691        self.ensure_open()?;
8692        if source_id.is_empty() {
8693            return Err(EngineError::WriteValidation);
8694        }
8695        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8696        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
8697
8698        let mut events: Vec<TraceEvent> = Vec::new();
8699        let mut nodes = connection
8700            .prepare(
8701                "SELECT write_cursor, kind FROM canonical_nodes WHERE source_id = ?1
8702                 ORDER BY write_cursor",
8703            )
8704            .map_err(|_| EngineError::Storage)?;
8705        let node_rows = nodes
8706            .query_map([source_id], |row| {
8707                Ok(TraceEvent {
8708                    write_cursor: row.get::<_, i64>(0)? as u64,
8709                    kind: row.get::<_, String>(1)?,
8710                    table: "canonical_nodes",
8711                })
8712            })
8713            .map_err(|_| EngineError::Storage)?;
8714        for row in node_rows {
8715            events.push(row.map_err(|_| EngineError::Storage)?);
8716        }
8717
8718        let mut edges = connection
8719            .prepare(
8720                "SELECT write_cursor, kind FROM canonical_edges WHERE source_id = ?1
8721                 ORDER BY write_cursor",
8722            )
8723            .map_err(|_| EngineError::Storage)?;
8724        let edge_rows = edges
8725            .query_map([source_id], |row| {
8726                Ok(TraceEvent {
8727                    write_cursor: row.get::<_, i64>(0)? as u64,
8728                    kind: row.get::<_, String>(1)?,
8729                    table: "canonical_edges",
8730                })
8731            })
8732            .map_err(|_| EngineError::Storage)?;
8733        for row in edge_rows {
8734            events.push(row.map_err(|_| EngineError::Storage)?);
8735        }
8736
8737        events.sort_by_key(|e| e.write_cursor);
8738        Ok(TraceReport { source_ref: source_id.to_string(), events })
8739    }
8740
8741    /// OPP-12 Phase-1 (0.8.19 Slice 10) — resolve a lifecycle-verb id argument to
8742    /// the BARE `logical_id` it addresses, enforcing `Logical`(`l:`)-only
8743    /// addressability (design §3). An untagged string is taken as a bare
8744    /// `logical_id` (the `l:` form); an explicit `l:`-prefixed string is stripped
8745    /// to its value; a `Content`(`h:`) or `Passage`(`p:`) id is a typed
8746    /// [`EngineError::NotLifecycleAddressable`] refusal (never a panic / no-op).
8747    fn resolve_lifecycle_target(id: &str) -> Result<String, EngineError> {
8748        match IdSpace::parse(id) {
8749            Some(parsed) => match parsed.space {
8750                IdSpaceKind::Logical => Ok(parsed.value),
8751                other => Err(EngineError::NotLifecycleAddressable { id_space: other }),
8752            },
8753            // Untagged — no id-space prefix; treat as a bare logical_id (l: space).
8754            None => Ok(id.to_string()),
8755        }
8756    }
8757
8758    /// OPP-12 Phase-1 (0.8.19 Slice 10, R-TR-1/2) — move a governed node between
8759    /// existence states per the engine-enforced legal-transition table (design
8760    /// §2): promote `pending→active`, reject `pending→deleted`, soft-delete
8761    /// `active→deleted`, undelete `deleted→active`. `to_state` is a full
8762    /// [`LifecycleState`], but `Pending` (create-time only) and `Purged`
8763    /// (`purge`-only) are never legal `transition` targets, nor are self-loops or
8764    /// any move from a non-existent/`purged` row — each returns a typed
8765    /// [`EngineError::IllegalTransition`] enumerating the legal targets.
8766    ///
8767    /// `reason` semantics (design §3 gap-6): promote/undelete CLEAR `reason` to
8768    /// `NULL` (the row is admitted; no standing cause); reject/soft-delete SET
8769    /// `reason` to the supplied value (`NULL` allowed but the delete-family
8770    /// expects it). `reason` is advisory — the engine never interprets it.
8771    ///
8772    /// Keys on the BARE `logical_id` (`l:` space only); a `Content`(`h:`) or
8773    /// `Passage`(`p:`) id raises [`EngineError::NotLifecycleAddressable`].
8774    /// The state flip mutates the single active (`superseded_at IS NULL`) row; a
8775    /// `deleted` row STAYS node-FTS / vector indexed (gap-5) — only the
8776    /// `state='active'` default filter excludes those shadows, so an undelete
8777    /// needs no re-projection there.
8778    ///
8779    /// 0.8.20 Slice 15d fix-2 [P2] — the row-owned ATTRIBUTE projection
8780    /// (`canonical_attributes` / `property_search_index`) is the exception: it has
8781    /// NO read-side lifecycle filter (the property-FTS5 table cannot carry one), so
8782    /// it is maintained AT REST to track the backfill's set
8783    /// (projected ⟺ active ∧ non-superseded). Promote/undelete PROJECT the declared
8784    /// attributes; soft-delete PURGES them; reject is a no-op.
8785    pub fn transition(
8786        &self,
8787        logical_id: &str,
8788        to_state: LifecycleState,
8789        reason: Option<String>,
8790    ) -> Result<(), EngineError> {
8791        self.ensure_open()?;
8792        let lid = Self::resolve_lifecycle_target(logical_id)?;
8793
8794        // Settle in-flight projection work first: the async projection worker
8795        // commits vector/FTS shadows on its OWN connection, so a state flip issued
8796        // while a worker holds the write lock would SQLITE_BUSY. Draining
8797        // (unfrozen so any unprojected row completes) leaves the worker idle; a
8798        // bare state flip enqueues no new projection work.
8799        //
8800        // Slice 40 B3 aligns the worker with `commit_batch`:
8801        // `commit_projection_outcomes` acquires `BEGIN IMMEDIATE` before its reads.
8802        // This drain remains load-bearing because the worker owns a separate
8803        // connection while this state flip still reads before its own write; it
8804        // keeps that deferred transaction out of the worker's write window.
8805        self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8806
8807        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8808        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8809        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8810
8811        // The lifecycle state lives on the single active (superseded_at IS NULL)
8812        // version; a `deleted` row is still that active version, just flagged.
8813        // fix-2 [P2] — also read its `write_cursor` + `body` so the row-owned
8814        // attribute projection can be maintained after the state flip.
8815        let current: Option<(String, i64, String)> = tx
8816            .query_row(
8817                "SELECT state, write_cursor, body FROM canonical_nodes \
8818                 WHERE logical_id = ?1 AND superseded_at IS NULL",
8819                params![lid],
8820                |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
8821            )
8822            .optional()
8823            .map_err(|_| EngineError::Storage)?;
8824
8825        // A missing active row is an absent/purged node — the terminal `Purged`
8826        // state for legality purposes (nothing is a legal target from there).
8827        let from_state = match &current {
8828            Some((s, _, _)) => LifecycleState::from_str_opt(s).ok_or(EngineError::Storage)?,
8829            None => LifecycleState::Purged,
8830        };
8831
8832        if !is_legal_transition_move(from_state, to_state) {
8833            return Err(EngineError::IllegalTransition {
8834                from_state,
8835                to_state,
8836                legal: from_state.legal_next_states(),
8837            });
8838        }
8839
8840        if matches!(to_state, LifecycleState::Active) {
8841            if let Some((_, _, body)) = &current {
8842                validate_nested_projection_sources_for_body(&tx, body)?;
8843            }
8844        }
8845
8846        // Admit (promote/undelete) → clear reason; exclude (reject/soft-delete) →
8847        // set the supplied reason. `to_state` is Active or Deleted here.
8848        let new_reason: Option<String> = match to_state {
8849            LifecycleState::Active => None,
8850            _ => reason,
8851        };
8852        tx.execute(
8853            "UPDATE canonical_nodes SET state = ?1, reason = ?2 \
8854             WHERE logical_id = ?3 AND superseded_at IS NULL",
8855            params![to_state.as_str(), new_reason, lid],
8856        )
8857        .map_err(|_| EngineError::Storage)?;
8858
8859        // fix-2 [P2] — maintain the row-owned attribute projection so it keeps
8860        // tracking the backfill's set (projected ⟺ active ∧ non-superseded). The
8861        // transitioned row is the single non-superseded version, so the invariant
8862        // reduces to `projected ⟺ to_state == Active`. We PURGE unconditionally
8863        // (idempotent — a no-op on the never-projected pending / already-purged
8864        // deleted arms) then RE-PROJECT when landing `Active`. This covers every
8865        // legal move: promote (pending→active) projects the withheld attributes;
8866        // soft-delete (active→deleted) purges; undelete (deleted→active)
8867        // re-projects; reject (pending→deleted) is a no-op. The property tables
8868        // (`canonical_attributes` / `property_search_index`) carry NO read-side
8869        // lifecycle filter — unlike node-FTS / vector shadows, which the canonical
8870        // read path already excludes when non-active — so they MUST be maintained
8871        // at rest, the same rationale as fix-1's purge-on-supersede. Node-FTS /
8872        // vector shadows are deliberately left intact (gap-5: a deleted row STAYS
8873        // indexed; only the `state='active'` default read filter hides it).
8874        if let Some((cursor, body)) = current.as_ref().map(|(_, c, b)| (*c, b.as_str())) {
8875            purge_row_projections_for_cursor_in(
8876                &tx,
8877                cursor,
8878                &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
8879            )
8880            .map_err(|_| EngineError::Storage)?;
8881            if matches!(to_state, LifecycleState::Active) {
8882                project_node_attributes(&tx, cursor, body).map_err(|_| EngineError::Storage)?;
8883                refresh_vector_attr_values_for_row(&tx, cursor, body)
8884                    .map_err(|_| EngineError::Storage)?;
8885            }
8886        }
8887        tx.commit().map_err(|_| EngineError::Storage)?;
8888        self.counters.record_admin();
8889        Ok(())
8890    }
8891
8892    /// 0.8.20 Slice 15d (R-20-PR / C-1) — the projection registry as a
8893    /// DECLARATIVE, IDEMPOTENT apply. The engine is the SOLE projection authority
8894    /// (Q3): it diffs the supplied `specs` against the durable registry and
8895    /// backfills the difference in ONE transaction. Cheap projections
8896    /// (`filterable`, `searchable→FTS`) are built same-transaction; `rankable`
8897    /// and the `searchable→vector` sub-target are PERSISTED but deferred (F9 /
8898    /// Slice 20) — declaring them never errors (graceful-absent, Q6a).
8899    ///
8900    /// 0.8.20 Slice 23 (`R-20-SV`) — **SPEC VALIDATION.** A spec that carries an
8901    /// `fts` or `vector` sub-object WITHOUT [`ProjectionRole::Searchable`] is an
8902    /// INVALID SPEC and is refused with [`EngineError::WriteValidation`] (HITL
8903    /// 2026-07-24; see [`apply_projection_config`] for the full rationale). A
8904    /// rejected request is a TOTAL no-op. `read_projections` is unaffected — it
8905    /// is a pure read — so a LEGACY row in that shape still reports verbatim but
8906    /// can no longer be re-applied.
8907    ///
8908    /// `drop` is EXPLICIT (C3, `api-surface.md:27`): omission of a live
8909    /// projection from `specs` does NOT drop it; removal requires naming it in
8910    /// `drop`. An incompatible/destructive change to a live projection that is
8911    /// NOT in `drop` is refused with [`EngineError::ProjectionDestructive`], the
8912    /// destructive delta surfaced — never silent data loss. Re-applying an
8913    /// unchanged spec diffs to a no-op ([`ProjectionDelta::unchanged`]).
8914    ///
8915    /// Pair with [`Engine::read_projections`] to see current state before
8916    /// applying.
8917    pub fn configure_projections(
8918        &self,
8919        specs: &[ProjectionSpec],
8920        drop: &[String],
8921    ) -> Result<ProjectionDelta, EngineError> {
8922        self.ensure_open()?;
8923        // Settle in-flight async projection work first. The worker commits on its
8924        // own connection with `BEGIN IMMEDIATE`; a backfill issued in that write
8925        // window would SQLITE_BUSY.
8926        self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
8927
8928        // The backfill is gated on a usable dense runtime. Without one, the
8929        // declaration persists and defers rather than queueing unsafe work. A
8930        // later approved open grafts it; idempotent apply remains a repair door.
8931        let dense_arm_live = self.usable_dense_runtime();
8932        let (delta, enqueued_backfill) = {
8933            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
8934            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
8935            let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
8936            let applied = apply_projection_config(&tx, specs, drop, dense_arm_live)?;
8937            tx.commit().map_err(|_| EngineError::Storage)?;
8938            applied
8939        };
8940        // 0.8.20 Slice 20c (R-20-DR remainder) — the C4 rider's second half. The
8941        // enrolment + terminal-clear committed above; now WAKE the dispatcher, or
8942        // it sleeps on `pending_scan == false` and the very next `drain` burns its
8943        // whole timeout waiting for work nobody scheduled. `drain` itself stays
8944        // PASSIVE (a barrier, never a trigger) — the notify belongs here, on the
8945        // enqueue side. Deliberately after the connection guard is dropped: the
8946        // dispatcher immediately opens its own connection to scan.
8947        if enqueued_backfill {
8948            self.projection_runtime.notify_new_work();
8949        }
8950        self.counters.record_admin();
8951        Ok(delta)
8952    }
8953
8954    /// 0.8.20 Slice 15d (R-20-PR) — read the current projection registry (C5
8955    /// introspection: `read.projections`). Returns every declared
8956    /// [`ProjectionSpec`] sorted by name, so a caller can inspect current state
8957    /// (and the destructive delta a change would cause) BEFORE applying. Pure
8958    /// read; never mutates.
8959    ///
8960    /// 0.8.20 Slice 20 (R-20-DR) — this is ALSO the surface that populates the
8961    /// engine-set [`ProjectionVector::dense_readiness`] READ METADATA. It is
8962    /// derived here, on the way out (see [`derive_dense_readiness`]); the durable
8963    /// registry stores no readiness. Only a spec that declares the
8964    /// `searchable→vector` sub-object carries one — `filterable` and
8965    /// `searchable→FTS` are same-transaction and have no readiness axis.
8966    pub fn read_projections(&self) -> Result<Vec<ProjectionSpec>, 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 registry = load_projection_registry(connection).map_err(|_| EngineError::Storage)?;
8971        // Derived ONCE per call, so every vector projection in one read reports a
8972        // consistent readiness (they are all served by the one vector pipeline).
8973        // Skipped entirely when no vector projection is declared, keeping the
8974        // no-vector default path free of the extra probe.
8975        let mut readiness: Option<DenseReadiness> = None;
8976        let mut specs: Vec<ProjectionSpec> =
8977            registry.iter().map(|(name, stored)| stored.to_spec(name)).collect();
8978        for spec in &mut specs {
8979            if let Some(vector) = spec.vector.as_mut() {
8980                let value = match readiness {
8981                    Some(value) => value,
8982                    None => {
8983                        let value =
8984                            derive_dense_readiness(connection, self.usable_dense_runtime())?;
8985                        readiness = Some(value);
8986                        value
8987                    }
8988                };
8989                vector.dense_readiness = Some(value);
8990            }
8991        }
8992        Ok(specs)
8993    }
8994
8995    /// Read the current projection-runtime status without changing the engine.
8996    ///
8997    /// Unlike [`read_projections`][Self::read_projections], this returns a
8998    /// purpose-built status facade rather than a decorated caller declaration.
8999    /// It reads the durable registry plus current session facts only: it does
9000    /// not configure projections, enroll kinds, enqueue work, call `drain`, or
9001    /// notify the projection scheduler. It uses the ordinarily opened engine
9002    /// connection and may take its lock; it is not a `ReaderWorkerPool` request
9003    /// and does not open a separately read-only SQLite connection. A legacy
9004    /// stored vector sub-object that is not `searchable` is `NotDeclared`,
9005    /// because the effective-arm predicate is the engine's
9006    /// `StoredProjection::wants_vector` predicate.
9007    pub fn read_projection_status(&self) -> Result<ProjectionRuntimeStatus, EngineError> {
9008        self.ensure_open()?;
9009        let runtime_embedder_available = self.usable_dense_runtime();
9010        let runtime_unavailability_reason = if runtime_embedder_available {
9011            ProjectionRuntimeUnavailabilityReason::None
9012        } else if self.dense_disabled.load(Ordering::Acquire) {
9013            ProjectionRuntimeUnavailabilityReason::VectorEquivalenceDisabled
9014        } else {
9015            ProjectionRuntimeUnavailabilityReason::NoRuntime
9016        };
9017
9018        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9019        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9020        let registry = load_projection_registry(connection).map_err(|_| EngineError::Storage)?;
9021        let has_effective_vector_arm = registry.values().any(StoredProjection::wants_vector);
9022        let effective_dense_readiness = if has_effective_vector_arm {
9023            match derive_dense_readiness(connection, runtime_embedder_available)? {
9024                DenseReadiness::Unavailable => ProjectionStatusDenseReadiness::Unavailable,
9025                DenseReadiness::Embedding => ProjectionStatusDenseReadiness::Embedding,
9026                DenseReadiness::Ready => ProjectionStatusDenseReadiness::Ready,
9027            }
9028        } else {
9029            ProjectionStatusDenseReadiness::NotDeclared
9030        };
9031        let projections = registry
9032            .into_iter()
9033            .map(|(name, stored)| {
9034                let dense_readiness = if stored.wants_vector() {
9035                    effective_dense_readiness
9036                } else {
9037                    ProjectionStatusDenseReadiness::NotDeclared
9038                };
9039                ProjectionRuntimeStatusEntry { name, dense_readiness }
9040            })
9041            .collect();
9042        let vector_unsupported_kinds = if has_effective_vector_arm {
9043            unsupported_vector_kinds(connection).map_err(|_| EngineError::Storage)?
9044        } else {
9045            Vec::new()
9046        };
9047
9048        Ok(ProjectionRuntimeStatus {
9049            runtime_embedder_available,
9050            runtime_unavailability_reason,
9051            projections,
9052            vector_unsupported_kinds,
9053        })
9054    }
9055
9056    /// OPP-12 Phase-1 (0.8.19 Slice 10, R-PG-1/2) — irreversibly hard-erase a
9057    /// governed node. A SEPARATE verb from [`Engine::transition`] (NOT on the
9058    /// `recovery_denylist`). Precondition: DELETED-FIRST — legal only from
9059    /// `deleted` (else a typed [`EngineError::IllegalTransition`] to `purged`);
9060    /// IDEMPOTENT — purging an already-absent/already-purged id is a no-op
9061    /// success. Keys on the bare `logical_id` (`l:` only); `h:`/`p:` →
9062    /// [`EngineError::NotLifecycleAddressable`].
9063    ///
9064    /// In ONE transaction, physically erases every ROW-OWNED target for the node
9065    /// (design §3 / gap-3): all `canonical_nodes` versions; its `search_index`,
9066    /// `search_index_edges`, `search_index_v2` FTS rows; its `vector_default`
9067    /// (vec0) + `_fathomdb_vector_rows` vectors; its `_fathomdb_projection_terminal`
9068    /// bookkeeping; and — CASCADE-REMOVE, no content-free stubs — every
9069    /// `canonical_edges` row touching it (`from_id`/`to_id`) plus those edges'
9070    /// projection shadows. The global/kind-level registries
9071    /// `_fathomdb_projection_state` and `_fathomdb_vector_kinds` are NOT keyed to
9072    /// a node id and are DELIBERATELY untouched.
9073    ///
9074    /// Erasure completeness relies on the standing `PRAGMA secure_delete=ON`
9075    /// (design §3 gap-4) which zeroes every freed page — so no per-purge `VACUUM`.
9076    /// (Freelist content written on a pre-20 DB before `secure_delete` was on is a
9077    /// documented residual; there is no forced migration-time `VACUUM`.)
9078    pub fn purge(&self, logical_id: &str) -> Result<(), EngineError> {
9079        self.ensure_open()?;
9080        let lid = Self::resolve_lifecycle_target(logical_id)?;
9081
9082        // Drain in-flight projection work before the erase, exactly as
9083        // `excise_source` does: SQLite-WAL would otherwise let a worker that
9084        // already dequeued a job for a purged cursor commit its vec0 /
9085        // `_fathomdb_vector_rows` INSERT after our DELETE releases the writer
9086        // lock, leaving residue that defeats the erasure sweep.
9087        // Settle every pending projection FIRST (unfrozen) so no unprojected row
9088        // is left behind that a subsequent freeze would wedge `drain` on, and so
9089        // the async worker is idle. THEN freeze the scanner (no new work is queued
9090        // while we erase), confirm idle, and erase in one writer transaction.
9091        // Freezing before the first drain would stall projection of any
9092        // just-written row → `database_has_pending_projection_work` never clears →
9093        // `drain` times out into `Scheduler`.
9094        self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
9095        self.projection_runtime.set_frozen(true);
9096        let outcome = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS).and_then(|()| self.purge_inner(&lid));
9097        self.projection_runtime.set_frozen(false);
9098        // 0.8.20 Slice 5b (R-20-E5/E6) — the rows are gone from the tables; now
9099        // finish the erasure AT REST (telemetry sink + `-wal` bytes) before
9100        // reporting success. Runs after the connection guard inside
9101        // `purge_inner` has been dropped: `complete_erasure_at_rest` re-acquires
9102        // it for the checkpoint.
9103        outcome?;
9104        self.complete_erasure_at_rest("purge")
9105    }
9106
9107    /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
9108    /// returned: they are enqueued for redaction inside this transaction (see
9109    /// [`enqueue_pending_redaction`]), because a caller-held vector is lost on the
9110    /// retry path that codex §9 P2 found.
9111    fn purge_inner(&self, lid: &str) -> Result<(), EngineError> {
9112        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9113        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9114        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9115
9116        // Precondition on the active row's state. Absent (never-created or
9117        // already-purged) → idempotent no-op success.
9118        let current: Option<String> = tx
9119            .query_row(
9120                "SELECT state FROM canonical_nodes \
9121                 WHERE logical_id = ?1 AND superseded_at IS NULL",
9122                params![lid],
9123                |r| r.get::<_, String>(0),
9124            )
9125            .optional()
9126            .map_err(|_| EngineError::Storage)?;
9127        let from_state = match current {
9128            None => {
9129                // Idempotent: nothing to erase.
9130                tx.commit().map_err(|_| EngineError::Storage)?;
9131                return Ok(());
9132            }
9133            Some(s) => LifecycleState::from_str_opt(&s).ok_or(EngineError::Storage)?,
9134        };
9135        if from_state != LifecycleState::Deleted {
9136            // Deleted-first precondition. Dropping `tx` rolls back (no-op read).
9137            return Err(EngineError::IllegalTransition {
9138                from_state,
9139                to_state: LifecycleState::Purged,
9140                legal: from_state.legal_next_states(),
9141            });
9142        }
9143
9144        // Collect every version cursor for the node, plus every cursor of an edge
9145        // that touches it (either endpoint), across ALL versions — the projection
9146        // shadow tables are keyed by these per-row `write_cursor`s.
9147        let node_cursors: Vec<i64> = {
9148            let mut stmt = tx
9149                .prepare("SELECT write_cursor FROM canonical_nodes WHERE logical_id = ?1")
9150                .map_err(|_| EngineError::Storage)?;
9151            let rows = stmt
9152                .query_map(params![lid], |row| row.get::<_, i64>(0))
9153                .map_err(|_| EngineError::Storage)?;
9154            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9155        };
9156        let edge_cursors: Vec<i64> = {
9157            let mut stmt = tx
9158                .prepare(
9159                    "SELECT write_cursor FROM canonical_edges \
9160                     WHERE from_id = ?1 OR to_id = ?1",
9161                )
9162                .map_err(|_| EngineError::Storage)?;
9163            let rows = stmt
9164                .query_map(params![lid], |row| row.get::<_, i64>(0))
9165                .map_err(|_| EngineError::Storage)?;
9166            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9167        };
9168
9169        // 0.8.20 Slice 5b (R-20-E6) — the stable ids the telemetry sink may have
9170        // persisted for these rows, collected BEFORE the DELETEs.
9171        let erased_stable_ids = collect_erased_stable_ids(
9172            &tx,
9173            "SELECT logical_id, body FROM canonical_nodes WHERE logical_id = ?1",
9174            "SELECT logical_id, body FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1",
9175            lid,
9176        )?;
9177
9178        // Erase the row-owned projection shadows for every collected cursor.
9179        // 0.8.20 Slice 5a (R-20-E1): registry-driven — the hand-rolled delete
9180        // list is gone, so a newly registered projection table is erased here
9181        // without touching this site. vec0 rowid == the canonical row's
9182        // write_cursor (see `_fathomdb_vector_rows`).
9183        for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
9184            erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?;
9185        }
9186
9187        // Erase the canonical rows: all node versions + all touching edges
9188        // (gap-3 CASCADE-REMOVE — no content-free stubs in Phase-1).
9189        tx.execute("DELETE FROM canonical_nodes WHERE logical_id = ?1", params![lid])
9190            .map_err(|_| EngineError::Storage)?;
9191        tx.execute("DELETE FROM canonical_edges WHERE from_id = ?1 OR to_id = ?1", params![lid])
9192            .map_err(|_| EngineError::Storage)?;
9193
9194        // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
9195        // erasure now owes, atomically with the deletes above. Only when a sink
9196        // is attached: with telemetry never enabled there is no file the ids
9197        // could have leaked into, so there is nothing to owe.
9198        let pending_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9199        let enqueued =
9200            self.telemetry_enabled.load(Ordering::Acquire) && !erased_stable_ids.is_empty();
9201        if enqueued {
9202            enqueue_pending_redaction(&tx, "purge", &erased_stable_ids, pending_cursor)?;
9203        }
9204
9205        tx.commit().map_err(|_| EngineError::Storage)?;
9206        if enqueued {
9207            self.next_cursor.store(pending_cursor, Ordering::SeqCst);
9208        }
9209        self.counters.record_admin();
9210        Ok(())
9211    }
9212
9213    /// 0.8.20 Slice 5d (R-20-E4, design §4 item 9b) — the **governed SDK
9214    /// erasure verb**. Deletes every canonical row attributable to `source_id`,
9215    /// plus its row-owned projections, and finishes the erasure at rest.
9216    ///
9217    /// This is NOT `operator`-gated: erasing content a consumer wrote is an
9218    /// application obligation, not a recovery workflow. Before this slice the
9219    /// only erasure path was [`Engine::excise_source`], which lives behind the
9220    /// operator feature (i.e. the CLI), so an SDK-only consumer holding a
9221    /// deletion obligation over ANONYMOUS content — content with no
9222    /// `logical_id`, therefore not reachable by [`Engine::purge`] — had no way
9223    /// to discharge it at all. That gap is what R-20-E4 closes.
9224    ///
9225    /// **One engine path.** `erase_source` and `excise_source` are the SAME
9226    /// operation: both delegate to [`Engine::erase_source_shared`]. They are
9227    /// not competing implementations, and no behaviour is duplicated.
9228    ///
9229    /// **Validation differs, deliberately.** `erase_source` admits only ids
9230    /// [`SourceId::new`] would admit, so a caller cannot aim the governed verb
9231    /// at the engine's reserved `_`-prefixed namespace (`_engine:*` substrate,
9232    /// or the `_legacy:pre-0.8.20` cohort migration step 21 back-filled — a
9233    /// single call against which would erase every pre-0.8.20 anonymous row).
9234    /// `excise_source` stays permissive precisely BECAUSE it is the recovery
9235    /// seam: R-20-E8 requires an operator to be able to excise `_legacy:`.
9236    ///
9237    /// **Not a recovery verb.** `erase_source` carries no REQ-054
9238    /// recovery-denylist name (`{recover, restore, repair, fix, rebuild}`); it
9239    /// is a lifecycle verb alongside `transition`/`purge`. AC-041 is unaffected.
9240    ///
9241    /// # Errors
9242    ///
9243    /// [`EngineError::WriteValidation`] for an empty, whitespace-only or
9244    /// reserved `source_id`; [`EngineError::ErasureIncomplete`] if the erasure
9245    /// could not be completed at rest (see [`Engine::complete_erasure_at_rest`]).
9246    pub fn erase_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
9247        // Construct-to-validate: reuse the newtype's rule rather than restating
9248        // it, so the erasure boundary and the write boundary cannot drift.
9249        let _validated = SourceId::new(source_id)?;
9250        self.erase_source_shared("erase_source", source_id)
9251    }
9252
9253    /// Phase 9 Pack B / AC-028a/b/c source excise — the **operator/recovery**
9254    /// spelling of [`Engine::erase_source`], sharing one engine path with it.
9255    ///
9256    /// Kept `operator`-gated and kept permissive about reserved ids: this is
9257    /// the seam an operator uses to excise `_legacy:pre-0.8.20` (R-20-E8) or
9258    /// `_engine:*` substrate, which the governed SDK verb refuses.
9259    #[cfg(feature = "operator")]
9260    pub fn excise_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
9261        if source_id.is_empty() {
9262            self.ensure_open()?;
9263            return Err(EngineError::WriteValidation);
9264        }
9265        self.erase_source_shared("excise_source", source_id)
9266    }
9267
9268    /// The single erasure implementation behind [`Engine::erase_source`] and
9269    /// [`Engine::excise_source`]. `verb` names the caller for the telemetry
9270    /// redaction record only; the deletion semantics are identical.
9271    ///
9272    /// Non-perturbation: rows from other sources (and rows with NULL
9273    /// `source_id`) are untouched; the projection cursor is NOT reset
9274    /// and no blanket projection rebuild is issued.
9275    fn erase_source_shared(
9276        &self,
9277        verb: &'static str,
9278        source_id: &str,
9279    ) -> Result<ExciseReport, EngineError> {
9280        self.ensure_open()?;
9281
9282        // Drain MUST succeed before the excise transaction. SQLite-WAL
9283        // would otherwise allow a worker that already dequeued a job
9284        // for an excised cursor to commit its INSERT into vec0 /
9285        // _fathomdb_vector_rows after our DELETE releases the writer
9286        // lock, leaving residue and breaking AC-028b. Surface the
9287        // timeout instead of swallowing it (Pack A pattern).
9288        //
9289        // ORDER IS LOAD-BEARING, exactly as in `purge`: settle every pending
9290        // projection FIRST (UNFROZEN), and only THEN freeze the scanner and
9291        // confirm idle. Freezing first parks the dispatcher, so a row written
9292        // moments ago can never be scanned and enqueued — while `drain` ->
9293        // `wait_for_idle` keeps seeing it via
9294        // `database_has_pending_projection_work`, which reads the DATABASE and
9295        // not the queue. The result is that the ordinary sequence "write a
9296        // vector-indexed row, then erase it" stalls for the whole
9297        // LIFECYCLE_DRAIN_TIMEOUT_MS and fails with `Scheduler`.
9298        // (codex §9 [P2]; `erase_source_drains_before_freezing`.)
9299        self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS)?;
9300        self.projection_runtime.set_frozen(true);
9301        let drain_result = self.drain(LIFECYCLE_DRAIN_TIMEOUT_MS);
9302        let outcome = drain_result.and_then(|()| self.excise_source_inner(verb, source_id));
9303        self.projection_runtime.set_frozen(false);
9304        // 0.8.20 Slice 5b (R-20-E5/E6) — finish the erasure AT REST before
9305        // reporting success: redact the erased stable ids out of the telemetry
9306        // sink, then truncate the `-wal` so the erased bytes are not still
9307        // readable on disk. On persistent checkpoint BUSY this returns
9308        // `ErasureIncomplete` rather than an `ExciseReport`.
9309        let report = outcome?;
9310        self.complete_erasure_at_rest(verb)?;
9311        Ok(report)
9312    }
9313
9314    /// Doctor `verify-embedder` seam (AC-040a). Compares the
9315    /// `_fathomdb_embedder_profiles` row to the operator-supplied
9316    /// `name:revision` identity + dimension; never raises on mismatch.
9317    #[cfg(feature = "operator")]
9318    pub fn verify_embedder(
9319        &self,
9320        supplied_identity: &str,
9321        supplied_dimension: u32,
9322    ) -> Result<VerifyEmbedderReport, EngineError> {
9323        self.ensure_open()?;
9324        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9325        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9326        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
9327        let stored_identity = format!("{}:{}", stored.name, stored.revision);
9328        let identity_match = stored_identity == supplied_identity;
9329        let dimension_match = stored.dimension == supplied_dimension;
9330        let status = match (identity_match, dimension_match) {
9331            (true, true) => VerifyEmbedderStatus::Match,
9332            (false, true) => VerifyEmbedderStatus::IdentityMismatch,
9333            (true, false) => VerifyEmbedderStatus::DimensionMismatch,
9334            (false, false) => VerifyEmbedderStatus::BothMismatch,
9335        };
9336        Ok(VerifyEmbedderReport {
9337            stored_identity,
9338            stored_dimension: stored.dimension,
9339            supplied_identity: supplied_identity.to_string(),
9340            supplied_dimension,
9341            status,
9342        })
9343    }
9344
9345    /// Doctor `dump-schema` seam (AC-040a). Returns the
9346    /// `PRAGMA user_version` sentinel plus the table + index inventory
9347    /// from `sqlite_schema`, excluding `sqlite_*` internal rows.
9348    /// Canonical tables appear first per [`CANONICAL_TABLES`].
9349    #[cfg(feature = "operator")]
9350    pub fn dump_schema(&self) -> Result<DumpSchemaReport, EngineError> {
9351        self.ensure_open()?;
9352        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9353        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9354        let user_version: u32 = connection
9355            .query_row("PRAGMA user_version", [], |row| row.get(0))
9356            .map_err(|_| EngineError::Storage)?;
9357        let tables = read_schema_objects(connection, "table")?;
9358        let indexes = read_schema_objects(connection, "index")?;
9359        Ok(DumpSchemaReport { user_version, tables: order_canonical_first(tables), indexes })
9360    }
9361
9362    /// Doctor `dump-row-counts` seam (AC-040a). Emits canonical-table
9363    /// counts only; projection / FTS / vec0 shadow tables are excluded.
9364    #[cfg(feature = "operator")]
9365    pub fn dump_row_counts(&self) -> Result<DumpRowCountsReport, EngineError> {
9366        self.ensure_open()?;
9367        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9368        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9369        let mut counts = Vec::with_capacity(CANONICAL_TABLES.len());
9370        for name in CANONICAL_TABLES {
9371            let rows: u64 = connection
9372                .query_row(&format!("SELECT COUNT(*) FROM {name}"), [], |row| row.get(0))
9373                .map_err(|_| EngineError::Storage)?;
9374            counts.push(TableRowCount { name: (*name).to_string(), rows });
9375        }
9376        Ok(DumpRowCountsReport { counts })
9377    }
9378
9379    /// 0.8.20 Slice 5d (R-20-E8, design §4 item 11) — doctor
9380    /// `orphan-provenance` seam: a **read-only** per-`source_id` census over
9381    /// `canonical_nodes` + `canonical_edges`.
9382    ///
9383    /// Answers the operator question the erasure work made askable: *"for this
9384    /// database, is every row actually reachable by some erasure verb?"* A row
9385    /// is reachable by `erase_source` / `excise_source` via `source_id`, or —
9386    /// **if it is a NODE** — by `purge` via `logical_id`. A row with neither is
9387    /// un-erasable, and is counted into
9388    /// [`OrphanProvenanceReport::unerasable_rows`].
9389    ///
9390    /// The node/edge asymmetry is load-bearing and mirrors migration step 21:
9391    /// an EDGE's `logical_id` is a supersession identity only and confers no
9392    /// purge-addressability, so a NULL-`source_id` edge is un-erasable however
9393    /// governed it looks. See the query comment below.
9394    ///
9395    /// CLI-only (no SDK parity), matching the `dump-*` diagnostic family.
9396    ///
9397    /// Read-only by construction: this method issues SELECTs exclusively and
9398    /// opens no transaction.
9399    #[cfg(feature = "operator")]
9400    pub fn orphan_provenance(&self) -> Result<OrphanProvenanceReport, EngineError> {
9401        self.ensure_open()?;
9402        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9403        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9404
9405        // One UNION ALL over both canonical tables so a source that spans nodes
9406        // AND edges reports as a single bucket.
9407        //
9408        // TWO DIFFERENT SUMS, and the difference is the whole point:
9409        //
9410        // * `governed` counts `logical_id` carriers — a reporting figure;
9411        // * `purge_addressable` counts rows that `purge` can actually reach,
9412        //   and it is NODE-ONLY (the edge arm contributes a literal 0).
9413        //
9414        // This is the same node/edge asymmetry migration step 21 carries, for
9415        // the same reason, and the two must stay in step: `purge_inner`
9416        // resolves its target exclusively through `canonical_nodes` (`SELECT
9417        // state FROM canonical_nodes WHERE logical_id = ?1`) and then erases
9418        // edges by ENDPOINT (`from_id`/`to_id`). It NEVER resolves an edge by
9419        // edge `logical_id` — an edge `logical_id` is only a SUPERSESSION
9420        // identity and confers no purge-addressability whatsoever.
9421        //
9422        // Crediting an edge's `logical_id` here made the diagnostic subtract
9423        // exactly the rows it exists to find: a NULL-`source_id` edge is
9424        // reachable by no erasure verb at all, yet `orphan-provenance` would
9425        // exit CLEAN on precisely the legacy/corrupt shape step 21 closes.
9426        // False assurance from a governance verb is worse than no verb.
9427        // (codex §9 [P2]; `null_source_governed_edge_counts_as_unerasable`.)
9428        let mut stmt = connection
9429            .prepare(
9430                "SELECT source_id,
9431                        COUNT(*) AS rows_total,
9432                        SUM(CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END) AS governed,
9433                        SUM(purge_addressable) AS purge_addressable
9434                   FROM (SELECT source_id,
9435                                logical_id,
9436                                CASE WHEN logical_id IS NOT NULL THEN 1 ELSE 0 END
9437                                    AS purge_addressable
9438                           FROM canonical_nodes
9439                         UNION ALL
9440                         SELECT source_id, logical_id, 0 AS purge_addressable
9441                           FROM canonical_edges)
9442                  GROUP BY source_id
9443                  ORDER BY rows_total DESC, source_id",
9444            )
9445            .map_err(|_| EngineError::Storage)?;
9446
9447        let rows = stmt
9448            .query_map([], |row| {
9449                let source_id: Option<String> = row.get(0)?;
9450                let rows: i64 = row.get(1)?;
9451                let governed: i64 = row.get(2)?;
9452                let purge_addressable: i64 = row.get(3)?;
9453                Ok((source_id, rows, governed, purge_addressable))
9454            })
9455            .map_err(|_| EngineError::Storage)?;
9456
9457        let mut sources = Vec::new();
9458        let mut total_rows: u64 = 0;
9459        let mut unerasable_rows: u64 = 0;
9460        for row in rows {
9461            let (source_id, rows, governed, purge_addressable) =
9462                row.map_err(|_| EngineError::Storage)?;
9463            let rows = u64::try_from(rows).unwrap_or(0);
9464            let governed_rows = u64::try_from(governed).unwrap_or(0);
9465            let purge_addressable = u64::try_from(purge_addressable).unwrap_or(0);
9466            total_rows = total_rows.saturating_add(rows);
9467            if source_id.is_none() {
9468                // No provenance: only the PURGE-ADDRESSABLE subset (governed
9469                // NODES) is reachable. The remainder — including every governed
9470                // EDGE, whose `logical_id` reaches nothing — is reachable by no
9471                // erasure verb at all.
9472                unerasable_rows =
9473                    unerasable_rows.saturating_add(rows - purge_addressable.min(rows));
9474            }
9475            let reserved = source_id.as_deref().is_some_and(|s| s.starts_with('_'));
9476            sources.push(OrphanProvenanceSource { source_id, rows, governed_rows, reserved });
9477        }
9478
9479        Ok(OrphanProvenanceReport { sources, total_rows, unerasable_rows })
9480    }
9481
9482    /// Doctor `dump-profile` seam (AC-040a). Returns the stored
9483    /// embedder identity + dimension plus the registered vectorized
9484    /// kinds from `_fathomdb_vector_kinds`.
9485    #[cfg(feature = "operator")]
9486    pub fn dump_profile(&self) -> Result<DumpProfileReport, EngineError> {
9487        self.ensure_open()?;
9488        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9489        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9490        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
9491        let mut stmt = connection
9492            .prepare("SELECT kind FROM _fathomdb_vector_kinds ORDER BY kind")
9493            .map_err(|_| EngineError::Storage)?;
9494        let rows =
9495            stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
9496        let mut vectorized_kinds = Vec::new();
9497        for row in rows {
9498            vectorized_kinds.push(row.map_err(|_| EngineError::Storage)?);
9499        }
9500        Ok(DumpProfileReport {
9501            embedder_identity: format!("{}:{}", stored.name, stored.revision),
9502            embedder_dimension: stored.dimension,
9503            vectorized_kinds,
9504        })
9505    }
9506
9507    /// Recover `--truncate-wal` seam. Runs
9508    /// `PRAGMA wal_checkpoint(TRUNCATE)` and returns the three counters
9509    /// SQLite reports. `status = Busy` when SQLite signalled a blocked
9510    /// checkpoint (`busy != 0`); the WAL may still be partially
9511    /// checkpointed in that case.
9512    #[cfg(feature = "operator")]
9513    pub fn truncate_wal(&self) -> Result<TruncateWalReport, EngineError> {
9514        self.ensure_open()?;
9515        // The operator verb keeps SQLite's own busy handler: `recover
9516        // --truncate-wal` is an explicit, foreground operator act, so waiting out
9517        // a transient reader is the helpful behaviour.
9518        self.wal_checkpoint_truncate_once(true)
9519    }
9520
9521    /// One `PRAGMA wal_checkpoint(TRUNCATE)` on the writer connection.
9522    ///
9523    /// NOT operator-gated: the erasure verbs (`purge` is a default-feature verb)
9524    /// need it too, and a `#[cfg(feature = "operator")]` helper would break the
9525    /// default build. Acquires the connection mutex, so callers must NOT already
9526    /// hold it — every erasure verb calls this AFTER its transaction has
9527    /// committed and the guard has been dropped.
9528    ///
9529    /// `honor_busy_timeout = false` suppresses SQLite's busy handler for the
9530    /// duration of the checkpoint. rusqlite installs a **5 s** default
9531    /// `busy_timeout`, so a blocked checkpoint sits for 5 s before reporting
9532    /// `busy` — under the erasure verbs' bounded retry that compounds to a ~25 s
9533    /// stall on a verb that is supposed to fail fast. The erasure path therefore
9534    /// takes the immediate `busy` answer and runs its OWN short backoff; the
9535    /// prior value is restored before returning, on every path.
9536    fn wal_checkpoint_truncate_once(
9537        &self,
9538        honor_busy_timeout: bool,
9539    ) -> Result<TruncateWalReport, EngineError> {
9540        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9541        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9542
9543        let restore_timeout_ms: Option<i64> = if honor_busy_timeout {
9544            None
9545        } else {
9546            let previous: i64 = connection
9547                .query_row("PRAGMA busy_timeout", [], |row| row.get(0))
9548                .map_err(|_| EngineError::Storage)?;
9549            connection.busy_timeout(Duration::ZERO).map_err(|_| EngineError::Storage)?;
9550            Some(previous)
9551        };
9552
9553        let checkpoint: rusqlite::Result<(i64, i64, i64)> =
9554            connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
9555                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
9556            });
9557
9558        if let Some(previous) = restore_timeout_ms {
9559            let previous = u64::try_from(previous.max(0)).unwrap_or(0);
9560            connection
9561                .busy_timeout(Duration::from_millis(previous))
9562                .map_err(|_| EngineError::Storage)?;
9563        }
9564
9565        let (busy, log_frames, checkpointed_frames) =
9566            checkpoint.map_err(|_| EngineError::Storage)?;
9567        let status = if busy == 0 { TruncateWalStatus::Done } else { TruncateWalStatus::Busy };
9568        Ok(TruncateWalReport {
9569            status,
9570            busy: busy.max(0) as u32,
9571            log_frames: log_frames.max(0) as u32,
9572            checkpointed_frames: checkpointed_frames.max(0) as u32,
9573        })
9574    }
9575
9576    /// 0.8.20 Slice 5b (R-20-E5) — complete an erasure **at rest** after the
9577    /// erasing transaction has committed. Two obligations, in order:
9578    ///
9579    /// 1. **Telemetry redaction** — drop the erased stable ids out of the opt-in
9580    ///    telemetry sink. Driven from the DURABLE pending queue
9581    ///    ([`Engine::discharge_pending_redactions`]), NOT from the ids the caller
9582    ///    happens to be holding, so a retry after a failed redaction still knows
9583    ///    what it owes.
9584    /// 2. **WAL truncation** — `wal_checkpoint(TRUNCATE)` with a BOUNDED retry.
9585    ///    `PRAGMA secure_delete=ON` zeroes pages freed inside the database file,
9586    ///    but the erased content also lives in the write-ahead log as committed
9587    ///    frames from the ORIGINAL insert; the erasure DELETE appends new frames
9588    ///    rather than rewriting old ones, so without a truncating checkpoint the
9589    ///    erased body stays `grep`-able in `<db>-wal`.
9590    ///
9591    /// A concurrent reader pinning a WAL snapshot makes the checkpoint report
9592    /// `busy`. After [`ERASURE_WAL_TRUNCATE_ATTEMPTS`] tries the verb raises
9593    /// [`EngineError::ErasureIncomplete`] — **an erasure verb must never report
9594    /// success on an incomplete erasure.** The retry budget is deliberately small
9595    /// (~100 ms total): the caller retries the verb, the verb does not block.
9596    fn complete_erasure_at_rest(&self, verb: &'static str) -> Result<(), EngineError> {
9597        // The ids are NOT passed in: they were persisted inside the erasing
9598        // transaction, and this drains that queue. The WAL truncation below then
9599        // runs AFTER the pending rows have been deleted, so the freed pages
9600        // holding them (zeroed by `secure_delete=ON`) are checkpointed out too.
9601        self.discharge_pending_redactions(verb)?;
9602
9603        let mut last: Option<TruncateWalReport> = None;
9604        for attempt in 0..ERASURE_WAL_TRUNCATE_ATTEMPTS {
9605            let report = self.wal_checkpoint_truncate_once(false)?;
9606            if report.status == TruncateWalStatus::Done {
9607                return Ok(());
9608            }
9609            last = Some(report);
9610            if attempt + 1 < ERASURE_WAL_TRUNCATE_ATTEMPTS {
9611                std::thread::sleep(Duration::from_millis(ERASURE_WAL_TRUNCATE_BACKOFF_MS));
9612            }
9613        }
9614        let frames = last.map_or(0, |r| r.log_frames);
9615        Err(EngineError::ErasureIncomplete {
9616            stage: "wal_checkpoint".to_string(),
9617            detail: format!(
9618                "`{verb}` deleted its rows, but `wal_checkpoint(TRUNCATE)` reported BUSY on all \
9619                 {ERASURE_WAL_TRUNCATE_ATTEMPTS} attempts ({frames} frames still in the log) — a \
9620                 concurrent reader is pinning a WAL snapshot, so the erased bytes remain readable \
9621                 in the `-wal` file. Retry once the reader has finished."
9622            ),
9623        })
9624    }
9625
9626    /// 0.8.20 Slice 5 fix-1 (codex §9 P2) — perform every telemetry redaction the
9627    /// engine still OWES, from the durable pending queue.
9628    ///
9629    /// **The defect this closes.** Redaction necessarily runs after the erasing
9630    /// transaction commits (the sink is a file, not a table, so it cannot join
9631    /// the transaction). When it failed, the verb correctly raised
9632    /// `ErasureIncomplete { stage: "telemetry_redaction" }` and told the operator
9633    /// to retry — but the retry recomputed the id set by querying the canonical
9634    /// tables, whose rows the FIRST call had already deleted. It therefore got an
9635    /// EMPTY set, hit the empty-id fast path in
9636    /// [`Engine::redact_telemetry_stable_ids`], and returned success while the
9637    /// leaked `l:`/`h:` ids were still sitting in the sink. An erasure verb
9638    /// reporting success on an incomplete erasure is precisely what R-20-E5
9639    /// forbids, and it is the worst failure mode available to this slice: silent,
9640    /// and indistinguishable from a real erasure.
9641    ///
9642    /// **The mechanism — an intent log.** The ids are captured BEFORE the deletes
9643    /// (they are derived from `logical_id`/`body`, which the deletes destroy) and
9644    /// written into [`ERASURE_PENDING_REDACTION_COLLECTION`] INSIDE the same
9645    /// transaction, so "the rows are gone" and "a redaction is owed for them"
9646    /// commit atomically. There is no window in which the rows are deleted and
9647    /// the obligation is unrecorded. A pending row is deleted only once its
9648    /// redaction has actually been performed, so the obligation survives process
9649    /// death, and the empty-id fast path is unreachable while one is outstanding:
9650    /// this drains the QUEUE, never the caller's id vector.
9651    ///
9652    /// The queue is drained by EVERY erasure verb, not just a retry of the one
9653    /// that failed — an outstanding obligation is the engine's, not one call's.
9654    ///
9655    /// **Honest refusal.** If a redaction is owed but no telemetry sink is
9656    /// attached to this `Engine` (only reachable if the process restarted between
9657    /// the failure and the retry without re-enabling telemetry), the ids really
9658    /// are still in the sink file and this returns `ErasureIncomplete` rather
9659    /// than guessing. Re-enable telemetry on the same sink and retry.
9660    ///
9661    /// **Exposure tradeoff, stated plainly.** A pending row holds the stable ids
9662    /// in the database for the window between the delete and the redaction. That
9663    /// is a strict improvement: those ids are, during exactly that window,
9664    /// already readable in the telemetry sink — which is the leak being closed —
9665    /// and the pending row is deleted the moment the sink is clean, on pages
9666    /// `secure_delete=ON` zeroes and the subsequent `TRUNCATE` checkpoint clears
9667    /// from the log.
9668    fn discharge_pending_redactions(&self, verb: &'static str) -> Result<(), EngineError> {
9669        let pending = self.load_pending_redactions()?;
9670        if pending.is_empty() {
9671            return Ok(());
9672        }
9673
9674        let mut ids: Vec<String> =
9675            pending.iter().flat_map(|(_, ids)| ids.iter().cloned()).collect();
9676        ids.sort_unstable();
9677        ids.dedup();
9678
9679        // A queue entry exists ⇒ a sink was attached when the rows were deleted ⇒
9680        // the ids are in that file. Never clear the queue without redacting.
9681        if !self.telemetry_enabled.load(Ordering::Acquire) {
9682            return Err(EngineError::ErasureIncomplete {
9683                stage: "telemetry_redaction".to_string(),
9684                detail: format!(
9685                    "`{verb}` has {} outstanding telemetry redaction(s) covering {} erased \
9686                     stable id(s), but no telemetry sink is attached to this engine — the ids \
9687                     cannot be removed from the sink file. Re-enable telemetry on the same sink \
9688                     path and retry.",
9689                    pending.len(),
9690                    ids.len()
9691                ),
9692            });
9693        }
9694
9695        // On failure the queue rows stay put and the error propagates: the verb
9696        // does not report success, and the next call retries the same obligation.
9697        self.redact_telemetry_stable_ids(verb, &ids)?;
9698
9699        let row_ids: Vec<i64> = pending.iter().map(|(row_id, _)| *row_id).collect();
9700        self.clear_pending_redactions(&row_ids)
9701    }
9702
9703    /// Read the outstanding redaction queue: `(operational_mutations.id, ids)`.
9704    fn load_pending_redactions(&self) -> Result<Vec<(i64, Vec<String>)>, EngineError> {
9705        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9706        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9707        let mut stmt = connection
9708            .prepare(
9709                "SELECT id, payload_json FROM operational_mutations \
9710                 WHERE collection_name = ?1 ORDER BY id",
9711            )
9712            .map_err(|_| EngineError::Storage)?;
9713        let rows = stmt
9714            .query_map([ERASURE_PENDING_REDACTION_COLLECTION], |row| {
9715                Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
9716            })
9717            .map_err(|_| EngineError::Storage)?;
9718        let mut pending = Vec::new();
9719        for row in rows {
9720            let (row_id, payload) = row.map_err(|_| EngineError::Storage)?;
9721            // A payload we cannot parse is an obligation we cannot discharge;
9722            // keeping it (empty) is safe — it never unblocks a false success,
9723            // and `discharge_pending_redactions` still refuses.
9724            let ids = serde_json::from_str::<serde_json::Value>(&payload)
9725                .ok()
9726                .and_then(|v| v.get("erased_stable_ids").cloned())
9727                .and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
9728                .unwrap_or_default();
9729            pending.push((row_id, ids));
9730        }
9731        Ok(pending)
9732    }
9733
9734    /// Retire queue entries whose redaction has been PERFORMED. Committed before
9735    /// the caller's WAL truncation so the freed pages are checkpointed out.
9736    fn clear_pending_redactions(&self, row_ids: &[i64]) -> Result<(), EngineError> {
9737        if row_ids.is_empty() {
9738            return Ok(());
9739        }
9740        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9741        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
9742        let mut stmt = connection
9743            .prepare("DELETE FROM operational_mutations WHERE id = ?1")
9744            .map_err(|_| EngineError::Storage)?;
9745        for row_id in row_ids {
9746            stmt.execute([row_id]).map_err(|_| EngineError::Storage)?;
9747        }
9748        Ok(())
9749    }
9750
9751    /// 0.8.20 Slice 5b (R-20-E6) — SELECTIVE redaction of erased stable ids from
9752    /// the opt-in telemetry sink.
9753    ///
9754    /// `capture_telemetry` persists `result_stable_ids` — `l:`/`h:` prefixed ids
9755    /// — into a JSONL file that outlives the erased rows, and nothing in the
9756    /// engine could previously remove them. A retained `l:` id is not inert:
9757    /// [`derive_logical_id`] is `SHA256(lowercase(kind) + ":" + lowercase(name))`,
9758    /// and the case-folding of BOTH inputs shrinks the preimage space, so a
9759    /// surviving id is dictionary-attackable back to the natural key it was
9760    /// derived from. An `h:` id is a plain `SHA256(body)`, confirmable against a
9761    /// guessed body.
9762    ///
9763    /// **This MUST NOT truncate the sink.** `sink_path` is CALLER-SUPPLIED and
9764    /// may hold unrelated operator eval history that the erasure obligation never
9765    /// covered; the v3 truncation approach was rejected as unsafe. Only the
9766    /// matching `result_stable_ids` ELEMENTS are replaced with
9767    /// [`REDACTED_STABLE_ID`], preserving record count, record order and
9768    /// positional alignment with the parallel `result_ids` array. Lines that are
9769    /// not engine-authored JSON events are copied through verbatim.
9770    ///
9771    /// **Crash safety.** The rewrite is write-temp-then-`rename`: a sibling
9772    /// `.redact.tmp` is written and fsynced, then atomically renamed over the
9773    /// sink, so a crash leaves either the old file or the new one — never a
9774    /// half-rewritten sink. The telemetry mutex is held across the whole rewrite,
9775    /// so no in-process `capture_telemetry` can append into the window; an
9776    /// out-of-process appender is handled by re-reading and folding in the tail
9777    /// delta before the rename (bounded retry).
9778    ///
9779    /// The privacy contract is unchanged: query TEXT and `source_id` are never
9780    /// captured (ADR-0.8.8 §C), so there is nothing else in the sink to redact.
9781    /// The fast-OFF atomic guard is preserved — when telemetry was never enabled
9782    /// this is a single relaxed-ordering load and no mutex acquisition.
9783    fn redact_telemetry_stable_ids(
9784        &self,
9785        verb: &'static str,
9786        erased_stable_ids: &[String],
9787    ) -> Result<(), EngineError> {
9788        // Fast OFF path — mirrors `capture_telemetry`. No mutex, no I/O.
9789        if erased_stable_ids.is_empty() || !self.telemetry_enabled.load(Ordering::Acquire) {
9790            return Ok(());
9791        }
9792        let guard = self.telemetry.lock().map_err(|_| EngineError::Storage)?;
9793        let Some(sink) = guard.as_ref() else { return Ok(()) };
9794        let erased: std::collections::HashSet<&str> =
9795            erased_stable_ids.iter().map(String::as_str).collect();
9796
9797        match redact_jsonl_stable_ids(&sink.path, &erased) {
9798            Ok(()) => Ok(()),
9799            // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P2) — `NotFound` is NOT a
9800            // discharge. It previously returned `Ok(())` ("the sink is gone,
9801            // nothing to redact"), which cleared the durable pending queue and
9802            // let the verb report success. That inference does not hold: a path
9803            // cannot distinguish `rm` from `mv`, and log rotation of a
9804            // caller-supplied sink is an ordinary operational event that leaves
9805            // the erased `l:`/`h:` ids fully readable under the rotated name.
9806            //
9807            // The burden of proof is on DISCHARGING the obligation, and the
9808            // engine cannot meet it here: `TelemetrySink` holds a PATH, not an
9809            // open handle, so there is no `nlink == 0` witness that the inode was
9810            // actually unlinked — and even that would not cover a copy taken
9811            // before the deletion. So there is no narrow provable case to carve
9812            // out, and `NotFound` fails closed.
9813            //
9814            // This cannot fire spuriously for a sink that never existed:
9815            // `enable_telemetry` CREATES the file before arming capture, so for
9816            // any engine with telemetry enabled the sink demonstrably existed and
9817            // `NotFound` means it existed and then vanished.
9818            Err(err) => Err(EngineError::ErasureIncomplete {
9819                stage: "telemetry_redaction".to_string(),
9820                detail: if err.kind() == std::io::ErrorKind::NotFound {
9821                    format!(
9822                        "`{verb}` deleted its rows, but the telemetry sink {} no longer exists, \
9823                         so the erased stable ids could not be redacted from it. A missing path \
9824                         does NOT prove the sink was deleted — if it was rotated or moved aside, \
9825                         the erased ids are still readable under its new name. The pending \
9826                         redaction is durable: restore the sink at this path and retry (if the \
9827                         sink really was destroyed, an empty file at this path discharges the \
9828                         obligation).",
9829                        sink.path.display()
9830                    )
9831                } else {
9832                    format!(
9833                        "`{verb}` deleted its rows, but the erased stable ids could not be \
9834                         redacted from the telemetry sink {}: {err}",
9835                        sink.path.display()
9836                    )
9837                },
9838            }),
9839        }
9840    }
9841
9842    /// The erased rows' prefixed stable ids ([`IdSpace::to_prefixed`]) are NOT
9843    /// returned to the caller for redaction (R-20-E6). They are enqueued INSIDE
9844    /// this transaction via [`enqueue_pending_redaction`]: a caller-held vector
9845    /// is lost on the retry path, which is exactly the false-success codex §9 P2
9846    /// found. Only the report comes back.
9847    ///
9848    /// 0.8.20 Slice 5d (R-20-E4): no longer `operator`-gated — it is the shared
9849    /// body behind BOTH `erase_source` (governed SDK) and `excise_source`
9850    /// (operator seam). Still private; the gate that matters is on the two
9851    /// public spellings.
9852    fn excise_source_inner(
9853        &self,
9854        verb: &'static str,
9855        source_id: &str,
9856    ) -> Result<ExciseReport, EngineError> {
9857        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
9858        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
9859        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
9860
9861        // Collect the cursor sets up-front so we can targeted-delete
9862        // shadow rows AND emit an accurate audit row in one txn.
9863        let node_cursors: Vec<i64> = {
9864            let mut stmt = tx
9865                .prepare("SELECT write_cursor FROM canonical_nodes WHERE source_id = ?1")
9866                .map_err(|_| EngineError::Storage)?;
9867            let rows = stmt
9868                .query_map([source_id], |row| row.get::<_, i64>(0))
9869                .map_err(|_| EngineError::Storage)?;
9870            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9871        };
9872        let edge_cursors: Vec<i64> = {
9873            let mut stmt = tx
9874                .prepare("SELECT write_cursor FROM canonical_edges WHERE source_id = ?1")
9875                .map_err(|_| EngineError::Storage)?;
9876            let rows = stmt
9877                .query_map([source_id], |row| row.get::<_, i64>(0))
9878                .map_err(|_| EngineError::Storage)?;
9879            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
9880        };
9881
9882        // 0.8.20 Slice 5b (R-20-E6) — stable ids the telemetry sink may hold for
9883        // these rows, collected BEFORE the DELETEs.
9884        let erased_stable_ids = collect_erased_stable_ids(
9885            &tx,
9886            "SELECT logical_id, body FROM canonical_nodes WHERE source_id = ?1",
9887            "SELECT logical_id, body FROM canonical_edges WHERE source_id = ?1",
9888            source_id,
9889        )?;
9890
9891        // 0.8.20 Slice 5a (R-20-E1) — registry-driven erasure. The previous
9892        // hand-rolled list here OMITTED `search_index_v2`, a CONTENT-STORING
9893        // FTS5 table (no `content=''`) that keeps the document body verbatim:
9894        // after `excise_source` the erased body was still on disk, invisible to
9895        // every functional test because both v2 read paths discard candidates
9896        // lacking a live `canonical_nodes` row. `erase_row_projections` covers
9897        // every registered projection, so the omission cannot recur.
9898        let mut shadow_invalidated: u64 = 0;
9899        for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
9900            shadow_invalidated = shadow_invalidated.saturating_add(
9901                erase_row_projections(&tx, *cursor).map_err(|_| EngineError::Storage)?,
9902            );
9903        }
9904
9905        let nodes_excised = tx
9906            .execute("DELETE FROM canonical_nodes WHERE source_id = ?1", [source_id])
9907            .map_err(|_| EngineError::Storage)? as u64;
9908        let edges_excised = tx
9909            .execute("DELETE FROM canonical_edges WHERE source_id = ?1", [source_id])
9910            .map_err(|_| EngineError::Storage)? as u64;
9911
9912        // AC-028a audit row: a single append on the
9913        // `excise_source_audit` collection naming the excised source.
9914        //
9915        // DURABILITY (0.8.20 Slice 5b, design v5 §2 defect D-A; HITL-ruled
9916        // 2026-07-19: *"there must be an auditable record of deletion event."*).
9917        // This row lands in `operational_mutations`, the same table the retention
9918        // sweep drains — and it is written BEFORE the workload that follows it,
9919        // so an oldest-`id`-first sweep evicted it FIRST. It is now protected:
9920        // `excise_source_audit` is in `ERASURE_AUDIT_COLLECTIONS`, which
9921        // `enforce_provenance_retention` excludes. The proof of erasure is no
9922        // longer destructible by ordinary retention pressure.
9923        //
9924        // NON-PII `source_id` (rationale corrected in this slice). v4 §3.6
9925        // justified the "`source_id` must not be PII" rule by claiming the audit
9926        // row retains it *permanently, by design*. That premise was FALSE — the
9927        // row was sweepable. The rule stands on a different and simpler footing:
9928        // this row persists the caller's raw `source_id` verbatim, and an
9929        // `excise_source` that erased the payload while keeping an identifying
9930        // source label would not be an erasure. The exemption above makes the
9931        // retention now genuinely indefinite, which makes the rule MORE
9932        // load-bearing, not less.
9933        //
9934        // `next_cursor` after a prior write holds the LAST committed cursor;
9935        // mirror the vec writer pattern (load + 1, then store post-commit)
9936        // so the audit row's `write_cursor` is strictly greater than every
9937        // canonical row that preceded it.
9938        let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
9939        let payload = serde_json::json!({
9940            "source_id": source_id,
9941            "excised_at": excised_at,
9942            "nodes_excised": nodes_excised,
9943            "edges_excised": edges_excised,
9944            "projections_invalidated": shadow_invalidated,
9945        })
9946        .to_string();
9947        let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
9948        tx.execute(
9949            "INSERT INTO operational_mutations(
9950                collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
9951             ) VALUES('excise_source_audit', ?1, 'append', ?2, NULL, ?3)",
9952            params![source_id, payload, audit_cursor],
9953        )
9954        .map_err(|_| EngineError::Storage)?;
9955
9956        // 0.8.20 Slice 5 fix-1 (codex §9 P2) — durably record the redaction this
9957        // erasure owes, atomically with the deletes. Shares `audit_cursor`: one
9958        // erasure event, and this row is retired as soon as the sink is clean.
9959        if self.telemetry_enabled.load(Ordering::Acquire) {
9960            enqueue_pending_redaction(&tx, verb, &erased_stable_ids, audit_cursor)?;
9961        }
9962
9963        tx.commit().map_err(|_| EngineError::Storage)?;
9964        self.next_cursor.store(audit_cursor, Ordering::SeqCst);
9965        Ok(ExciseReport {
9966            source_ref: source_id.to_string(),
9967            nodes_excised,
9968            edges_excised,
9969            projections_invalidated: shadow_invalidated,
9970        })
9971    }
9972
9973    /// 0.8.20 Slice 5b (R-20-E7) — erase ONE op-store record, by collection and
9974    /// record key, from both op-store shapes: every `operational_mutations`
9975    /// version of the key (append-only-log collections) and its
9976    /// `operational_state` row (latest-state collections).
9977    ///
9978    /// **Refuses the engine's own erasure bookkeeping** (0.8.20 Slice 5 fix-3,
9979    /// codex §9 round-3 P1): a `collection` for which
9980    /// [`is_erasure_bookkeeping_collection`] holds raises
9981    /// [`EngineError::InvalidArgument`] before anything is deleted. Aimed at the
9982    /// pending-redaction queue this verb otherwise destroys an outstanding
9983    /// erasure obligation, after which the next erasure verb reports success with
9984    /// the erased ids still in the telemetry sink; aimed at the audit trail it
9985    /// destroys the auditable record of the deletion event.
9986    ///
9987    /// Before this slice the op-store had NO record-level delete at all:
9988    /// [`enforce_provenance_retention`] is a cap sweep, not an erasure verb, so a
9989    /// caller holding an erasure obligation over an op-store record had no way to
9990    /// discharge it. Idempotent — erasing an absent key is a zero-count success.
9991    ///
9992    /// Like the other erasure verbs this finishes at rest (telemetry is not
9993    /// involved — op-store record keys never reach the telemetry sink — but the
9994    /// `-wal` is), so it can return [`EngineError::ErasureIncomplete`].
9995    ///
9996    /// AUDIT (D-A). Appends a row to the retention-exempt `excise_record_audit`
9997    /// collection. Unlike `source_id`, a `record_key` carries NO non-PII rule:
9998    /// it is arbitrary caller-supplied text and may itself be the identifier
9999    /// being erased. The audit therefore records a SHA-256 digest of
10000    /// `collection` + `record_key`, never the key — enough to prove *that* a
10001    /// specific record was erased to anyone who already knows the key, and
10002    /// useless to anyone who does not.
10003    #[cfg(feature = "operator")]
10004    pub fn excise_collection_record(
10005        &self,
10006        collection: &str,
10007        record_key: &str,
10008    ) -> Result<ExciseRecordReport, EngineError> {
10009        self.ensure_open()?;
10010        if collection.is_empty() || record_key.is_empty() {
10011            return Err(EngineError::WriteValidation);
10012        }
10013        // 0.8.20 Slice 5 fix-3 (codex §9 round-3 P1) — the engine's own erasure
10014        // bookkeeping is not caller data and is not excisable. See
10015        // `is_erasure_bookkeeping_collection` for why each member is protected.
10016        // Checked BEFORE any deletion so the refusal is total, not partial.
10017        if is_erasure_bookkeeping_collection(collection) {
10018            return Err(EngineError::InvalidArgument {
10019                msg: format!(
10020                    "`{collection}` is engine-internal erasure bookkeeping and cannot be excised \
10021                     by `excise_collection_record`. The pending-redaction queue records an \
10022                     erasure the engine still owes (deleting it would let a later verb report \
10023                     success on an incomplete erasure, R-20-E5), and the erasure-audit \
10024                     collections are the auditable record of the deletion event. Pending \
10025                     redactions retire themselves once performed; retry the erasure verb instead."
10026                ),
10027            });
10028        }
10029        let report = self.excise_collection_record_inner(collection, record_key)?;
10030        self.complete_erasure_at_rest("excise_collection_record")?;
10031        Ok(report)
10032    }
10033
10034    #[cfg(feature = "operator")]
10035    fn excise_collection_record_inner(
10036        &self,
10037        collection: &str,
10038        record_key: &str,
10039    ) -> Result<ExciseRecordReport, EngineError> {
10040        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
10041        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
10042        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
10043
10044        let records_excised = tx
10045            .execute(
10046                "DELETE FROM operational_mutations
10047                 WHERE collection_name = ?1 AND record_key = ?2",
10048                params![collection, record_key],
10049            )
10050            .map_err(|_| EngineError::Storage)? as u64;
10051        let state_rows_excised = tx
10052            .execute(
10053                "DELETE FROM operational_state
10054                 WHERE collection_name = ?1 AND record_key = ?2",
10055                params![collection, record_key],
10056            )
10057            .map_err(|_| EngineError::Storage)? as u64;
10058
10059        let record_digest = digest_record_identity(collection, record_key);
10060        let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
10061        let payload = serde_json::json!({
10062            "collection": collection,
10063            "record_digest": record_digest,
10064            "excised_at": excised_at,
10065            "records_excised": records_excised,
10066            "state_rows_excised": state_rows_excised,
10067        })
10068        .to_string();
10069        let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
10070        tx.execute(
10071            "INSERT INTO operational_mutations(
10072                collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
10073             ) VALUES('excise_record_audit', ?1, 'append', ?2, NULL, ?3)",
10074            params![record_digest, payload, audit_cursor],
10075        )
10076        .map_err(|_| EngineError::Storage)?;
10077
10078        tx.commit().map_err(|_| EngineError::Storage)?;
10079        self.next_cursor.store(audit_cursor, Ordering::SeqCst);
10080        self.counters.record_admin();
10081        Ok(ExciseRecordReport {
10082            collection: collection.to_string(),
10083            record_digest,
10084            records_excised,
10085            state_rows_excised,
10086        })
10087    }
10088
10089    #[cfg(feature = "operator")]
10090    fn run_rebuild(
10091        &self,
10092        include_fts: bool,
10093        kind: RebuildKind,
10094    ) -> Result<RebuildReport, EngineError> {
10095        self.projection_runtime.set_frozen(true);
10096        // Drain MUST succeed: rebuild_shadow_state truncates shadow rows,
10097        // and SQLite-WAL allows a worker that already dequeued a job to
10098        // commit its `INSERT OR IGNORE INTO _fathomdb_vector_rows / vec0`
10099        // after our truncate releases the writer lock, leaving stale
10100        // rows. Surfacing the timeout (instead of swallowing it) lets the
10101        // operator retry rather than silently corrupt the rebuild.
10102        let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
10103        let result = drain_result.and_then(|()| self.rebuild_shadow_state(include_fts, kind));
10104        self.projection_runtime.set_frozen(false);
10105        result
10106    }
10107
10108    #[cfg(feature = "operator")]
10109    fn rebuild_shadow_state(
10110        &self,
10111        include_fts: bool,
10112        kind: RebuildKind,
10113    ) -> Result<RebuildReport, EngineError> {
10114        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
10115        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
10116        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
10117        // 0.8.20 Slice 5a (R-20-E1) — registry-driven invalidation. A full
10118        // rebuild truncates EVERY row-owned projection (the previous hand-rolled
10119        // list omitted `search_index_v2`, so a rebuild neither dropped stale v2
10120        // rows nor repopulated the table); a vec0-only rebuild truncates the
10121        // vector + readiness classes exactly as before. Kind-owned watermark
10122        // state (`_fathomdb_projection_state`) is deliberately NOT truncated —
10123        // readiness is reset by rewinding the projection cursor below.
10124        let rows_invalidated = if include_fts {
10125            truncate_all_row_projections(&tx).map_err(|_| EngineError::Storage)?
10126        } else {
10127            truncate_row_projections_in(&tx, &[ProjectionClass::Vector, ProjectionClass::Readiness])
10128                .map_err(|_| EngineError::Storage)?
10129        };
10130        store_projection_cursor(&tx, 0).map_err(|_| EngineError::Storage)?;
10131        // 0.8.20 Slice 5a (R-20-E1, work item 1) — the replay runs through the
10132        // SAME two projectors the write path uses, so the rebuilt projections
10133        // are identical to what a re-write would have produced. `include_fts`
10134        // selects the pass: a vec0-only rebuild must not write FTS rows.
10135        let pass = if include_fts { ProjectionPass::Write } else { ProjectionPass::VectorOnly };
10136        let mut rows_rebuilt: u64 = 0;
10137        for row in canonical_node_rows(&tx).map_err(|_| EngineError::Storage)? {
10138            project_canonical_node_row(
10139                &tx,
10140                row.cursor,
10141                &row.kind,
10142                &row.body,
10143                row.row_kind,
10144                pass,
10145                // fix-2 [P2]: the attribute half of the replay tracks the backfill's
10146                // active-and-non-superseded row set; FTS / vector shadows still
10147                // rebuild for every row (read-side lifecycle filter, unchanged).
10148                row.attr_projected,
10149            )
10150            .map_err(|_| EngineError::Storage)?;
10151            if include_fts {
10152                rows_rebuilt = rows_rebuilt.saturating_add(1);
10153            }
10154        }
10155        // fix-26 [P2]: rebuild the edge shadows from active canonical_edges
10156        // (G11 search_index_edges).
10157        // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex
10158        // §9 [P2]): mirror the graph-traversal recency filter
10159        // (`edge_validity_sql`) here too, so a full rebuild does not re-surface
10160        // an edge that recency consolidation already invalidated.
10161        // 0.8.20 Slice 5a: body-less structural edges are now included in the
10162        // replay. They project no FTS/vector row, but the write path DOES record
10163        // their readiness terminal — which this rebuild truncated and (before
10164        // this slice) never restored, stalling `advance_projection_cursor`.
10165        // TC-33: the filter is generated by `edge_validity_sql` and `:now` is
10166        // bound (?1) rather than inlined as `datetime('now')`.
10167        let edge_rows: Vec<(i64, String, Option<String>)> = {
10168            let edge_sql = format!(
10169                "SELECT write_cursor, kind, body FROM canonical_edges \
10170                 WHERE superseded_at IS NULL{}",
10171                edge_validity_sql("canonical_edges", 1)
10172            );
10173            let mut edge_stmt = tx.prepare(&edge_sql).map_err(|_| EngineError::Storage)?;
10174            let rows = edge_stmt
10175                .query_map(params![current_epoch_seconds()], |row| {
10176                    Ok((
10177                        row.get::<_, i64>(0)?,
10178                        row.get::<_, String>(1)?,
10179                        row.get::<_, Option<String>>(2)?,
10180                    ))
10181                })
10182                .map_err(|_| EngineError::Storage)?
10183                .collect::<rusqlite::Result<_>>()
10184                .map_err(|_| EngineError::Storage)?;
10185            rows
10186        };
10187        for (cursor, kind, body) in edge_rows {
10188            let has_body = body.is_some();
10189            project_canonical_edge_row(&tx, cursor as u64, &kind, body.as_deref(), pass)
10190                .map_err(|_| EngineError::Storage)?;
10191            if include_fts && has_body {
10192                rows_rebuilt = rows_rebuilt.saturating_add(1);
10193            }
10194        }
10195        let projection_cursor_after =
10196            load_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
10197        tx.commit().map_err(|_| EngineError::Storage)?;
10198        Ok(RebuildReport { kind, rows_invalidated, rows_rebuilt, projection_cursor_after })
10199    }
10200
10201    fn ensure_open(&self) -> Result<(), EngineError> {
10202        if self.closed.load(Ordering::SeqCst) {
10203            return Err(EngineError::Closing);
10204        }
10205
10206        Ok(())
10207    }
10208}
10209
10210fn batch_is_admin(batch: &[PreparedWrite]) -> bool {
10211    !batch.is_empty() && batch.iter().all(|w| matches!(w, PreparedWrite::AdminSchema { .. }))
10212}
10213
10214// 0.7.0 Pack 2 (ADR-0.7.0-vector-binary-quant § 2; handoff § 2.2):
10215// bit-KNN candidate-set size for the two-phase read path. Tuned with
10216// the recall@10 floor in tests/perf_gates.rs::ac_013b_recall_at_10_floor.
10217//
10218// Bumped from 64 → 192 in EU-5a2 per the HITL 2026-05-29 fine-grained
10219// K-sweep result (dev/notes/0.7.1-default-embedder-research.md §5.4):
10220// K=192 sits above the recall-plateau knee for the default embedder.
10221// Public-visible so the EU-5a2 machinery test can assert the value.
10222pub const TOP_K_BIT_CANDIDATES: usize = 192;
10223
10224/// EU-5a2 — number of documents required before the workspace's
10225/// `_fathomdb_embedder_profiles.mean_vec` is pinned for the default
10226/// profile. Per `dev/design/embedder.md` §0.3 (compute-once-on-first-
10227/// ingest lifecycle). Public-visible so the EU-5a2 machinery test can
10228/// assert the value.
10229pub const MEAN_VEC_PIN_THRESHOLD: u64 = 256;
10230
10231/// Historical name for the public default ranked-result count (10).
10232///
10233/// Vector candidate fanout is separately at least the caller-requested result
10234/// limit and `TOP_K_BIT_CANDIDATES`; the test seam may raise that fanout without
10235/// changing visible result cardinality. There is no environment-variable
10236/// override on the hot path.
10237pub const SEARCH_RERANK_LIMIT: usize = 10;
10238
10239/// Default number of ranked hits returned by public retrieval APIs.
10240pub const DEFAULT_SEARCH_RESULT_LIMIT: usize = SEARCH_RERANK_LIMIT;
10241
10242/// Largest ranked-hit count a public retrieval request may select.
10243pub const MAX_SEARCH_RESULT_LIMIT: usize = 100;
10244
10245fn validate_search_result_limit(limit: usize) -> Result<usize, EngineError> {
10246    if !(1..=MAX_SEARCH_RESULT_LIMIT).contains(&limit) {
10247        return Err(EngineError::InvalidArgument {
10248            msg: format!(
10249                "search result limit must be an integer in 1..={MAX_SEARCH_RESULT_LIMIT}; got {limit}"
10250            ),
10251        });
10252    }
10253    Ok(limit)
10254}
10255
10256/// EU-5a2 — streaming f64 accumulator for the mean-centering pipeline,
10257/// per `dev/design/embedder.md` §0.3 (f64 chosen to bound numerical
10258/// drift across `MEAN_VEC_PIN_THRESHOLD` adds). Owned by the projection
10259/// worker; materialized into the schema column at the threshold cross.
10260#[derive(Clone, Debug)]
10261struct MeanAccumulator {
10262    sum: Vec<f64>,
10263    count: u64,
10264}
10265
10266impl MeanAccumulator {
10267    fn new(dim: usize) -> Self {
10268        Self { sum: vec![0.0; dim], count: 0 }
10269    }
10270
10271    fn add(&mut self, v: &[f32]) {
10272        debug_assert_eq!(v.len(), self.sum.len(), "accumulator dim mismatch");
10273        for (slot, value) in self.sum.iter_mut().zip(v.iter()) {
10274            *slot += f64::from(*value);
10275        }
10276        self.count = self.count.saturating_add(1);
10277    }
10278
10279    fn materialize(&self) -> Vec<f32> {
10280        if self.count == 0 {
10281            return vec![0.0; self.sum.len()];
10282        }
10283        let denom = self.count as f64;
10284        self.sum.iter().map(|s| (s / denom) as f32).collect()
10285    }
10286
10287    fn count(&self) -> u64 {
10288        self.count
10289    }
10290}
10291
10292/// 0.7.2 PR-2b — cosine similarity between two equal-length vectors.
10293/// Returns 1.0 for a pair with a zero-norm operand (treated as "no drift
10294/// signal"), so the detector never fires on a degenerate all-zero mean.
10295fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
10296    if a.len() != b.len() {
10297        return 1.0;
10298    }
10299    let mut dot = 0.0f64;
10300    let mut na = 0.0f64;
10301    let mut nb = 0.0f64;
10302    for (x, y) in a.iter().zip(b.iter()) {
10303        dot += f64::from(*x) * f64::from(*y);
10304        na += f64::from(*x) * f64::from(*x);
10305        nb += f64::from(*y) * f64::from(*y);
10306    }
10307    if na == 0.0 || nb == 0.0 {
10308        return 1.0;
10309    }
10310    (dot / (na.sqrt() * nb.sqrt())) as f32
10311}
10312
10313/// EU-5b — at-pin pin-and-requantize pass per `dev/design/embedder.md`
10314/// §0.5. Runs INSIDE the caller's SQLite transaction so the mean_vec
10315/// INSERT/UPDATE + the per-row sign-bit UPDATEs commit atomically.
10316///
10317/// For each pre-pin row, recomputes `bits' = sign_quantize(f32 - mean)`
10318/// via the SQL extension's `vec_quantize_binary`, then UPDATEs the
10319/// row's `embedding_bin` column.
10320fn run_pin_and_requantize_pass(
10321    tx: &rusqlite::Transaction<'_>,
10322    rows: &[(i64, Vec<u8>)],
10323    mean: &[f32],
10324) -> Result<(u64, Vec<EmbedderEvent>), EngineError> {
10325    let mut updated: u64 = 0;
10326    let dim = mean.len();
10327    // sqlite-vec's vec0 xUpdate path discards SQL-function result subtypes
10328    // (see sqlite-vec.c §vec0Update_UpdateVectorColumn — "subtypes don't
10329    // appear to survive xColumn -> xUpdate, it's always 0"), so a direct
10330    // `UPDATE ... SET embedding_bin = vec_quantize_binary(?)` reads the
10331    // bound value as a float32-tagged vector and trips the column-type
10332    // check. We work around by DELETE+INSERT inside the same transaction:
10333    // INSERT preserves the BIT subtype on `vec_quantize_binary`. The
10334    // surrounding pin-commit tx keeps the rewrite atomic.
10335    for (rowid, blob) in rows {
10336        if blob.len() != dim * 4 {
10337            return Err(EngineError::Storage);
10338        }
10339        let un_centered = decode_vector_blob(blob);
10340        let centered = subtract_mean(&un_centered, mean);
10341        let centered_blob = encode_vector_blob(&centered);
10342
10343        let (source_type, kind, created_at): (String, String, i64) = tx
10344            .query_row(
10345                "SELECT source_type, kind, created_at FROM vector_default WHERE rowid = ?1",
10346                params![rowid],
10347                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
10348            )
10349            .map_err(|_| EngineError::Storage)?;
10350
10351        // 0.8.20 Slice 15e — this DELETE+INSERT re-quantize (its BIT-subtype
10352        // workaround, condition #4's SEPARATE same-shape op) must PRESERVE every
10353        // `filterable` `attr_<hex>` value, not just re-`''` them: a projection may
10354        // have been declared before the pin. Read the live attr columns + this
10355        // row's values BEFORE the DELETE, then re-bind them. Empty ⇒ the INSERT is
10356        // byte-identical to the shipped statement.
10357        let attr_cols = actual_vector_attr_columns(tx).map_err(|_| EngineError::Storage)?;
10358        let attr_vals: Vec<String> = if attr_cols.is_empty() {
10359            Vec::new()
10360        } else {
10361            let select = attr_cols.join(", ");
10362            tx.query_row(
10363                &format!("SELECT {select} FROM vector_default WHERE rowid = ?1"),
10364                params![rowid],
10365                |row| {
10366                    let mut vals = Vec::with_capacity(attr_cols.len());
10367                    for i in 0..attr_cols.len() {
10368                        vals.push(row.get::<_, String>(i)?);
10369                    }
10370                    Ok(vals)
10371                },
10372            )
10373            .map_err(|_| EngineError::Storage)?
10374        };
10375
10376        delete_vector_partition_row(tx, *rowid).map_err(|_| EngineError::Storage)?;
10377
10378        // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0 TEXT
10379        // metadata is NOT NULL-able).
10380        let mut cols_sql = String::new();
10381        let mut ph_sql = String::new();
10382        for (i, col) in attr_cols.iter().enumerate() {
10383            cols_sql.push_str(&format!(", {col}"));
10384            ph_sql.push_str(&format!(", ?{}", 7 + i));
10385        }
10386        let sql = format!(
10387            "INSERT INTO vector_default(
10388                rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
10389             ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
10390        );
10391        let mut pv: Vec<rusqlite::types::Value> = vec![
10392            rusqlite::types::Value::Integer(*rowid),
10393            rusqlite::types::Value::Blob(blob.clone()),
10394            rusqlite::types::Value::Blob(centered_blob),
10395            rusqlite::types::Value::Text(source_type),
10396            rusqlite::types::Value::Text(kind),
10397            rusqlite::types::Value::Integer(created_at),
10398        ];
10399        for v in attr_vals {
10400            pv.push(rusqlite::types::Value::Text(v));
10401        }
10402        tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))
10403            .map_err(|_| EngineError::Storage)?;
10404
10405        updated = updated.saturating_add(1);
10406    }
10407    let events = vec![EmbedderEvent::MeanVecPinned {
10408        dim: u32::try_from(dim).unwrap_or(u32::MAX),
10409        doc_count: updated,
10410    }];
10411    Ok((updated, events))
10412}
10413
10414/// EU-5a2 — back-compat test-only count+emit helper. Preserved so the
10415/// EU-5a2 machinery test stays green; the EU-5b production path uses
10416/// `run_pin_and_requantize_pass`.
10417fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
10418    let mut updated: u64 = 0;
10419    let dim = mean.len();
10420    for (_rowid, blob) in rows {
10421        if blob.len() != dim * 4 {
10422            continue;
10423        }
10424        updated = updated.saturating_add(1);
10425    }
10426    let events = vec![EmbedderEvent::MeanVecPinned {
10427        dim: u32::try_from(dim).unwrap_or(u32::MAX),
10428        doc_count: updated,
10429    }];
10430    (updated, events)
10431}
10432
10433/// EU-5a2 — test-visible re-exports of the mean-centering internals.
10434/// Per the handoff RED tests; the production accumulator and re-quantize
10435/// pass are otherwise crate-private.
10436#[doc(hidden)]
10437pub mod mean_centering_internals_for_test {
10438    use super::{EmbedderEvent, MeanAccumulator};
10439
10440    pub struct AccumulatorHandle(MeanAccumulator);
10441
10442    #[must_use]
10443    pub fn new_mean_accumulator(dim: usize) -> AccumulatorHandle {
10444        AccumulatorHandle(MeanAccumulator::new(dim))
10445    }
10446
10447    pub fn accumulator_add(handle: &mut AccumulatorHandle, v: &[f32]) {
10448        handle.0.add(v);
10449    }
10450
10451    #[must_use]
10452    pub fn accumulator_materialize(handle: &AccumulatorHandle) -> Vec<f32> {
10453        handle.0.materialize()
10454    }
10455
10456    #[must_use]
10457    pub fn accumulator_count(handle: &AccumulatorHandle) -> u64 {
10458        handle.0.count()
10459    }
10460
10461    #[must_use]
10462    pub fn run_requantize_pass(rows: &[(i64, Vec<u8>)], mean: &[f32]) -> (u64, Vec<EmbedderEvent>) {
10463        super::run_requantize_pass(rows, mean)
10464    }
10465}
10466
10467/// G9 — Reciprocal Rank Fusion constant. IR-C (2026-06-10b,
10468/// `performance-output-and-compare.md`) found the standard `k≈60` slightly too
10469/// high: the recall gain is concentrated at the top of the list, where a lower
10470/// `k` sharpens rank-1/2 contributions. `k=30` is the validated operating point
10471/// (`k10 > k30 > k60 > k100` on the sweep, `30` the conservative middle).
10472/// Fusion is on **rank**, never raw score.
10473pub const RRF_K: f64 = 30.0;
10474
10475/// G9 / IR-C — per-branch RRF weights. The sweep's optimum is strongly
10476/// **text-dominant** (`text:vector ≈ 3:1`): the lexical (BM25) arm carries
10477/// exact-fact recall and the dense arm, over-weighted, is a net drag on
10478/// exploratory recall (`performance-output-and-compare.md`, 2026-06-10b/e). A
10479/// branch contributes `weight / (RRF_K + rank)`.
10480pub const RRF_WEIGHT_VECTOR: f64 = 1.0;
10481pub const RRF_WEIGHT_TEXT: f64 = 3.0;
10482/// R3 (Slice 30) — graph arm RRF weight. Conservative starting value (equal to
10483/// `RRF_WEIGHT_VECTOR`). Without R2 per-class delta data the graph arm weight
10484/// cannot be calibrated; 1.0 is the minimum non-zero contribution. The graph
10485/// arm surfaces newly-reachable nodes from BFS traversal; it is not meant to
10486/// override the primary text/vector signals. Revisable after R2 data arrives.
10487/// See `dev/design/slice-30-design.md` §Q2.
10488pub const RRF_WEIGHT_GRAPH: f64 = 1.0;
10489
10490/// G12-recency — additive recency weight. Must satisfy two constraints:
10491/// 1. Small enough to never override a clear RRF signal: a gap of > RECENCY_WEIGHT
10492///    between two hits' RRF scores means the stronger RRF hit always wins.
10493/// 2. Large enough to break exact ties: any hit with a higher `write_cursor` (more
10494///    recent) gets RECENCY_WEIGHT × 1.0 > 0 nudge and wins a tied comparison.
10495///
10496/// Value 0.002 satisfies the near-tie-nudge contract with respect to the
10497/// committed test (`recency_does_not_override_a_clear_rrf_signal`):
10498/// the test's RRF gap is 0.01, which is larger than 0.002, so recency
10499/// never overrides it. Note: this value is larger than the minimum
10500/// vector-only rank-step at deep ranks (~0.00101 for adjacent ranks near
10501/// the bottom), so recency can flip a single-rank vector difference at
10502/// deep ranks — by design, recency is a near-tie nudge, and "near-tie"
10503/// is scoped to the test gap (0.01), not to every possible rank step.
10504///
10505/// 0.8.1 Slice 10 fix: the previous value `0.5/RRF_K ≈ 0.01667` violated
10506/// the test gap constraint (it exceeded 0.01). Lowered to 0.002.
10507pub const RECENCY_WEIGHT: f64 = 0.002;
10508
10509/// 0.8.8 Slice 15 — the lowercase wire string for a retrieval arm (telemetry +
10510/// the same spelling `SearchHit.branch` crosses every binding).
10511fn branch_str(branch: SoftFallbackBranch) -> &'static str {
10512    match branch {
10513        SoftFallbackBranch::Vector => "vector",
10514        SoftFallbackBranch::Text => "text",
10515        SoftFallbackBranch::TextEdge => "text_edge",
10516        SoftFallbackBranch::GraphArm => "graph_arm",
10517    }
10518}
10519
10520/// 0.8.8 Slice 15 — append one JSON value as a line to the telemetry sink
10521/// (append-only, local file; no network). Best-effort caller handles the error.
10522fn append_jsonl(path: &Path, value: &serde_json::Value) -> std::io::Result<()> {
10523    let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
10524    writeln!(file, "{value}")?;
10525    Ok(())
10526}
10527
10528/// 0.8.20 Slice 5b (R-20-E6) — rewrite a telemetry JSONL sink with every
10529/// `result_stable_ids` element in `erased` replaced by [`REDACTED_STABLE_ID`].
10530///
10531/// **Selective, never truncating.** Every line is carried across: records that
10532/// reference no erased id are byte-identical, records that do keep their shape
10533/// and only lose the matching id VALUES, and a line that is not an
10534/// engine-authored JSON event (an operator note, a hand-appended record) is
10535/// copied through verbatim. The sink is a caller-supplied path that may hold
10536/// unrelated eval history; destroying it is not part of any erasure obligation.
10537///
10538/// **Crash safety.** Write-temp-then-`rename`: the redacted content goes to a
10539/// sibling `<sink>.redact.tmp`, is `sync_all`ed, and is then atomically renamed
10540/// over the sink. A crash at any point leaves either the intact old file or the
10541/// complete new one — never a half-rewritten sink. `rename` is atomic because
10542/// the temp file is a sibling (same directory ⇒ same filesystem).
10543///
10544/// **Concurrent appends.** The caller holds the telemetry mutex, so no
10545/// in-process `capture_telemetry` can append into the window. An out-of-process
10546/// appender is still possible (the sink is just a file), so before renaming we
10547/// re-check the source length: if it grew, the tail delta is read, redacted and
10548/// appended, and the check repeats — bounded, so a pathologically hot external
10549/// writer surfaces as an error rather than an unbounded loop.
10550fn redact_jsonl_stable_ids(
10551    path: &Path,
10552    erased: &std::collections::HashSet<&str>,
10553) -> std::io::Result<()> {
10554    /// Bound on the re-check loop for an out-of-process appender.
10555    const MAX_TAIL_FOLDS: usize = 8;
10556
10557    let mut source = std::fs::read(path)?;
10558    let mut redacted = redact_jsonl_bytes(&source, erased);
10559
10560    for _ in 0..MAX_TAIL_FOLDS {
10561        let current = std::fs::read(path)?;
10562        if current.len() == source.len() {
10563            let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
10564            tmp_name.push(".redact.tmp");
10565            let tmp = path.with_file_name(tmp_name);
10566            {
10567                let mut file = std::fs::File::create(&tmp)?;
10568                file.write_all(&redacted)?;
10569                file.sync_all()?;
10570            }
10571            std::fs::rename(&tmp, path)?;
10572            return Ok(());
10573        }
10574        // Someone appended while we were building the replacement: fold the
10575        // delta in (redacted) rather than dropping it, then re-check.
10576        if current.len() > source.len() && current.starts_with(&source) {
10577            redacted.extend_from_slice(&redact_jsonl_bytes(&current[source.len()..], erased));
10578        } else {
10579            // The file was rewritten under us, not appended to. Start over.
10580            redacted = redact_jsonl_bytes(&current, erased);
10581        }
10582        source = current;
10583    }
10584    Err(std::io::Error::other(format!(
10585        "telemetry sink {} is being appended to faster than it can be redacted",
10586        path.display()
10587    )))
10588}
10589
10590/// Line-wise redaction of a JSONL byte buffer. Non-JSON and non-event lines are
10591/// passed through unchanged, as is a trailing partial line (no terminating
10592/// newline) — the sink is append-only, so a partial tail is a torn write, not
10593/// ours to normalize.
10594fn redact_jsonl_bytes(bytes: &[u8], erased: &std::collections::HashSet<&str>) -> Vec<u8> {
10595    let mut out = Vec::with_capacity(bytes.len());
10596    let mut rest = bytes;
10597    while !rest.is_empty() {
10598        let (line, tail) = match rest.iter().position(|b| *b == b'\n') {
10599            Some(idx) => (&rest[..idx], &rest[idx + 1..]),
10600            // No trailing newline: a torn/partial final line. Pass it through.
10601            None => {
10602                out.extend_from_slice(rest);
10603                break;
10604            }
10605        };
10606        match redact_jsonl_line(line, erased) {
10607            Some(replacement) => out.extend_from_slice(replacement.as_bytes()),
10608            None => out.extend_from_slice(line),
10609        }
10610        out.push(b'\n');
10611        rest = tail;
10612    }
10613    out
10614}
10615
10616/// `Some(replacement)` when the line is an engine-authored telemetry event whose
10617/// `result_stable_ids` referenced an erased id; `None` to pass it through.
10618fn redact_jsonl_line(line: &[u8], erased: &std::collections::HashSet<&str>) -> Option<String> {
10619    let text = std::str::from_utf8(line).ok()?;
10620    let mut value: serde_json::Value = serde_json::from_str(text).ok()?;
10621    let ids = value.get_mut("result_stable_ids")?.as_array_mut()?;
10622    let mut touched = false;
10623    for id in ids.iter_mut() {
10624        if id.as_str().is_some_and(|s| erased.contains(s)) {
10625            *id = serde_json::Value::from(REDACTED_STABLE_ID);
10626            touched = true;
10627        }
10628    }
10629    touched.then(|| value.to_string())
10630}
10631
10632/// G9 — fuse the vector and text branches with Reciprocal Rank Fusion.
10633///
10634/// Delegates to [`fuse_three_arms`] with an empty graph arm. The two-arm
10635/// contract is preserved: `fuse_rrf(v, t)` == `fuse_three_arms(v, t, vec![])`.
10636/// All existing callers are unaffected.
10637///
10638/// See [`fuse_three_arms`] for the full RRF formula documentation.
10639#[doc(hidden)]
10640#[must_use]
10641pub fn fuse_rrf(vector_hits: Vec<SearchHit>, text_hits: Vec<SearchHit>) -> Vec<SearchHit> {
10642    fuse_three_arms(vector_hits, text_hits, vec![])
10643}
10644
10645/// R3 (Slice 30) — fuse vector, text, and graph arms with Reciprocal Rank Fusion.
10646///
10647/// Each branch contributes `weight / (RRF_K + rank)` (1-based rank within that
10648/// branch; `weight` = [`RRF_WEIGHT_VECTOR`] / [`RRF_WEIGHT_TEXT`] /
10649/// [`RRF_WEIGHT_GRAPH`], text-dominant per IR-C), accumulated **keyed on
10650/// `SearchHit.body`**, so a body surfaced by multiple branches accumulates all
10651/// terms (agreement boosts it). The fused value is written into `SearchHit.score`.
10652/// A body in multiple branches surfaces **once** with the **vector** branch's
10653/// identity (vector-first), then graph arm identity for non-vector hits, then
10654/// text. Output is sorted by score descending, then vector-first, then insertion
10655/// order — a pure, deterministic function of the three input lists.
10656///
10657/// With an empty `graph_hits` (`vec![]`), the output is byte-identical to the
10658/// pre-Slice-30 two-arm `fuse_rrf`. This is the backward-compatibility contract.
10659///
10660/// This is the **unconditional** new ranking (HITL Q3 — no `fusion_mode` knob,
10661/// no legacy path). Graph arm is opt-in via `use_graph_arm=true`.
10662#[doc(hidden)]
10663#[must_use]
10664pub fn fuse_three_arms(
10665    vector_hits: Vec<SearchHit>,
10666    text_hits: Vec<SearchHit>,
10667    graph_hits: Vec<SearchHit>,
10668) -> Vec<SearchHit> {
10669    struct Entry {
10670        hit: SearchHit,
10671        score: f64,
10672        in_vector: bool,
10673        order: usize,
10674    }
10675    let mut entries: Vec<Entry> = Vec::new();
10676    let mut accumulate = |hit: SearchHit, rank0: usize, in_vector: bool, weight: f64| {
10677        let contrib = weight / (RRF_K + (rank0 as f64 + 1.0));
10678        if let Some(existing) = entries.iter_mut().find(|e| e.hit.body == hit.body) {
10679            // Dedup on body; the representative hit (vector-first) is retained.
10680            existing.score += contrib;
10681        } else {
10682            let order = entries.len();
10683            entries.push(Entry { hit, score: contrib, in_vector, order });
10684        }
10685    };
10686    for (rank0, hit) in vector_hits.into_iter().enumerate() {
10687        accumulate(hit, rank0, true, RRF_WEIGHT_VECTOR);
10688    }
10689    for (rank0, hit) in text_hits.into_iter().enumerate() {
10690        accumulate(hit, rank0, false, RRF_WEIGHT_TEXT);
10691    }
10692    for (rank0, hit) in graph_hits.into_iter().enumerate() {
10693        // Graph arm: vector-first=false (never overrides an existing vector hit's
10694        // representative identity; only new bodies from the graph arm get GraphArm
10695        // as their branch identity). The in_vector=false ensures graph arm hits
10696        // never sort ahead of vector hits on exact score ties.
10697        accumulate(hit, rank0, false, RRF_WEIGHT_GRAPH);
10698    }
10699    entries.sort_by(|a, b| {
10700        b.score
10701            .partial_cmp(&a.score)
10702            .unwrap_or(std::cmp::Ordering::Equal)
10703            // vector-first on equal score (true sorts before false).
10704            .then_with(|| b.in_vector.cmp(&a.in_vector))
10705            .then_with(|| a.order.cmp(&b.order))
10706    });
10707    entries
10708        .into_iter()
10709        .map(|mut e| {
10710            e.hit.score = e.score;
10711            e.hit
10712        })
10713        .collect()
10714}
10715
10716/// G12-recency — reweight fused hits toward the more recent (higher
10717/// `write_cursor`/`id`) AFTER bit-KNN (never a vec0 predicate). Gated by the
10718/// caller's dedicated recency flag; `enabled=false` is a no-op (pure RRF).
10719#[doc(hidden)]
10720#[must_use]
10721pub fn apply_recency_reweight(hits: Vec<SearchHit>, enabled: bool) -> Vec<SearchHit> {
10722    if !enabled || hits.len() < 2 {
10723        return hits;
10724    }
10725    let min_id = hits.iter().map(|h| h.write_cursor).min().unwrap_or(0);
10726    let max_id = hits.iter().map(|h| h.write_cursor).max().unwrap_or(0);
10727    if max_id == min_id {
10728        return hits;
10729    }
10730    let span = (max_id - min_id) as f64;
10731    let mut reweighted: Vec<SearchHit> = hits
10732        .into_iter()
10733        .map(|mut h| {
10734            let norm = (h.write_cursor - min_id) as f64 / span;
10735            h.score += RECENCY_WEIGHT * norm;
10736            h
10737        })
10738        .collect();
10739    // Stable sort preserves the fused order on exact ties.
10740    reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10741    reweighted
10742}
10743
10744/// 0.8.16 Slice 5 / F9 — OFF-by-default importance/confidence reweight, applied
10745/// to the fused hits AFTER bit-KNN + RRF (mirrors [`apply_recency_reweight`]).
10746///
10747/// Multiplicative-on-fused (ADR-0.8.16 §2.2, HITL-SIGNED 2026-07-08): a hit's
10748/// score is scaled by node `importance` (`importance_by_id`) × edge `confidence`
10749/// (`confidence_by_id`), each keyed by the hit's interim id (`write_cursor`). A
10750/// missing key = `NULL` = never assigned = graceful-absent ⇒ neutral (1.0), the
10751/// OPP-12 Q6a graceful-absent state. Node hits carry `importance`; graph/edge hits
10752/// carry `confidence`; the two id-spaces never collide (cursors are globally
10753/// unique), so each hit gets exactly one non-neutral factor.
10754///
10755/// **R-F9-4 graceful-neutral identity:** when `enabled` but *no* hit has a
10756/// non-neutral factor (every importance/confidence absent), the input is returned
10757/// **unchanged** — byte-identical to the `enabled == false` result (no re-sort),
10758/// so declaring the mechanism never perturbs an all-absent corpus.
10759#[must_use]
10760pub fn apply_importance_reweight(
10761    hits: Vec<SearchHit>,
10762    importance_by_id: &HashMap<u64, f64>,
10763    confidence_by_id: &HashMap<u64, f64>,
10764    enabled: bool,
10765) -> Vec<SearchHit> {
10766    if !enabled {
10767        return hits;
10768    }
10769    // Graceful-neutral fast path (R-F9-4): if nothing is weighted, do not touch
10770    // order or scores — identical to the reweight-OFF result.
10771    let any_weighted = hits.iter().any(|h| {
10772        importance_by_id.contains_key(&h.write_cursor)
10773            || confidence_by_id.contains_key(&h.write_cursor)
10774    });
10775    if !any_weighted {
10776        return hits;
10777    }
10778    let mut reweighted: Vec<SearchHit> = hits
10779        .into_iter()
10780        .map(|mut h| {
10781            let importance = importance_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10782            let confidence = confidence_by_id.get(&h.write_cursor).copied().unwrap_or(1.0);
10783            h.score *= importance * confidence;
10784            h
10785        })
10786        .collect();
10787    // Stable sort preserves the fused order on exact ties.
10788    reweighted.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
10789    reweighted
10790}
10791
10792/// 0.8.16 Slice 5 / F9 — build the per-hit importance/confidence weight maps for
10793/// the candidate `hits` from the durable columns (`canonical_nodes.importance`,
10794/// `canonical_edges.confidence`). Only NON-NULL values are inserted; an absent
10795/// value stays out of the map (graceful-absent ⇒ neutral in
10796/// [`apply_importance_reweight`]). Prepared statements are guarded so a pre-step-18
10797/// / pre-step-14 schema (no column) yields empty maps rather than an error.
10798fn build_importance_confidence_maps(
10799    tx: &rusqlite::Connection,
10800    hits: &[SearchHit],
10801) -> rusqlite::Result<(HashMap<u64, f64>, HashMap<u64, f64>)> {
10802    let mut importance_by_id: HashMap<u64, f64> = HashMap::new();
10803    let mut confidence_by_id: HashMap<u64, f64> = HashMap::new();
10804    if let Ok(mut stmt) =
10805        tx.prepare("SELECT importance FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1")
10806    {
10807        for h in hits {
10808            if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10809                importance_by_id.insert(h.write_cursor, v);
10810            }
10811        }
10812    }
10813    if let Ok(mut stmt) = tx.prepare(
10814        "SELECT confidence FROM canonical_edges \
10815         WHERE write_cursor = ?1 AND superseded_at IS NULL LIMIT 1",
10816    ) {
10817        for h in hits {
10818            if let Ok(Some(v)) = stmt.query_row([h.write_cursor], |r| r.get::<_, Option<f64>>(0)) {
10819                confidence_by_id.insert(h.write_cursor, v);
10820            }
10821        }
10822    }
10823    Ok((importance_by_id, confidence_by_id))
10824}
10825
10826/// 0.8.1 Slice 10 (R1) — CE rerank seam.
10827///
10828/// `rerank_depth = 0` (or model absent / `default-reranker` feature off): returns
10829/// `hits` **unchanged** — byte-identical to the old identity stub. This is the
10830/// soft-fallback contract.
10831///
10832/// `rerank_depth > 0` with the `default-reranker` feature on and the model
10833/// loaded: scores the top-`rerank_depth` (query, passage) pairs with the
10834/// TinyBERT-L-2 cross-encoder, blends CE score with the RRF score using the
10835/// formula from the design memo (Decision 5), re-sorts the top-N, and appends
10836/// the remainder in their original RRF order.
10837///
10838/// Score-blend (Decision 5): `α × sigmoid(ce_logit) + (1−α) × rrf_score_normalized`
10839/// where both CE and RRF scores are normalized to [0,1] over the reranked pool.
10840///
10841/// 0.8.5 (EXP-0): `alpha` (clamped to `[0,1]`) and `pool_n` (the reranked-pool
10842/// size, clamped to `hits.len()`) are caller-supplied. The defaults
10843/// `alpha = 0.3, pool_n = rerank_depth` reproduce the pre-slice blend exactly.
10844/// `rerank_depth == 0` remains the identity gate regardless of `pool_n`.
10845///
10846/// This is the rerank hook, **not** the dropped `fusion_mode` knob.
10847#[doc(hidden)]
10848#[must_use]
10849pub fn rerank_fused(
10850    _query: &str,
10851    hits: Vec<SearchHit>,
10852    rerank_depth: usize,
10853    alpha: f64,
10854    pool_n: usize,
10855) -> Vec<SearchHit> {
10856    // Soft-fallback: depth=0 → identity (byte-identical to old stub). NOTE this
10857    // early gate is independent of `pool_n`: `rerank_depth == 0, pool_n = 10`
10858    // does NOT rerank (0.8.5 D4).
10859    if rerank_depth == 0 {
10860        return hits;
10861    }
10862
10863    // Feature-gated CE inference. In the default build (no feature) this block
10864    // compiles away and `hits` is returned unchanged regardless of `rerank_depth`.
10865    // FIX-1: pass `&hits` (borrow) so `hits` remains owned for the soft-fallback path.
10866    #[cfg(feature = "default-reranker")]
10867    {
10868        if let Some(reranked) = ce_rerank(_query, &hits, rerank_depth, alpha, pool_n) {
10869            return reranked;
10870        }
10871    }
10872
10873    // 0.8.5: the bindings/default callers pass `alpha = 0.3, pool_n = rerank_depth`;
10874    // referenced here so the no-feature build does not warn on unused params.
10875    #[cfg(not(feature = "default-reranker"))]
10876    let _ = (alpha, pool_n);
10877
10878    // Model absent (feature off, weights not loaded, or CE returned None) →
10879    // soft-fallback: return input unchanged.
10880    hits
10881}
10882
10883/// 0.8.2 Slice E2 — standalone CE rerank of a caller-supplied passage list.
10884///
10885/// The pure, testable core that the `fathomdb.rerank` pyo3 binding is a thin
10886/// wrapper over. Slice 5's `fused_rerank` comparator must CE-rerank its OWN
10887/// in-harness fused(bm25+dense) pool — a pool the engine's `search()` never
10888/// constructs — so the CE has to be reachable over an arbitrary passage list,
10889/// not just the engine's capped text-only pool. This adapts `(id, body, score)`
10890/// passages into `SearchHit`s (`kind = "passage"`, `branch = Vector`,
10891/// `source_id = None`; only `body` and `score` feed the blend), runs them
10892/// through [`rerank_fused`], and projects back to `(id, score, ce_score)` in the reranked
10893/// order.
10894///
10895/// Contract (inherited verbatim from `rerank_fused`): `rerank_depth == 0` OR an
10896/// empty list returns the input order WITH the input scores, byte-identical — no
10897/// model load, no network. With `--features default-reranker` and
10898/// `rerank_depth > 0` the CE blends the top-`depth` and may reorder; with the
10899/// feature off the CE path compiles away and this is always identity.
10900///
10901/// 0.8.2 Slice E2 fix-1 [P2]: returns `Err` when any passage carries a non-finite
10902/// score (NaN / ±inf), mirroring the malformed-passage loud-fail contract.
10903/// Callers (pyo3 `rerank` binding, tests) must handle `Result`.
10904/// (`#[must_use]` removed: `Result` is already `#[must_use]`.)
10905pub fn rerank_passages(
10906    query: &str,
10907    passages: Vec<(u64, String, f64)>,
10908    rerank_depth: usize,
10909    alpha: f64,
10910    pool_n: usize,
10911) -> Result<Vec<(u64, f64, Option<f64>)>, String> {
10912    // [P2] guard: reject non-finite scores before they reach normalization/sort.
10913    // A NaN or ±inf score would produce NaN blended scores and an unstable sort
10914    // order — surface the error early as the typed WriteValidationError at the
10915    // pyo3 boundary (mirroring the malformed-passage loud-fail contract).
10916    for (id, _, score) in &passages {
10917        if !score.is_finite() {
10918            return Err(format!(
10919                "rerank: non-finite score for passage id={id}: {score} \
10920                 (NaN/\u{00b1}inf must not reach the normalization/sort step)"
10921            ));
10922        }
10923    }
10924    let hits: Vec<SearchHit> = passages
10925        .into_iter()
10926        .map(|(id, body, score)| SearchHit {
10927            // C-2: synthetic passages carry no canonical identity — mint the
10928            // `Passage` (`p:`) id from the caller-supplied ordinal. The ordinal
10929            // is ALSO kept as the engine-internal positional cursor so the
10930            // projection below returns it byte-unchanged.
10931            id: IdSpace::passage(id.to_string()),
10932            write_cursor: id,
10933            kind: "passage".to_string(),
10934            body,
10935            score,
10936            branch: SoftFallbackBranch::Vector,
10937            source_id: None,
10938            ce_score: None,
10939        })
10940        .collect();
10941    // 0.8.5 — project `(id, score, ce_score)` so the binding can surface the CE
10942    // score per candidate; `ce_score` is `None` for the identity / out-of-pool path.
10943    // The projected id is the caller's ordinal (the engine-internal `write_cursor`).
10944    Ok(rerank_fused(query, hits, rerank_depth, alpha, pool_n)
10945        .into_iter()
10946        .map(|h| (h.write_cursor, h.score, h.ce_score))
10947        .collect())
10948}
10949
10950/// 0.8.1 Slice 10 — score-blend reranking when CE model is loaded.
10951///
10952/// Returns `Some(reranked)` if the model is available, `None` otherwise
10953/// (caller then applies the soft-fallback).
10954///
10955/// Design memo Decision 5:
10956/// - CE normalized = sigmoid(raw_logit) ∈ [0,1]
10957/// - RRF normalized = min-max of `hit.score` over the top-K pool
10958/// - `final_score = 0.3 × ce_norm + 0.7 × rrf_norm`
10959/// - Hits beyond `rerank_depth` keep their original RRF scores and order.
10960#[cfg(feature = "default-reranker")]
10961fn ce_rerank(
10962    _query: &str,
10963    hits: &[SearchHit], // FIX-1: borrow, not move — caller retains ownership for soft-fallback
10964    _rerank_depth: usize, // 0.8.5: pool sizing moved to `pool_n`; depth gate stays in `rerank_fused`.
10965    alpha: f64,
10966    pool_n: usize,
10967) -> Option<Vec<SearchHit>> {
10968    // 0.8.5 (D3) — clamp α to [0,1] silently here so EVERY path (engine search,
10969    // `rerank_passages`, the bindings) is covered by one clamp, matching the
10970    // existing `pool_n.min(len)` clamp idiom.
10971    // codex §9 P2-1: `f64::clamp(NaN)` returns NaN (clamp does NOT map NaN into
10972    // range) — a non-finite α would then make every blended score NaN and destroy
10973    // the ranking. The high-level SDKs reject non-finite α, but the low-level
10974    // `rerank()` / direct-Rust callers don't, so fall back to the documented
10975    // default α=0.3 here for any non-finite input.
10976    let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 0.3 };
10977    // fix-1 [P2]: short-circuit before touching the singleton when there is
10978    // nothing to rerank — avoids loading/downloading the ~17 MB model for an
10979    // empty result set and prevents memoizing a transient load failure.
10980    if hits.is_empty() {
10981        return Some(vec![]);
10982    }
10983
10984    // Try to get the loaded model. Returns None when weights are absent.
10985    let model = CandleCrossEncoder::try_get_loaded()?;
10986
10987    // 0.8.5 (D4) — the reranked pool is the top `pool_n` (caller resolves the
10988    // `unwrap_or(rerank_depth)` default at the binding), clamped to the hit count.
10989    let n = pool_n.min(hits.len());
10990    let top = &hits[..n]; // no split_at_mut needed; borrow slices directly
10991    let rest = &hits[n..];
10992
10993    // --- RRF min-max normalization over the top-N pool ---
10994    let rrf_min = top.iter().map(|h| h.score).fold(f64::INFINITY, f64::min);
10995    let rrf_max = top.iter().map(|h| h.score).fold(f64::NEG_INFINITY, f64::max);
10996    let rrf_span = rrf_max - rrf_min;
10997
10998    // Batched CE scoring: ONE forward over the whole top-N pool instead of N
10999    // per-pair forwards. The ranking math below (RRF min-max norm, sigmoid,
11000    // ALPHA blend, sort) is byte-unchanged — only the scoring is batched.
11001    let bodies: Vec<&str> = top.iter().map(|h| h.body.as_str()).collect();
11002    let raw_logits = model.score_batch(_query, &bodies);
11003
11004    let mut scored: Vec<(f64, SearchHit)> = top
11005        .iter()
11006        .zip(raw_logits)
11007        .map(|(h, raw_logit)| {
11008            let rrf_norm = if rrf_span > 0.0 { (h.score - rrf_min) / rrf_span } else { 1.0 };
11009            // Sigmoid for CE normalization: 1/(1+exp(-x)).
11010            let ce_norm = 1.0 / (1.0 + (-raw_logit).exp());
11011            // 0.8.5 — α is the caller-supplied (clamped) blend weight; default 0.3
11012            // reproduces the pre-slice `const ALPHA = 0.3` blend exactly.
11013            let blended = alpha * ce_norm + (1.0 - alpha) * rrf_norm;
11014            // 0.8.5 (D1) — expose the per-candidate CE score on in-pool hits.
11015            let mut hit = h.clone();
11016            hit.ce_score = Some(ce_norm);
11017            (blended, hit)
11018        })
11019        .collect();
11020
11021    // Sort top-N by blended score descending (stable within ties by original order).
11022    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
11023
11024    let mut result: Vec<SearchHit> = scored
11025        .into_iter()
11026        .map(|(score, mut h)| {
11027            h.score = score;
11028            h
11029        })
11030        .collect();
11031
11032    // Append hits beyond rerank_depth in their original RRF order.
11033    result.extend_from_slice(rest);
11034    Some(result)
11035}
11036
11037/// 0.8.1 Slice 10 (R1) / 0.8.2 Slice E1 — CPU TinyBERT-L-2 cross-encoder.
11038///
11039/// Thin engine-side handle over the embedder crate's `CandleTinyBertReranker`
11040/// (Candle BERT stack + `tokenizers`, pinned `cross-encoder/ms-marco-TinyBERT-
11041/// L2-v2`). The model is loaded once, process-wide, the first time
11042/// `rerank_depth > 0` reaches the CE path (lazy init via the `OnceLock` below);
11043/// on cache miss that first load fetches the ~17 MB weights over the network
11044/// (sha256-verified). When the weights are absent and the network is
11045/// unavailable, the load fails and `try_get_loaded()` returns `None` so the
11046/// caller soft-falls-back to RRF order — it never panics.
11047///
11048/// Footprint: this whole type compiles ONLY under `default-reranker`. With the
11049/// feature off the CE path compiles away and `rerank_fused` is always identity.
11050/// With the feature on, `rerank_depth == 0` short-circuits in `rerank_fused`
11051/// BEFORE this is ever touched, so depth-0 stays byte-identical and no-network.
11052/// Similarly, an empty hit set short-circuits in `ce_rerank` before the singleton
11053/// is consulted (fix-1 [P2]).
11054#[cfg(feature = "default-reranker")]
11055struct CandleCrossEncoder {
11056    inner: &'static fathomdb_embedder::CandleTinyBertReranker,
11057}
11058
11059/// Process-wide lazily-initialized reranker. `None` once initialization has
11060/// been attempted and failed (no weights + no network) — memoized so a failed
11061/// load is not retried on every query.
11062#[cfg(feature = "default-reranker")]
11063fn reranker_singleton() -> Option<&'static fathomdb_embedder::CandleTinyBertReranker> {
11064    static CELL: std::sync::OnceLock<Option<fathomdb_embedder::CandleTinyBertReranker>> =
11065        std::sync::OnceLock::new();
11066    CELL.get_or_init(|| fathomdb_embedder::CandleTinyBertReranker::try_load().ok()).as_ref()
11067}
11068
11069#[cfg(feature = "default-reranker")]
11070impl CandleCrossEncoder {
11071    /// Returns a model handle if the reranker is (or can be) loaded, `None`
11072    /// otherwise. The first call drives the lazy load (cache probe → gated
11073    /// download); subsequent calls reuse the memoized result.
11074    fn try_get_loaded() -> Option<Self> {
11075        Some(Self { inner: reranker_singleton()? })
11076    }
11077
11078    /// Score a (query, passage) pair. Returns the raw cross-encoder logit, or
11079    /// `0.0` (a neutral logit → sigmoid 0.5) if the forward pass errors, so a
11080    /// single bad pair degrades to a neutral CE contribution rather than
11081    /// panicking in the reader thread.
11082    fn score(&self, query: &str, passage: &str) -> f64 {
11083        self.inner.score(query, passage).map(f64::from).unwrap_or(0.0)
11084    }
11085
11086    /// Batched [`score`](Self::score): score every `(query, passage_i)` pair in a
11087    /// single forward pass. Returns one logit per passage in input order, each
11088    /// honoring the same neutral-`0.0`-on-error contract as [`score`](Self::score).
11089    ///
11090    /// Fallback: if the batched forward errors as a whole (e.g. an OOM or a
11091    /// tokenize failure on one pair surfaces as a batch `Err`), we DO NOT
11092    /// neutralize the entire pool — we fall back to per-pair [`score`](Self::score),
11093    /// so a single bad pair degrades only its own element to a neutral logit while
11094    /// the rest keep their real scores. Empty input → empty output (no forward).
11095    fn score_batch(&self, query: &str, passages: &[&str]) -> Vec<f64> {
11096        match self.inner.score_batch(query, passages) {
11097            Ok(logits) => logits.into_iter().map(f64::from).collect(),
11098            Err(_) => passages.iter().map(|p| self.score(query, p)).collect(),
11099        }
11100    }
11101}
11102
11103/// 0.8.20 Slice 15e fix-2 finding 1 [P2] + keystone closeout fix-3 (codex §9 [P2],
11104/// TOCTOU) — reject a search filter that names an attribute with NO declared
11105/// `filterable` projection, ON THE READER'S OWN SNAPSHOT, before the vec0 SQL is
11106/// built.
11107///
11108/// The vector arm lowers each `filter.attributes` term to `AND attr_<hex>=?`
11109/// against a vec0 metadata column that exists ONLY for a declared `filterable`
11110/// projection (the reshape in [`reconcile_vector_attr_columns`] tracks exactly the
11111/// registry's `filterable` set; see [`desired_vector_attr_columns`]). A name that
11112/// is not a declared `filterable` projection therefore has no column: the vec0 KNN
11113/// would fail with `no such column` (surfacing as an opaque `Storage` error),
11114/// while the FTS arm ([`hit_attributes_pass_filter`]) would silently no-match. That
11115/// divergence violates ADR-0.8.11 D3 (every filter term has a DEFINED, arm-uniform
11116/// outcome).
11117///
11118/// fix-3 snapshot contract: this is called from [`read_search_in_tx`] INSIDE the
11119/// reader's `DEFERRED` transaction, so the `_fathomdb_projection_registry` it reads
11120/// and the `vector_default` columns [`vector_filter_clause`] compiles against are
11121/// the SAME WAL snapshot. A concurrent `configure_projections` DROP either commits
11122/// before this snapshot is pinned (then BOTH the registry and the vec0 columns show
11123/// the attribute gone ⇒ a consistent `InvalidFilter`) or after (then it is invisible
11124/// to this transaction and BOTH still show it declared ⇒ the query runs against the
11125/// column it validated). The registry's `filterable` set is the arm-INDEPENDENT
11126/// authority (correct even with no embedder / no `vector_default`, where a
11127/// declared-`filterable` term still filters legitimately via the row-owned
11128/// `canonical_attributes` EAV store). The caller re-raises
11129/// [`SearchReaderError::InvalidFilter`] as the EXISTING typed
11130/// [`EngineError::InvalidFilter`], so both arms see the SAME rejection because it is
11131/// raised before either runs.
11132fn validate_filter_attributes_on_snapshot(
11133    conn: &Connection,
11134    filter: &SearchFilter,
11135) -> Result<(), SearchReaderError> {
11136    if filter.attributes.is_empty() {
11137        return Ok(());
11138    }
11139    // `?` maps a registry-read failure to `SearchReaderError::Sqlite` (unchanged
11140    // `Storage` semantics for a genuine backend fault) via the `From` impl.
11141    let registry = load_projection_registry(conn)?;
11142    for (name, _value) in &filter.attributes {
11143        let declared_filterable =
11144            registry.get(name).is_some_and(|s| s.roles.contains(&ProjectionRole::Filterable));
11145        if !declared_filterable {
11146            return Err(SearchReaderError::InvalidFilter(format!(
11147                "filter attribute {name:?} is not a declared `filterable` projection; \
11148                 declare it via configure_projections before filtering on it"
11149            )));
11150        }
11151    }
11152    Ok(())
11153}
11154
11155/// G10 — the `AND col=?n` predicate fragment appended to the phase-1 candidates
11156/// `WHERE` for the present filter fields. Placeholders are numbered from `?3`
11157/// (`?1` = sign-quant query, `?2` = f32 rerank query). Field order is canonical
11158/// (`source_type`, `kind`, `created_after`, `status`), THEN the Slice-15e
11159/// `filterable`-attribute predicates (`attr_<hex>=?n`) in `attributes` order, and
11160/// is mirrored exactly by [`vector_filter_values`]. Empty for `None`/all-`None`
11161/// (byte-identity path).
11162fn vector_filter_clause(filter: Option<&SearchFilter>) -> String {
11163    let Some(filter) = filter else {
11164        return String::new();
11165    };
11166    if filter.is_unfiltered() {
11167        return String::new();
11168    }
11169    // 0.8.20 Slice 15e — the attribute predicates encode the (arbitrary,
11170    // possibly space/unicode-bearing) registry name into the byte-safe
11171    // `attr_<hex>` column vec0 accepts (vec0 rejects quoted identifiers). Owned
11172    // strings so they live past the closure; the shipped metadata columns are
11173    // static `&str`.
11174    let mut cols: Vec<(String, &str)> = Vec::new();
11175    if filter.source_type.is_some() {
11176        cols.push(("source_type".to_string(), "="));
11177    }
11178    if filter.kind.is_some() {
11179        cols.push(("kind".to_string(), "="));
11180    }
11181    if filter.created_after.is_some() {
11182        cols.push(("created_at".to_string(), ">="));
11183    }
11184    if filter.status.is_some() {
11185        cols.push(("status".to_string(), "="));
11186    }
11187    for (name, _value) in &filter.attributes {
11188        cols.push((attr_vec0_column(name), "="));
11189    }
11190    let mut clause = String::new();
11191    for (i, (col, op)) in cols.iter().enumerate() {
11192        clause.push_str(&format!(" AND {col}{op}?{}", i + 3));
11193    }
11194    clause
11195}
11196
11197/// G10 — the bound values for the present filter fields, in the SAME canonical
11198/// order as [`vector_filter_clause`] so placeholder `?{n}` lines up with value
11199/// `n-3`.
11200fn vector_filter_values(filter: Option<&SearchFilter>) -> Vec<rusqlite::types::Value> {
11201    use rusqlite::types::Value;
11202    let mut out = Vec::new();
11203    let Some(filter) = filter else {
11204        return out;
11205    };
11206    if filter.is_unfiltered() {
11207        return out;
11208    }
11209    if let Some(s) = &filter.source_type {
11210        out.push(Value::Text(s.clone()));
11211    }
11212    if let Some(s) = &filter.kind {
11213        out.push(Value::Text(s.clone()));
11214    }
11215    if let Some(c) = filter.created_after {
11216        out.push(Value::Integer(c));
11217    }
11218    if let Some(s) = &filter.status {
11219        out.push(Value::Text(s.clone()));
11220    }
11221    // 0.8.20 Slice 15e — attribute values, in the SAME order the clause appended
11222    // the `attr_<hex>` columns (after the four metadata fields). fix-3 [P2] — the
11223    // filter value is encoded `\x01 || V` to match the encoded PRESENT column
11224    // value, so `attr_<hex> = enc("")` matches present-empty but NEVER the
11225    // `''`-absent rows.
11226    for (_name, value) in &filter.attributes {
11227        out.push(Value::Text(encode_attr_vec0_present(value)));
11228    }
11229    out
11230}
11231
11232/// G10 — build the single phase-1 candidates statement. With `filter=None` (or
11233/// all-`None`) the `{filter_clause}` is empty and the SQL is **byte-identical to
11234/// 0.7.2** (the documented behavior-compat invariant; pinned by
11235/// `pr_g10_filtered_knn.rs`). The KNN form (`ORDER BY distance LIMIT top_k`, no
11236/// `k=`) is preserved.
11237fn build_vector_phase1_sql(filter: Option<&SearchFilter>, final_limit: usize) -> String {
11238    let filter_clause = vector_filter_clause(filter);
11239    format!(
11240        "WITH candidates AS (
11241                     SELECT rowid
11242                     FROM vector_default
11243                     WHERE embedding_bin MATCH vec_quantize_binary(vec_f32(?1)){filter_clause}
11244                     ORDER BY distance
11245                     LIMIT {top_k}
11246                 )
11247                 SELECT c.rowid, vec_distance_l2(v.embedding, vec_f32(?2)) AS l2
11248                 FROM candidates c
11249                 JOIN vector_default v ON v.rowid = c.rowid
11250                 ORDER BY l2
11251                 LIMIT {final_limit}",
11252        top_k = TOP_K_BIT_CANDIDATES,
11253    )
11254}
11255
11256/// Test seam — exposes [`build_vector_phase1_sql`] at the production
11257/// `SEARCH_RERANK_LIMIT` so `pr_g10_filtered_knn.rs` can pin the `filter=None`
11258/// byte-identity and the appended predicates.
11259#[doc(hidden)]
11260#[must_use]
11261pub fn vector_phase1_sql_for_test(filter: Option<&SearchFilter>) -> String {
11262    build_vector_phase1_sql(filter, SEARCH_RERANK_LIMIT)
11263}
11264
11265/// G10 — does a text-branch hit satisfy the filter? The vector branch is
11266/// pruned in-SQL; the text branch is constrained here against the same metadata:
11267/// `kind` directly, `source_type` via [`resolve_source_type`], and
11268/// `created_after`/`status` from `vector_default` by `rowid == write_cursor`. A
11269/// text-only row absent from the vector partition cannot satisfy a
11270/// `created_after`/`status` predicate, so it is excluded — filtered semantic
11271/// search is a vector-metadata capability.
11272fn text_hit_passes_filter(
11273    tx: &rusqlite::Transaction<'_>,
11274    id: u64,
11275    kind: &str,
11276    filter: Option<&SearchFilter>,
11277) -> rusqlite::Result<bool> {
11278    let Some(filter) = filter else {
11279        return Ok(true);
11280    };
11281    if filter.is_unfiltered() {
11282        return Ok(true);
11283    }
11284    if let Some(k) = &filter.kind {
11285        if kind != k {
11286            return Ok(false);
11287        }
11288    }
11289    if let Some(st) = &filter.source_type {
11290        match resolve_source_type(kind) {
11291            Ok(resolved) if resolved == st.as_str() => {}
11292            _ => return Ok(false),
11293        }
11294    }
11295    if filter.created_after.is_some() || filter.status.is_some() {
11296        let meta: Option<(i64, Option<String>)> = tx
11297            .query_row(
11298                "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
11299                [id as i64],
11300                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
11301            )
11302            .optional()?;
11303        let Some((created_at, status)) = meta else {
11304            // No vector-partition row: cannot satisfy a vec-metadata predicate.
11305            return Ok(false);
11306        };
11307        if let Some(bound) = filter.created_after {
11308            if created_at < bound {
11309                return Ok(false);
11310            }
11311        }
11312        if let Some(want) = &filter.status {
11313            if status.as_deref() != Some(want.as_str()) {
11314                return Ok(false);
11315            }
11316        }
11317    }
11318    // 0.8.20 Slice 15e fix-1 finding 1 [P2] — enforce the declared-`filterable`
11319    // attribute-equality predicates on the TEXT/FTS arm too (D3 total dispatch).
11320    if !hit_attributes_pass_filter(tx, id, filter)? {
11321        return Ok(false);
11322    }
11323    Ok(true)
11324}
11325
11326/// 0.8.20 Slice 15e fix-1 finding 1 [P2] — do a TEXT/FTS hit's `filterable`
11327/// attributes satisfy `filter.attributes`?
11328///
11329/// The vector arm enforces each `(attr_name, value)` pre-KNN as `attr_<hex>=?`
11330/// against the vec0 metadata column. The FTS/text arm must enforce the SAME
11331/// equality so a hybrid RRF fusion is coherent (ADR-0.8.11 D3 — every filter term
11332/// has a defined outcome on EVERY surface; no arm silently ignores it). Without
11333/// this, a doc returned by the FTS arm that FAILS the attribute filter still
11334/// surfaces in a hybrid search — a false positive.
11335///
11336/// The value is read from the row-owned `canonical_attributes` EAV table (keyed
11337/// by the hit's `write_cursor` + `attr_name`), which Slice 15d keeps active-only
11338/// and populates via the SAME [`extract_scalar_attribute`] that fills the vec0
11339/// `attr_<hex>` column — so the two arms see IDENTICAL values by construction.
11340///
11341/// # fix-3 [P2]: ABSENT vs PRESENT-EMPTY
11342///
11343/// A real empty-string value (`{"status":""}`) is DISTINCT from an absent
11344/// attribute. On this (FTS/text) arm the distinction is ROW EXISTENCE: a present
11345/// attribute (even value `''`) has a `canonical_attributes` row; an absent one has
11346/// NONE. So a filter `("status","")` matches present-empty (row exists, RAW value
11347/// `''`) but NOT absent (no row), and `("status","open")` matches only the
11348/// `open` row. The vec0 arm reaches the SAME verdict via its `\x01`-marker
11349/// encoding (see [`ATTR_VEC0_PRESENT_MARKER`]): present-empty is the bare marker,
11350/// absent is `''`, so `attr_<hex> = enc("")` matches present-empty but never
11351/// absent. `canonical_attributes.attr_value` and `property_search_index` stay RAW.
11352///
11353/// # 0.8.20 semantics (Finding 1 → HITL ruling (A)): attribute filters are NODE-scoped
11354///
11355/// Attribute projection is `PreparedWrite::Node`-gated (see `collect_projection_jobs`
11356/// / [`project_one_attribute`]): an EDGE is never projected into
11357/// `canonical_attributes`, and its `vector_default` row (kind `edge_fact`) carries
11358/// the `''` sentinel in every `attr_<hex>` column (the async worker reads the body
11359/// from `canonical_nodes`, which has no row for an edge cursor). Therefore an
11360/// attribute filter **excludes every edge hit** — on BOTH the edge-FTS arm (this
11361/// helper, keyed by the edge's write_cursor, reads `''`) and the edge-vector arm
11362/// (the pre-KNN `attr_<hex>='…'` predicate prunes the `''`-sentinel edge row) —
11363/// even when the edge body itself names the attribute. This is the intended
11364/// 0.8.20 behaviour, pinned by `attribute_filter_excludes_edge_hits_on_both_arms`.
11365///
11366/// The reserved widening is **(D) endpoint-node filtering** (an edge passes iff its
11367/// endpoint node(s) satisfy the attribute predicate): **(A) is (D) with an empty
11368/// endpoint rule.** (B) edges-pass-through and (C) project-edge-attributes are the
11369/// other reserved options. None are implemented in 0.8.20 — do not add a per-query
11370/// flag; a widening is a deliberate, separately-governed later slice.
11371fn hit_attributes_pass_filter(
11372    tx: &rusqlite::Transaction<'_>,
11373    id: u64,
11374    filter: &SearchFilter,
11375) -> rusqlite::Result<bool> {
11376    for (name, want) in &filter.attributes {
11377        // fix-3 [P2] — distinguish ABSENT from PRESENT-EMPTY by ROW EXISTENCE: a
11378        // present attribute (including one whose value is a real empty string `''`)
11379        // has a `canonical_attributes` row; an absent one has NONE. The outer
11380        // `Option` is row existence; the RAW `attr_value` is compared verbatim.
11381        // This mirrors the vec0 arm exactly (present-empty matches `("k","")`;
11382        // absent matches nothing, including `""`), so a fused hybrid search is
11383        // coherent. `canonical_attributes.attr_value` stays RAW (unencoded).
11384        let stored: Option<Option<String>> = tx
11385            .query_row(
11386                "SELECT attr_value FROM canonical_attributes \
11387                 WHERE write_cursor = ?1 AND attr_name = ?2 LIMIT 1",
11388                params![id as i64, name],
11389                |row| row.get::<_, Option<String>>(0),
11390            )
11391            .optional()?;
11392        match stored {
11393            // Present (row exists) and the RAW value equals the filter value.
11394            Some(Some(v)) if v.as_str() == want.as_str() => {}
11395            // Absent (no row), present-but-NULL, or present-but-different ⇒ fail.
11396            _ => return Ok(false),
11397        }
11398    }
11399    Ok(true)
11400}
11401
11402/// G11 (Slice 15) — does an edge FTS hit satisfy the filter?
11403///
11404/// Edge FTS hits always have `source_type = "edge_fact"` (the partition
11405/// discriminant). Their `row.kind` is the **relation** kind (e.g. `"owns"`,
11406/// `"works_for"`), not a node kind, so [`text_hit_passes_filter`] MUST NOT be
11407/// used for edge hits: `resolve_source_type(relation_kind)` returns `Err` for
11408/// unknown kinds, causing every edge hit to be silently rejected when a
11409/// `source_type` filter is set — the exact inverse of correct behaviour.
11410///
11411/// Edge bodies ARE projected into `vector_default` (rowid = `write_cursor`),
11412/// so `created_after` / `status` are satisfied by querying `vector_default`
11413/// exactly as [`text_hit_passes_filter`] does for node hits.
11414///
11415/// Rules:
11416/// - `source_type`: pass iff `None` **or** `== "edge_fact"`.
11417/// - `kind`: filter on the relation kind (`row.kind`) if specified.
11418/// - `created_after` / `status`: query `vector_default WHERE rowid = write_cursor`;
11419///   if absent from the vector partition the hit cannot satisfy a vec-metadata
11420///   predicate and is excluded.
11421fn edge_fts_hit_passes_filter(
11422    tx: &rusqlite::Transaction<'_>,
11423    write_cursor: u64,
11424    row_kind: &str,
11425    filter: Option<&SearchFilter>,
11426) -> rusqlite::Result<bool> {
11427    let Some(filter) = filter else {
11428        return Ok(true);
11429    };
11430    if filter.is_unfiltered() {
11431        return Ok(true);
11432    }
11433    if let Some(ref st) = filter.source_type {
11434        if st != "edge_fact" {
11435            return Ok(false); // filter targets a specific non-edge source_type
11436        }
11437    }
11438    if let Some(ref k) = filter.kind {
11439        if k != row_kind {
11440            return Ok(false); // kind filter applies to the relation kind
11441        }
11442    }
11443    // Edge bodies are projected into vector_default; check created_after/status
11444    // there, the same way text_hit_passes_filter does for node hits.
11445    if filter.created_after.is_some() || filter.status.is_some() {
11446        let meta: Option<(i64, Option<String>)> = tx
11447            .query_row(
11448                "SELECT created_at, status FROM vector_default WHERE rowid = ?1 LIMIT 1",
11449                [write_cursor as i64],
11450                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
11451            )
11452            .optional()?;
11453        let Some((created_at, status)) = meta else {
11454            // No vector-partition row: cannot satisfy a vec-metadata predicate.
11455            return Ok(false);
11456        };
11457        if let Some(bound) = filter.created_after {
11458            if created_at < bound {
11459                return Ok(false);
11460            }
11461        }
11462        if let Some(want) = &filter.status {
11463            if status.as_deref() != Some(want.as_str()) {
11464                return Ok(false);
11465            }
11466        }
11467    }
11468    // 0.8.20 Slice 15e fix-1 finding 1 [P2] — an edge body is projected into
11469    // vector_default (rowid = write_cursor) with the same `attr_<hex>` pre-KNN
11470    // columns as a node, and Slice 15d projects an edge's `filterable` attributes
11471    // into `canonical_attributes` keyed by its write_cursor. Enforce the same
11472    // attribute equality here so the edge-FTS arm matches the vector arm (D3 total
11473    // dispatch). An edge that carries no such attribute reads the `''` sentinel and
11474    // fails a non-empty equality, exactly as on the vector arm.
11475    if !hit_attributes_pass_filter(tx, write_cursor, filter)? {
11476        return Ok(false);
11477    }
11478    Ok(true)
11479}
11480
11481/// Apply every edge filter except declared projection attributes. This isolates
11482/// the explanatory count from edge candidates rejected for an independent
11483/// source-type, relation-kind, or vec-metadata predicate.
11484fn edge_fts_hit_passes_non_attribute_filter(
11485    tx: &rusqlite::Transaction<'_>,
11486    write_cursor: u64,
11487    row_kind: &str,
11488    filter: Option<&SearchFilter>,
11489) -> rusqlite::Result<bool> {
11490    let Some(filter) = filter else {
11491        return Ok(true);
11492    };
11493    let mut non_attribute_filter = filter.clone();
11494    non_attribute_filter.attributes.clear();
11495    edge_fts_hit_passes_filter(tx, write_cursor, row_kind, Some(&non_attribute_filter))
11496}
11497
11498/// Read projection cursor and matching body rows inside one read tx.
11499fn read_projected_text_in_tx(
11500    reader: &mut Connection,
11501    query: &str,
11502    name: &str,
11503    filter: Option<&SearchFilter>,
11504    limit: usize,
11505    view: ReadView,
11506) -> ProjectedTextReaderResponse {
11507    let compiled = compile_text_query(query);
11508    let frozen = view.freeze();
11509    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11510    let registry = load_projection_registry(&tx)?;
11511    let declared = registry.get(name).ok_or_else(|| {
11512        SearchReaderError::InvalidFilter(format!("projected text field {name:?} is not declared"))
11513    })?;
11514    if !declared.wants_property_fts() {
11515        return Err(SearchReaderError::InvalidFilter(format!(
11516            "projected text field {name:?} is not a declared `searchable` projection with property FTS"
11517        )));
11518    }
11519    if let Some(filter) = filter {
11520        validate_filter_attributes_on_snapshot(&tx, filter)?;
11521    }
11522    let validity = frozen.validity_sql("n", 3);
11523    let sql = format!(
11524        "SELECT p.write_cursor, bm25(property_search_index), n.kind, n.body, n.logical_id, n.source_id
11525         FROM property_search_index p JOIN canonical_nodes n ON n.write_cursor = p.write_cursor
11526         WHERE p.attr_name = ?1 AND property_search_index MATCH ?2
11527           AND n.superseded_at IS NULL AND n.state = 'active'{validity}
11528         ORDER BY bm25(property_search_index) ASC, p.write_cursor ASC"
11529    );
11530    let mut params = vec![
11531        rusqlite::types::Value::Text(name.to_string()),
11532        rusqlite::types::Value::Text(compiled.match_expression),
11533    ];
11534    if let Some(now) = frozen.now_param() {
11535        params.push(rusqlite::types::Value::Integer(now));
11536    }
11537    let mut stmt = tx.prepare(&sql)?;
11538    let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
11539        Ok((
11540            row.get::<_, i64>(0)?,
11541            row.get::<_, f64>(1)?,
11542            row.get::<_, String>(2)?,
11543            row.get::<_, String>(3)?,
11544            row.get::<_, Option<String>>(4)?,
11545            row.get::<_, Option<String>>(5)?,
11546        ))
11547    })?;
11548    let mut results = Vec::new();
11549    for row in rows {
11550        let (cursor, bm25, kind, body, logical_id, source_id) = row?;
11551        if !text_hit_passes_filter(&tx, cursor as u64, &kind, filter)? {
11552            continue;
11553        }
11554        results.push(SearchHit {
11555            id: derive_stable_id(logical_id.as_deref(), &body),
11556            write_cursor: cursor as u64,
11557            kind,
11558            body,
11559            score: -bm25,
11560            branch: SoftFallbackBranch::Text,
11561            source_id,
11562            ce_score: None,
11563        });
11564        if results.len() >= limit {
11565            break;
11566        }
11567    }
11568    let projection_cursor = load_projection_cursor(&tx)?;
11569    Ok(SearchResult { projection_cursor, soft_fallback: None, results, explanation: None })
11570}
11571
11572// The 8th parameter (`vector_stage_only`) is the additive GA-2 / ◆ B-1
11573// measurement seam; the reader-worker call site threads each field through
11574// explicitly (mirroring the existing `recency_enabled` plumbing), so a wrapper
11575// struct would only obscure that 1:1 mapping for a test-only flag.
11576#[allow(clippy::too_many_arguments)]
11577fn read_search_in_tx(
11578    reader: &mut Connection,
11579    compiled: &fathomdb_query::CompiledQuery,
11580    query_vector: Option<&str>,
11581    query_vector_bin: Option<&str>,
11582    final_limit: usize,
11583    candidate_limit: usize,
11584    direct_text_candidate_limit: Option<usize>,
11585    filter: Option<&SearchFilter>,
11586    recency_enabled: bool,
11587    importance_enabled: bool,
11588    vector_stage_only: bool,
11589    raw_query: &str,
11590    rerank_depth: usize,
11591    use_graph_arm: bool,
11592    alpha: f64,
11593    pool_n: usize,
11594    explain: bool,
11595    view: ReadView,
11596) -> ReaderResponse {
11597    // 0.8.20 Slice 15b fix-2 (R-20-NV) — the `:now` instant is read HERE, in
11598    // Rust, ONCE per query, and bound positionally into every node-hydration
11599    // SELECT. Never `datetime('now')` / `strftime('%s','now')`: an inline clock
11600    // would make the query non-deterministic, untestable, and re-evaluated per
11601    // row. `None` ⇒ the view relaxes validity ⇒ no conjunct is emitted and
11602    // nothing is bound (`validity_sql` returns the empty string).
11603    //
11604    // fix-3 (F2): FREEZE the view here, at the single point every arm flows
11605    // through. `freeze()` is the only place on this path that reads the clock;
11606    // downstream arms hold a `FrozenView` and have no way to resolve a second,
11607    // different instant. Previously the graph arm re-derived it from the raw
11608    // `ReadView`, so a boundary-straddling query could have its arms disagree.
11609    let view = view.freeze();
11610    let now_param = view.now_param();
11611    // fix-3 (codex §9 [P2], TOCTOU) — test-only rendezvous: parks the worker here,
11612    // BEFORE the deferred snapshot is pinned, so a test can commit a concurrent
11613    // `configure_projections` DROP in the exact race window. Disarmed (no-op) in
11614    // production and on every non-race test.
11615    reader_search_hook::fire();
11616    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
11617    let cursor = load_projection_cursor(&tx)?;
11618    // fix-3 (codex §9 [P2], TOCTOU) — validate every filter attribute name on THIS
11619    // reader transaction's snapshot, before `build_vector_phase1_sql` emits
11620    // `AND attr_<hex>=?` and before the FTS arm probes `canonical_attributes`. The
11621    // registry read and the vec0 query now share ONE snapshot (see
11622    // `validate_filter_attributes_on_snapshot`), so a `configure_projections` DROP
11623    // racing this search yields a consistent typed `InvalidFilter` — never the
11624    // opaque `no such column` `Storage` error fix-2's writer-connection check could
11625    // still leak. Skipped when the filter carries no attribute terms (common path).
11626    if let Some(filter) = filter {
11627        validate_filter_attributes_on_snapshot(&tx, filter)?;
11628    }
11629    let vector_results = if let Some(query_vector) = query_vector {
11630        let mut rowids = Vec::new();
11631        let bin_vector = query_vector_bin.unwrap_or(query_vector);
11632        {
11633            // Phase 1: bit-KNN over `embedding_bin` to a top-K candidate
11634            // set; Phase 2: f32 rerank on the candidate set via
11635            // vec_distance_l2 against the retained `embedding` column.
11636            // EU-5a2: ?1 is the (possibly centered) sign-quant input,
11637            // ?2 is the un-centered f32 for vec_distance_l2 — both sides
11638            // of the f32 cosine use un-centered vectors.
11639            // Slice 18: `final_limit` is the caller's public result limit
11640            // (default 10, validated through the public API). `candidate_limit`
11641            // controls only vector phase-2 fanout: the test-only
11642            // `set_search_limit_for_test` seam may raise it for recall tests.
11643            // The seam never changes `final_limit`; visible results are still
11644            // truncated to that caller-requested limit after ranking.
11645            // G10: the metadata filter is appended to this single phase-1
11646            // statement (`AND col=?n` from ?3); `filter=None` keeps the SQL
11647            // byte-identical to 0.7.2. `?1`/`?2` are the sign-quant + f32 query
11648            // vectors; filter values bind at ?3.. in `vector_filter_clause`
11649            // order.
11650            // fix-3 (F1, codex §9 [P2]) — OVERFETCH the phase-2 rerank so the
11651            // validity/existence filter applied at hydration cannot starve the
11652            // result set.
11653            //
11654            // The defect: hydration drops rows that are expired, superseded or
11655            // inactive, but it ran on candidates ALREADY truncated to
11656            // `final_limit`. If the nearest `final_limit` neighbours were all
11657            // out-of-window they consumed every slot and were then dropped, so
11658            // valid rows just below the cutoff were never considered — a
11659            // default search silently returned too few hits, or none.
11660            //
11661            // Why overfetch rather than filtering in SQL: the natural fix is to
11662            // join `canonical_nodes` into the candidate query, but (i) phase 1
11663            // is a `vec0` KNN and ADR-0.8.11 D3 forbids demoting it with
11664            // non-metadata predicates, and (ii) there is NO index on
11665            // `canonical_nodes(write_cursor)`, so an `EXISTS` per candidate
11666            // would be a full scan × the whole pool on EVERY query — a
11667            // guaranteed cost to fix a degenerate case.
11668            //
11669            // Overfetching is free by comparison: phase 2 already computes
11670            // `vec_distance_l2` for all `TOP_K_BIT_CANDIDATES` in order to sort
11671            // them, so raising the LIMIT only returns more of a result set that
11672            // was already materialized. No extra vec0 work, no schema change,
11673            // no new index, no second query. The hydration loop below then
11674            // stops at `final_limit` SURVIVING hits, so the common case does
11675            // exactly as many hydration probes as before.
11676            //
11677            // `max` (not a bare constant) preserves both the test seam's
11678            // deeper candidate fanout and the caller's requested result limit.
11679            let candidate_limit = candidate_limit.max(final_limit).max(TOP_K_BIT_CANDIDATES);
11680            let sql = build_vector_phase1_sql(filter, candidate_limit);
11681            let mut params: Vec<rusqlite::types::Value> = vec![
11682                rusqlite::types::Value::Text(bin_vector.to_string()),
11683                rusqlite::types::Value::Text(query_vector.to_string()),
11684            ];
11685            params.extend(vector_filter_values(filter));
11686            let mut statement = tx.prepare(&sql)?;
11687            let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
11688                Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
11689            })?;
11690            for row in rows.flatten() {
11691                rowids.push(row);
11692            }
11693        }
11694        // G1: carry the canonical row's `write_cursor` (interim id), `kind`,
11695        // `body`, and the `vec_distance_l2` rerank score per hit. The
11696        // `_fathomdb_vector_rows.rowid` equals the canonical `write_cursor`,
11697        // so the candidate rowid IS the hit id.
11698        //
11699        // G11 (Slice 15) fix: edge bodies are projected into vector_default under
11700        // kind = "edge_fact"; their write_cursor is in canonical_edges, not
11701        // canonical_nodes. Try canonical_nodes first; fall back to canonical_edges
11702        // for edge-fact hits so they are not silently dropped.
11703        let mut results = Vec::new();
11704        // Cause-A: the two node/edge SELECTs additively fetch `logical_id` so the
11705        // hit can carry a stable cross-session id (derive_stable_id). Read-only
11706        // additive column — ordering/scores are untouched.
11707        // fix-1 (codex §9): co-locate BOTH existence guards. Node supersession
11708        // is tombstone-then-insert (`commit_batch`) — the prior `canonical_nodes`
11709        // row is kept (same `write_cursor`, `state = 'active'`, `superseded_at`
11710        // set) and, unlike the edge path (fix-30), its stale `vector_default` row
11711        // is NOT pruned, so the phase-1 bit-KNN can still surface the OLD cursor.
11712        // Without `superseded_at IS NULL` here that superseded version would
11713        // hydrate and leak stale content through vector search. This matches the
11714        // edge branch below and every other retrieval site (design §2: enforce
11715        // the exclusion at EVERY retrieval site). It only drops already-superseded
11716        // rows → a no-op on the all-active / non-superseded corpus.
11717        // TC-31 (0.8.20 Slice 10a): both hydration SELECTs additively fetch the
11718        // canonical row's OWN `source_id` so a vector hit carries the provenance
11719        // `erase_source` consumes. These statements already read the canonical
11720        // row by `write_cursor`, so this is one extra COLUMN on an existing
11721        // lookup — NOT an extra query. (A per-hit `WHERE write_cursor = ?`
11722        // probe would be a full scan: there is no index on
11723        // `canonical_nodes(write_cursor)`. This site already pays that cost by
11724        // construction; TC-31 must not add a second one.) Read-only additive
11725        // column — row-set, ordering and scores are untouched.
11726        // fix-2 (codex §9 [P2]): the validity conjunct comes from
11727        // `ReadView::validity_sql` — the SAME generator the five read verbs use.
11728        // It is NOT hand-rolled here: Slice 10's whole design is that the
11729        // predicate exists in exactly ONE place, so no retrieval site can drift
11730        // from another. `?1` is the candidate rowid, so `:now` binds at `?2`.
11731        // On a corpus that never authored a window every row is NULL/NULL and
11732        // the conjunct matches everything ⇒ default behaviour is unchanged.
11733        let node_validity = view.validity_sql("canonical_nodes", 2);
11734        let mut node_stmt = tx.prepare(&format!(
11735            "SELECT kind, body, logical_id, source_id FROM canonical_nodes \
11736             WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'\
11737             {node_validity} LIMIT 1"
11738        ))?;
11739        // fix-2 (codex §9 [P2]): an edge body projected into `vector_default`
11740        // (kind = "edge_fact") is hydrated HERE by write_cursor. Gating on
11741        // `superseded_at` alone let an EXPIRED edge (`t_invalid <= :now`) surface
11742        // its body through the VECTOR arm — the same "validity enforced on
11743        // traversal, not on search" gap Slice 15b closed for nodes, now on the
11744        // edge-vector read path. Apply the shared `edge_validity_sql` predicate
11745        // (the ONE generator every edge read site uses) so no arm can drift.
11746        // `?1` is the rowid, so the edge `:now` binds at `?2`; the instant is the
11747        // frozen `view.edge_now()` — a bound value, never `datetime('now')`
11748        // (the :9161 no-inline-clock rule). edge_now is ALWAYS present, so unlike
11749        // node validity this conjunct is unconditional (an edge invalidated in the
11750        // past stays excluded even when node existence is relaxed).
11751        let edge_validity = edge_validity_sql("canonical_edges", 2);
11752        let mut edge_stmt = tx.prepare(&format!(
11753            "SELECT body, logical_id, source_id FROM canonical_edges \
11754             WHERE write_cursor = ?1 AND superseded_at IS NULL AND body IS NOT NULL\
11755             {edge_validity} LIMIT 1"
11756        ))?;
11757        // The bound parameter list for the node lookup: the candidate rowid,
11758        // plus `:now` when (and only when) the view emitted a validity conjunct.
11759        // One instant for the whole query — resolved once, above, not per row.
11760        let node_params = |rowid: i64| -> Vec<rusqlite::types::Value> {
11761            let mut p = vec![rusqlite::types::Value::Integer(rowid)];
11762            if let Some(now) = now_param {
11763                p.push(rusqlite::types::Value::Integer(now));
11764            }
11765            p
11766        };
11767        for (rowid, score) in rowids {
11768            // fix-3 (F1): the candidate list is now the OVERFETCHED pool in
11769            // exact-L2 order, so the caller's cutoff is applied HERE — after
11770            // the validity/existence filter, not before it. Bounded worst case:
11771            // at most `TOP_K_BIT_CANDIDATES` hydration probes when nearly every
11772            // candidate is filtered out; exactly `final_limit` (i.e. unchanged)
11773            // when nothing is. Ordering is unchanged — the surviving rows are
11774            // still emitted nearest-first — so on a corpus with no windows this
11775            // loop yields byte-identical results to the pre-fix code.
11776            if results.len() >= final_limit {
11777                break;
11778            }
11779            if let Ok((kind, body, logical_id, source_id)) =
11780                node_stmt.query_row(rusqlite::params_from_iter(node_params(rowid)), |row| {
11781                    Ok((
11782                        row.get::<_, String>(0)?,
11783                        row.get::<_, String>(1)?,
11784                        row.get::<_, Option<String>>(2)?,
11785                        row.get::<_, Option<String>>(3)?,
11786                    ))
11787                })
11788            {
11789                let id = derive_stable_id(logical_id.as_deref(), &body);
11790                results.push(SearchHit {
11791                    id,
11792                    write_cursor: rowid as u64,
11793                    kind,
11794                    body,
11795                    score,
11796                    branch: SoftFallbackBranch::Vector,
11797                    // TC-31: the NODE's own provenance (a node hit is erased by
11798                    // the document it was written from).
11799                    source_id,
11800                    ce_score: None,
11801                });
11802            } else if let Ok((body, logical_id, source_id)) =
11803                edge_stmt.query_row(rusqlite::params![rowid, view.edge_now()], |row| {
11804                    Ok((
11805                        row.get::<_, String>(0)?,
11806                        row.get::<_, Option<String>>(1)?,
11807                        row.get::<_, Option<String>>(2)?,
11808                    ))
11809                })
11810            {
11811                let id = derive_stable_id(logical_id.as_deref(), &body);
11812                results.push(SearchHit {
11813                    id,
11814                    write_cursor: rowid as u64,
11815                    kind: "edge_fact".to_string(),
11816                    body,
11817                    score,
11818                    branch: SoftFallbackBranch::TextEdge,
11819                    // TC-31: the EDGE's own provenance — consistent with the
11820                    // graph arm's existing edge-source semantics.
11821                    source_id,
11822                    ce_score: None,
11823                });
11824            }
11825        }
11826        results
11827    } else {
11828        Vec::new()
11829    };
11830    let vector_rows_visible = !vector_results.is_empty();
11831    let soft_fallback = if query_vector.is_some() && !vector_rows_visible {
11832        tx.query_row(
11833            "SELECT 1
11834             FROM search_index
11835             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = search_index.kind
11836             LEFT JOIN _fathomdb_projection_terminal
11837               ON _fathomdb_projection_terminal.write_cursor = search_index.write_cursor
11838             WHERE search_index MATCH ?1
11839              AND _fathomdb_projection_terminal.write_cursor IS NULL
11840             LIMIT 1",
11841            [compiled.match_expression.as_str()],
11842            |_row| Ok(SoftFallback { branch: SoftFallbackBranch::Vector }),
11843        )
11844        .ok()
11845    } else {
11846        None
11847    };
11848    // Collect the text branch (ranked by `write_cursor`, as 0.7.2), then
11849    // post-filter it against the same metadata the vector branch was pruned by
11850    // in SQL (the vector branch is filtered in phase 1; the text branch has no
11851    // metadata columns of its own).
11852    let text_candidates: Vec<SearchHit> = {
11853        // 0.7.0 perf-experiments: optional FTS5 LIMIT cap. Gated on
11854        // FATHOMDB_PERF_EXPERIMENTS=1; opt-in via
11855        // FATHOMDB_PERF_SEARCH_LIMIT=<k>. No-op by default — preserves
11856        // 0.6.x unbounded result-set semantics. Removed (or made the
11857        // hardcoded default) at Wave 5 landing per
11858        // dev/plans/0.7.0-perf-experiments.md.
11859        let perf_limit: Option<usize> = if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_some() {
11860            std::env::var("FATHOMDB_PERF_SEARCH_LIMIT").ok().and_then(|s| s.parse().ok())
11861        } else {
11862            None
11863        };
11864        // Only the explicit direct text-only API fixes its node candidate window.
11865        // A missing vector is not sufficient evidence of that API: hybrid search
11866        // can also take its no-vector fallback and must retain its existing input
11867        // behavior. The fixed direct bound makes node inputs invariant across
11868        // accepted public limits before edge-body fusion and final truncation.
11869        let fts_only_limit =
11870            direct_text_candidate_limit.or_else(|| query_vector.is_none().then_some(final_limit));
11871        // G1: SELECT body + kind + write_cursor (interim id) and the
11872        // `bm25()` text-relevance score. IR-C (2026-06-10,
11873        // `performance-output-and-compare.md`): the per-branch rank RRF fuses on
11874        // must be **`bm25()` relevance**, not `write_cursor` (insertion order) —
11875        // the prior `ORDER BY write_cursor` meant the lexical arm never ranked by
11876        // relevance, the single biggest fusion bug. `bm25()` is more-negative ⇒
11877        // better, so ascending puts best matches first; `write_cursor` is the
11878        // deterministic tiebreak. The filter is applied as a Rust post-filter so
11879        // the unfiltered path is untouched.
11880        let limit_clause =
11881            fts_only_limit.or(perf_limit).map(|k| format!(" LIMIT {k}")).unwrap_or_default();
11882        // Cause-A: PREFER a logical_id-bearing query — LEFT JOIN canonical_nodes so
11883        // node hits carry the `l:`-tagged stable id. The join is 1:1 on
11884        // `write_cursor` (search_index holds node bodies only; edge bodies live in
11885        // search_index_edges), so the row-set and the `bm25(search_index),
11886        // write_cursor` ordering are byte-unchanged — only `cn.logical_id` is added.
11887        // FALL BACK to the original (logical_id-free) query on pre-step-12 schemas
11888        // (v10) whose `canonical_nodes` lacks `logical_id`: those hits key by the
11889        // `h:` content-hash. This keeps old-schema search byte-identical to the
11890        // pre-Cause-A behaviour (the prepare of the plain SQL is the exact prior
11891        // statement). Columns are qualified because both tables expose `write_cursor`.
11892        // CORRECTNESS (0.8.11.2 pico): `AND cn.superseded_at IS NULL` drops
11893        // superseded node versions. Node supersession is tombstone-then-insert
11894        // (`commit_batch`): the prior `canonical_nodes` row is UPDATEd to set
11895        // `superseded_at` (row kept, same write_cursor) and a NEW `search_index`
11896        // row is inserted for the new cursor — the OLD `search_index` row is
11897        // never deleted, so without this filter both versions stay live in FTS
11898        // and the stale one is returned. The other arms already filter this way
11899        // (edge branch, graph-arm node seed, point-recall `read_get_by_id`);
11900        // only this default node-text branch was missing it. The `LEFT JOIN` is
11901        // KEPT (not switched to inner): an active row joins to its `cn` with
11902        // `superseded_at = NULL` (kept); a superseded row joins to its tombstoned
11903        // `cn` with `superseded_at` NOT NULL (dropped); a legacy/orphan
11904        // `search_index` row with no `cn` gets `superseded_at = NULL` via the
11905        // LEFT JOIN (KEPT — preserves prior behaviour for ownerless rows).
11906        // TC-31 (0.8.20 Slice 10a): `cn.source_id` is selected off the SAME
11907        // already-present 1:1 LEFT JOIN that supplies `cn.logical_id` — one extra
11908        // column, no extra query, no row-set or ordering change.
11909        // fix-2 (codex §9 [P2]): the node-body FTS branch takes the SAME
11910        // validity conjunct, generated by `ReadView::validity_sql` rather than
11911        // hand-rolled — the predicate lives in exactly one place (Slice 10).
11912        // `?1` is the MATCH expression, so `:now` binds at `?2`.
11913        //
11914        // The generated conjunct is NULL-PERMISSIVE by construction
11915        // (`valid_from IS NULL OR ...`), which is exactly what this LEFT JOIN
11916        // needs: an ownerless `search_index` row with no `cn` reads NULL on both
11917        // columns and is KEPT, preserving the deliberate keep-ownerless
11918        // behaviour the `superseded_at` / `state` conjuncts above encode with
11919        // their explicit `OR ... IS NULL`. No extra `OR IS NULL` is needed here,
11920        // and none may be added: that would be a second, drifting copy of the
11921        // predicate.
11922        //
11923        // NO-REGRESSION: on a corpus that never authored a window every
11924        // `cn.valid_from` / `cn.valid_until` is NULL (step 22 back-filled NULL
11925        // with no DEFAULT), so both disjuncts are TRUE for every row and the
11926        // row-set, the `bm25(search_index), write_cursor` ordering and the
11927        // scores are all byte-unchanged.
11928        let text_validity = view.validity_sql("cn", 2);
11929        let join_sql = format!(
11930            "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11931             bm25(search_index), cn.logical_id, cn.source_id FROM search_index \
11932             LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11933             WHERE search_index MATCH ?1 \
11934               AND cn.superseded_at IS NULL \
11935               AND (cn.state = 'active' OR cn.state IS NULL)\
11936               {text_validity} \
11937             ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11938        );
11939        // `:now` rides at ?2 only when the view emitted a conjunct; the relaxed
11940        // view produces the byte-identical single-parameter statement.
11941        let mut text_params: Vec<rusqlite::types::Value> =
11942            vec![rusqlite::types::Value::Text(compiled.match_expression.clone())];
11943        if let Some(now) = now_param {
11944            text_params.push(rusqlite::types::Value::Integer(now));
11945        }
11946        if let Ok(mut statement) = tx.prepare(&join_sql) {
11947            let rows =
11948                statement.query_map(rusqlite::params_from_iter(text_params.iter()), |row| {
11949                    let body = row.get::<_, String>(0)?;
11950                    let logical_id = row.get::<_, Option<String>>(4)?;
11951                    Ok(SearchHit {
11952                        id: derive_stable_id(logical_id.as_deref(), &body),
11953                        body,
11954                        kind: row.get::<_, String>(1)?,
11955                        write_cursor: row.get::<_, i64>(2)? as u64,
11956                        score: row.get::<_, f64>(3)?,
11957                        branch: SoftFallbackBranch::Text,
11958                        // TC-31: the NODE's own provenance. NULL for a legacy /
11959                        // TC-11-spared governed row, and NULL for an ownerless
11960                        // `search_index` row the LEFT JOIN keeps with no `cn`.
11961                        source_id: row.get::<_, Option<String>>(5)?,
11962                        ce_score: None,
11963                    })
11964                })?;
11965            rows.flatten().collect()
11966        } else {
11967            // No `superseded_at IS NULL` filter here (and none is possible): this
11968            // fallback fires only on pre-step-12 schemas whose `canonical_nodes`
11969            // lacks `logical_id` — and step-12 adds `logical_id` and
11970            // `superseded_at` in the SAME migration, so this schema has neither
11971            // column. Supersession (`commit_batch`) is a no-op without
11972            // `logical_id`, so no superseded node rows can exist on this path.
11973            //
11974            // TC-31 (0.8.20 Slice 10a): `source_id` arrived in step 8, `logical_id`
11975            // in step 12, so a schema that lands HERE (no `logical_id`) may still
11976            // HAVE `source_id` — steps 8..11. Try a provenance-bearing variant
11977            // first, adding only `cn.source_id` over the SAME 1:1 LEFT JOIN shape
11978            // used above (row-set and ordering unchanged; a missing `cn` row keeps
11979            // NULL as before). Fall back to the historical, byte-identical
11980            // provenance-free statement on a pre-step-8 schema, where the column
11981            // genuinely does not exist and `None` is the only truthful answer.
11982            let source_sql = format!(
11983                "SELECT search_index.body, search_index.kind, search_index.write_cursor, \
11984                 bm25(search_index), cn.source_id FROM search_index \
11985                 LEFT JOIN canonical_nodes cn ON cn.write_cursor = search_index.write_cursor \
11986                 WHERE search_index MATCH ?1 \
11987                 ORDER BY bm25(search_index), search_index.write_cursor{limit_clause}"
11988            );
11989            if let Ok(mut statement) = tx.prepare(&source_sql) {
11990                let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
11991                    let body = row.get::<_, String>(0)?;
11992                    Ok(SearchHit {
11993                        // No logical_id column on this schema → content-hash id.
11994                        id: derive_stable_id(None, &body),
11995                        body,
11996                        kind: row.get::<_, String>(1)?,
11997                        write_cursor: row.get::<_, i64>(2)? as u64,
11998                        score: row.get::<_, f64>(3)?,
11999                        branch: SoftFallbackBranch::Text,
12000                        source_id: row.get::<_, Option<String>>(4)?,
12001                        ce_score: None,
12002                    })
12003                })?;
12004                rows.flatten().collect()
12005            } else {
12006                // Pre-step-8: no `source_id` column anywhere. Byte-identical to
12007                // the historical statement.
12008                let plain_sql = format!(
12009                    "SELECT body, kind, write_cursor, bm25(search_index) FROM search_index \
12010                     WHERE search_index MATCH ?1 \
12011                     ORDER BY bm25(search_index), write_cursor{limit_clause}"
12012                );
12013                let mut statement = tx.prepare(&plain_sql)?;
12014                let rows = statement.query_map([compiled.match_expression.as_str()], |row| {
12015                    let body = row.get::<_, String>(0)?;
12016                    Ok(SearchHit {
12017                        // No logical_id column on this schema → content-hash id.
12018                        id: derive_stable_id(None, &body),
12019                        body,
12020                        kind: row.get::<_, String>(1)?,
12021                        write_cursor: row.get::<_, i64>(2)? as u64,
12022                        score: row.get::<_, f64>(3)?,
12023                        branch: SoftFallbackBranch::Text,
12024                        source_id: None,
12025                        ce_score: None,
12026                    })
12027                })?;
12028                rows.flatten().collect()
12029            }
12030        }
12031    };
12032    let mut text_results: Vec<SearchHit> = Vec::with_capacity(text_candidates.len());
12033    for hit in text_candidates {
12034        if text_hit_passes_filter(&tx, hit.write_cursor, &hit.kind, filter)? {
12035            text_results.push(hit);
12036        }
12037    }
12038
12039    // G11 (Slice 15) — edge-body FTS branch from `search_index_edges`.
12040    // Appended to text_results; tagged with SoftFallbackBranch::TextEdge so
12041    // callers can distinguish edge hits from node hits.
12042    //
12043    // fix-1 [P2]: JOIN canonical_edges to exclude superseded edge rows
12044    // (invalidate-not-accumulate can leave a superseded body in the FTS index).
12045    // fix-2 [P2]: use edge_fts_hit_passes_filter (NOT text_hit_passes_filter).
12046    // Edge hits always have source_type="edge_fact"; text_hit_passes_filter
12047    // calls resolve_source_type(relation_kind) which returns Err for unknown
12048    // relation kinds, silently rejecting every edge hit when a source_type
12049    // filter is set — the exact inverse of correct behaviour.
12050    // fix-3 [P2]: edge_fts_hit_passes_filter now queries vector_default for
12051    // created_after/status (mirroring text_hit_passes_filter). Collect edge
12052    // candidates into a Vec first (drops stmt borrow on tx) so we can pass
12053    // &tx to edge_fts_hit_passes_filter without a borrow conflict.
12054    let edge_candidates: Vec<SearchHit> = {
12055        // Cause-A: the JOIN to canonical_edges already exists; additively select
12056        // `ce.logical_id` (edges always carry one) for the stable hit-id. No
12057        // ordering/row-set change.
12058        // TC-31 (0.8.20 Slice 10a): `ce.source_id` rides the SAME existing inner
12059        // JOIN as `ce.logical_id` — one extra column, no extra query, no
12060        // row-set/ordering change. An edge hit carries the EDGE's own provenance,
12061        // matching the graph arm's edge-source semantics.
12062        // fix-2 (codex §9 [P2]): the JOIN already dropped superseded edge rows,
12063        // but a body-bearing edge written with `t_invalid <= :now` (expired /
12064        // invalidated) still MATCHed and surfaced its body through ordinary
12065        // search — edge temporal validity was enforced on the graph-traversal and
12066        // projection paths but NOT on this FTS read path. Apply the shared
12067        // `edge_validity_sql` conjunct (the ONE generator every edge read site
12068        // uses, so no path can drift). `?1` is the MATCH expression, so the edge
12069        // `:now` binds at `?2`; the instant is the frozen `view.edge_now()` — a
12070        // bound value, never `datetime('now')` (the :9161 no-inline-clock rule),
12071        // and always present (edge invalidation is not relaxed by node existence
12072        // relaxation).
12073        let edge_validity = edge_validity_sql("ce", 2);
12074        let edge_sql = format!(
12075            "SELECT sei.body, sei.kind, sei.write_cursor, bm25(search_index_edges), \
12076             ce.logical_id, ce.source_id \
12077             FROM search_index_edges sei \
12078             JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
12079             WHERE search_index_edges MATCH ?1 \
12080               AND ce.superseded_at IS NULL{edge_validity} \
12081             ORDER BY bm25(search_index_edges), sei.write_cursor"
12082        );
12083        // search_index_edges may not exist on very old DBs not yet at step-14;
12084        // ignore the error gracefully (returns empty slice).
12085        if let Ok(mut stmt) = tx.prepare(&edge_sql) {
12086            if let Ok(rows) = stmt.query_map(
12087                rusqlite::params![compiled.match_expression.as_str(), view.edge_now()],
12088                |row| {
12089                    let body = row.get::<_, String>(0)?;
12090                    let logical_id = row.get::<_, Option<String>>(4)?;
12091                    Ok(SearchHit {
12092                        id: derive_stable_id(logical_id.as_deref(), &body),
12093                        body,
12094                        kind: row.get::<_, String>(1)?,
12095                        write_cursor: row.get::<_, i64>(2)? as u64,
12096                        score: row.get::<_, f64>(3)?,
12097                        branch: SoftFallbackBranch::TextEdge,
12098                        // TC-31: the EDGE's own provenance.
12099                        source_id: row.get::<_, Option<String>>(5)?,
12100                        ce_score: None,
12101                    })
12102                },
12103            ) {
12104                rows.flatten().collect()
12105            } else {
12106                Vec::new()
12107            }
12108        } else {
12109            Vec::new()
12110        }
12111    };
12112    // Attribute predicates intentionally apply only to node projections. Count
12113    // edge-FTS candidates that would otherwise pass when the caller requested
12114    // the opt-in explanation, without adding work to the default search path.
12115    let mut dropped_edge_hits = 0_u32;
12116    for row in edge_candidates {
12117        if edge_fts_hit_passes_filter(&tx, row.write_cursor, &row.kind, filter)? {
12118            text_results.push(row);
12119        } else if explain
12120            && filter.is_some_and(|active_filter| !active_filter.attributes.is_empty())
12121            && edge_fts_hit_passes_non_attribute_filter(&tx, row.write_cursor, &row.kind, filter)?
12122        {
12123            dropped_edge_hits = dropped_edge_hits.saturating_add(1);
12124        }
12125    }
12126    tx.commit()?;
12127
12128    // GA-2 / Slice-40 (◆ B-1) measurement seam: when `vector_stage_only` is set
12129    // (only ever by the eu7 recall harness via `set_vector_stage_only_for_test`,
12130    // off for every production caller), return the pre-fusion VECTOR-branch
12131    // ranking (bit-KNN K=192 + f32 rerank) verbatim, skipping `fuse_rrf` /
12132    // recency / `rerank_fused`. This exposes the ANN-quantization FIDELITY
12133    // signal — vector top-N vs the exact-f32 VECTOR top-10 ground truth — that
12134    // the AC-075 0.90 floor is defined to measure. It is NOT a `fusion_mode`
12135    // knob: the production branch below is byte-unchanged and RRF stays
12136    // unconditional.
12137    // G0 Phase-2 (BLOCK-1) side-channel meter — default (all-zero, rate 0.0) on
12138    // the non-graph-arm paths; populated by the BFS seed phase when graph-arm runs.
12139    let mut graph_stats = GraphFrontierStats::default();
12140
12141    // 0.8.8 EXP-OBS (Slice 5) — capture per-arm rank maps + counts BEFORE the arms
12142    // are consumed by fusion. All reads; only when `explain` (else zero work).
12143    // `body_rank_map` keeps the FIRST occurrence (== the rank `fuse_three_arms`
12144    // uses, which dedups keeping the first). `*_fused_scores` is captured from the
12145    // post-recency / pre-CE intermediate so `fused_score` is faithful to what
12146    // `ce_rerank` normalizes.
12147    let body_rank_map = |hits: &[SearchHit]| -> HashMap<String, u32> {
12148        let mut m: HashMap<String, u32> = HashMap::new();
12149        for (i, h) in hits.iter().enumerate() {
12150            m.entry(h.body.clone()).or_insert(i as u32);
12151        }
12152        m
12153    };
12154    let body_score_map = |hits: &[SearchHit]| -> HashMap<String, f64> {
12155        hits.iter().map(|h| (h.body.clone(), h.score)).collect()
12156    };
12157
12158    let (exp_vector_ranks, exp_text_ranks, exp_vector_n, exp_text_n) = if explain {
12159        (
12160            Some(body_rank_map(&vector_results)),
12161            Some(body_rank_map(&text_results)),
12162            vector_results.len() as u32,
12163            text_results.len() as u32,
12164        )
12165    } else {
12166        (None, None, 0, 0)
12167    };
12168    let mut exp_graph_ranks: Option<HashMap<String, u32>> = None;
12169    let mut exp_fused_scores: Option<HashMap<String, f64>> = None;
12170    let mut exp_graph_n: u32 = 0;
12171    // F9 (0.8.16 Slice 5) — per-hit importance/confidence contribution maps
12172    // (keyed by hit id == write_cursor), captured for the explain sidecar.
12173    let mut exp_importance: Option<HashMap<u64, f64>> = None;
12174    let mut exp_confidence: Option<HashMap<u64, f64>> = None;
12175
12176    let mut results = if vector_stage_only {
12177        vector_results
12178    } else if use_graph_arm {
12179        // R3 (Slice 30) — graph arm: BFS over temporal fact-edges seeded from
12180        // the top-10 two-arm fused candidates, depth ≤ 3, cap 50.
12181        // Temporal filter: superseded_at IS NULL AND (t_invalid IS NULL OR t_invalid > now).
12182        // Synthesized-node penalty: kind = 'unknown' → score *= 0.3.
12183        //
12184        // Approach: compute the two-arm fused result first (for BFS seeding),
12185        // then fuse three arms: the two-arm result (as "vector" arm), an empty
12186        // text arm, and the graph candidates. The two-arm result preserves all
12187        // existing ranking semantics; the graph arm contributes new candidates.
12188        let two_arm_fused = fuse_rrf(vector_results, text_results);
12189        // C1: seed the graph arm from the query's FTS match expression (entities /
12190        // edge-facts), not the doc-node fused hits. `fused_hits` is still passed for
12191        // the seed-body exclusion set.
12192        let (graph_candidates, stats, graph_edge_confidence) = bfs_graph_arm_candidates(
12193            reader,
12194            &two_arm_fused,
12195            compiled.match_expression.as_str(),
12196            3,
12197            50,
12198            view,
12199        )?;
12200        graph_stats = stats;
12201        if explain {
12202            exp_graph_ranks = Some(body_rank_map(&graph_candidates));
12203            exp_graph_n = graph_candidates.len() as u32;
12204        }
12205        // Named intermediate (byte-identical to the prior nested call) so explain
12206        // can read the pre-CE fused scores without perturbing the ranking.
12207        let fused = apply_recency_reweight(
12208            fuse_three_arms(two_arm_fused, vec![], graph_candidates),
12209            recency_enabled,
12210        );
12211        // F9 — importance (node) / confidence (edge) reweight, OFF by default.
12212        // Order: AFTER recency (consistent placement), BEFORE the CE rerank seam.
12213        let (imp_map, mut conf_map) = if importance_enabled || explain {
12214            build_importance_confidence_maps(reader, &fused).unwrap_or_default()
12215        } else {
12216            (HashMap::new(), HashMap::new())
12217        };
12218        // F9 FIX-1: `build_importance_confidence_maps` keys edge confidence on the
12219        // EDGE `write_cursor`, which never matches a graph-arm NODE hit's cursor —
12220        // so it alone leaves graph-arm hits with no edge confidence. Merge the
12221        // BFS-collected per-node traversing-edge confidence (node cursor ⇒ conf).
12222        // Node/edge cursors are globally unique, so there is never a key collision
12223        // with the edge-fact confidence above; `or_insert` documents that intent.
12224        if importance_enabled || explain {
12225            for (cursor, conf) in &graph_edge_confidence {
12226                conf_map.entry(*cursor).or_insert(*conf);
12227            }
12228        }
12229        let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
12230        if explain {
12231            exp_importance = Some(imp_map);
12232            exp_confidence = Some(conf_map);
12233            exp_fused_scores = Some(body_score_map(&fused));
12234        }
12235        rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
12236    } else {
12237        // G9 + G12: RRF-fuse the two ranked branches (keyed on body, vector-first
12238        // tiebreak) into the unconditional new ranking, recency-reweight (gated,
12239        // off by default), then pass through the identity rerank seam. The
12240        // vector-empty `soft_fallback` signal was computed above, BEFORE this
12241        // branch-collapse.
12242        let fused = apply_recency_reweight(fuse_rrf(vector_results, text_results), recency_enabled);
12243        // F9 — importance (node) / confidence (edge) reweight, OFF by default.
12244        // Same placement as the graph-arm branch: after recency, before CE rerank.
12245        let (imp_map, conf_map) = if importance_enabled || explain {
12246            build_importance_confidence_maps(reader, &fused).unwrap_or_default()
12247        } else {
12248            (HashMap::new(), HashMap::new())
12249        };
12250        let fused = apply_importance_reweight(fused, &imp_map, &conf_map, importance_enabled);
12251        if explain {
12252            exp_importance = Some(imp_map);
12253            exp_confidence = Some(conf_map);
12254            exp_fused_scores = Some(body_score_map(&fused));
12255        }
12256        rerank_fused(raw_query, fused, rerank_depth, alpha, pool_n)
12257    };
12258
12259    results.truncate(final_limit);
12260
12261    // 0.8.8 EXP-OBS — assemble the sidecar `Explanation` from the captured maps +
12262    // the final `results`. `embedder_id` is left empty here (the worker has no
12263    // identity) and filled by `search_inner_with_stats`.
12264    let explanation = if explain {
12265        let fused_scores = exp_fused_scores.unwrap_or_default();
12266        let per_hit: Vec<PerHitExplain> = results
12267            .iter()
12268            .map(|h| PerHitExplain {
12269                // `PerHitExplain.id` carries the engine-internal positional
12270                // `write_cursor` (the pre-C-2 `SearchHit.id`), matching the
12271                // telemetry `result_ids` / importance-map key space; the typed
12272                // `SearchHit.id` is the separate caller-facing identity.
12273                id: h.write_cursor,
12274                arm: h.branch,
12275                vector_rank: exp_vector_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
12276                text_rank: exp_text_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
12277                graph_rank: exp_graph_ranks.as_ref().and_then(|m| m.get(&h.body).copied()),
12278                fused_score: fused_scores.get(&h.body).copied().unwrap_or(h.score),
12279                ce_score: h.ce_score,
12280                blended: h.score,
12281                importance: exp_importance.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
12282                confidence: exp_confidence.as_ref().and_then(|m| m.get(&h.write_cursor).copied()),
12283            })
12284            .collect();
12285        let ce_active = rerank_depth > 0 && per_hit.iter().any(|p| p.ce_score.is_some());
12286        Some(Explanation {
12287            trace: QueryTrace {
12288                query_chars: raw_query.chars().count() as u32,
12289                k: final_limit as u32,
12290                rerank_depth: rerank_depth as u32,
12291                pool_n: pool_n as u32,
12292                alpha,
12293                use_graph_arm,
12294                recency: recency_enabled,
12295                embedder_id: String::new(),
12296                ce_active,
12297                vector_hits: exp_vector_n,
12298                text_hits: exp_text_n,
12299                graph_hits: exp_graph_n,
12300                dropped_edge_hits,
12301            },
12302            per_hit,
12303        })
12304    } else {
12305        None
12306    };
12307
12308    Ok((cursor, soft_fallback, results, graph_stats, explanation))
12309}
12310
12311/// R3 (Slice 30) + C1 (0.8.1 graph-arm seeding) — graph-arm BFS candidate generation.
12312///
12313/// **C1 seeding (the BLOCK-1 fix):** the frontier is seeded from the graph's OWN
12314/// query-matched text surfaces — NOT from doc-node hits (doc nodes carry
12315/// `logical_id = NULL`, so the old doc-seeding produced an empty frontier). Two
12316/// seed sources are unioned on `match_expression` (the compiled FTS query):
12317///   A. **edge-fact FTS** (`search_index_edges`) — both endpoints (`from_id`,
12318///      `to_id`) of matched, temporally-live, non-fallback edges;
12319///   B. **entity-node FTS** (`search_index` ⋈ `canonical_nodes`) — matched nodes
12320///      with `logical_id IS NOT NULL` (excludes doc nodes — the bug surface).
12321/// Each distinct candidate `logical_id` is counted in `seeds_considered`; those
12322/// confirmed active in `canonical_nodes` are `seeds_resolved` and pushed onto the
12323/// frontier (dangling edge endpoints count considered-but-unresolved).
12324///
12325/// Phase 2 is unchanged: BFS over `canonical_edges` with the temporal filter,
12326/// carrying each traversed edge's `source_id` (G0 BLOCK-2) onto the emitted hit.
12327/// Collects reachable node bodies (up to `cap`) as [`SearchHit`]s tagged
12328/// `SoftFallbackBranch::GraphArm`. Score = `1.0 / (1.0 + hop_count)` with a
12329/// synthesized-node penalty (`kind = 'unknown'` → score *= 0.3). Bodies already
12330/// present in `fused_hits` are excluded (already covered by the two-arm result).
12331///
12332/// **F9 (0.8.16 Slice 5) confidence carry:** the third tuple element maps each
12333/// emitted graph-arm hit's `write_cursor` (its `SearchHit.id`, a NODE cursor) to
12334/// the `confidence` of the EDGE traversed to reach that node — the input the F9
12335/// reweight (`graph_rrf_score(edge) = confidence × 1/(K+bfs_rank)`) consumes.
12336/// `build_importance_confidence_maps` keys edge confidence on the EDGE
12337/// `write_cursor`, which never equals a reached node's cursor, so without this
12338/// carry edge confidence never reaches a graph-arm hit. **Determinism rule (matches
12339/// the BLOCK-2 provenance carry):** when several edges reach the same node, the
12340/// FIRST edge to claim the node in the `visited` dedup wins — i.e. the edge that
12341/// produced the node's winning `bfs_rank` (seeds are considered before Phase-2
12342/// neighbors; within a phase, `ORDER BY write_cursor` makes the earliest-written
12343/// edge win). A NULL edge confidence is simply not inserted ⇒ neutral (1.0).
12344fn bfs_graph_arm_candidates(
12345    reader: &mut Connection,
12346    fused_hits: &[SearchHit],
12347    match_expression: &str,
12348    max_depth: u32,
12349    cap: usize,
12350    view: FrozenView,
12351) -> rusqlite::Result<(Vec<SearchHit>, GraphFrontierStats, HashMap<u64, f64>)> {
12352    // fix-2 (codex §9 [P2]): the opt-in graph arm hydrates NODES too, so it takes
12353    // the same validity conjunct as the vector and FTS branches — otherwise
12354    // `search_reranked(.., use_graph_arm = true)` would keep the exact leak the
12355    // other two branches just closed. Same generator, same bound `:now`.
12356    //
12357    // fix-3 (F2): the instant arrives ALREADY RESOLVED in the `FrozenView` — it
12358    // is the identical value the vector and FTS arms bound. This arm cannot
12359    // re-read the clock: a `FrozenView` carries no route to one.
12360    let now_param = view.now_param();
12361    // C1 — seed-FTS fan-out cap per source (A: edge endpoints, B: entity nodes).
12362    const SEED_FTS_N: usize = 10;
12363    const SYNTHESIZED_PENALTY: f64 = 0.3;
12364
12365    // Bodies already in the fused result — exclude these from graph arm output.
12366    let seed_bodies: std::collections::HashSet<&str> =
12367        fused_hits.iter().map(|h| h.body.as_str()).collect();
12368
12369    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12370
12371    let mut frontier: VecDeque<(String, u32)> = VecDeque::new(); // (logical_id, depth)
12372    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
12373    let mut candidates: Vec<SearchHit> = Vec::new();
12374    // F9 (0.8.16 Slice 5) — per-hit traversing-edge confidence, keyed by the
12375    // emitted hit's NODE `write_cursor`. First edge to reach a node wins (visited
12376    // dedup); NULL confidence is never inserted (⇒ neutral in the reweight).
12377    let mut edge_confidence_by_cursor: HashMap<u64, f64> = HashMap::new();
12378    // G0 Phase-2 (BLOCK-1) frontier meter — distinct seed candidates considered vs
12379    // resolved-active; `resolved_seed_rate` flips 0→>0 once entities/edge-facts seed.
12380    let mut stats = GraphFrontierStats::default();
12381    {
12382        // C1 seeding — gather distinct candidate (logical_id, provenance source_id)
12383        // pairs from the graph's OWN query-matched FTS surfaces (NOT doc-node hits).
12384        // Order-preserving dedup (first provenance wins) so `seeds_considered` counts
12385        // each candidate once. `source_id` is the session the seed traces back to: the
12386        // matched edge's `source_id` (source A) or the entity node's own (source B).
12387        // F9: each seed carries the confidence of the edge that surfaced it
12388        // (`None` for entity-FTS seeds, which have no traversing edge).
12389        let mut candidate_seeds: Vec<(String, Option<String>, Option<f64>)> = Vec::new();
12390        let mut seen_candidates: std::collections::HashSet<String> =
12391            std::collections::HashSet::new();
12392        let push_candidate =
12393            |lid: String,
12394             source_id: Option<String>,
12395             confidence: Option<f64>,
12396             seen: &mut std::collections::HashSet<String>,
12397             out: &mut Vec<(String, Option<String>, Option<f64>)>| {
12398                if seen.insert(lid.clone()) {
12399                    out.push((lid, source_id, confidence));
12400                }
12401            };
12402
12403        // Seed source A — edge-fact endpoints (primary). Both endpoints of each
12404        // matched, temporally-live, non-fallback edge are candidate seeds, tagged with
12405        // the edge's `source_id` provenance and (F9) `confidence`. `search_index_edges`
12406        // may be absent on very old DBs (< step-14) — degrade to no edge seeds rather
12407        // than error.
12408        // TC-33: `?1` MATCH, `?2` LIMIT ⇒ the edge `:now` binds at `?3`.
12409        if let Ok(mut edge_seed_stmt) = tx.prepare(&format!(
12410            "SELECT ce.from_id, ce.to_id, ce.source_id, ce.confidence \
12411             FROM search_index_edges sei \
12412             JOIN canonical_edges ce ON ce.write_cursor = sei.write_cursor \
12413             WHERE search_index_edges MATCH ?1 \
12414               AND ce.superseded_at IS NULL{} \
12415               AND (ce.temporal_fallback IS NULL OR ce.temporal_fallback = 0) \
12416             ORDER BY bm25(search_index_edges), sei.write_cursor \
12417             LIMIT ?2",
12418            edge_validity_sql("ce", 3)
12419        )) {
12420            let rows = edge_seed_stmt.query_map(
12421                rusqlite::params![match_expression, SEED_FTS_N as i64, view.edge_now()],
12422                |row| {
12423                    Ok((
12424                        row.get::<_, String>(0)?,
12425                        row.get::<_, String>(1)?,
12426                        row.get::<_, Option<String>>(2)?,
12427                        row.get::<_, Option<f64>>(3)?,
12428                    ))
12429                },
12430            )?;
12431            for quad in rows {
12432                let (from_id, to_id, source_id, confidence) = quad?;
12433                push_candidate(
12434                    from_id,
12435                    source_id.clone(),
12436                    confidence,
12437                    &mut seen_candidates,
12438                    &mut candidate_seeds,
12439                );
12440                push_candidate(
12441                    to_id,
12442                    source_id,
12443                    confidence,
12444                    &mut seen_candidates,
12445                    &mut candidate_seeds,
12446                );
12447            }
12448        }
12449
12450        // Seed source B — entity-node FTS (isolated / strongly-named entities).
12451        // `logical_id IS NOT NULL` structurally excludes doc nodes (the bug surface).
12452        // Provenance = the node's own `source_id` (the session it was extracted from).
12453        {
12454            // `?1` MATCH, `?2` LIMIT ⇒ `:now` binds at `?3`.
12455            let seed_validity = view.validity_sql("cn", 3);
12456            let mut node_seed_stmt = tx.prepare(&format!(
12457                "SELECT cn.logical_id, cn.source_id \
12458                 FROM search_index si \
12459                 JOIN canonical_nodes cn ON cn.write_cursor = si.write_cursor \
12460                 WHERE search_index MATCH ?1 \
12461                   AND cn.superseded_at IS NULL \
12462                   AND cn.state = 'active' \
12463                   AND cn.logical_id IS NOT NULL\
12464                   {seed_validity} \
12465                 ORDER BY bm25(search_index), si.write_cursor \
12466                 LIMIT ?2"
12467            ))?;
12468            let mut seed_params: Vec<rusqlite::types::Value> = vec![
12469                rusqlite::types::Value::Text(match_expression.to_string()),
12470                rusqlite::types::Value::Integer(SEED_FTS_N as i64),
12471            ];
12472            if let Some(now) = now_param {
12473                seed_params.push(rusqlite::types::Value::Integer(now));
12474            }
12475            let rows = node_seed_stmt
12476                .query_map(rusqlite::params_from_iter(seed_params.iter()), |row| {
12477                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12478                })?;
12479            for pair in rows {
12480                let (lid, source_id) = pair?;
12481                // Entity-FTS seed: no traversing edge ⇒ no edge confidence (neutral).
12482                push_candidate(lid, source_id, None, &mut seen_candidates, &mut candidate_seeds);
12483            }
12484        }
12485
12486        // Resolve + emit: a seed is `resolved` only if an ACTIVE canonical_node carries
12487        // that logical_id (dangling edge endpoints count considered-not-resolved). A
12488        // resolved seed is BOTH a BFS root AND emitted as a graph-arm candidate (depth
12489        // 0, hop_score 1.0) — so an edge-only query match surfaces the connected ENTITY
12490        // nodes, not just the fact body (codex §9 [P2]). Seeds whose body is already in
12491        // the two-arm result are skipped; the cap is respected.
12492        let active_validity = view.validity_sql("canonical_nodes", 2);
12493        let mut active_stmt = tx.prepare(&format!(
12494            "SELECT kind, body, write_cursor FROM canonical_nodes \
12495             WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
12496             {active_validity} LIMIT 1"
12497        ))?;
12498        for (lid, source_id, seed_confidence) in candidate_seeds {
12499            stats.seeds_considered += 1;
12500            let mut active_params: Vec<rusqlite::types::Value> =
12501                vec![rusqlite::types::Value::Text(lid.clone())];
12502            if let Some(now) = now_param {
12503                active_params.push(rusqlite::types::Value::Integer(now));
12504            }
12505            let row: Option<(String, String, i64)> = active_stmt
12506                .query_row(rusqlite::params_from_iter(active_params.iter()), |r| {
12507                    Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
12508                })
12509                .optional()?;
12510            if let Some((kind, body, write_cursor)) = row {
12511                stats.seeds_resolved += 1;
12512                if visited.insert(lid.clone()) {
12513                    // Cause-A: the seed's `logical_id` is in hand (`lid`) — derive the
12514                    // stable id before `lid` is moved onto the frontier (zero extra query).
12515                    let id = derive_stable_id(Some(&lid), &body);
12516                    frontier.push_back((lid, 0));
12517                    if !seed_bodies.contains(body.as_str()) && candidates.len() < cap {
12518                        // depth-0 hop_score = 1.0/(1.0+0) = 1.0; synthesized penalty for
12519                        // 'unknown' kind (mirrors the Phase-2 neighbor scoring).
12520                        let score = if kind == "unknown" { SYNTHESIZED_PENALTY } else { 1.0 };
12521                        // F9: an edge-seeded endpoint carries its seeding edge's
12522                        // confidence (source A); entity-FTS seeds carry None.
12523                        if let Some(c) = seed_confidence {
12524                            edge_confidence_by_cursor.insert(write_cursor as u64, c);
12525                        }
12526                        candidates.push(SearchHit {
12527                            id,
12528                            write_cursor: write_cursor as u64,
12529                            kind,
12530                            body,
12531                            score,
12532                            branch: SoftFallbackBranch::GraphArm,
12533                            source_id,
12534                            ce_score: None,
12535                        });
12536                    }
12537                }
12538            }
12539        }
12540    }
12541    stats.frontier_nonempty = !frontier.is_empty();
12542
12543    // Phase 2: BFS over canonical_edges (temporal filter). `candidates` already
12544    // holds the depth-0 emitted seeds; BFS appends the reachable neighbors.
12545    // Both statements are prepared ONCE outside the loops — re-preparing inside
12546    // would issue O(frontier_size × neighbors) sqlite3_prepare_v2 calls.
12547    let mut edge_stmt = tx.prepare(
12548        // G0 Phase-2 (BLOCK-2): carry the traversed edge's `source_id` so a
12549        // graph-reached neighbor can resolve back to the session it was extracted
12550        // from. `ORDER BY e.write_cursor` makes the traversal deterministic: when
12551        // several active edges connect this node to the SAME neighbor with
12552        // different `source_id`s, the earliest-written edge wins the `visited`
12553        // dedup, so the carried provenance is stable (not SQLite-order-dependent).
12554        // (codex §9 [P2]; the design §B already rejected the memo's arbitrary
12555        // `LIMIT 1` lookup for the same reason.)
12556        // F9: also carry the traversed edge's `confidence` — the reweight input for
12557        // the reached node (keyed downstream by the node's `write_cursor`). Same
12558        // determinism as `source_id`: the earliest-written edge wins the `visited`
12559        // dedup, so the reached node's confidence is the winning-`bfs_rank` edge's.
12560        // TC-33: `?1` is the anchor logical_id ⇒ the edge `:now` binds at `?2`.
12561        &format!(
12562            "SELECT e.from_id, e.to_id, e.source_id, e.confidence \
12563             FROM canonical_edges e \
12564             WHERE (e.from_id = ?1 OR e.to_id = ?1) \
12565               AND e.superseded_at IS NULL{} \
12566               AND (e.temporal_fallback IS NULL OR e.temporal_fallback = 0) \
12567             ORDER BY e.write_cursor \
12568             LIMIT 64",
12569            edge_validity_sql("e", 2)
12570        ),
12571    )?;
12572    // Fetch write_cursor alongside kind+body so graph-arm hits carry a real id
12573    // for apply_recency_reweight (id=0 would force min_id=0 and distort span).
12574    let body_validity = view.validity_sql("canonical_nodes", 2);
12575    let mut body_stmt = tx.prepare(&format!(
12576        "SELECT kind, body, write_cursor FROM canonical_nodes \
12577         WHERE logical_id = ?1 AND superseded_at IS NULL AND state = 'active'\
12578         {body_validity} \
12579         LIMIT 1"
12580    ))?;
12581
12582    while let Some((lid, depth)) = frontier.pop_front() {
12583        if candidates.len() >= cap {
12584            break;
12585        }
12586        if depth >= max_depth {
12587            continue;
12588        }
12589
12590        // Fetch temporal-live neighbors via edges, each paired with the
12591        // traversing edge's `source_id` (BLOCK-2 provenance carry) and (F9)
12592        // `confidence` (the reweight input for the reached node).
12593        let neighbors: Vec<(String, Option<String>, Option<f64>)> = {
12594            let rows = edge_stmt.query_map(params![&lid, view.edge_now()], |row| {
12595                Ok((
12596                    row.get::<_, String>(0)?,
12597                    row.get::<_, String>(1)?,
12598                    row.get::<_, Option<String>>(2)?,
12599                    row.get::<_, Option<f64>>(3)?,
12600                ))
12601            })?;
12602            rows.flatten()
12603                .map(|(from_id, to_id, source_id, confidence)| {
12604                    let neighbor = if from_id == lid { to_id } else { from_id };
12605                    (neighbor, source_id, confidence)
12606                })
12607                .collect()
12608        };
12609
12610        for (neighbor, edge_source_id, edge_confidence) in neighbors {
12611            if visited.contains(&neighbor) {
12612                continue;
12613            }
12614            visited.insert(neighbor.clone());
12615
12616            // Fetch neighbor body + write_cursor from canonical_nodes.
12617            let mut body_params: Vec<rusqlite::types::Value> =
12618                vec![rusqlite::types::Value::Text(neighbor.clone())];
12619            if let Some(now) = now_param {
12620                body_params.push(rusqlite::types::Value::Integer(now));
12621            }
12622            let row: Option<(String, String, i64)> = body_stmt
12623                .query_row(rusqlite::params_from_iter(body_params.iter()), |row| {
12624                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?))
12625                })
12626                .optional()?;
12627
12628            if let Some((kind, body, write_cursor)) = row {
12629                // Skip bodies already covered by the two-arm result.
12630                if !seed_bodies.contains(body.as_str()) {
12631                    let hop_score = 1.0 / (1.0 + (depth + 1) as f64);
12632                    let score =
12633                        if kind == "unknown" { hop_score * SYNTHESIZED_PENALTY } else { hop_score };
12634                    // Cause-A: the neighbor's `logical_id` is `neighbor` (still in
12635                    // scope here; only moved onto the frontier below) — derive the
12636                    // stable id with no extra query.
12637                    let id = derive_stable_id(Some(&neighbor), &body);
12638                    // F9: record the traversing edge's confidence for this node
12639                    // (first edge wins — this is the winning-`bfs_rank` edge).
12640                    if let Some(c) = edge_confidence {
12641                        edge_confidence_by_cursor.insert(write_cursor as u64, c);
12642                    }
12643                    candidates.push(SearchHit {
12644                        id,
12645                        write_cursor: write_cursor as u64,
12646                        kind,
12647                        body,
12648                        score,
12649                        branch: SoftFallbackBranch::GraphArm,
12650                        // BLOCK-2: the session this fact-edge was extracted from.
12651                        source_id: edge_source_id.clone(),
12652                        ce_score: None,
12653                    });
12654                    if candidates.len() >= cap {
12655                        break;
12656                    }
12657                }
12658                // Always push neighbor to frontier for further BFS expansion.
12659                frontier.push_back((neighbor, depth + 1));
12660            }
12661        }
12662    }
12663
12664    drop(edge_stmt);
12665    drop(body_stmt);
12666    tx.commit()?;
12667    stats.graph_candidates_emitted = candidates.len() as u32;
12668    Ok((candidates, stats, edge_confidence_by_cursor))
12669}
12670
12671/// Slice 30 (G3) — the ~1M cap on a single op-store read-back page. The public
12672/// `read.collection` / `read.mutations` LIMIT is `min(caller_limit, this)`, so
12673/// no API path can issue an unbounded SELECT. Cursor/limit hardening under a
12674/// genuine ~1M-row append-only log is reserved-gap Slice 32.
12675const READ_COLLECTION_MAX_LIMIT: usize = 1_000_000;
12676
12677/// Slice 30 (G2) — active-only point lookup by `logical_id` on the DEFERRED
12678/// reader tx (mirrors `read_search_in_tx`'s snapshot-stable BEGIN DEFERRED). One
12679/// returned slot per requested id, in REQUEST ORDER; `None` where no ACTIVE row
12680/// (`superseded_at IS NULL`) carries that id. Mirrors the `:4170` canonical
12681/// projection columns + `logical_id`; superseded versions are never returned.
12682fn read_get_by_id_in_tx(
12683    reader: &mut Connection,
12684    logical_ids: &[String],
12685    view: &ReadView,
12686) -> rusqlite::Result<Vec<Option<NodeRecord>>> {
12687    if logical_ids.is_empty() {
12688        return Ok(Vec::new());
12689    }
12690    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12691    // De-duplicate the requested ids for the IN(...) probe, then re-expand into
12692    // request order (a repeated id echoes the same active row).
12693    let mut found: HashMap<String, NodeRecord> = HashMap::new();
12694    {
12695        let unique: Vec<&String> = {
12696            let mut seen = std::collections::HashSet::new();
12697            logical_ids.iter().filter(|id| seen.insert((*id).clone())).collect()
12698        };
12699        let placeholders = std::iter::repeat_n("?", unique.len()).collect::<Vec<_>>().join(", ");
12700        // The `?` placeholders above auto-number 1..=unique.len(), so the
12701        // validity instant takes the next positional slot.
12702        let now_idx = unique.len() + 1;
12703        let node_sql = view.node_sql("canonical_nodes", now_idx);
12704        // R-20-RV: with `include_superseded` a logical_id can match several
12705        // rows. `ORDER BY write_cursor` + last-write-wins into `found` resolves
12706        // the slot DETERMINISTICALLY to the most recent version, rather than
12707        // leaving it at the mercy of scan order.
12708        let sql = format!(
12709            "SELECT logical_id, kind, body, write_cursor
12710             FROM canonical_nodes
12711             WHERE logical_id IN ({placeholders}){node_sql}
12712             ORDER BY write_cursor"
12713        );
12714        let mut statement = tx.prepare(&sql)?;
12715        let mut binds: Vec<rusqlite::types::Value> =
12716            unique.iter().map(|s| rusqlite::types::Value::Text((*s).clone())).collect();
12717        if let Some(now) = view.now_param() {
12718            binds.push(rusqlite::types::Value::Integer(now));
12719        }
12720        let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
12721            let logical_id: String = row.get(0)?;
12722            Ok(NodeRecord {
12723                logical_id,
12724                kind: row.get(1)?,
12725                body: row.get(2)?,
12726                write_cursor: row.get::<_, i64>(3)? as u64,
12727            })
12728        })?;
12729        for row in rows {
12730            let record = row?;
12731            found.insert(record.logical_id.clone(), record);
12732        }
12733    }
12734    // tx is read-only; dropping it rolls back the (empty) transaction.
12735    let out = logical_ids.iter().map(|id| found.get(id).cloned()).collect();
12736    Ok(out)
12737}
12738
12739/// Slice 30 (G3) — paginated op-store read-back over `operational_mutations` for
12740/// one `collection`, `ORDER BY id`, on the DEFERRED reader tx. The effective SQL
12741/// LIMIT is `min(limit, READ_COLLECTION_MAX_LIMIT)`; a caller `limit == 0`
12742/// returns an empty `Vec` without a SELECT. The after-id cursor (`id > ?`,
12743/// default 0) excludes the boundary row. The `_for_test` SELECTs
12744/// (`lib.rs` op-store probes) are a shape oracle only — this is a new statement.
12745///
12746/// Slice 33 (G3 / F4-READ) — hardened under a genuine large multi-collection log:
12747/// the SELECT rides the step-13 `operational_mutations(collection_name, id)`
12748/// index (`SEARCH … USING INDEX …(collection_name=? AND id>?)`), so the per-page
12749/// cost is O(page) — the leading `collection_name` equality fixes the prefix and
12750/// the trailing `id` serves both the cursor range and `ORDER BY id` with no temp
12751/// B-tree. The cursor is normalized with `.max(0)` so a negative `after_id` is
12752/// explicitly clamped to the start of the log (ids are ≥ 1) and is never confused
12753/// with a row id; `after_id` past the end and unknown collections yield empty
12754/// pages.
12755fn read_collection_in_tx(
12756    reader: &mut Connection,
12757    collection: &str,
12758    after_id: Option<i64>,
12759    limit: usize,
12760) -> rusqlite::Result<Vec<OpStoreRow>> {
12761    if limit == 0 {
12762        return Ok(Vec::new());
12763    }
12764    let clamped = limit.min(READ_COLLECTION_MAX_LIMIT) as i64;
12765    // Normalize the cursor: a negative after_id is clamped to the start of the
12766    // log. `operational_mutations.id` is autoincrement (≥ 1), so `id > 0` is the
12767    // full log; clamping removes the "is a negative cursor a sentinel or a row
12768    // id?" ambiguity without changing happy-path semantics.
12769    let after = after_id.unwrap_or(0).max(0);
12770    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12771    let mut statement = tx.prepare(
12772        "SELECT id, collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
12773         FROM operational_mutations
12774         WHERE collection_name = ?1 AND id > ?2
12775         ORDER BY id
12776         LIMIT ?3",
12777    )?;
12778    let rows = statement.query_map(params![collection, after, clamped], |row| {
12779        Ok(OpStoreRow {
12780            id: row.get(0)?,
12781            collection: row.get(1)?,
12782            record_key: row.get(2)?,
12783            op_kind: row.get(3)?,
12784            payload: row.get(4)?,
12785            schema_id: row.get(5)?,
12786            write_cursor: row.get::<_, i64>(6)? as u64,
12787        })
12788    })?;
12789    let mut out = Vec::new();
12790    for row in rows {
12791        out.push(row?);
12792    }
12793    Ok(out)
12794}
12795
12796/// Slice 35 (G4) — execute `read.list` inside a DEFERRED reader transaction.
12797///
12798/// Builds parameterized SQL: `kind = ?1 AND superseded_at IS NULL [AND
12799/// json_extract(body, '$.field') <op> ?N ...]` — injection-safe because:
12800///   (a) `kind` is `?1` (bound parameter);
12801///   (b) each predicate value is a bound `?N` parameter;
12802///   (c) the json_extract path is the ALLOWLIST ENTRY (a server-side constant
12803///       validated at `Predicate` construction time), never the raw caller string;
12804///   (d) `ComparisonOp` compiles to a server-side literal operator string from a
12805///       closed enum, not a caller-supplied string.
12806fn read_list_in_tx(
12807    reader: &mut Connection,
12808    kind: &str,
12809    predicates: &[Predicate],
12810    limit: usize,
12811    view: &ReadView,
12812) -> rusqlite::Result<Vec<NodeRecord>> {
12813    if limit == 0 {
12814        return Ok(Vec::new());
12815    }
12816    // Build the SQL WHERE clauses for each predicate.
12817    // Parameters: ?1 = kind; ?2..?N = predicate values; limit is inlined.
12818    // `logical_id IS NOT NULL` is a SQL-level predicate so that LIMIT counts
12819    // only rows that can be represented as NodeRecord (which requires a non-null
12820    // String logical_id). Anonymous nodes (PreparedWrite::Node { logical_id: None })
12821    // cannot be included in NodeRecord results and are excluded before LIMIT.
12822    // When predicates are present we add `json_valid(body)` so rows with
12823    // non-JSON bodies are skipped rather than causing a `malformed JSON` error.
12824    let json_valid_guard = if predicates.is_empty() { "" } else { " AND json_valid(body)" };
12825    // R-20-RV/R-20-NV: the view's predicates replace the previously hard-coded
12826    // existence pair. The validity instant takes the positional slot AFTER the
12827    // predicate binds (?1 = kind, ?2..=?(1+n) = predicate values), so it is
12828    // `?{predicates.len() + 2}`. Positional `?N` is order-independent in SQLite,
12829    // so emitting it here — textually before the predicate clauses appended
12830    // below — is safe and unambiguous.
12831    let now_idx = predicates.len() + 2;
12832    let node_sql = view.node_sql("canonical_nodes", now_idx);
12833    let mut sql = format!(
12834        "SELECT logical_id, kind, body, write_cursor \
12835         FROM canonical_nodes \
12836         WHERE kind = ?1{node_sql} \
12837         AND logical_id IS NOT NULL{json_valid_guard}"
12838    );
12839
12840    // Predicate params start at ?2.
12841    for (i, pred) in predicates.iter().enumerate() {
12842        let param_idx = i + 2; // ?1 is kind
12843        sql.push_str(" AND ");
12844        sql.push_str(&pred.to_sql_clause(param_idx));
12845    }
12846    sql.push_str(&format!(" LIMIT {limit}"));
12847
12848    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
12849    let mut statement = tx.prepare(&sql)?;
12850
12851    // Bind all parameters: [kind, predicate_values...]
12852    let mut params: Vec<rusqlite::types::Value> = Vec::with_capacity(2 + predicates.len());
12853    params.push(rusqlite::types::Value::Text(kind.to_string()));
12854    for pred in predicates {
12855        params.push(pred.bind_value());
12856    }
12857    // Lands at index `now_idx` (= predicates.len() + 2), matching `?{now_idx}`
12858    // emitted by `ReadView::validity_sql`. Omitted entirely when the view
12859    // relaxes validity, in which case no `?{now_idx}` was emitted either.
12860    if let Some(now) = view.now_param() {
12861        params.push(rusqlite::types::Value::Integer(now));
12862    }
12863
12864    let rows = statement.query_map(rusqlite::params_from_iter(params.iter()), |row| {
12865        Ok(NodeRecord {
12866            logical_id: row.get(0)?,
12867            kind: row.get(1)?,
12868            body: row.get(2)?,
12869            write_cursor: row.get::<_, i64>(3)? as u64,
12870        })
12871    })?;
12872
12873    let mut out = Vec::new();
12874    for row in rows {
12875        out.push(row?);
12876    }
12877    Ok(out)
12878}
12879
12880// ---------------------------------------------------------------------------
12881// Slice 20 (G5/G6) — BFS graph-traversal helpers
12882// ---------------------------------------------------------------------------
12883
12884/// Hard cap on the number of nodes returned by a single `graph_neighbors` call.
12885/// Ported from v0.5.6 `MAX_TRAVERSAL_DEPTH` (applied as a LIMIT on the CTE and
12886/// the final SELECT). Defense-in-depth against unbounded traversal.
12887const GRAPH_NEIGHBORS_HARD_CAP: usize = 50;
12888
12889/// Build the BFS CTE SQL for the given `direction`, under `view`.
12890///
12891/// Parameters (positional):
12892///   `?1` — root `logical_id`
12893///   `?2` — max_depth (`u32`, SDK-facing depth ceiling ≤ 3)
12894///   `?3` — R-20-NV node-validity instant (`:now` seam), emitted at EVERY node
12895///          position; omitted entirely when the view relaxes validity.
12896///
12897/// `LIMIT {GRAPH_NEIGHBORS_HARD_CAP}` appears on both the CTE and the final SELECT.
12898///
12899/// # Why one template instead of three
12900///
12901/// The three directions previously carried three hand-maintained copies of the
12902/// CTE, each repeating the node predicate at THREE positions (anchor, recursive
12903/// join, final projection) — nine hand-written copies in total. R-20-RV requires
12904/// a relax flag to apply at every one of them, and nine copies is exactly the
12905/// shape in which "it works on `Outgoing` but silently not on `Both`" hides. The
12906/// directions are folded into ONE template parameterised by the two things that
12907/// actually differ (the edge join condition and the traversed-to expression), so
12908/// `view.node_sql(...)` is written once per position and applying to all three
12909/// directions is structural rather than a thing to remember.
12910///
12911/// **TC-33: the `canonical_edges` temporal filter is now parameterised too.** It
12912/// was `datetime(e.t_invalid) > datetime('now')` inline, deliberately left alone
12913/// while edge validity was ISO-8601 TEXT. Edge timestamps are INTEGER epoch
12914/// seconds now, so the predicate is generated by [`edge_validity_sql`] and binds
12915/// the frozen instant at `?4` — no inline clock remains in this template.
12916fn build_bfs_sql(direction: TraversalDirection, view: &ReadView) -> String {
12917    let cap = GRAPH_NEIGHBORS_HARD_CAP;
12918    // cte_cap: the SQLite CTE LIMIT counts path-rows, not distinct nodes. In a
12919    // multigraph (multiple parallel edges between the same pair of nodes), the CTE
12920    // can contain duplicate-target rows before the final SELECT DISTINCT. A cap of
12921    // cap+1 would be exhausted by ~50 parallel edges to the same node, preventing
12922    // other neighbors from being discovered. Use cap*cap as a generous safety
12923    // ceiling that still bounds CTE growth for any realistic graph while allowing
12924    // the final SELECT LIMIT cap to be the authoritative distinct-node cap.
12925    let cte_cap = cap * cap;
12926    // Cycle guard uses char(30) (ASCII Record Separator, 0x1E) as delimiter instead
12927    // of comma, so logical_ids containing commas are handled correctly. char(30) is
12928    // a non-printable control character that callers cannot place in logical_id values
12929    // via normal text input.
12930    //
12931    // `?3` is the node-validity instant. Positional (not named), so the repeated
12932    // occurrences across the three node positions all bind the SAME value once.
12933    const NOW_IDX: usize = 3;
12934    // TC-33: `?4` is the EDGE-validity instant, bound separately because the node
12935    // instant is `Option` (relaxed by `include_out_of_window`) while edge recency
12936    // is always applied.
12937    const EDGE_NOW_IDX: usize = 4;
12938
12939    // The ONLY two things that differ between directions.
12940    let (edge_join, target_expr) = match direction {
12941        TraversalDirection::Outgoing => ("e.from_id = t.logical_id", "e.to_id"),
12942        TraversalDirection::Incoming => ("e.to_id = t.logical_id", "e.from_id"),
12943        TraversalDirection::Both => (
12944            "(e.from_id = t.logical_id OR e.to_id = t.logical_id)",
12945            "CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END",
12946        ),
12947    };
12948
12949    // Position 1 (anchor), position 2 (recursive join), position 3 (final
12950    // projection) — the view is applied at all three, for every direction.
12951    let anchor_node = view.node_sql("n", NOW_IDX);
12952    let next_node = view.node_sql("next_n", NOW_IDX);
12953    let projection_node = view.node_sql("n", NOW_IDX);
12954    let edge_valid = edge_validity_sql("e", EDGE_NOW_IDX);
12955
12956    format!(
12957        "WITH RECURSIVE
12958  traversal(logical_id, depth, visited) AS (
12959    SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12960    FROM canonical_nodes n
12961    WHERE n.logical_id = ?1{anchor_node}
12962    UNION ALL
12963    SELECT {target_expr}, t.depth + 1, t.visited || {target_expr} || char(30)
12964    FROM traversal t
12965    JOIN canonical_edges e ON {edge_join}
12966    JOIN canonical_nodes next_n ON next_n.logical_id = {target_expr}{next_node}
12967    WHERE t.depth < ?2
12968      AND e.superseded_at IS NULL{edge_valid}
12969      AND instr(t.visited, char(30) || {target_expr} || char(30)) = 0
12970    LIMIT {cte_cap}
12971  )
12972SELECT DISTINCT n.logical_id, n.kind, n.body, n.write_cursor
12973FROM traversal tr
12974JOIN canonical_nodes n ON n.logical_id = tr.logical_id
12975WHERE tr.logical_id != ?1{projection_node}
12976LIMIT {cap}"
12977    )
12978}
12979
12980/// Build the BFS CTE SQL for `search_expand` — identical to `build_bfs_sql`
12981/// but the final SELECT uses `GROUP BY` + `MIN(tr.depth)` so that each
12982/// expanded node carries its actual BFS distance from the root.
12983///
12984/// Returns 5 columns: logical_id, kind, body, write_cursor, min_depth.
12985fn build_bfs_with_depth_sql() -> String {
12986    let cap = GRAPH_NEIGHBORS_HARD_CAP;
12987    let cte_cap = cap * cap; // same multigraph-safe headroom as build_bfs_sql
12988                             // TC-33: `?1` anchor, `?2` depth ⇒ the edge `:now` binds at `?3`. This is a
12989                             // SECOND, separate BFS template — the edge-validity predicate has to be
12990                             // re-grounded here too or `search_expand` silently keeps the old semantics.
12991    let edge_valid = edge_validity_sql("e", 3);
12992    format!(
12993        "WITH RECURSIVE
12994  traversal(logical_id, depth, visited) AS (
12995    SELECT n.logical_id, 0, char(30) || n.logical_id || char(30)
12996    FROM canonical_nodes n
12997    WHERE n.logical_id = ?1 AND n.superseded_at IS NULL AND n.state = 'active'
12998    UNION ALL
12999    SELECT
13000      CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END,
13001      t.depth + 1,
13002      t.visited || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)
13003    FROM traversal t
13004    JOIN canonical_edges e ON (e.from_id = t.logical_id OR e.to_id = t.logical_id)
13005    JOIN canonical_nodes next_n
13006      ON next_n.logical_id = CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END
13007      AND next_n.superseded_at IS NULL AND next_n.state = 'active'
13008    WHERE t.depth < ?2
13009      AND e.superseded_at IS NULL{edge_valid}
13010      AND instr(t.visited,
13011            char(30) || CASE WHEN e.from_id = t.logical_id THEN e.to_id ELSE e.from_id END || char(30)) = 0
13012    LIMIT {cte_cap}
13013  )
13014SELECT n.logical_id, n.kind, n.body, n.write_cursor, MIN(tr.depth) AS min_depth
13015FROM traversal tr
13016JOIN canonical_nodes n ON n.logical_id = tr.logical_id
13017WHERE n.superseded_at IS NULL AND n.state = 'active'
13018  AND tr.logical_id != ?1
13019GROUP BY n.logical_id
13020LIMIT {cap}"
13021    )
13022}
13023
13024/// 0.8.20 Slice 10b (R-20-NV) — the validity-boundary hook, on the DEFERRED
13025/// reader transaction.
13026///
13027/// Reports nodes whose `valid_from` and/or `valid_until` falls in the half-open
13028/// interval `(since, upper]`. Both bounds are BOUND parameters (`?1`, `?2`) —
13029/// the node-validity path never inlines `datetime('now')`.
13030///
13031/// The view's EXISTENCE conjunct applies (default: current + active rows only);
13032/// its VALIDITY conjunct deliberately does not, because the question is "did
13033/// this window cross a boundary", not "is this row valid now".
13034fn crossed_boundary_since_in_tx(
13035    reader: &mut Connection,
13036    since: i64,
13037    view: &ReadView,
13038) -> rusqlite::Result<Vec<BoundaryCrossing>> {
13039    // `now_param()` is None exactly when the view relaxes validity, which here
13040    // means "no upper bound on the interval".
13041    let upper = view.now_param().unwrap_or(i64::MAX);
13042    let existence = view.existence_sql("canonical_nodes");
13043    // `1 = 1` keeps the leading ` AND ` of `existence_sql` well-formed even when
13044    // every existence flag is relaxed and the conjunct is empty.
13045    let sql = format!(
13046        "SELECT logical_id, kind, body, write_cursor, valid_from, valid_until \
13047         FROM canonical_nodes \
13048         WHERE 1 = 1{existence} \
13049           AND logical_id IS NOT NULL \
13050           AND ( (valid_from IS NOT NULL AND valid_from > ?1 AND valid_from <= ?2) \
13051              OR (valid_until IS NOT NULL AND valid_until > ?1 AND valid_until <= ?2) ) \
13052         ORDER BY write_cursor"
13053    );
13054    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
13055    let mut statement = tx.prepare(&sql)?;
13056    let rows = statement.query_map(params![since, upper], |row| {
13057        let valid_from: Option<i64> = row.get(4)?;
13058        let valid_until: Option<i64> = row.get(5)?;
13059        Ok(BoundaryCrossing {
13060            node: NodeRecord {
13061                logical_id: row.get(0)?,
13062                kind: row.get(1)?,
13063                body: row.get(2)?,
13064                write_cursor: row.get::<_, i64>(3)? as u64,
13065            },
13066            became_valid_at: valid_from.filter(|t| *t > since && *t <= upper),
13067            became_invalid_at: valid_until.filter(|t| *t > since && *t <= upper),
13068        })
13069    })?;
13070    let mut out = Vec::new();
13071    for row in rows {
13072        out.push(row?);
13073    }
13074    Ok(out)
13075}
13076
13077/// Slice 20 (G5) — execute a bounded BFS on the DEFERRED reader transaction.
13078/// Called inside the reader worker loop.
13079fn graph_neighbors_in_tx(
13080    reader: &mut Connection,
13081    root_logical_id: &str,
13082    depth: u32,
13083    direction: TraversalDirection,
13084    view: &ReadView,
13085) -> rusqlite::Result<Vec<NodeRecord>> {
13086    let sql = build_bfs_sql(direction, view);
13087    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
13088    let depth_i64 = depth as i64;
13089    let mut statement = tx.prepare(&sql)?;
13090    // ?1 root, ?2 depth, ?3 = the NODE validity instant, ?4 = the EDGE validity
13091    // instant (TC-33).
13092    //
13093    // ?3 is bound UNCONDITIONALLY even when the view relaxes node validity and
13094    // `build_bfs_sql` emitted no `?3`: the template still references ?4, so
13095    // SQLite's parameter count is 4 and the positions must not shift. Binding an
13096    // index the SQL never reads is harmless; letting ?4's value slide into ?3
13097    // would silently compare edge times against a placeholder.
13098    let frozen = (*view).freeze();
13099    let binds: Vec<rusqlite::types::Value> = vec![
13100        rusqlite::types::Value::Text(root_logical_id.to_string()),
13101        rusqlite::types::Value::Integer(depth_i64),
13102        rusqlite::types::Value::Integer(frozen.now_param().unwrap_or_default()),
13103        rusqlite::types::Value::Integer(frozen.edge_now()),
13104    ];
13105    let rows = statement.query_map(rusqlite::params_from_iter(binds.iter()), |row| {
13106        Ok(NodeRecord {
13107            logical_id: row.get(0)?,
13108            kind: row.get(1)?,
13109            body: row.get(2)?,
13110            write_cursor: row.get::<_, i64>(3)? as u64,
13111        })
13112    })?;
13113    let mut out = Vec::new();
13114    for row in rows {
13115        out.push(row?);
13116    }
13117    Ok(out)
13118}
13119
13120/// Slice 20 (G6) — resolve search hit `write_cursor`s to `logical_id`s, run
13121/// BFS for each root, and merge into a [`SearchExpandResult`]. Called inside
13122/// the reader worker loop on the DEFERRED reader transaction.
13123fn search_expand_in_tx(
13124    reader: &mut Connection,
13125    search_hits: &[SearchHit],
13126    depth: u32,
13127) -> rusqlite::Result<SearchExpandResult> {
13128    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
13129
13130    // Step 1: resolve write_cursor → logical_id for each search hit.
13131    // Possible outcomes per hit:
13132    //   - None: no matching write_cursor in canonical_nodes (superseded) → drop.
13133    //   - Some(""):  row exists but logical_id IS NULL (anonymous node) or TextEdge hit
13134    //                → keep as valid search result, skip BFS expansion (empty sentinel).
13135    //   - Some(lid): active named node → keep; use as BFS root.
13136    let mut hit_logical_ids: Vec<Option<String>> = Vec::with_capacity(search_hits.len());
13137    {
13138        let mut node_stmt = tx.prepare(
13139            "SELECT logical_id FROM canonical_nodes
13140             WHERE write_cursor = ?1 AND superseded_at IS NULL AND state = 'active'
13141             LIMIT 1",
13142        )?;
13143        let mut edge_stmt = tx.prepare(
13144            "SELECT 1 FROM canonical_edges
13145             WHERE write_cursor = ?1 AND superseded_at IS NULL
13146             LIMIT 1",
13147        )?;
13148        for hit in search_hits {
13149            if hit.branch == SoftFallbackBranch::TextEdge {
13150                // Edge-body hit: verify the edge row is still active in THIS snapshot.
13151                // Stale edge hits (superseded between search and expansion) are dropped.
13152                let cursor_i64 = hit.write_cursor as i64;
13153                let active: Option<i32> =
13154                    edge_stmt.query_row([cursor_i64], |row| row.get(0)).optional()?;
13155                if active.is_some() {
13156                    hit_logical_ids.push(Some(String::new())); // sentinel: keep hit, skip BFS
13157                } else {
13158                    hit_logical_ids.push(None); // superseded edge: drop
13159                }
13160            } else {
13161                let cursor_i64 = hit.write_cursor as i64;
13162                // Returns Option<Option<String>>:
13163                //   None         → no row → superseded
13164                //   Some(None)   → row with NULL logical_id → anonymous node
13165                //   Some(Some(s)) → active named node
13166                let resolved = node_stmt
13167                    .query_row([cursor_i64], |row| row.get::<_, Option<String>>(0))
13168                    .optional()?;
13169                match resolved {
13170                    None => hit_logical_ids.push(None), // superseded: drop
13171                    Some(None) => hit_logical_ids.push(Some(String::new())), // anon: keep, skip BFS
13172                    Some(Some(lid)) => hit_logical_ids.push(Some(lid)), // named: keep + BFS root
13173                }
13174            }
13175        }
13176    }
13177
13178    // Build a set of logical_ids present in the search hits (for deduplication).
13179    // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
13180    let hit_id_set: std::collections::HashSet<String> =
13181        hit_logical_ids.iter().filter_map(|id| id.clone()).filter(|s| !s.is_empty()).collect();
13182
13183    // Step 2: for each root logical_id, run the BFS and collect expanded nodes.
13184    // A node already in `hit_id_set` is NOT added to `expanded`.
13185    // Use the depth-aware variant so each node reports its actual BFS distance.
13186    let bfs_sql = build_bfs_with_depth_sql();
13187    let depth_i64 = depth as i64;
13188    // nearest_hop: for each expanded logical_id track the minimum hop count
13189    // seen across ALL search-hit roots. A node reachable from multiple roots
13190    // at different depths must report the shortest distance (nearest root).
13191    let mut nearest_hop: std::collections::HashMap<String, (NodeRecord, u32)> =
13192        std::collections::HashMap::new();
13193
13194    if depth > 0 {
13195        let mut bfs_stmt = tx.prepare(&bfs_sql)?;
13196        // TC-33: `?3` is the edge-validity instant. `search_expand` has no
13197        // `ReadView` in scope, so it uses the default (strict) semantics —
13198        // resolved ONCE here, not per root, so every root in one call agrees.
13199        let edge_now = current_epoch_seconds();
13200        for root_id in hit_logical_ids.iter().flatten().filter(|s| !s.is_empty()) {
13201            let neighbor_rows =
13202                bfs_stmt.query_map(params![root_id, depth_i64, edge_now], |row| {
13203                    let node = NodeRecord {
13204                        logical_id: row.get(0)?,
13205                        kind: row.get(1)?,
13206                        body: row.get(2)?,
13207                        write_cursor: row.get::<_, i64>(3)? as u64,
13208                    };
13209                    let min_depth: i64 = row.get(4)?;
13210                    Ok((node, min_depth as u32))
13211                })?;
13212            for row_result in neighbor_rows {
13213                let (node, hop_count) = row_result?;
13214                if hit_id_set.contains(&node.logical_id) {
13215                    // Already a search hit — skip (search score takes priority).
13216                    continue;
13217                }
13218                nearest_hop
13219                    .entry(node.logical_id.clone())
13220                    .and_modify(|(_, prev_hop)| {
13221                        if hop_count < *prev_hop {
13222                            *prev_hop = hop_count;
13223                        }
13224                    })
13225                    .or_insert((node, hop_count));
13226            }
13227        }
13228    }
13229
13230    // Materialize expanded in insertion order (deterministic for tests).
13231    let mut expanded: Vec<(NodeRecord, u32)> = nearest_hop.into_values().collect();
13232    expanded.sort_by(|(a, _), (b, _)| a.logical_id.cmp(&b.logical_id));
13233
13234    // Filter search_hits to only include those whose write_cursor resolved to an
13235    // active logical_id in THIS snapshot. Hits that were superseded between the
13236    // search phase and the expansion phase (the two-snapshot window) are dropped
13237    // rather than returned with stale data.
13238    let resolved_hits: Vec<SearchHit> = search_hits
13239        .iter()
13240        .zip(hit_logical_ids.iter())
13241        .filter_map(|(hit, lid)| lid.as_ref().map(|_| hit.clone()))
13242        .collect();
13243
13244    // Build `all_logical_ids` = resolved search-hit logical_ids + expanded node ids.
13245    // Empty-string sentinels (TextEdge hits) are excluded — they are not real node ids.
13246    let mut all_logical_ids: Vec<String> =
13247        hit_logical_ids.into_iter().flatten().filter(|s| !s.is_empty()).collect();
13248    for (node, _) in &expanded {
13249        if !all_logical_ids.contains(&node.logical_id) {
13250            all_logical_ids.push(node.logical_id.clone());
13251        }
13252    }
13253
13254    Ok(SearchExpandResult { search_hits: resolved_hits, expanded, all_logical_ids })
13255}
13256
13257/// Slice 20 test seam — run `EXPLAIN QUERY PLAN` on the BFS CTE SQL and return
13258/// the plan `detail` column (column index 3) for each row. Used by
13259/// `explain_plan_uses_indexes` to assert index usage.
13260fn explain_graph_neighbors_in_tx(
13261    reader: &mut Connection,
13262    root_logical_id: &str,
13263    depth: u32,
13264    direction: TraversalDirection,
13265) -> rusqlite::Result<Vec<String>> {
13266    // The EXPLAIN index-usage gate measures the DEFAULT (strict) read path.
13267    let view = ReadView::default();
13268    let bfs_sql = build_bfs_sql(direction, &view);
13269    let explain_sql = format!("EXPLAIN QUERY PLAN {bfs_sql}");
13270    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
13271    let depth_i64 = depth as i64;
13272    let mut statement = tx.prepare(&explain_sql)?;
13273    // EXPLAIN QUERY PLAN returns rows: (id, parent, notused, detail).
13274    // We collect the `detail` column (index 3).
13275    // The strict view emits `?3` (the node-validity instant) at every node
13276    // position; TC-33 adds `?4`, the edge-validity instant.
13277    let frozen = view.freeze();
13278    let now = frozen.now_param().expect("the strict view always binds a validity instant");
13279    let rows = statement
13280        .query_map(params![root_logical_id, depth_i64, now, frozen.edge_now()], |row| {
13281            row.get::<_, String>(3)
13282        })?;
13283    let mut out = Vec::new();
13284    for row in rows {
13285        out.push(row?);
13286    }
13287    Ok(out)
13288}
13289
13290fn projection_dispatcher_loop(shared: Arc<ProjectionRuntimeShared>) {
13291    let connection = match open_runtime_connection(&shared.path) {
13292        Ok(connection) => connection,
13293        Err(_) => return,
13294    };
13295    // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — read ONCE:
13296    // `ProjectionRuntimeShared::embedder` is fixed for the session's lifetime.
13297    let dense_arm_live = shared.embedder.is_some();
13298    loop {
13299        let in_flight = {
13300            let mut state = match shared.state.lock() {
13301                Ok(state) => state,
13302                Err(_) => return,
13303            };
13304            while !state.stopping
13305                && (!state.pending_scan
13306                    || state.frozen
13307                    || state.active_jobs + state.queued_jobs >= PROJECTION_INFLIGHT_LIMIT)
13308            {
13309                state = match shared.state_cvar.wait(state) {
13310                    Ok(state) => state,
13311                    Err(_) => return,
13312                };
13313            }
13314            if state.stopping {
13315                return;
13316            }
13317            state.pending_scan = false;
13318            state.in_flight.clone()
13319        };
13320
13321        // Fetch up to the in-flight budget in one SQL roundtrip and
13322        // enqueue them as a batch — previously this loop fetched ONE job
13323        // per cycle, which capped projection throughput at one row per
13324        // scanner/worker handshake regardless of how much work was queued
13325        // in canonical_nodes.
13326        let budget = {
13327            let state = match shared.state.lock() {
13328                Ok(state) => state,
13329                Err(_) => return,
13330            };
13331            PROJECTION_INFLIGHT_LIMIT.saturating_sub(state.active_jobs + state.queued_jobs)
13332        };
13333        let fetch_cap = budget.clamp(1, PROJECTION_SCAN_FETCH);
13334        // With no usable dense runtime a NODE job can only come back DEFERRED
13335        // (`ProjectionOutcome::Deferred`),
13336        // which by design records no terminal, so dispatching one would re-fetch
13337        // the SAME cursor forever. fix-5 (codex §9 round 4 [P1]) moved that
13338        // exclusion INSIDE the scan, so the `LIMIT` applies to the already-filtered
13339        // set and a pending EDGE body behind a full window of node rows is still
13340        // reachable. See `next_pending_projection_jobs`.
13341        let fetched =
13342            next_pending_projection_jobs(&connection, &in_flight, fetch_cap, dense_arm_live);
13343        // Cheap assertion only — it can never DROP a job, which is precisely what
13344        // the fix-4 shape did.
13345        debug_assert!(
13346            fetched
13347                .as_ref()
13348                .map(|jobs| dense_arm_live || jobs.iter().all(|job| job.kind == EDGE_FACT_KIND))
13349                .unwrap_or(true),
13350            "no-embedder scan returned a NODE job: the exclusion must be in the scan's SQL"
13351        );
13352        match fetched {
13353            Ok(jobs) if !jobs.is_empty() => {
13354                if let Ok(mut state) = shared.state.lock() {
13355                    state.queued_jobs = state.queued_jobs.saturating_add(jobs.len());
13356                    for job in &jobs {
13357                        state.in_flight.insert(job.cursor);
13358                    }
13359                    state.pending_scan = true;
13360                    shared.state_cvar.notify_all();
13361                }
13362                if let Ok(mut queue) = shared.queue.lock() {
13363                    for job in jobs {
13364                        queue.push_back(job);
13365                    }
13366                    shared.queue_cvar.notify_all();
13367                }
13368            }
13369            Ok(_) => {}
13370            Err(_) => {
13371                if let Ok(mut state) = shared.state.lock() {
13372                    state.pending_scan = false;
13373                    shared.state_cvar.notify_all();
13374                }
13375            }
13376        }
13377    }
13378}
13379
13380fn projection_worker_loop(shared: Arc<ProjectionRuntimeShared>) {
13381    let mut connection = match open_runtime_connection(&shared.path) {
13382        Ok(connection) => connection,
13383        Err(_) => return,
13384    };
13385    if ensure_vector_partition(&mut connection, shared.embedder_identity.dimension).is_err() {
13386        return;
13387    }
13388    loop {
13389        let jobs = {
13390            let mut queue = match shared.queue.lock() {
13391                Ok(queue) => queue,
13392                Err(_) => return,
13393            };
13394            loop {
13395                let stopping = shared.state.lock().map(|state| state.stopping).unwrap_or(true);
13396                if stopping && queue.is_empty() {
13397                    return;
13398                }
13399                if let Some(job) = queue.pop_front() {
13400                    let mut jobs = vec![job];
13401                    while jobs.len() < PROJECTION_COMMIT_BATCH {
13402                        let Some(job) = queue.pop_front() else {
13403                            break;
13404                        };
13405                        jobs.push(job);
13406                    }
13407                    if let Ok(mut state) = shared.state.lock() {
13408                        state.queued_jobs = state.queued_jobs.saturating_sub(jobs.len());
13409                        state.active_jobs = state.active_jobs.saturating_add(jobs.len());
13410                        shared.state_cvar.notify_all();
13411                    }
13412                    break jobs;
13413                }
13414                queue = match shared.queue_cvar.wait(queue) {
13415                    Ok(queue) => queue,
13416                    Err(_) => return,
13417                };
13418            }
13419        };
13420
13421        // EU-5f — isolate worker faults. A panic inside `embed()` (or the
13422        // commit) must not skip the state cleanup below, or `active_jobs`
13423        // would stay elevated forever and `wait_for_idle` / `drain` would
13424        // wedge into `EngineError::Scheduler` (Finding A). Mirrors the
13425        // reader pool's `LiveGuard` panic-safety. The local commit tx rolls
13426        // back on unwind, leaving the connection clean for reuse.
13427        let commit_result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13428            run_projection_jobs(&shared, &mut connection, &jobs)
13429        })) {
13430            Ok(result) => result,
13431            Err(_) => commit_projection_panic_failures(&shared, &mut connection, &jobs),
13432        };
13433        if let Err(err) = commit_result {
13434            // Host subscribers are arbitrary application code. Their panic must
13435            // not bypass the mandatory state cleanup below, or the durable
13436            // pending row would stay stranded in `in_flight` forever.
13437            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13438                report_projection_commit_failure(&shared, &err);
13439            }));
13440            #[cfg(debug_assertions)]
13441            if let Some((reported, release)) = shared
13442                .projection_commit_failure_pause
13443                .lock()
13444                .unwrap_or_else(|poisoned| poisoned.into_inner())
13445                .take()
13446            {
13447                reported.wait();
13448                release.wait();
13449            }
13450        }
13451
13452        if let Ok(mut state) = shared.state.lock() {
13453            state.active_jobs = state.active_jobs.saturating_sub(jobs.len());
13454            for job in &jobs {
13455                state.in_flight.remove(&job.cursor);
13456            }
13457            if !state.stopping {
13458                state.pending_scan = true;
13459            }
13460            shared.state_cvar.notify_all();
13461        }
13462    }
13463}
13464
13465enum ProjectionOutcome {
13466    /// `blob` is the un-centered f32 BLOB persisted to
13467    /// `vector_default.embedding`. `bin_blob` is the (possibly centered)
13468    /// f32 BLOB fed to `vec_quantize_binary` for the sign-bit column.
13469    /// EU-5a2: `bin_blob == blob` unless the identity is MC-required
13470    /// AND a mean_vec is pinned.
13471    Success {
13472        cursor: u64,
13473        kind: String,
13474        blob: Vec<u8>,
13475        bin_blob: Vec<u8>,
13476    },
13477    Failure {
13478        cursor: u64,
13479        failure_code: &'static str,
13480    },
13481    /// 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — the ENVIRONMENT could
13482    /// not serve this row, as distinct from the embed FAILING. Records nothing
13483    /// at all: no `projection_failures` audit row and, decisively, **no
13484    /// terminal**. The row stays `terminal IS NULL`, i.e. PENDING, so the next
13485    /// session that DOES have an embedder picks it up through the ordinary
13486    /// scheduler — no graft path and no recovery machinery.
13487    ///
13488    /// The only producer is the absent-embedder check at the top of
13489    /// [`run_projection_job`]. That condition cannot change within a session, so
13490    /// this can never become a retry loop for a genuinely-failing row.
13491    ///
13492    /// It carries NO cursor, deliberately: the other two variants carry one
13493    /// because they identify the row they are about to WRITE, and this variant
13494    /// writes nothing at all. A row's deferral is represented on disk by the
13495    /// continued ABSENCE of its `_fathomdb_projection_terminal` row, which is
13496    /// exactly the state it was already in.
13497    Deferred,
13498}
13499
13500fn run_projection_jobs(
13501    shared: &ProjectionRuntimeShared,
13502    connection: &mut Connection,
13503    jobs: &[ProjectionJob],
13504) -> rusqlite::Result<()> {
13505    let outcomes = embed_projection_batch(shared, jobs);
13506    commit_projection_outcomes(connection, &outcomes, shared)
13507}
13508
13509/// Embed a whole commit-batch in ONE `embed_batch` call (amortizes per-call
13510/// overhead; saturates the GPU — minutes -> seconds on a full-corpus embed). The
13511/// batched path is the fast HAPPY path only; on ANY anomaly — no embedder, breaker
13512/// open, single job, batch timeout/failure, row-count or per-row dimension mismatch
13513/// — it falls back to the proven per-job [`run_projection_job`], which carries the
13514/// full retry + circuit-breaker + failure-isolation semantics. So batching can only
13515/// make the common case faster, never change correctness. A panic inside the batch
13516/// embed resume-unwinds exactly like the per-embed watchdog, so the worker's
13517/// batch-level `catch_unwind` records `ProjectionPanic` as before.
13518///
13519/// Batching is **opt-in** via `FATHOMDB_PROJECTION_BATCH=1` (`true`/`on` accepted).
13520/// It reshapes the PR-9 per-embed watchdog/breaker accounting into per-batch, so the
13521/// conservative DEFAULT keeps the proven per-job path — leaving every PR-9 safety
13522/// test (watchdog, serialization, circuit breaker) behaving exactly as before. The
13523/// eval GPU-embed run sets the env to get the batched-forward speedup (minutes ->
13524/// seconds), where the per-job fallback below still backs every error case.
13525fn projection_batch_enabled() -> bool {
13526    matches!(
13527        std::env::var("FATHOMDB_PROJECTION_BATCH").ok().as_deref(),
13528        Some("1") | Some("true") | Some("on")
13529    )
13530}
13531
13532fn embed_projection_batch(
13533    shared: &ProjectionRuntimeShared,
13534    jobs: &[ProjectionJob],
13535) -> Vec<ProjectionOutcome> {
13536    let per_job = || jobs.iter().map(|job| run_projection_job(shared, job)).collect();
13537
13538    let Some(embedder) = shared.embedder.as_ref() else {
13539        return per_job();
13540    };
13541    if jobs.len() < 2
13542        || shared.embed_circuit_open.load(Ordering::Relaxed)
13543        || !projection_batch_enabled()
13544    {
13545        return per_job();
13546    }
13547
13548    let bodies: Vec<String> = jobs.iter().map(|job| job.body.clone()).collect();
13549    let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
13550    // Each row keeps its single-embed budget worst-case (batch <= COMMIT_BATCH=16).
13551    let batch_timeout = embed_timeout.saturating_mul(jobs.len() as u32);
13552
13553    let vectors = {
13554        // PR-9 — serialize the embedder call (ONE batched call at a time) and make
13555        // the breaker decision with the guard held (race-free vs other workers),
13556        // mirroring `run_projection_job`. The batch thread counts as one live embed.
13557        let _embed_permit =
13558            shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13559        let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
13560        if shared.embed_circuit_open.load(Ordering::Relaxed)
13561            || (threshold != 0 && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
13562        {
13563            shared.embed_circuit_open.store(true, Ordering::Relaxed);
13564            return per_job();
13565        }
13566        match embed_batch_with_watchdog(
13567            embedder,
13568            &bodies,
13569            batch_timeout,
13570            &shared.live_embed_threads,
13571        ) {
13572            Ok(vectors) => vectors,
13573            // Timeout / failed / disconnected -> the per-job path retries each row
13574            // and engages the breaker exactly as before.
13575            Err(_) => return per_job(),
13576        }
13577    };
13578
13579    if vectors.len() != jobs.len() {
13580        return per_job();
13581    }
13582    let mut outcomes = Vec::with_capacity(jobs.len());
13583    for (job, vector) in jobs.iter().zip(vectors) {
13584        if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
13585            // A row came back wrong-dim: fall back per-job for the whole batch
13586            // (rare; keeps the dimension-mismatch failure path identical).
13587            return per_job();
13588        }
13589        // Mirror run_projection_job's post-embed step exactly: persisted f32 BLOB is
13590        // un-centered; centering for the binary column is finalized in
13591        // commit_projection_outcomes (so bin_blob == blob here).
13592        let blob = encode_vector_blob(&vector);
13593        let bin_blob = blob.clone();
13594        outcomes.push(ProjectionOutcome::Success {
13595            cursor: job.cursor,
13596            kind: job.kind.clone(),
13597            blob,
13598            bin_blob,
13599        });
13600    }
13601    outcomes
13602}
13603
13604/// EU-5f — record every job in a panicked batch as a terminal projection
13605/// failure so the scheduler does not re-enqueue and re-panic on the same
13606/// cursors. Best-effort; runs after the worker caught a panic.
13607fn commit_projection_panic_failures(
13608    shared: &ProjectionRuntimeShared,
13609    connection: &mut Connection,
13610    jobs: &[ProjectionJob],
13611) -> rusqlite::Result<()> {
13612    let outcomes: Vec<ProjectionOutcome> = jobs
13613        .iter()
13614        .map(|job| ProjectionOutcome::Failure {
13615            cursor: job.cursor,
13616            failure_code: "ProjectionPanic",
13617        })
13618        .collect();
13619    commit_projection_outcomes(connection, &outcomes, shared)
13620}
13621
13622/// Route a background projection-commit failure through the engine's existing
13623/// host subscriber path. A SQLite error retains its stable SQLite code; a
13624/// rusqlite-layer error is an engine storage failure rather than a fabricated
13625/// SQLite diagnostic.
13626fn report_projection_commit_failure(shared: &ProjectionRuntimeShared, err: &rusqlite::Error) {
13627    let event = if let Some(code) = sqlite_extended_code_name(err) {
13628        lifecycle::Event {
13629            phase: lifecycle::Phase::Failed,
13630            source: lifecycle::EventSource::SqliteInternal,
13631            category: lifecycle::EventCategory::Error,
13632            code: Some(code),
13633        }
13634    } else {
13635        lifecycle::Event {
13636            phase: lifecycle::Phase::Failed,
13637            source: lifecycle::EventSource::Engine,
13638            category: lifecycle::EventCategory::Error,
13639            code: Some("StorageError"),
13640        }
13641    };
13642    shared.subscribers.dispatch(&event);
13643}
13644
13645/// PR-9 — ADR-0.6.0-embedder-protocol **Invariant 5**: run one `embed()`
13646/// under a per-call deadline. A hung (non-panicking) embed would otherwise
13647/// park a projection worker forever — the EU-5f `catch_unwind` only catches
13648/// *panics*. On timeout we return `RuntimeEmbedderError::Timeout`, which the
13649/// caller's existing retry/failure path already handles.
13650///
13651/// Cancellation follows Invariant 5 exactly: the embed runs on a detached
13652/// thread that is allowed to *finish + discard* its result — never aborted
13653/// mid-call (there is no safe thread-cancel API). The caller (the projection
13654/// worker) holds `embed_serialize` across this call, but DROPS it the moment
13655/// this returns — including on timeout — so the abandoned detached thread
13656/// runs lock-free and a hung embed can neither hold the serialization guard
13657/// forever nor deadlock the pool. (The commit happens later, outside this
13658/// call, under the separate `commit_gate`.)
13659///
13660/// Panic-transparent: if `embed()` panics, the panic payload is captured on
13661/// the watchdog thread and resumed on the worker thread, so the existing
13662/// batch-level `catch_unwind` records `ProjectionPanic` exactly as before.
13663///
13664/// `live` counts embed threads currently alive: incremented before the spawn
13665/// and decremented by the thread when it finishes (even if its result was
13666/// abandoned on timeout). The caller reads it to bound the abandoned-thread
13667/// leak via the circuit breaker.
13668fn embed_with_watchdog(
13669    embedder: &Arc<dyn Embedder>,
13670    body: &str,
13671    timeout: Duration,
13672    live: &Arc<AtomicU64>,
13673) -> Result<Vec<f32>, RuntimeEmbedderError> {
13674    let (tx, rx) = mpsc::channel();
13675    let embedder = Arc::clone(embedder);
13676    let body = body.to_string();
13677    // Count this embed thread as live before spawning; the thread decrements
13678    // when it finishes, whether or not its result is still wanted.
13679    live.fetch_add(1, Ordering::Relaxed);
13680    let live_thread = Arc::clone(live);
13681    thread::spawn(move || {
13682        let outcome =
13683            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(&body)));
13684        // The receiver may already be gone (this call timed out): an async
13685        // channel send never blocks, and a send to a dropped receiver is a
13686        // no-op error we deliberately ignore — the result is discarded.
13687        let _ = tx.send(outcome);
13688        live_thread.fetch_sub(1, Ordering::Relaxed);
13689    });
13690    match rx.recv_timeout(timeout) {
13691        Ok(Ok(result)) => result,
13692        Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13693        Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13694        // The watchdog thread dropped its sender without sending — should not
13695        // happen (panics are captured above), but treat as a failed embed so
13696        // the retry/failure path engages rather than silently succeeding.
13697        Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13698            message: "embed watchdog thread dropped its result channel".to_string(),
13699        }),
13700    }
13701}
13702
13703/// Batch sibling of [`embed_with_watchdog`]: run ONE `embed_batch` on a detached,
13704/// timeout-bounded thread. Same Invariant-5 cancellation contract (the thread is
13705/// allowed to finish + discard on timeout, never aborted mid-call), same
13706/// panic-transparency (a panic is resumed on the caller so the worker's batch-level
13707/// `catch_unwind` records `ProjectionPanic`), same `live` accounting (one batch
13708/// thread = one live embed, bounding the abandoned-thread leak via the breaker).
13709fn embed_batch_with_watchdog(
13710    embedder: &Arc<dyn Embedder>,
13711    bodies: &[String],
13712    timeout: Duration,
13713    live: &Arc<AtomicU64>,
13714) -> Result<Vec<Vec<f32>>, RuntimeEmbedderError> {
13715    let (tx, rx) = mpsc::channel();
13716    let embedder = Arc::clone(embedder);
13717    let bodies = bodies.to_vec();
13718    live.fetch_add(1, Ordering::Relaxed);
13719    let live_thread = Arc::clone(live);
13720    thread::spawn(move || {
13721        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
13722            let refs: Vec<&str> = bodies.iter().map(String::as_str).collect();
13723            embedder.embed_batch(&refs)
13724        }));
13725        let _ = tx.send(outcome);
13726        live_thread.fetch_sub(1, Ordering::Relaxed);
13727    });
13728    match rx.recv_timeout(timeout) {
13729        Ok(Ok(result)) => result,
13730        Ok(Err(panic_payload)) => std::panic::resume_unwind(panic_payload),
13731        Err(mpsc::RecvTimeoutError::Timeout) => Err(RuntimeEmbedderError::Timeout),
13732        Err(mpsc::RecvTimeoutError::Disconnected) => Err(RuntimeEmbedderError::Failed {
13733            message: "embed batch watchdog thread dropped its result channel".to_string(),
13734        }),
13735    }
13736}
13737
13738fn run_projection_job(shared: &ProjectionRuntimeShared, job: &ProjectionJob) -> ProjectionOutcome {
13739    // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — an ABSENT embedder is an
13740    // ENVIRONMENT fact, not an embed failure, and it CANNOT appear mid-job:
13741    // `ProjectionRuntimeShared::embedder` is fixed for the whole session. So for a
13742    // NODE row the whole retry ladder (0 + 1 + 4 + 16 s) can only reach a
13743    // conclusion that was already knowable at entry — answer it NOW, with the
13744    // NON-TERMINAL `Deferred`: no audit row, no terminal, and therefore a write
13745    // the next live-embedder session can still recover.
13746    //
13747    // That is codex's finding. With the kind already ENROLLED, the shipped
13748    // `'failed'` terminal was PERMANENT (nothing reopens one, and nothing should:
13749    // re-enqueueing one would loop a genuinely-failing row forever), so the write
13750    // was lost while `dense_readiness` read `ready`.
13751    //
13752    // `projection_dispatcher_loop` already declines to dispatch node jobs in a
13753    // no-embedder session — it must, or the still-pending row would be re-scanned
13754    // in a hot loop. This check is the LOCAL backstop for the same invariant:
13755    // whatever reaches a worker with no embedder must not be TERMINATED. Keeping
13756    // the invariant beside the code that would otherwise write the terminal is
13757    // what makes it hold if that dispatcher-side filter is ever loosened.
13758    //
13759    // EDGE rows deliberately fall THROUGH to the shipped path, ladder and all.
13760    // `'edge_fact'` is auto-registered by the edge write itself, UN-gated on the
13761    // embedder (`project_canonical_edge_row`, G11 — see the note in
13762    // `enrol_batch_vector_kinds`), so an edge body written with no embedder is
13763    // outstanding the moment it lands. Deferring it would leave `drain` and
13764    // `excise_source` returning `EngineError::Scheduler` on paths with nothing to
13765    // do with the dense arm (MEASURED: 4 shipped tests across
13766    // `tc31_source_id_on_every_hit`, `provenance_mandatory` and
13767    // `multidoc_extractor_provenance`). Making edges recoverable needs their
13768    // enrolment gated the way fix-2 gated node kinds — reported as OOS-13, and
13769    // outside codex's finding, which is the node path.
13770    //
13771    // Their LADDER is left alone for a second, separately MEASURED reason:
13772    // shortening it makes the worker's terminal-commit land while a caller's own
13773    // write is still open, which used to trip the governed write-race. Measured on
13774    // `consolidate_provider` under 6-way concurrency: 0/48 failures with the
13775    // ladder, 8/48 without. Left byte-for-byte as shipped; the ladder length is
13776    // reported as OOS-17 rather than newly exposed by a fix round.
13777    //
13778    // 0.8.20 Slice 21 (TC-57) — this note used to name that race
13779    // `SQLITE_BUSY_SNAPSHOT` and call it PRE-EXISTING. Both are corrected: the
13780    // characterized mechanism is plain `SQLITE_BUSY` (5) on a read→write lock
13781    // PROMOTION, with the busy handler invoked ZERO times (SQLite skips it for
13782    // deadlock avoidance), so no `busy_timeout` could absorb it;
13783    // `SQLITE_BUSY_SNAPSHOT` (517) is only a second, narrower exit of the same
13784    // shape. And the race is FIXED — `commit_batch` now takes `BEGIN IMMEDIATE`
13785    // (see the note there), so the governed path never promotes. The 0/48-vs-8/48
13786    // measurement above stands as the reason not to shorten the ladder, but it is
13787    // no longer load-bearing for correctness of the governed write path.
13788    if shared.embedder.is_none() && job.kind != EDGE_FACT_KIND {
13789        return ProjectionOutcome::Deferred;
13790    }
13791    // PR-9 — embed circuit breaker (see `embed_circuit_open`). Once abandoned
13792    // (timed-out) embed threads have piled up to the threshold the embedder is
13793    // treated as broken; fail subsequent jobs fast WITHOUT attempting an embed,
13794    // so a wedged embedder cannot keep leaking abandoned watchdog threads. This
13795    // entry check is the fast path; the latch decision itself is made under the
13796    // embed guard below (race-free against other workers).
13797    if shared.embed_circuit_open.load(Ordering::Relaxed) {
13798        return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: "EmbedderError" };
13799    }
13800    let delays = shared.retry_delays_ms.lock().map(|delays| delays.clone()).unwrap_or_default();
13801    let mut last_code = "EmbedderError";
13802    for (attempt, delay_ms) in std::iter::once(0_u64).chain(delays.iter().copied()).enumerate() {
13803        if attempt > 0 {
13804            if shared.state.lock().map(|state| state.stopping).unwrap_or(true) {
13805                return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13806            }
13807            thread::sleep(Duration::from_millis(delay_ms));
13808        }
13809        // PR-9 — re-check the breaker on every attempt, not just at entry:
13810        // another worker (or an earlier attempt of this job) may have latched
13811        // it while we were sleeping between retries. Bail before spawning yet
13812        // another timeout-bound watchdog thread, so the abandoned-thread leak
13813        // stays bounded even on the multi-retry path.
13814        if shared.embed_circuit_open.load(Ordering::Relaxed) {
13815            return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
13816        }
13817        // PR-9 / ADR-0.6.0 Invariant 5 — every embed runs under the per-call
13818        // watchdog deadline so a hung embed surfaces Timeout instead of
13819        // parking this worker forever.
13820        let embed_timeout = Duration::from_millis(shared.embed_timeout_ms.load(Ordering::Relaxed));
13821        let vector = match shared.embedder.as_ref() {
13822            Some(embedder) => {
13823                // PR-9 — serialize the embed call engine-side (see
13824                // `embed_serialize`): the shared embedder is invoked one call
13825                // at a time, for SAFETY with arbitrary caller-supplied
13826                // embedders (throughput is ~neutral on the candle default).
13827                // The guard is held across the watchdog call and released
13828                // here, so commit/IO below stays parallel and a timed-out
13829                // embed frees it. The guard owns no data; a panic-resumed
13830                // embed poisons it, so we recover the inner guard rather than
13831                // wedge the whole pool.
13832                let _embed_permit =
13833                    shared.embed_serialize.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13834                // PR-9 — breaker decision, made WITH the guard held so it is
13835                // race-free against other workers: if abandoned embed threads
13836                // from earlier timeouts have piled up to the threshold, latch
13837                // the breaker and fail fast WITHOUT spawning another one. The
13838                // live count is checked here (also covers a breaker latched by
13839                // another worker while we were queued on the lock), bounding
13840                // the abandoned-thread leak to ~threshold regardless of whether
13841                // the embedder hangs always or only intermittently.
13842                let threshold = shared.embed_circuit_threshold.load(Ordering::Relaxed);
13843                if shared.embed_circuit_open.load(Ordering::Relaxed)
13844                    || (threshold != 0
13845                        && shared.live_embed_threads.load(Ordering::Relaxed) >= threshold)
13846                {
13847                    shared.embed_circuit_open.store(true, Ordering::Relaxed);
13848                    return ProjectionOutcome::Failure {
13849                        cursor: job.cursor,
13850                        failure_code: last_code,
13851                    };
13852                }
13853                match embed_with_watchdog(
13854                    embedder,
13855                    &job.body,
13856                    embed_timeout,
13857                    &shared.live_embed_threads,
13858                ) {
13859                    Ok(vector) => vector,
13860                    Err(RuntimeEmbedderError::Timeout) => {
13861                        // The embed thread is now abandoned (still counted in
13862                        // live_embed_threads until it returns); the breaker
13863                        // check above caps how many can accumulate.
13864                        last_code = "EmbedderError";
13865                        continue;
13866                    }
13867                    Err(RuntimeEmbedderError::Failed { .. }) => {
13868                        last_code = "EmbedderError";
13869                        continue;
13870                    }
13871                }
13872            }
13873            None => {
13874                last_code = "EmbedderNotConfiguredError";
13875                continue;
13876            }
13877        };
13878
13879        if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
13880            last_code = "EmbedderDimensionMismatchError";
13881            continue;
13882        }
13883
13884        let blob = encode_vector_blob(&vector);
13885        // EU-5a2 mean-centering apply path (projection write side). The
13886        // f32 BLOB persisted is ALWAYS un-centered; `bin_blob` carries
13887        // the (possibly centered) f32 fed to `vec_quantize_binary`. The
13888        // centering decision is finalized in `commit_projection_outcomes`
13889        // where the writer connection is in-hand and the read of
13890        // `_fathomdb_embedder_profiles.mean_vec` is in the same tx as
13891        // the INSERT. NoopEmbedder (EU-5a2's only live identity) is not
13892        // MC-required, so `bin_blob == blob` throughout EU-5a2.
13893        let bin_blob = blob.clone();
13894        return ProjectionOutcome::Success {
13895            cursor: job.cursor,
13896            kind: job.kind.clone(),
13897            blob,
13898            bin_blob,
13899        };
13900    }
13901
13902    ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code }
13903}
13904
13905/// 0.8.20 Slice 20 fix-1 (codex §9 [P2]) — the ONE definition of "a canonical
13906/// EDGE row the vector pipeline still owes an embed for".
13907///
13908/// Two call sites must agree on this predicate and had drifted:
13909///
13910/// - [`next_pending_projection_jobs`] — the SCHEDULER, and therefore the
13911///   authority on what will actually be embedded. It joins
13912///   `_fathomdb_vector_kinds` on `'edge_fact'`, so an edge body is only ever
13913///   scheduled when that kind is registered.
13914/// - [`connection_has_pending_projection_work`] — the PROBE behind
13915///   `drain`/`wait_for_idle` and, since this slice, `dense_readiness`. It
13916///   omitted that join.
13917///
13918/// The consequence of the drift: a live edge body written while `edge_fact` was
13919/// not a registered vector kind (e.g. edges carried forward from before the G11
13920/// edge-vector pipeline, which is what auto-registers the kind) counted as
13921/// outstanding work the scheduler would NEVER take. `dense_readiness` reported
13922/// `embedding` forever and `drain` could never report idle — both the mirror
13923/// image of R-20-DR's property. Building both edge arms from this one fragment
13924/// makes a repeat drift unrepresentable.
13925///
13926/// Emits the `FROM`/`JOIN` clauses plus the shared `WHERE` predicates, with the
13927/// edge table aliased `ce` and the projection terminal aliased `pt`; `now_idx`
13928/// is the 1-based bind index of the `:now` seam that [`edge_validity_sql`]
13929/// consumes. Callers may append further `AND` predicates.
13930///
13931/// **The one predicate deliberately NOT shared** is the scheduler's
13932/// `write_cursor > :cursor` watermark filter, which the scheduler appends and
13933/// the probe must not: per the G11 (Slice 15) fix-1 note on the probe, the
13934/// probe has to see edge bodies left un-projected BELOW the watermark when the
13935/// engine closed mid-flight, or `drain` would report idle with edge vectors
13936/// still missing on reopen. That asymmetry is intentional and load-bearing; the
13937/// row-eligibility predicates above are not, and are shared.
13938fn pending_edge_projection_from_where(now_idx: usize) -> String {
13939    format!(
13940        "FROM canonical_edges ce
13941         JOIN _fathomdb_vector_kinds
13942           ON _fathomdb_vector_kinds.kind = 'edge_fact'
13943         LEFT JOIN _fathomdb_projection_terminal pt
13944           ON pt.write_cursor = ce.write_cursor
13945         WHERE ce.body IS NOT NULL
13946           AND ce.superseded_at IS NULL{}
13947           AND pt.write_cursor IS NULL",
13948        edge_validity_sql("ce", now_idx)
13949    )
13950}
13951
13952/// The SCHEDULER's scan: the next `max_jobs` pending projection jobs in
13953/// `write_cursor` order.
13954///
13955/// `dense_arm_live` is `ProjectionRuntimeShared::embedder.is_some()`, read once
13956/// per dispatcher because it is fixed for the session's lifetime.
13957///
13958/// # fix-5 (codex §9 round 4 [P1]) — why the node exclusion is IN the SQL
13959///
13960/// With no usable dense runtime a NODE job can only come back
13961/// [`ProjectionOutcome::Deferred`], which by design records no terminal (fix-4),
13962/// so dispatching one would re-fetch the SAME cursor forever: a hot loop for the
13963/// whole life of the session. fix-4 suppressed that by filtering the vector this
13964/// function RETURNS — i.e. after the `ORDER BY … LIMIT`, so the `LIMIT` still
13965/// applied to the UNFILTERED set. More than `PROJECTION_SCAN_FETCH` pending node
13966/// rows ordered before a pending EDGE body therefore filled the entire window
13967/// with jobs that were then all dropped, the dispatcher went back to sleep with
13968/// `pending_scan` already consumed, and the edge body was never scheduled at all
13969/// — permanently, since those node rows stay pending for the session's life. The
13970/// exclusion belongs here, where the `LIMIT` applies to the ALREADY-FILTERED set
13971/// and a later edge job is always reachable.
13972///
13973/// Edges are NOT excluded: `'edge_fact'` is auto-registered by the edge write
13974/// itself, un-gated on the embedder (`project_canonical_edge_row`, G11, and the
13975/// note in [`Engine::enrol_batch_vector_kinds`]), so an edge body still
13976/// TERMINATES on an absent embedder exactly as it has shipped since G11. Making
13977/// edges recoverable too needs that enrolment gated first (OOS-13).
13978fn next_pending_projection_jobs(
13979    connection: &Connection,
13980    in_flight: &BTreeSet<u64>,
13981    max_jobs: usize,
13982    dense_arm_live: bool,
13983) -> rusqlite::Result<Vec<ProjectionJob>> {
13984    if max_jobs == 0 {
13985        return Ok(Vec::new());
13986    }
13987    let cursor = load_projection_cursor(connection)?;
13988    // Over-fetch by `in_flight.len()` so the post-filter still returns
13989    // up to `max_jobs` after skipping cursors already in-flight.
13990    let sql_limit = max_jobs.saturating_add(in_flight.len()).min(256);
13991    // G11 (Slice 15) — UNION extends the projection queue to include edge bodies.
13992    // Edge bodies use kind `'edge_fact'` so `resolve_source_type` maps them to
13993    // `source_type = 'edge_fact'` in `vector_default` (partition correctness).
13994    // The UNION is ordered by write_cursor so projection proceeds in
13995    // insertion order across nodes and edges.
13996    //
13997    // fix-5 [P1]: with no dense arm the NODE arm is omitted outright rather than
13998    // predicated false, so the planner never walks it. The edge arm keeps both
13999    // binds (`?1` the cursor, `?2` the `:now` seam), so the bound parameter set
14000    // is identical either way.
14001    let node_arm = if dense_arm_live {
14002        "SELECT canonical_nodes.write_cursor AS write_cursor,
14003                    canonical_nodes.kind AS kind,
14004                    canonical_nodes.body AS body
14005             FROM canonical_nodes
14006             JOIN _fathomdb_vector_kinds
14007               ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
14008             LEFT JOIN _fathomdb_projection_terminal
14009               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
14010             WHERE canonical_nodes.write_cursor > ?1
14011               AND _fathomdb_projection_terminal.write_cursor IS NULL
14012
14013             UNION ALL
14014
14015             "
14016    } else {
14017        ""
14018    };
14019    let sql = format!(
14020        "SELECT write_cursor, kind, body FROM (
14021             {node_arm}SELECT ce.write_cursor AS write_cursor,
14022                    'edge_fact' AS kind,
14023                    ce.body AS body
14024             {edge_arm}
14025               AND ce.write_cursor > ?1
14026         ) ORDER BY write_cursor
14027         LIMIT {sql_limit}",
14028        // fix-1 [P2]: the edge arm's row-eligibility predicates come from the
14029        // shared fragment so this and `connection_has_pending_projection_work`
14030        // cannot disagree about what is outstanding. The `write_cursor > ?1`
14031        // watermark is appended here and ONLY here — see the fragment's doc.
14032        // TC-33: `?1` is the projection cursor ⇒ the edge `:now` binds at `?2`.
14033        edge_arm = pending_edge_projection_from_where(2)
14034    );
14035    let mut statement = connection.prepare_cached(&sql)?;
14036    let rows = statement.query_map(params![cursor, current_epoch_seconds()], |row| {
14037        Ok(ProjectionJob { cursor: row.get(0)?, kind: row.get(1)?, body: row.get(2)? })
14038    })?;
14039    let mut jobs = Vec::with_capacity(max_jobs);
14040    for row in rows {
14041        let job = row?;
14042        if in_flight.contains(&job.cursor) {
14043            continue;
14044        }
14045        jobs.push(job);
14046        if jobs.len() >= max_jobs {
14047            break;
14048        }
14049    }
14050    Ok(jobs)
14051}
14052
14053fn database_has_pending_projection_work(path: &Path) -> rusqlite::Result<bool> {
14054    let connection = open_runtime_connection(path)?;
14055    connection_has_pending_projection_work(&connection)
14056}
14057
14058/// 0.8.20 Slice 20 (R-20-DR) — the body of
14059/// [`database_has_pending_projection_work`], lifted so it can also run on a
14060/// connection the caller ALREADY holds (the engine's own connection, inside
14061/// [`Engine::read_projections`]) instead of opening a runtime connection from a
14062/// path. Both callers run the same two arms and the same predicates — which is
14063/// the point. Readiness and `drain`/`wait_for_idle` must key off ONE definition
14064/// of "outstanding embed", or readiness could report `ready` for work `drain`
14065/// still waits on.
14066///
14067/// fix-1 (codex §9 [P2]) — the edge arm is no longer a hand-copied mirror of
14068/// the scheduler's: both are built from
14069/// [`pending_edge_projection_from_where`]. The copy had lost the
14070/// `_fathomdb_vector_kinds` join, so this probe reported permanent pending work
14071/// for edge bodies the scheduler would never schedule. That was PRE-EXISTING —
14072/// it reached `Engine::drain` through `wait_for_idle` before readiness existed.
14073fn connection_has_pending_projection_work(connection: &Connection) -> rusqlite::Result<bool> {
14074    let cursor = load_projection_cursor(connection)?;
14075    // Check canonical_nodes for un-projected work.
14076    let has_node_work: bool = connection
14077        .query_row(
14078            "SELECT 1
14079             FROM canonical_nodes
14080             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
14081             LEFT JOIN _fathomdb_projection_terminal
14082               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
14083             WHERE canonical_nodes.write_cursor > ?1
14084               AND _fathomdb_projection_terminal.write_cursor IS NULL
14085             LIMIT 1",
14086            [cursor],
14087            |_row| Ok(true),
14088        )
14089        .or_else(|err| match err {
14090            rusqlite::Error::QueryReturnedNoRows => Ok(false),
14091            _ => Err(err),
14092        })?;
14093    if has_node_work {
14094        return Ok(true);
14095    }
14096    // G11 (Slice 15) fix-1 [P2] — also check canonical_edges for edge bodies
14097    // that were not projected before the engine closed. Without this check,
14098    // drain() returns idle while edge vectors remain unembedded on reopen.
14099    // fix-31 [P2]: exclude superseded edges from the pending check so the
14100    // scheduler does not pick up stale tombstoned rows as projection work.
14101    // 0.8.12 Slice A (R-CON-2 named default-ON blocker; Slice-20 codex §9
14102    // [P2]) — also exclude t_invalid-excluded (recency-consolidated) edges,
14103    // mirroring `next_pending_projection_jobs`'s edge arm. Required: without
14104    // this mirror, a rebuild-truncated t_invalid edge that
14105    // `next_pending_projection_jobs` now correctly skips would never gain a
14106    // `_fathomdb_projection_terminal` row, so this probe would flag it as
14107    // phantom-pending forever and `drain()`/`wait_for_idle` would hang.
14108    // Slice-20 fix-1 [P2]: the mirror is now STRUCTURAL — the arm is built from
14109    // `pending_edge_projection_from_where`, the same fragment the scheduler
14110    // uses — because the hand-copied mirror had already lost the
14111    // `_fathomdb_vector_kinds` join and produced exactly the phantom-pending
14112    // hang described above for edge bodies under an unregistered `edge_fact`.
14113    connection
14114        .query_row(
14115            // TC-33: no other parameter here ⇒ the edge `:now` binds at `?1`.
14116            // No `write_cursor > cursor` filter — see the fragment's doc for
14117            // why the probe deliberately looks BELOW the watermark too.
14118            &format!("SELECT 1 {} LIMIT 1", pending_edge_projection_from_where(1)),
14119            params![current_epoch_seconds()],
14120            |_row| Ok(true),
14121        )
14122        .or_else(|err| match err {
14123            rusqlite::Error::QueryReturnedNoRows => Ok(false),
14124            _ => Err(err),
14125        })
14126}
14127
14128/// 0.8.20 Slice 20 (R-20-DR) — the `dense_readiness` of the `searchable→vector`
14129/// projection, DERIVED. There is no stored flag and this feature adds no schema
14130/// step or `MIGRATIONS` entry; later unrelated migrations do not affect that
14131/// property.
14132///
14133/// **Why derived is the design, not a shortcut.** §4.1 invariant 1 requires
14134/// `{ vector-insert ∧ dense_readiness := ready }` to be ONE transaction, with a
14135/// torn `ready`-without-vector FORBIDDEN. A stored flag is precisely the thing
14136/// that can tear. Deriving it makes the invariant true **by construction**:
14137/// readiness is a pure function of state that
14138/// [`commit_projection_outcomes`] already writes inside a single transaction —
14139/// the `vector_default` / `_fathomdb_vector_rows` INSERTs, the
14140/// `_fathomdb_projection_terminal` row ([`record_projection_terminal`]) and the
14141/// readiness watermark ([`advance_projection_cursor`], which only ever steps
14142/// over cursors that ALREADY hold a terminal) all commit together or not at all.
14143/// So `ready` cannot be observed before the vector is durable, and the only
14144/// reachable torn state is the tolerated one (`embedding` with the vector
14145/// absent — the dense arm simply reads as partial).
14146///
14147/// It reuses the EXACT predicate `drain`/`wait_for_idle` use
14148/// ([`connection_has_pending_projection_work`]), so "readiness is `ready`" and
14149/// "`drain` reports idle" cannot disagree.
14150///
14151/// **Scope note (honest boundary).** The predicate is corpus-wide, not
14152/// per-attribute, because Slice 15d persists the `searchable→vector` sub-object
14153/// but DEFERS building any per-attribute embedding (`ProjectionDelta::deferred`)
14154/// — every declared vector projection is served by the one engine vector
14155/// pipeline, so per-projection scoping has no distinct meaning yet. A stored
14156/// column would not have been more specific; it would only have been tearable.
14157/// When per-attribute embedding lands, this function is where the scoping goes.
14158///
14159/// **Failure boundary.** A row whose embed FAILED terminally records a `failed`
14160/// terminal (no vector row), so it stops being outstanding. With a usable dense
14161/// runtime, readiness returns to `ready`: the row will never embed, so reporting
14162/// `embedding` forever would be a lie. The usable-runtime predicate takes
14163/// precedence: when no usable runtime exists the state is `Unavailable`; when
14164/// it exists this function selects `Embedding` / `Ready` without changing the
14165/// failed-terminal boundary. Failures stay
14166/// separately observable through the `projection_failures` collection. A
14167/// `ready` corpus can therefore lack a vector row only for a failed terminal; it
14168/// is NOT a torn write because no `up_to_date` terminal exists for it.
14169fn derive_dense_readiness(
14170    connection: &Connection,
14171    dense_runtime_usable: bool,
14172) -> Result<DenseReadiness, EngineError> {
14173    if !dense_runtime_usable {
14174        return Ok(DenseReadiness::Unavailable);
14175    }
14176    if connection_has_pending_projection_work(connection).map_err(|_| EngineError::Storage)? {
14177        Ok(DenseReadiness::Embedding)
14178    } else {
14179        Ok(DenseReadiness::Ready)
14180    }
14181}
14182
14183struct CanonicalNodeRow {
14184    cursor: u64,
14185    kind: String,
14186    body: String,
14187    row_kind: RowKind,
14188    /// fix-2 [P2] — whether this row is in the attribute projection's row set
14189    /// (`state = 'active' AND superseded_at IS NULL`, the exact `backfill_attribute`
14190    /// predicate). A projector-replay rebuild uses this to gate the attribute
14191    /// projection so it does not re-surface a pending / superseded node's values.
14192    /// Node-FTS / vector shadows are rebuilt for every row (their stale versions
14193    /// are excluded by the read-side lifecycle join, unchanged from before).
14194    attr_projected: bool,
14195}
14196
14197/// 0.8.0 Slice 5 (G1) — re-tokenize `search_index` from the canonical source
14198/// rows after the step-11 tokenizer-default upgrade drops + recreates the FTS5
14199/// virtual table. Projection-only: it reads `canonical_nodes` (the source of
14200/// truth, untouched) and rewrites the FTS shadow; it performs **no**
14201/// source-record migration. Every canonical node already carries an FTS row at
14202/// write time (the projection-time INSERT is unconditional), so reinserting
14203/// every node exactly reproduces the prior index content under the new
14204/// tokenizer. Runs in a single transaction on the writer connection before
14205/// readers spawn.
14206///
14207/// Crash-retryable (fix-1): the reindex and its durable completion marker
14208/// (`SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` in `_fathomdb_open_state`)
14209/// commit together in ONE `BEGIN IMMEDIATE…COMMIT`. A crash before the commit
14210/// rolls both back, leaving no marker; the next open re-runs. A crash after
14211/// the commit finds the marker present and skips. Idempotent.
14212fn reproject_search_index_after_tokenizer_upgrade(connection: &Connection) -> rusqlite::Result<()> {
14213    let rows = canonical_node_rows(connection)?;
14214    connection.execute_batch("BEGIN IMMEDIATE")?;
14215    let result = (|| {
14216        // 0.8.20 Slice 5a (R-20-E1) — registry-driven: re-tokenize EVERY
14217        // node-FTS projection, not just `search_index`. `search_index_v2` uses
14218        // the SAME tokenizer (`porter unicode61 remove_diacritics 2`), so it is
14219        // equally invalidated by a tokenizer-default upgrade; before this slice
14220        // it was neither cleared nor re-tokenized here. Edge FTS is out of scope
14221        // for this open-path repair (it postdates the step-11 upgrade and is
14222        // rebuilt by `rebuild_projections`).
14223        truncate_row_projections_in(connection, &[ProjectionClass::NodeFts])?;
14224        for row in &rows {
14225            project_canonical_node_row(
14226                connection,
14227                row.cursor,
14228                &row.kind,
14229                &row.body,
14230                row.row_kind,
14231                ProjectionPass::FtsOnly,
14232                // FtsOnly never touches the attribute store (predates step 24), so
14233                // `node_active` is inert here; forward the row's flag anyway (it is
14234                // the backfill's active-and-non-superseded predicate) so the field
14235                // has a reader in every build configuration.
14236                row.attr_projected,
14237            )?;
14238        }
14239        connection.execute(
14240            "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
14241             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
14242            params![SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY, "1"],
14243        )?;
14244        Ok(())
14245    })();
14246    match result {
14247        Ok(()) => connection.execute_batch("COMMIT"),
14248        Err(err) => {
14249            let _ = connection.execute_batch("ROLLBACK");
14250            Err(err)
14251        }
14252    }
14253}
14254
14255/// 0.8.0 Slice 5 (G1) fix-1 — has the post-tokenizer-upgrade re-tokenization
14256/// committed durably on this DB? Keys off the
14257/// `SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY` row written inside the reindex
14258/// transaction; its absence on a v11 DB means the reindex never committed
14259/// (fresh-after-step-11 or crash-in-window) and must (re-)run.
14260///
14261/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
14262/// reproject): that table is created by migration step 1, so its absence means
14263/// the DB never ran our migrations (e.g. a synthetic DB whose `user_version`
14264/// was stamped to 11 by hand, or a legacy/foreign shape). Such DBs are
14265/// rejected by the downstream embedder-identity/integrity probes; the reproject
14266/// must not run — and must not mask those errors — on them. On a genuinely
14267/// migrated DB the table always exists, so the crash-repair path is unaffected.
14268fn search_index_tokenizer_reproject_complete(connection: &Connection) -> rusqlite::Result<bool> {
14269    match connection.query_row(
14270        "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
14271        [SEARCH_INDEX_TOKENIZER_REPROJECT_MARKER_KEY],
14272        |row| row.get::<_, String>(0),
14273    ) {
14274        Ok(value) => Ok(value == "1"),
14275        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
14276        Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
14277            if message.contains("no such table") =>
14278        {
14279            Ok(true)
14280        }
14281        Err(err) => Err(err),
14282    }
14283}
14284
14285/// 0.8.20 Slice 15c (TC-33) fix-6 — has the one-time edge-vector prune committed
14286/// durably on this DB? Keys off the [`EDGE_VECTOR_PRUNE_MARKER_KEY`] row written
14287/// inside the prune transaction; its absence means the prune never ran (a DB
14288/// upgraded before this fix shipped, or a crash between the step-23 commit and
14289/// the prune commit) and must (re-)run.
14290///
14291/// A MISSING `_fathomdb_open_state` table is reported as "complete" (skip the
14292/// prune) — that table is created by migration step 1, so its absence means the
14293/// DB never ran our migrations (a synthetic/foreign shape rejected downstream);
14294/// the prune must not run, and must not mask those errors, on it. Mirrors
14295/// [`search_index_tokenizer_reproject_complete`].
14296fn edge_vector_prune_complete(connection: &Connection) -> rusqlite::Result<bool> {
14297    match connection.query_row(
14298        "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
14299        [EDGE_VECTOR_PRUNE_MARKER_KEY],
14300        |row| row.get::<_, String>(0),
14301    ) {
14302        Ok(value) => Ok(value == "1"),
14303        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
14304        Err(rusqlite::Error::SqliteFailure(_, Some(ref message)))
14305            if message.contains("no such table") =>
14306        {
14307            Ok(true)
14308        }
14309        Err(err) => Err(err),
14310    }
14311}
14312
14313/// 0.8.20 Slice 15c (TC-33) fix-6 — delete every `vector_default` (vec0) row that
14314/// has NO `_fathomdb_vector_rows` sidecar entry, then record the durable
14315/// completion marker, all in one `BEGIN IMMEDIATE` transaction (crash-retryable:
14316/// a crash before COMMIT leaves no marker and the next open re-runs).
14317///
14318/// A vec0 row and its sidecar row are written and deleted TOGETHER (same
14319/// transaction) on every steady-state path, so a sidecar-less vec0 row is ONLY
14320/// ever produced by the step-23 recreate, which drops the edge rows and their
14321/// sidecar entries but cannot reach the engine-created vec0 table. So this
14322/// targets exactly the dropped edges' orphans and touches NOTHING on a healthy
14323/// corpus. Node vec0 rows keep their sidecar entry, so they are never pruned —
14324/// node recall is unaffected.
14325///
14326/// The orphans are gathered with plain scans (both proven vec0 forms — a full
14327/// `SELECT rowid FROM vector_default` and per-`rowid` `DELETE`) and diffed in
14328/// Rust, rather than relying on a compound `DELETE ... WHERE rowid NOT IN (...)`
14329/// over the virtual table.
14330fn prune_orphaned_edge_vectors(connection: &Connection) -> rusqlite::Result<()> {
14331    connection.execute_batch("BEGIN IMMEDIATE")?;
14332    let result = (|| {
14333        let sidecar: std::collections::HashSet<i64> = {
14334            let mut statement =
14335                connection.prepare("SELECT write_cursor FROM _fathomdb_vector_rows")?;
14336            let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
14337            let mut set = std::collections::HashSet::new();
14338            for r in rows {
14339                set.insert(r?);
14340            }
14341            set
14342        };
14343        let vec_rowids: Vec<i64> = {
14344            let mut statement = connection.prepare("SELECT rowid FROM vector_default")?;
14345            let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
14346            let mut out = Vec::new();
14347            for r in rows {
14348                out.push(r?);
14349            }
14350            out
14351        };
14352        for rowid in vec_rowids {
14353            if !sidecar.contains(&rowid) {
14354                // vec0 rowid IS the canonical write_cursor; delete by rowid (the
14355                // proven vec0 delete form, as `prune_edge_projection_shadows`),
14356                // through the one TC-76-safe vec0-delete primitive.
14357                delete_vector_partition_row(connection, rowid)?;
14358            }
14359        }
14360        connection.execute(
14361            "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
14362             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
14363            params![EDGE_VECTOR_PRUNE_MARKER_KEY, "1"],
14364        )?;
14365        Ok(())
14366    })();
14367    match result {
14368        Ok(()) => connection.execute_batch("COMMIT"),
14369        Err(err) => {
14370            let _ = connection.execute_batch("ROLLBACK");
14371            Err(err)
14372        }
14373    }
14374}
14375
14376fn canonical_node_rows(connection: &Connection) -> rusqlite::Result<Vec<CanonicalNodeRow>> {
14377    // fix-2 [P2] — also read `state` + `superseded_at` so a replay rebuild can gate
14378    // the attribute projection to the backfill's row set. `attr_projected` mirrors
14379    // the exact `backfill_attribute` predicate (`state = 'active' AND
14380    // superseded_at IS NULL`): a NULL/foreign state is NOT 'active' and so is
14381    // excluded, identical to the SQL equality.
14382    let mut statement = connection.prepare(
14383        "SELECT write_cursor, kind, body, row_kind, state, superseded_at \
14384         FROM canonical_nodes ORDER BY write_cursor",
14385    )?;
14386    let rows = statement.query_map([], |row| {
14387        let state: Option<String> = row.get::<_, Option<String>>(4)?;
14388        let superseded_at: Option<i64> = row.get::<_, Option<i64>>(5)?;
14389        Ok(CanonicalNodeRow {
14390            cursor: row.get::<_, u64>(0)?,
14391            kind: row.get::<_, String>(1)?,
14392            body: row.get::<_, String>(2)?,
14393            row_kind: row_kind_from_column(&row.get::<_, String>(3)?),
14394            attr_projected: state.as_deref() == Some("active") && superseded_at.is_none(),
14395        })
14396    })?;
14397    rows.collect()
14398}
14399
14400/// 0.8.20 Slice 5a — inverse of [`RowKind::as_str`] for the stored
14401/// `canonical_nodes.row_kind` column. An unrecognized spelling degrades to
14402/// `Leaf`, the column DEFAULT and the shape every pre-EXP-S row carries; that
14403/// keeps a projector replay behavior-identical to the pre-registry rebuild,
14404/// which ignored `row_kind` entirely.
14405fn row_kind_from_column(value: &str) -> RowKind {
14406    match value {
14407        "coverage" => RowKind::Coverage,
14408        "graph" => RowKind::Graph,
14409        _ => RowKind::Leaf,
14410    }
14411}
14412
14413#[cfg(feature = "operator")]
14414fn hex_encode(bytes: &[u8]) -> String {
14415    let mut out = String::with_capacity(bytes.len() * 2);
14416    for byte in bytes {
14417        out.push(hex_nibble(byte >> 4));
14418        out.push(hex_nibble(byte & 0x0f));
14419    }
14420    out
14421}
14422
14423#[cfg(feature = "operator")]
14424fn hex_nibble(value: u8) -> char {
14425    match value {
14426        0..=9 => (b'0' + value) as char,
14427        10..=15 => (b'a' + value - 10) as char,
14428        _ => unreachable!(),
14429    }
14430}
14431
14432#[cfg(feature = "operator")]
14433fn physical_section(connection: &Connection, full: bool) -> Section {
14434    let mut findings = Vec::new();
14435    if let Err(err) = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0)) {
14436        findings.push(Finding {
14437            code: "E_CORRUPT_HEADER",
14438            stage: "PhysicalProbe",
14439            locator: locator_from_rusqlite_error(&err),
14440            doc_anchor: "design/recovery.md#header-malformed",
14441            detail: format!("page_count probe failed: {err}"),
14442        });
14443    }
14444    if full {
14445        match collect_integrity_check_findings(connection) {
14446            Ok(rows) => findings.extend(rows),
14447            Err(err) => findings.push(Finding {
14448                code: "E_CORRUPT_INTEGRITY_CHECK",
14449                stage: "IntegrityCheck",
14450                locator: locator_from_rusqlite_error(&err),
14451                doc_anchor: "design/recovery.md#integrity-check-full-findings",
14452                detail: format!("PRAGMA integrity_check failed: {err}"),
14453            }),
14454        }
14455    }
14456    if findings.is_empty() {
14457        Section::Clean
14458    } else {
14459        Section::Findings(findings)
14460    }
14461}
14462
14463#[cfg(feature = "operator")]
14464fn logical_section(connection: &Connection) -> Section {
14465    let mut findings = Vec::new();
14466    if let Err(err) = connection.query_row("PRAGMA schema_version", [], |row| row.get::<_, i64>(0))
14467    {
14468        findings.push(Finding {
14469            code: "E_CORRUPT_SCHEMA",
14470            stage: "SchemaProbe",
14471            locator: locator_from_rusqlite_error(&err),
14472            doc_anchor: "design/recovery.md#schema-inconsistent",
14473            detail: format!("schema_version probe failed: {err}"),
14474        });
14475    }
14476    match connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) {
14477        Ok(0) => findings.push(Finding {
14478            code: "E_CORRUPT_SCHEMA",
14479            stage: "SchemaProbe",
14480            locator: CorruptionLocator::MigrationStep { from: 0, to: 0 },
14481            doc_anchor: "design/recovery.md#schema-inconsistent",
14482            detail: "user_version is zero".to_string(),
14483        }),
14484        Ok(_) => {}
14485        Err(err) => findings.push(Finding {
14486            code: "E_CORRUPT_SCHEMA",
14487            stage: "SchemaProbe",
14488            locator: locator_from_rusqlite_error(&err),
14489            doc_anchor: "design/recovery.md#schema-inconsistent",
14490            detail: format!("user_version probe failed: {err}"),
14491        }),
14492    }
14493    if findings.is_empty() {
14494        Section::Clean
14495    } else {
14496        Section::Findings(findings)
14497    }
14498}
14499
14500#[cfg(feature = "operator")]
14501fn semantic_section(connection: &Connection) -> Section {
14502    match load_default_profile(connection) {
14503        Ok(_) => Section::Clean,
14504        Err(rusqlite::Error::QueryReturnedNoRows) => Section::Findings(vec![Finding {
14505            code: "E_CORRUPT_EMBEDDER_IDENTITY",
14506            stage: "EmbedderIdentity",
14507            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
14508            doc_anchor: "design/recovery.md#embedder-identity-drift",
14509            detail: "default embedder profile row is missing".to_string(),
14510        }]),
14511        Err(err) => Section::Findings(vec![Finding {
14512            code: "E_CORRUPT_EMBEDDER_IDENTITY",
14513            stage: "EmbedderIdentity",
14514            locator: locator_from_rusqlite_error(&err),
14515            doc_anchor: "design/recovery.md#embedder-identity-drift",
14516            detail: format!("default embedder profile probe failed: {err}"),
14517        }]),
14518    }
14519}
14520
14521#[cfg(feature = "operator")]
14522fn collect_integrity_check_findings(connection: &Connection) -> rusqlite::Result<Vec<Finding>> {
14523    let mut statement = connection.prepare("PRAGMA integrity_check")?;
14524    let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
14525    let mut findings = Vec::new();
14526    for row in rows {
14527        let message = row?;
14528        if message == "ok" {
14529            continue;
14530        }
14531        findings.push(Finding {
14532            code: "E_CORRUPT_INTEGRITY_CHECK",
14533            stage: "IntegrityCheck",
14534            locator: CorruptionLocator::OpaqueSqliteError {
14535                sqlite_extended_code: rusqlite::ffi::SQLITE_CORRUPT,
14536            },
14537            doc_anchor: "design/recovery.md#integrity-check-full-findings",
14538            detail: message,
14539        });
14540    }
14541    Ok(findings)
14542}
14543
14544#[cfg(feature = "operator")]
14545fn locator_from_rusqlite_error(err: &rusqlite::Error) -> CorruptionLocator {
14546    let extended = err.sqlite_error().map(|inner| inner.extended_code).unwrap_or(0);
14547    CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: extended }
14548}
14549
14550fn open_runtime_connection(path: &Path) -> rusqlite::Result<Connection> {
14551    let connection = Connection::open(path)?;
14552    connection.pragma_update(None, "journal_mode", "WAL")?;
14553    // OPP-12 Phase-1 (0.8.19 Slice 10, design §3 gap-4) — `secure_delete=ON` at
14554    // EVERY open. The projection/vector-rewrite runtime connection performs
14555    // DELETEs (shadow-table rewrites), so its freed pages must be scrubbed too;
14556    // setting the pragma only on the writer left a GDPR-erasure leak here.
14557    connection.pragma_update(None, "secure_delete", "ON")?;
14558    Ok(connection)
14559}
14560
14561fn load_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
14562    connection
14563        .query_row(
14564            "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
14565            [PROJECTION_CURSOR_KEY],
14566            |row| row.get::<_, String>(0),
14567        )
14568        .map(|value| value.parse::<u64>().unwrap_or(0))
14569        .or_else(|err| match err {
14570            rusqlite::Error::QueryReturnedNoRows => Ok(0),
14571            _ => Err(err),
14572        })
14573}
14574
14575fn store_projection_cursor(connection: &Connection, cursor: u64) -> rusqlite::Result<()> {
14576    connection.execute(
14577        "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
14578         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
14579        params![PROJECTION_CURSOR_KEY, cursor.to_string()],
14580    )?;
14581    Ok(())
14582}
14583
14584fn record_projection_terminal(
14585    connection: &Connection,
14586    cursor: u64,
14587    state: &str,
14588) -> rusqlite::Result<()> {
14589    connection.execute(
14590        "INSERT OR IGNORE INTO _fathomdb_projection_terminal(write_cursor, state) VALUES(?1, ?2)",
14591        params![cursor, state],
14592    )?;
14593    Ok(())
14594}
14595
14596fn terminal_state_for_cursor(
14597    connection: &Connection,
14598    cursor: u64,
14599) -> rusqlite::Result<Option<String>> {
14600    connection
14601        .query_row(
14602            "SELECT state FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
14603            [cursor],
14604            |row| row.get::<_, String>(0),
14605        )
14606        .map(Some)
14607        .or_else(|err| match err {
14608            rusqlite::Error::QueryReturnedNoRows => Ok(None),
14609            _ => Err(err),
14610        })
14611}
14612
14613fn advance_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
14614    let mut cursor = load_projection_cursor(connection)?;
14615    loop {
14616        let next = cursor.saturating_add(1);
14617        if terminal_state_for_cursor(connection, next)?.is_some() {
14618            cursor = next;
14619        } else {
14620            break;
14621        }
14622    }
14623    store_projection_cursor(connection, cursor)?;
14624    Ok(cursor)
14625}
14626
14627fn commit_projection_outcomes(
14628    connection: &mut Connection,
14629    outcomes: &[ProjectionOutcome],
14630    shared: &ProjectionRuntimeShared,
14631) -> rusqlite::Result<()> {
14632    let embedder_identity = &shared.embedder_identity;
14633    let mc = identity_requires_mean_centering(embedder_identity);
14634    // EU-5f — serialize the whole commit across workers so the at-pin
14635    // re-quantize sees a totally-ordered history (see `commit_gate`).
14636    let _gate = shared.commit_gate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14637    // Take the WAL write lock before the reads below. A deferred transaction
14638    // would need a read-to-write promotion while a concurrent Engine::write
14639    // holds its own immediate transaction, which SQLite rejects without
14640    // invoking the busy handler and forces the worker to recompute the batch.
14641    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
14642    // The accumulator is mutable process state coupled to this transaction.
14643    // Keep the shared value untouched while building a candidate so rollback
14644    // cannot count a vector or consume the pin threshold prematurely.
14645    let mut shared_accumulator =
14646        shared.mean_accumulator.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
14647    let mut candidate_accumulator = shared_accumulator.clone();
14648    // EU-5a2/EU-5f — the live pinned mean. Read once at the top; may pin
14649    // mid-batch (set to `Some` after a threshold-crossing row below).
14650    let mut current_mean: Option<Vec<f32>> = if mc {
14651        tx.query_row(
14652            "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
14653            [],
14654            |row| row.get::<_, Option<Vec<u8>>>(0),
14655        )
14656        .ok()
14657        .flatten()
14658        .map(|bytes| decode_vector_blob(&bytes))
14659    } else {
14660        None
14661    };
14662    let mut staged_events: Vec<EmbedderEvent> = Vec::new();
14663    for outcome in outcomes {
14664        match outcome {
14665            ProjectionOutcome::Success { cursor, kind, blob, bin_blob } => {
14666                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14667                    continue;
14668                }
14669                // Build the threshold decision in the transaction-local
14670                // candidate. The shared accumulator changes only after commit.
14671                let pin_mean: Option<Vec<f32>> = if mc && current_mean.is_none() {
14672                    match candidate_accumulator.as_mut() {
14673                        Some(a) => {
14674                            a.add(&decode_vector_blob(bin_blob));
14675                            if a.count() >= MEAN_VEC_PIN_THRESHOLD {
14676                                let mean = a.materialize();
14677                                candidate_accumulator = None;
14678                                Some(mean)
14679                            } else {
14680                                None
14681                            }
14682                        }
14683                        None => None,
14684                    }
14685                } else {
14686                    None
14687                };
14688
14689                let source_type = resolve_source_type(kind).map_err(|_| {
14690                    rusqlite::Error::SqliteFailure(
14691                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
14692                        Some(format!("unknown kind for source_type mapping: {kind}")),
14693                    )
14694                })?;
14695                let now_unix =
14696                    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
14697                        as i64;
14698                tx.execute(
14699                    "INSERT OR IGNORE INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
14700                    params![cursor, kind, cursor],
14701                )?;
14702                // EU-5a2/EU-5f — sign-quant input is the mean-subtracted
14703                // vector iff a mean is live (`current_mean`); otherwise the
14704                // un-centered `bin_blob`. A row inserted just before the
14705                // crossing is centered retroactively by the re-quantize
14706                // pass below.
14707                let centered_blob: Vec<u8> = match &current_mean {
14708                    Some(mean) if mean.len() * 4 == bin_blob.len() => {
14709                        encode_vector_blob(&subtract_mean(&decode_vector_blob(bin_blob), mean))
14710                    }
14711                    _ => bin_blob.clone(),
14712                };
14713                // Slice 10 / G10 — `status` ships the empty-string sentinel (vec0
14714                // TEXT metadata is NOT NULL-able); no real population source yet
14715                // (reserved-gap candidate 13).
14716                //
14717                // 0.8.20 Slice 15e — when the live `vector_default` carries
14718                // `filterable` `attr_<hex>` columns, EVERY column must be bound
14719                // (vec0 rejects a partial-column INSERT). Bind each from the node
14720                // body's scalar extraction (or the `''` sentinel). The body is not
14721                // carried on the embed job, so it is read from `canonical_nodes` by
14722                // `write_cursor` (== rowid) — but ONLY when attr columns exist, so
14723                // the common no-filterable hot path stays byte-identical and does
14724                // NO extra lookup.
14725                if actual_vector_attr_columns(&tx)?.is_empty() {
14726                    tx.execute(
14727                        "INSERT OR IGNORE INTO vector_default(
14728                            rowid, embedding, embedding_bin, source_type, kind, created_at, status
14729                         ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, '')",
14730                        params![cursor, blob, centered_blob, source_type, kind, now_unix],
14731                    )?;
14732                } else {
14733                    let body: String = tx
14734                        .query_row(
14735                            "SELECT body FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1",
14736                            [*cursor as i64],
14737                            |row| row.get(0),
14738                        )
14739                        .optional()?
14740                        .unwrap_or_default();
14741                    let (cols_sql, ph_sql, attr_vals) =
14742                        vector_attr_insert_fragments(&tx, &body, 7)?;
14743                    let sql = format!(
14744                        "INSERT OR IGNORE INTO vector_default(
14745                            rowid, embedding, embedding_bin, source_type, kind, created_at, status{cols_sql}
14746                         ) VALUES(?1, ?2, vec_quantize_binary(?3), ?4, ?5, ?6, ''{ph_sql})"
14747                    );
14748                    let mut pv: Vec<rusqlite::types::Value> = vec![
14749                        rusqlite::types::Value::Integer(*cursor as i64),
14750                        rusqlite::types::Value::Blob(blob.clone()),
14751                        rusqlite::types::Value::Blob(centered_blob.clone()),
14752                        rusqlite::types::Value::Text(source_type.to_string()),
14753                        rusqlite::types::Value::Text(kind.to_string()),
14754                        rusqlite::types::Value::Integer(now_unix),
14755                    ];
14756                    pv.extend(attr_vals);
14757                    tx.execute(&sql, rusqlite::params_from_iter(pv.iter()))?;
14758                }
14759                record_projection_terminal(&tx, *cursor, "up_to_date")?;
14760
14761                // EU-5f — this row crossed the threshold: pin the mean and
14762                // re-quantize every row written so far (incl. earlier rows
14763                // in this same tx, which are visible to the SELECT) within
14764                // the same transaction so the pin is atomic.
14765                if let Some(mean) = pin_mean {
14766                    tx.execute(
14767                        "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14768                        params![encode_vector_blob(&mean)],
14769                    )?;
14770                    let rows: Vec<(i64, Vec<u8>)> = {
14771                        let mut statement = tx.prepare(
14772                            "SELECT rowid, embedding FROM vector_default ORDER BY rowid",
14773                        )?;
14774                        let mapped = statement.query_map([], |row| {
14775                            Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
14776                        })?;
14777                        let mut out = Vec::new();
14778                        for r in mapped {
14779                            out.push(r?);
14780                        }
14781                        out
14782                    };
14783                    let (doc_count, _) =
14784                        run_pin_and_requantize_pass(&tx, &rows, &mean).map_err(|_| {
14785                            rusqlite::Error::SqliteFailure(
14786                                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
14787                                Some("mean-centering re-quantize pass failed".to_string()),
14788                            )
14789                        })?;
14790                    staged_events.push(EmbedderEvent::MeanVecPinned {
14791                        dim: u32::try_from(mean.len()).unwrap_or(u32::MAX),
14792                        doc_count,
14793                    });
14794                    current_mean = Some(mean);
14795                }
14796            }
14797            ProjectionOutcome::Failure { cursor, failure_code } => {
14798                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
14799                    continue;
14800                }
14801                let existing: u64 = tx.query_row(
14802                    "SELECT COUNT(*) FROM operational_mutations
14803                     WHERE collection_name = 'projection_failures'
14804                       AND json_extract(payload_json, '$.write_cursor') = ?1",
14805                    [cursor],
14806                    |row| row.get(0),
14807                )?;
14808                if existing == 0 {
14809                    let payload = format!(
14810                        r#"{{"write_cursor":{cursor},"failure_code":"{failure_code}","recorded_at":0}}"#
14811                    );
14812                    tx.execute(
14813                        "INSERT INTO operational_mutations(
14814                            collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
14815                         ) VALUES('projection_failures', ?1, 'append', ?2, NULL, ?3)",
14816                        params![cursor.to_string(), payload, cursor],
14817                    )?;
14818                }
14819                record_projection_terminal(&tx, *cursor, "failed")?;
14820            }
14821            // 0.8.20 Slice 20c fix-4 (codex §9 round 3 [P1]) — record NOTHING.
14822            //
14823            // No `projection_failures` audit row (an ABSENT embedder is an
14824            // environment fact, not an embed failure) and, decisively, no
14825            // terminal: the row keeps `terminal IS NULL`, so
14826            // `advance_projection_cursor` below cannot step over it, the shared
14827            // `connection_has_pending_projection_work` predicate still reports it
14828            // outstanding, and `derive_dense_readiness` therefore reads
14829            // `embedding`. That is the ONLY torn state
14830            // `dev/design/record-lifecycle-protocol/projection-registry-and-async-embed.md`
14831            // §4.1 invariant 1 tolerates; the alternative — the enqueue-side gate
14832            // — puts an `'up_to_date'` terminal on an ENROLLED row with no
14833            // vector, which is the torn `ready` that invariant calls FORBIDDEN.
14834            //
14835            // Q6a graceful-absent governs ROLE DECLARATION ("you declared a
14836            // projection I cannot build yet" -> defer + graft), i.e. the
14837            // NOT-yet-enrolled case fix-1/fix-2 handle. Once a kind IS enrolled,
14838            // §4.1 invariant 1 governs. (HITL ruling, 0.8.20 Slice 20c fix-4.)
14839            //
14840            // Consumer-visible consequence, accepted deliberately and pinned by
14841            // `slice20c_flush_barrier`: for the REST of that no-embedder session
14842            // `dense_readiness` stays `embedding` and `drain` burns its timeout
14843            // into `EngineError::Scheduler`. Loud and recoverable, rather than
14844            // silent and lost.
14845            ProjectionOutcome::Deferred => {}
14846        }
14847    }
14848    // 0.7.2 PR-2bc S2 — the AUTOMATIC in-ingest drift detector (EWMA recent
14849    // mean + cos-threshold + debounce + 200k cap + `MeanRecomputeDeferred`)
14850    // was CARVED OUT and DEFERRED to 0.8.x; its recall premise was refuted
14851    // (the mean is a non-lever) and the benefit is unmeasured. The mean is
14852    // refreshed only on demand via `Engine::recompute_mean` (the
14853    // `doctor recompute-mean` verb). See `dev/design/embedder.md` §0.3 and
14854    // `dev/plans/prompts/0.8.x-auto-mean-drift-DEFERRED.md`. Nothing here
14855    // mutates `mean_vec` after the initial pin.
14856
14857    advance_projection_cursor(&tx)?;
14858    #[cfg(debug_assertions)]
14859    match shared.force_projection_commit_failure.swap(0, Ordering::SeqCst) {
14860        1 => {
14861            return Err(rusqlite::Error::SqliteFailure(
14862                rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
14863                Some("forced projection commit failure".to_string()),
14864            ));
14865        }
14866        2 => return Err(rusqlite::Error::InvalidQuery),
14867        _ => {}
14868    }
14869    tx.commit()?;
14870    // Commit and the accumulator transition become visible together. On every
14871    // earlier error the transaction and the local candidate drop, leaving the
14872    // runtime state exactly as it was before this attempt.
14873    *shared_accumulator = candidate_accumulator;
14874    // EU-5f — publish MeanVecPinned only after the pin tx is durable, so a
14875    // rolled-back pin never emits a spurious event.
14876    if !staged_events.is_empty() {
14877        if let Ok(mut events) = shared.pending_events.lock() {
14878            events.extend(staged_events);
14879        }
14880    }
14881    Ok(())
14882}
14883
14884/// EU-5f — open-time recovery pin (`dev/design/embedder.md` §0.3, Hazard 4).
14885/// Derives the corpus mean from the existing un-centered `vector_default`
14886/// rows, pins it, and re-quantizes every row, all in one transaction on the
14887/// single-threaded open connection (no workers running yet, so no gate is
14888/// needed). Called only when MC is required, no mean is pinned, and the row
14889/// count already meets the threshold.
14890fn recover_mean_vec_pin(
14891    connection: &mut Connection,
14892    identity: &EmbedderIdentity,
14893) -> Result<(), EngineError> {
14894    let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
14895    recompute_mean_in_tx(&tx, identity)?;
14896    tx.commit().map_err(|_| EngineError::Storage)?;
14897    Ok(())
14898}
14899
14900/// 0.7.2 PR-2b — shared mean (re)compute core, run INSIDE the caller's
14901/// transaction. Derives the FULL-corpus mean from the un-centered
14902/// `vector_default.embedding` BLOBs, writes `mean_vec`, and re-quantizes
14903/// EVERY row via the existing [`run_pin_and_requantize_pass`] so no row is
14904/// left under a stale centering.
14905///
14906/// This generalizes the EU-5f open-time recovery pin: it has NO "no mean
14907/// pinned yet" guard, so it equally serves the FIRST pin (recovery) and a
14908/// REFRESH of an already-pinned mean (PR-2b drift / `doctor recompute-mean`).
14909/// The caller owns the transaction boundary, which is what makes a fault
14910/// between the `mean_vec` UPDATE and re-quantize completion roll back
14911/// wholesale (`dev/design/embedder.md` §0.5 atomicity). It does NOT publish
14912/// any event — that is the caller's job, strictly post-durable-commit.
14913fn recompute_mean_in_tx(
14914    tx: &rusqlite::Transaction<'_>,
14915    identity: &EmbedderIdentity,
14916) -> Result<MeanRecomputeReport, EngineError> {
14917    recompute_mean_in_tx_inner(tx, identity, false)
14918}
14919
14920/// 0.7.2 PR-2b — recompute core with an optional fault-injection point. The
14921/// `fail_after_mean_update` flag (debug builds only, set via a test seam)
14922/// errors AFTER the `mean_vec` UPDATE but BEFORE the re-quantize completes,
14923/// so the caller's tx rolls back the partial recentering.
14924fn recompute_mean_in_tx_inner(
14925    tx: &rusqlite::Transaction<'_>,
14926    identity: &EmbedderIdentity,
14927    fail_after_mean_update: bool,
14928) -> Result<MeanRecomputeReport, EngineError> {
14929    let started = Instant::now();
14930    let dim = identity.dimension as usize;
14931    // The previously-pinned mean (if any) is read first so we can report
14932    // the pre-recompute drift cosine.
14933    let old_mean = read_pinned_mean_vec(tx, identity.dimension)?;
14934    let rows: Vec<(i64, Vec<u8>)> = {
14935        let mut statement = tx
14936            .prepare("SELECT rowid, embedding FROM vector_default ORDER BY rowid")
14937            .map_err(|_| EngineError::Storage)?;
14938        let mapped = statement
14939            .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)))
14940            .map_err(|_| EngineError::Storage)?;
14941        let mut out = Vec::new();
14942        for r in mapped {
14943            out.push(r.map_err(|_| EngineError::Storage)?);
14944        }
14945        out
14946    };
14947    let mut accumulator = MeanAccumulator::new(dim);
14948    for (_rowid, blob) in &rows {
14949        if blob.len() != dim * 4 {
14950            return Err(EngineError::Storage);
14951        }
14952        accumulator.add(&decode_vector_blob(blob));
14953    }
14954    let old_doc_count = accumulator.count();
14955    let mean = accumulator.materialize();
14956    let drift_cos_before = match &old_mean {
14957        Some(old) => cosine_similarity(&mean, old),
14958        None => 1.0,
14959    };
14960    tx.execute(
14961        "UPDATE _fathomdb_embedder_profiles SET mean_vec = ?1 WHERE profile = 'default'",
14962        params![encode_vector_blob(&mean)],
14963    )
14964    .map_err(|_| EngineError::Storage)?;
14965    if fail_after_mean_update {
14966        // Injected fault: bail before re-quantizing so the caller's tx
14967        // rolls back the `mean_vec` UPDATE too (crash-atomicity proof).
14968        return Err(EngineError::Storage);
14969    }
14970    let (doc_count, _) = run_pin_and_requantize_pass(tx, &rows, &mean)?;
14971    Ok(MeanRecomputeReport {
14972        dim: u32::try_from(dim).unwrap_or(u32::MAX),
14973        old_doc_count,
14974        doc_count_requantized: doc_count,
14975        drift_cos_before,
14976        mean_was_pinned: old_mean.is_some(),
14977        elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
14978    })
14979}
14980
14981/// Cap sweep over the op-store mutation log: keeps the newest `cap` SWEEPABLE
14982/// rows, dropping the oldest by `id`.
14983///
14984/// **Pending-redaction exemption (0.8.20 Slice 5 fix-1).**
14985/// [`ERASURE_PENDING_REDACTION_COLLECTION`] is exempt on the same principle for a
14986/// stronger reason: that row is not a record of a discharged obligation but an
14987/// UNDISCHARGED one. Sweeping it would silently drop an erasure the engine still
14988/// owes, and the next retry would then report success with the leaked stable ids
14989/// still in the telemetry sink — exactly the R-20-E5 violation this mechanism
14990/// exists to prevent.
14991///
14992/// **Erasure-audit exemption (0.8.20 Slice 5b, design v5 §2 defect D-A;
14993/// HITL-ruled 2026-07-19: *"there must be an auditable record of deletion
14994/// event."*).** Rows in [`ERASURE_AUDIT_COLLECTIONS`] are excluded from BOTH the
14995/// count and the DELETE, and are therefore **never removed by retention
14996/// pressure**. Previously this swept `operational_mutations` cap-first,
14997/// oldest-`id`-first, with no collection filter — so the `excise_source_audit`
14998/// row proving an erasure occurred shared one retention pool with the very
14999/// payloads it must prove erased, and (being written before whatever workload
15000/// followed) was among the first evicted. Accountability is a distinct
15001/// obligation from erasure; a sweep must not silently discharge it.
15002///
15003/// Consequence of excluding audit rows from the count: `cap` is a cap on
15004/// SWEEPABLE rows, not on the physical table size. That is deliberate — the
15005/// alternative (counting exempt rows toward the cap) would let a growing audit
15006/// trail evict ordinary provenance ever more aggressively, and in the limit
15007/// leave nothing sweepable while the sweep churned every write.
15008fn enforce_provenance_retention(connection: &Connection, cap: u64) -> rusqlite::Result<()> {
15009    if cap == 0 {
15010        return Ok(());
15011    }
15012    // Static, engine-internal identifiers — no caller input reaches this SQL.
15013    let exempt = ERASURE_AUDIT_COLLECTIONS
15014        .iter()
15015        .copied()
15016        .chain(std::iter::once(ERASURE_PENDING_REDACTION_COLLECTION))
15017        .map(|name| format!("'{name}'"))
15018        .collect::<Vec<_>>()
15019        .join(", ");
15020    let slack = cap.max(20) / 20;
15021    let upper = cap.saturating_add(slack.max(1));
15022    let count: u64 = connection.query_row(
15023        &format!(
15024            "SELECT COUNT(*) FROM operational_mutations
15025             WHERE collection_name NOT IN ({exempt})"
15026        ),
15027        [],
15028        |row| row.get(0),
15029    )?;
15030    if count <= upper {
15031        return Ok(());
15032    }
15033    let to_delete = count.saturating_sub(cap);
15034    connection.execute(
15035        &format!(
15036            "DELETE FROM operational_mutations
15037             WHERE id IN (
15038                 SELECT id FROM operational_mutations
15039                 WHERE collection_name NOT IN ({exempt})
15040                 ORDER BY id
15041                 LIMIT ?1
15042             )"
15043        ),
15044        [to_delete],
15045    )?;
15046    Ok(())
15047}
15048
15049/// 0.8.20 Slice 5b (R-20-E6) — the prefixed stable ids
15050/// ([`IdSpace::to_prefixed`]) of the canonical rows an erasure verb is about to
15051/// delete, so they can be redacted from the telemetry sink.
15052///
15053/// Must be called INSIDE the erasing transaction and BEFORE the DELETEs — after
15054/// them the rows, and with them the `logical_id`/`body` the ids derive from, are
15055/// gone. Both queries take one bound parameter (`?1`), applied to nodes and
15056/// edges respectively; `derive_stable_id` reproduces exactly what
15057/// `capture_telemetry` wrote into `result_stable_ids`.
15058fn collect_erased_stable_ids(
15059    tx: &Connection,
15060    node_sql: &str,
15061    edge_sql: &str,
15062    bind: &str,
15063) -> Result<Vec<String>, EngineError> {
15064    let mut ids = Vec::new();
15065    for sql in [node_sql, edge_sql] {
15066        let mut stmt = tx.prepare(sql).map_err(|_| EngineError::Storage)?;
15067        let rows = stmt
15068            .query_map(params![bind], |row| {
15069                Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?))
15070            })
15071            .map_err(|_| EngineError::Storage)?;
15072        for row in rows {
15073            let (logical_id, body) = row.map_err(|_| EngineError::Storage)?;
15074            ids.push(
15075                derive_stable_id(logical_id.as_deref(), body.as_deref().unwrap_or(""))
15076                    .to_prefixed(),
15077            );
15078        }
15079    }
15080    ids.sort_unstable();
15081    ids.dedup();
15082    Ok(ids)
15083}
15084
15085/// 0.8.20 Slice 5 fix-1 (codex §9 P2) — record, INSIDE the erasing transaction,
15086/// that a telemetry redaction is owed for `erased_stable_ids`.
15087///
15088/// Must be called in the same transaction as the DELETEs. That is the whole
15089/// point: "the rows are gone" and "a redaction is owed for them" then commit
15090/// atomically, so no crash or failure can leave the first true and the second
15091/// unrecorded. [`Engine::discharge_pending_redactions`] drains the queue and
15092/// deletes the entry only once the sink has actually been rewritten.
15093///
15094/// `record_key` is the VERB, never a stable id — the ids live in the payload,
15095/// which is deleted on discharge.
15096fn enqueue_pending_redaction(
15097    tx: &Connection,
15098    verb: &str,
15099    erased_stable_ids: &[String],
15100    write_cursor: u64,
15101) -> Result<(), EngineError> {
15102    if erased_stable_ids.is_empty() {
15103        return Ok(());
15104    }
15105    let payload =
15106        serde_json::json!({ "verb": verb, "erased_stable_ids": erased_stable_ids }).to_string();
15107    tx.execute(
15108        "INSERT INTO operational_mutations(
15109            collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
15110         ) VALUES(?1, ?2, 'append', ?3, NULL, ?4)",
15111        params![ERASURE_PENDING_REDACTION_COLLECTION, verb, payload, write_cursor],
15112    )
15113    .map_err(|_| EngineError::Storage)?;
15114    Ok(())
15115}
15116
15117/// 0.8.20 Slice 5b (R-20-E7) — the audit handle for an erased op-store record:
15118/// `SHA-256(collection + 0x1F + record_key)`, lowercase hex.
15119///
15120/// A record key is arbitrary caller-supplied text and may itself be the
15121/// identifier being erased, so a durable audit row must not echo it. `0x1F`
15122/// (ASCII unit separator) is the delimiter because it cannot appear in a
15123/// well-formed collection name, keeping the pairing unambiguous.
15124#[cfg(feature = "operator")]
15125fn digest_record_identity(collection: &str, record_key: &str) -> String {
15126    let mut hasher = Sha256::new();
15127    hasher.update(collection.as_bytes());
15128    hasher.update([0x1f_u8]);
15129    hasher.update(record_key.as_bytes());
15130    hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
15131}
15132
15133fn projection_status(
15134    connection: &Connection,
15135    kind: &str,
15136) -> Result<lifecycle::ProjectionStatus, EngineError> {
15137    let latest = connection
15138        .query_row(
15139            "SELECT COALESCE(MAX(write_cursor), 0) FROM canonical_nodes WHERE kind = ?1",
15140            [kind],
15141            |row| row.get::<_, u64>(0),
15142        )
15143        .map_err(|_| EngineError::Storage)?;
15144    if latest == 0 {
15145        return Ok(lifecycle::ProjectionStatus::UpToDate);
15146    }
15147    let pending: u64 = connection
15148        .query_row(
15149            "SELECT COUNT(*)
15150             FROM canonical_nodes
15151             LEFT JOIN _fathomdb_projection_terminal
15152               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
15153             WHERE canonical_nodes.kind = ?1
15154               AND _fathomdb_projection_terminal.write_cursor IS NULL",
15155            [kind],
15156            |row| row.get(0),
15157        )
15158        .map_err(|_| EngineError::Storage)?;
15159    if pending > 0 {
15160        return Ok(lifecycle::ProjectionStatus::Pending);
15161    }
15162    match terminal_state_for_cursor(connection, latest).map_err(|_| EngineError::Storage)? {
15163        Some(state) if state == "failed" => Ok(lifecycle::ProjectionStatus::Failed),
15164        _ => Ok(lifecycle::ProjectionStatus::UpToDate),
15165    }
15166}
15167
15168fn canonical_database_path(path: &Path) -> Result<PathBuf, EngineOpenError> {
15169    let parent = path
15170        .parent()
15171        .filter(|parent| !parent.as_os_str().is_empty())
15172        .unwrap_or_else(|| Path::new("."));
15173    let canonical_parent = parent.canonicalize().map_err(|_| EngineOpenError::Io {
15174        message: "database parent directory is not accessible".to_string(),
15175    })?;
15176    let file_name = path.file_name().ok_or_else(|| EngineOpenError::Io {
15177        message: "database path has no file name".to_string(),
15178    })?;
15179
15180    Ok(canonical_parent.join(file_name))
15181}
15182
15183fn acquire_lock(path: &Path) -> Result<File, EngineOpenError> {
15184    let lock_path = lock_path(path);
15185    let mut options = OpenOptions::new();
15186    options.read(true).write(true).create(true);
15187    #[cfg(unix)]
15188    options.mode(0o600);
15189
15190    let mut file = options.open(&lock_path).map_err(|_| EngineOpenError::Io {
15191        message: "could not open database lock file".to_string(),
15192    })?;
15193
15194    match file.try_lock() {
15195        Ok(()) => {
15196            let pid = std::process::id().to_string();
15197            let _ = file.set_len(0);
15198            let _ = file.seek(SeekFrom::Start(0));
15199            let _ = file.write_all(pid.as_bytes());
15200            Ok(file)
15201        }
15202        Err(std::fs::TryLockError::WouldBlock) => {
15203            Err(EngineOpenError::DatabaseLocked { holder_pid: read_holder_pid(&lock_path) })
15204        }
15205        Err(_) => {
15206            Err(EngineOpenError::Io { message: "could not acquire database lock".to_string() })
15207        }
15208    }
15209}
15210
15211fn lock_path(path: &Path) -> PathBuf {
15212    let mut lock_path = path.as_os_str().to_os_string();
15213    lock_path.push(LOCK_SUFFIX);
15214    PathBuf::from(lock_path)
15215}
15216
15217fn read_holder_pid(path: &Path) -> Option<u32> {
15218    std::fs::read_to_string(path).ok()?.trim().parse().ok()
15219}
15220
15221fn map_migration_error(err: SchemaMigrationError) -> EngineOpenError {
15222    match err {
15223        SchemaMigrationError::IncompatibleSchemaVersion { seen, supported } => {
15224            EngineOpenError::IncompatibleSchemaVersion { seen, supported }
15225        }
15226        SchemaMigrationError::MigrationError(report) => EngineOpenError::MigrationError {
15227            schema_version_before: report.schema_version_before,
15228            schema_version_current: report.schema_version_current,
15229            step_id: report.migration_steps.last().map_or(0, |step| step.step_id),
15230        },
15231        SchemaMigrationError::Storage { message } => {
15232            EngineOpenError::Io { message: message.to_string() }
15233        }
15234    }
15235}
15236
15237/// 0.7.0 perf-experiments hook: process-start `sqlite3_config` calls.
15238/// Runs exactly once per process; must precede any `Connection::open`.
15239/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. Each individual config
15240/// option is opt-in via its own env var so unrelated experiments do
15241/// not implicitly co-fire.
15242///
15243/// Currently supports:
15244/// - `FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF=1`:
15245///   `sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)` — drops the
15246///   allocator stats locking surface (whitepaper § 7.4). Composes
15247///   with other levers; small payoff alone.
15248///
15249/// Pattern: shutdown → config → initialize, mirroring B.1 attempt #2
15250/// (`d448263`, reverted). The captured rc for each config call is
15251/// logged to stderr so experiments can verify the call took effect.
15252fn init_perf_experiments_runtime() {
15253    static INIT: Once = Once::new();
15254    INIT.call_once(|| {
15255        if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
15256            return;
15257        }
15258        let memstatus_off =
15259            std::env::var_os("FATHOMDB_PERF_SQLITE_MEMSTATUS_OFF").is_some_and(|v| v == "1");
15260        // FATHOMDB_PERF_SQLITE_PAGECACHE=<page_size_bytes>:<page_count>
15261        // E.g. "4096:5000" => pre-allocate 4096 B × 5000 pages = 20 MB
15262        // global page-cache backing. SQLite distributes this across
15263        // connections; reduces global allocator pressure for page
15264        // cache fills.
15265        let pagecache = std::env::var("FATHOMDB_PERF_SQLITE_PAGECACHE").ok();
15266        // FATHOMDB_PERF_SQLITE_PCACHE2=1 installs the per-instance
15267        // custom page-cache allocator (pcache2.rs). Targets AC-020
15268        // residual contention on the default pcache1 mutex.
15269        let pcache2_on =
15270            std::env::var_os("FATHOMDB_PERF_SQLITE_PCACHE2").is_some_and(|v| v == "1");
15271        if !memstatus_off && pagecache.is_none() && !pcache2_on {
15272            return;
15273        }
15274        // SAFETY: sqlite3_shutdown / sqlite3_initialize are documented
15275        // as safe to call before any other SQLite API; sqlite3_config
15276        // must be called between shutdown and initialize. We pre-empt
15277        // rusqlite's lazy first-call sqlite3_initialize via this
15278        // explicit shutdown-then-config-then-initialize sequence,
15279        // identical to B.1 attempt #2's plumbing.
15280        unsafe {
15281            let rc_shutdown = rusqlite::ffi::sqlite3_shutdown();
15282            let rc_memstatus = if memstatus_off {
15283                rusqlite::ffi::sqlite3_config(rusqlite::ffi::SQLITE_CONFIG_MEMSTATUS, 0_i32)
15284            } else {
15285                -1
15286            };
15287            // SQLITE_CONFIG_PAGECACHE = 7 per sqlite3.h. With buffer=NULL,
15288            // SQLite allocates the backing memory itself but still
15289            // partitions it for use as the page-cache pool.
15290            let rc_pagecache = if let Some(spec) = pagecache.as_ref() {
15291                let mut parts = spec.split(':');
15292                let sz = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
15293                let n = parts.next().and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
15294                if sz > 0 && n > 0 {
15295                    rusqlite::ffi::sqlite3_config(
15296                        7, // SQLITE_CONFIG_PAGECACHE
15297                        std::ptr::null_mut::<std::ffi::c_void>(),
15298                        sz,
15299                        n,
15300                    )
15301                } else {
15302                    eprintln!(
15303                        "perf-experiment: bad FATHOMDB_PERF_SQLITE_PAGECACHE spec '{spec}' (expect '<bytes>:<count>')"
15304                    );
15305                    -1
15306                }
15307            } else {
15308                -1
15309            };
15310            let rc_pcache2 = if pcache2_on {
15311                // SQLITE_CONFIG_PCACHE2 = 18 per sqlite3.h. The methods
15312                // table must outlive the SQLite engine; we pass a
15313                // pointer to our static.
15314                rusqlite::ffi::sqlite3_config(
15315                    rusqlite::ffi::SQLITE_CONFIG_PCACHE2,
15316                    &raw const pcache2::PCACHE2_METHODS.0,
15317                )
15318            } else {
15319                -1
15320            };
15321            let rc_init = rusqlite::ffi::sqlite3_initialize();
15322            eprintln!(
15323                "perf-experiment: runtime-config rcs shutdown={rc_shutdown} \
15324                 memstatus={rc_memstatus} pagecache={rc_pagecache} pcache2={rc_pcache2} \
15325                 initialize={rc_init} (0=SQLITE_OK; 21=SQLITE_MISUSE; -1=not configured)"
15326            );
15327        }
15328    });
15329}
15330
15331fn register_sqlite_vec_extension() {
15332    static REGISTER: Once = Once::new();
15333    REGISTER.call_once(|| unsafe {
15334        let entrypoint: unsafe extern "C" fn(
15335            *mut rusqlite::ffi::sqlite3,
15336            *mut *mut std::os::raw::c_char,
15337            *const rusqlite::ffi::sqlite3_api_routines,
15338        ) -> std::os::raw::c_int = std::mem::transmute(sqlite3_vec_init as *const ());
15339        rusqlite::ffi::sqlite3_auto_extension(Some(entrypoint));
15340    });
15341}
15342
15343fn probe_open_integrity(connection: &Connection) -> Result<(), EngineOpenError> {
15344    // `SELECT COUNT(*) FROM sqlite_schema` forces a full traversal of the
15345    // sqlite_schema b-tree; this surfaces page-1 b-tree corruption that a
15346    // bare `PRAGMA schema_version` (which only reads the schema cookie
15347    // out of the file header) would miss.
15348    connection
15349        .query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| row.get::<_, i64>(0))
15350        .map(|_| ())
15351        .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))
15352}
15353
15354fn probe_database_header(connection: &Connection) -> Result<(), EngineOpenError> {
15355    connection
15356        .query_row("PRAGMA application_id", [], |row| row.get::<_, i64>(0))
15357        .map(|_| ())
15358        .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))
15359}
15360
15361/// Pre-`pragma WAL` sidecar validation. SQLite silently discards a WAL
15362/// file whose header magic is wrong or whose advertised page size is
15363/// outside `[512, SQLITE_MAX_PAGE_SIZE]`, which would cause us to lose
15364/// committed frames at open time. AC-035a requires that we instead
15365/// refuse to open with `Corruption(WalReplayFailure)` rather than
15366/// silently rebuild from a truncated WAL.
15367fn probe_wal_sidecar(db_path: &Path) -> Result<(), EngineOpenError> {
15368    let mut wal_path = db_path.as_os_str().to_owned();
15369    wal_path.push("-wal");
15370    let wal_path = PathBuf::from(wal_path);
15371    // Bounded read: the WAL header is fixed-layout in the first 32
15372    // bytes (magic + format + page-size + checkpoint-seq + salts +
15373    // checksums); frame data starts at offset 32 and is irrelevant to
15374    // the magic + page-size pre-check. A `std::fs::read` of the whole
15375    // sidecar would force an unclean-shutdown open path to allocate
15376    // and copy the entire WAL into memory before SQLite touches
15377    // recovery — a real latency + RSS regression on AC-035.
15378    use std::io::Read;
15379    let mut file = match std::fs::File::open(&wal_path) {
15380        Ok(file) => file,
15381        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
15382        Err(_) => return Ok(()),
15383    };
15384    let mut bytes = [0u8; 32];
15385    if file.read_exact(&mut bytes).is_err() {
15386        // A short (< 32-byte) sidecar carries no committed frames;
15387        // SQLite treats it as empty and re-initializes WAL state.
15388        return Ok(());
15389    }
15390    let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
15391    let page_size = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
15392    // WAL_MAGIC mask per SQLite `walIndexRecover`: low bit distinguishes
15393    // big-endian vs little-endian checksum encoding; the rest of the
15394    // magic is fixed.
15395    const WAL_MAGIC_MASK: u32 = 0xFFFF_FFFE;
15396    const WAL_MAGIC: u32 = 0x377F_0682;
15397    const SQLITE_MAX_PAGE_SIZE: u32 = 65536;
15398    let magic_ok = (magic & WAL_MAGIC_MASK) == WAL_MAGIC;
15399    let page_size_ok =
15400        page_size.is_power_of_two() && (512..=SQLITE_MAX_PAGE_SIZE).contains(&page_size);
15401    if magic_ok && page_size_ok {
15402        return Ok(());
15403    }
15404    Err(EngineOpenError::Corruption(CorruptionDetail {
15405        kind: CorruptionKind::WalReplayFailure,
15406        stage: OpenStage::WalReplay,
15407        locator: CorruptionLocator::FileOffset { offset: if !magic_ok { 0 } else { 8 } },
15408        recovery_hint: RecoveryHint {
15409            code: "E_CORRUPT_WAL_REPLAY",
15410            doc_anchor: "design/recovery.md#wal-replay-failures",
15411        },
15412    }))
15413}
15414
15415fn reject_legacy_shape(connection: &Connection) -> Result<(), EngineOpenError> {
15416    let has_legacy_table = table_exists(connection, "fathom_nodes")
15417        || table_exists(connection, "fathom_edges")
15418        || table_exists(connection, "fathom_chunks");
15419    if !has_legacy_table {
15420        return Ok(());
15421    }
15422
15423    let seen =
15424        connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)).unwrap_or(0);
15425    Err(EngineOpenError::IncompatibleSchemaVersion { seen, supported: SCHEMA_VERSION })
15426}
15427
15428fn table_exists(connection: &Connection, table: &str) -> bool {
15429    connection
15430        .query_row(
15431            "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
15432            [table],
15433            |_row| Ok(()),
15434        )
15435        .is_ok()
15436}
15437
15438#[cfg(feature = "operator")]
15439fn read_schema_objects(
15440    connection: &Connection,
15441    obj_type: &str,
15442) -> Result<Vec<SchemaObject>, EngineError> {
15443    let mut stmt = connection
15444        .prepare(
15445            "SELECT name, sql FROM sqlite_schema
15446             WHERE type = ?1 AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL
15447             ORDER BY name",
15448        )
15449        .map_err(|_| EngineError::Storage)?;
15450    let rows = stmt
15451        .query_map([obj_type], |row| {
15452            Ok(SchemaObject { name: row.get::<_, String>(0)?, sql: row.get::<_, String>(1)? })
15453        })
15454        .map_err(|_| EngineError::Storage)?;
15455    let mut out = Vec::new();
15456    for row in rows {
15457        out.push(row.map_err(|_| EngineError::Storage)?);
15458    }
15459    Ok(out)
15460}
15461
15462#[cfg(feature = "operator")]
15463fn order_canonical_first(mut objects: Vec<SchemaObject>) -> Vec<SchemaObject> {
15464    let mut canonical: Vec<SchemaObject> = Vec::new();
15465    for name in CANONICAL_TABLES {
15466        if let Some(pos) = objects.iter().position(|o| o.name == *name) {
15467            canonical.push(objects.remove(pos));
15468        }
15469    }
15470    canonical.extend(objects);
15471    canonical
15472}
15473
15474fn load_default_profile(connection: &Connection) -> rusqlite::Result<EmbedderIdentity> {
15475    connection.query_row(
15476        "SELECT name, revision, dimension FROM _fathomdb_embedder_profiles WHERE profile = ?1",
15477        [DEFAULT_VECTOR_PROFILE],
15478        |row| {
15479            Ok(EmbedderIdentity::new(
15480                row.get::<_, String>(0)?,
15481                row.get::<_, String>(1)?,
15482                row.get::<_, u32>(2)?,
15483            ))
15484        },
15485    )
15486}
15487
15488fn default_profile_dimension(connection: &Connection) -> Result<u32, EngineError> {
15489    load_default_profile(connection)
15490        .map(|identity| identity.dimension)
15491        .map_err(|_| EngineError::Storage)
15492}
15493
15494fn kind_is_vector_indexed(connection: &Connection, kind: &str) -> Result<bool, EngineError> {
15495    connection
15496        .query_row("SELECT 1 FROM _fathomdb_vector_kinds WHERE kind = ?1", [kind], |_row| Ok(()))
15497        .map(|_| true)
15498        .or_else(|err| match err {
15499            rusqlite::Error::QueryReturnedNoRows => Ok(false),
15500            _ => Err(EngineError::Storage),
15501        })
15502}
15503
15504fn ensure_vector_partition(connection: &mut Connection, dimension: u32) -> rusqlite::Result<()> {
15505    // 0.7.0 Pack 1 schema per dev/design/0.7.0-vector-quant-pack1.md D1/D2:
15506    // f32 `embedding` + binary-quant sibling `embedding_bin` + `source_type`
15507    // partition key + `kind` + `created_at`. The vec0 column type is
15508    // dim-parameterized, so the reshape lives here rather than in the
15509    // SQL-only migration framework — see fathomdb-schema migration step 9
15510    // and dev/plans/runs/0.7.0-PVQ-P1-IMPL-output.json for the deviation
15511    // from the design memo's "Choose (a)" guidance.
15512    //
15513    // Three paths:
15514    //   (1) no vector_default       -> CREATE at new shape.
15515    //   (2) old single-column shape -> stage + drop + recreate at new shape
15516    //                                  + repopulate with vec_quantize_binary.
15517    //   (3) already new shape       -> no-op.
15518    let existing_sql: Option<String> = connection
15519        .query_row(
15520            "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
15521            [DEFAULT_VECTOR_PARTITION],
15522            |row| row.get::<_, String>(0),
15523        )
15524        .optional()?;
15525
15526    // Slice 10 / G10 — 3-way shape-sentinel (fixes the prior
15527    // `contains("embedding_bin")` no-op that hid the `status` column from
15528    // existing Pack-1 DBs):
15529    //   `status` present       -> Pack-2 (current) shape, no-op.
15530    //   `embedding_bin` present -> Pack-1 -> stage + recreate + back-fill status.
15531    //   neither                 -> legacy single-column -> migrate to current.
15532    match existing_sql {
15533        None => create_vector_partition(connection, dimension),
15534        Some(sql) if sql.contains("status") => Ok(()),
15535        Some(sql) if sql.contains("embedding_bin") => {
15536            migrate_vector_partition_pack1_to_pack2(connection, dimension)
15537        }
15538        Some(_) => migrate_vector_partition_to_pack1(connection, dimension),
15539    }
15540}
15541
15542/// The current (Pack-2) `vector_default` vec0 shape. Slice 10 / G10 adds a plain
15543/// `status TEXT` metadata column — **not** aux (`+status`): aux columns
15544/// hard-error under a KNN `WHERE`, and the G10 filter constrains `status` in the
15545/// phase-1 KNN statement. `status` ships NULL plumbing only (no population source
15546/// yet).
15547///
15548/// 0.8.20 Slice 15e — `attr_cols` are the declared-`filterable` attribute columns
15549/// (byte-safe `attr_<hex>` identifiers, see [`attr_vec0_column`]), each a PLAIN
15550/// `TEXT` metadata column (never aux `+`), appended after `status`. **When
15551/// `attr_cols` is empty the produced SQL is byte-identical to the shipped shape**
15552/// — every existing caller passes `&[]`, so no shipped behaviour changes.
15553fn vector_partition_create_sql(
15554    dimension: u32,
15555    if_not_exists: bool,
15556    attr_cols: &[String],
15557) -> String {
15558    let guard = if if_not_exists { "IF NOT EXISTS " } else { "" };
15559    let mut attrs = String::new();
15560    for col in attr_cols {
15561        attrs.push_str(&format!(",{col} TEXT"));
15562    }
15563    format!(
15564        "CREATE VIRTUAL TABLE {guard}{DEFAULT_VECTOR_PARTITION} USING vec0(\
15565            embedding float[{dimension}],\
15566            embedding_bin bit[{dimension}],\
15567            source_type TEXT partition key,\
15568            kind TEXT,\
15569            created_at INTEGER,\
15570            status TEXT{attrs}\
15571         )"
15572    )
15573}
15574
15575fn create_vector_partition(connection: &Connection, dimension: u32) -> rusqlite::Result<()> {
15576    connection.execute_batch(&vector_partition_create_sql(dimension, true, &[]))
15577}
15578
15579/// 0.8.20 Slice 15e — encode an arbitrary registry attribute NAME into a vec0-safe
15580/// column identifier: `attr_` + lowercase hex of the name's UTF-8 bytes.
15581///
15582/// vec0 rejects quoted column identifiers, and a Slice-15d-validated attribute
15583/// name may contain spaces / unicode / `-`, so the raw name cannot be a column
15584/// identifier. Hex is injective (so the map is reversible by
15585/// [`decode_attr_vec0_column`]), matches `^attr_[0-9a-f]+$`, and can never collide
15586/// with a built-in metadata column (`embedding`, `embedding_bin`, `source_type`,
15587/// `kind`, `created_at`, `status` — none carry the `attr_` prefix followed by an
15588/// even-length hex string of the name).
15589fn attr_vec0_column(name: &str) -> String {
15590    let mut s = String::from("attr_");
15591    for b in name.as_bytes() {
15592        s.push_str(&format!("{b:02x}"));
15593    }
15594    s
15595}
15596
15597/// 0.8.20 Slice 15e — inverse of [`attr_vec0_column`]. Returns the original
15598/// attribute name for an `attr_<hex>` column, or `None` if `col` is not a
15599/// well-formed encoded attribute column (so the built-in metadata columns and any
15600/// vec0 shadow columns are skipped when enumerating a live table's attribute set).
15601fn decode_attr_vec0_column(col: &str) -> Option<String> {
15602    let hex = col.strip_prefix("attr_")?;
15603    if hex.is_empty() || hex.len() % 2 != 0 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
15604        return None;
15605    }
15606    let mut bytes = Vec::with_capacity(hex.len() / 2);
15607    let raw = hex.as_bytes();
15608    let mut i = 0;
15609    while i < raw.len() {
15610        let hi = (raw[i] as char).to_digit(16)?;
15611        let lo = (raw[i + 1] as char).to_digit(16)?;
15612        bytes.push((hi * 16 + lo) as u8);
15613        i += 2;
15614    }
15615    String::from_utf8(bytes).ok()
15616}
15617
15618/// 0.8.20 Slice 15e — the DESIRED `attr_<hex>` columns implied by the durable
15619/// projection registry: one per `filterable` projection, sorted by attribute name
15620/// (⇒ sorted by column, since hex encoding preserves byte order). This is the
15621/// derived-cache source the reshape reconciles the live vec0 shape against.
15622fn desired_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15623    let registry = load_projection_registry(conn)?;
15624    let mut cols: Vec<String> = registry
15625        .iter()
15626        .filter(|(_, stored)| stored.roles.contains(&ProjectionRole::Filterable))
15627        .map(|(name, _)| attr_vec0_column(name))
15628        .collect();
15629    cols.sort();
15630    Ok(cols)
15631}
15632
15633/// 0.8.20 Slice 15e — the `attr_<hex>` columns actually present on the live
15634/// `vector_default` vec0 table, parsed from its `CREATE VIRTUAL TABLE` SQL and
15635/// sorted. Empty when the table is absent. Parsing the SQL (rather than a PRAGMA)
15636/// keeps this robust across vec0 versions and shadow-table layouts.
15637fn actual_vector_attr_columns(conn: &Connection) -> rusqlite::Result<Vec<String>> {
15638    let sql: Option<String> = conn
15639        .query_row(
15640            "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
15641            [DEFAULT_VECTOR_PARTITION],
15642            |row| row.get::<_, String>(0),
15643        )
15644        .optional()?;
15645    let Some(sql) = sql else {
15646        return Ok(Vec::new());
15647    };
15648    let mut cols: Vec<String> = Vec::new();
15649    // Tokenize on any non-identifier byte; a token is an attribute column iff it
15650    // decodes as a well-formed `attr_<hex>` identifier.
15651    let mut token = String::new();
15652    let flush = |token: &mut String, cols: &mut Vec<String>| {
15653        if !token.is_empty() {
15654            if decode_attr_vec0_column(token).is_some() && !cols.contains(token) {
15655                cols.push(token.clone());
15656            }
15657            token.clear();
15658        }
15659    };
15660    for ch in sql.chars() {
15661        if ch.is_ascii_alphanumeric() || ch == '_' {
15662            token.push(ch);
15663        } else {
15664            flush(&mut token, &mut cols);
15665        }
15666    }
15667    flush(&mut token, &mut cols);
15668    cols.sort();
15669    Ok(cols)
15670}
15671
15672/// TC-76 deletes one `vector_default` row by rowid. The bare vec0 regression
15673/// test proves sqlite-vec 0.1.9 removes long TEXT metadata without a workaround.
15674fn delete_vector_partition_row(conn: &Connection, rowid: i64) -> rusqlite::Result<usize> {
15675    conn.execute(&format!("DELETE FROM {DEFAULT_VECTOR_PARTITION} WHERE rowid = ?1"), [rowid])
15676}
15677
15678/// 0.8.20 Slice 15e — reconcile the live `vector_default` attribute columns with
15679/// the registry's `filterable` set (TC-46: HITL-ratified NON-DESTRUCTIVE reshape,
15680/// following the shipped `migrate_vector_partition_pack1_to_pack2` precedent).
15681///
15682/// Diffs the DESIRED columns (from the registry) against the ACTUAL columns (on
15683/// the live table). When they already match — which is EVERY idempotent
15684/// re-registration and every boot re-derive that replays the same set — this is a
15685/// pure no-op: no reshape, no re-insert, vec0 untouched (so boot never silently
15686/// wipes a corpus). When they differ, performs ONE non-destructive reshape.
15687///
15688/// Returns `true` iff a reshape was performed. Runs the DDL directly on the passed
15689/// connection/transaction (no nested transaction), so a caller already inside a
15690/// write transaction (`configure_projections`) gets the reshape atomically with
15691/// its registry mutation. A no-op (and returns `false`) when `vector_default` does
15692/// not exist (a DB opened without an embedder).
15693fn reconcile_vector_attr_columns(conn: &Connection, dimension: u32) -> rusqlite::Result<bool> {
15694    let table_exists: bool = conn
15695        .query_row(
15696            "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
15697            [DEFAULT_VECTOR_PARTITION],
15698            |_| Ok(true),
15699        )
15700        .optional()?
15701        .unwrap_or(false);
15702    if !table_exists {
15703        return Ok(false);
15704    }
15705    let desired = desired_vector_attr_columns(conn)?;
15706    let actual = actual_vector_attr_columns(conn)?;
15707    if desired == actual {
15708        return Ok(false);
15709    }
15710    reshape_vector_partition_nondestructive(conn, dimension, &desired, &actual, false)?;
15711    Ok(true)
15712}
15713
15714/// Rebuild vec0 attribute metadata from the canonical EAV projection while
15715/// preserving the existing column set. A changed nested source can leave the
15716/// `attr_<hex>` schema untouched while changing every value behind that column.
15717fn refresh_vector_attr_values(conn: &Connection, dimension: u32) -> rusqlite::Result<()> {
15718    let desired = desired_vector_attr_columns(conn)?;
15719    let actual = actual_vector_attr_columns(conn)?;
15720    if desired != actual || !desired.is_empty() {
15721        reshape_vector_partition_nondestructive(conn, dimension, &desired, &actual, true)?;
15722    }
15723    Ok(())
15724}
15725
15726/// Refresh one reactivated node's vec0 metadata without reshaping the corpus.
15727///
15728/// Activation re-projects this node's canonical attributes after a source change
15729/// that could have happened while it was deleted. vec0 accepts metadata `UPDATE`s,
15730/// so only this row needs to be refreshed; a full partition reshape belongs to
15731/// registry shape/source reconciliation, not the ordinary lifecycle path.
15732fn refresh_vector_attr_values_for_row(
15733    conn: &Connection,
15734    rowid: i64,
15735    body: &str,
15736) -> rusqlite::Result<()> {
15737    let (cols_sql, _, mut values) = vector_attr_insert_fragments(conn, body, 1)?;
15738    if cols_sql.is_empty() {
15739        return Ok(());
15740    }
15741    let assignments = cols_sql
15742        .trim_start_matches(", ")
15743        .split(", ")
15744        .enumerate()
15745        .map(|(index, col)| format!("{col} = ?{}", index + 1))
15746        .collect::<Vec<_>>()
15747        .join(", ");
15748    values.push(rusqlite::types::Value::Integer(rowid));
15749    let rowid_index = values.len();
15750    conn.execute(
15751        &format!(
15752            "UPDATE {DEFAULT_VECTOR_PARTITION} SET {assignments} WHERE rowid = ?{rowid_index}"
15753        ),
15754        rusqlite::params_from_iter(values.iter()),
15755    )?;
15756    Ok(())
15757}
15758
15759/// 0.8.20 Slice 15e — the NON-DESTRUCTIVE reshape itself. Stages every live row
15760/// (base columns + all ACTUAL attribute columns), drops + recreates
15761/// `vector_default` at the DESIRED shape, then re-inserts each row.
15762///
15763/// THE FOUR LOAD-BEARING CONDITIONS (any one broken ⇒ silently wrong results):
15764///   1. `rowid` is listed EXPLICITLY in the re-insert (a vec0 row maps to its node
15765///      by `rowid == write_cursor`; auto-assigned rowids would decouple every
15766///      embedding from its node);
15767///   2. each attribute column is PLAIN `TEXT` metadata (via
15768///      [`vector_partition_create_sql`]), never a vec0 `aux`/`+` column (aux
15769///      hard-errors a filtered KNN);
15770///   3. a DESIRED column with no ACTUAL predecessor back-fills each row from the
15771///      already-populated `canonical_attributes` (fix-1 finding 2) so a
15772///      pre-existing row whose body carries the attribute is immediately
15773///      filterable; the `''` sentinel (vec0 TEXT metadata is NOT-NULL-able) is
15774///      used ONLY where the attribute is genuinely absent, so an absent row
15775///      cleanly fails-to-match instead of erroring;
15776///   4. `embedding_bin` is copied VERBATIM via `vec_bit(...)` — NOT re-quantized —
15777///      so old rows keep their (possibly mean-centered) bits and stay Hamming-
15778///      comparable to new rows.
15779///
15780/// Runs on `conn` directly (the caller owns the transaction). Transactional
15781/// atomicity + reader isolation are the caller's responsibility, exactly as for
15782/// `migrate_vector_partition_pack1_to_pack2`.
15783fn reshape_vector_partition_nondestructive(
15784    conn: &Connection,
15785    dimension: u32,
15786    desired_cols: &[String],
15787    actual_cols: &[String],
15788    refresh_values: bool,
15789) -> rusqlite::Result<()> {
15790    // Stage: base columns + every ACTUAL attribute column (so no at-rest value is
15791    // lost, even for a column being dropped).
15792    let mut stage_defs = String::new();
15793    let mut stage_names =
15794        String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15795    for col in actual_cols {
15796        stage_defs.push_str(&format!(",\n             {col} TEXT"));
15797        stage_names.push_str(&format!(", {col}"));
15798    }
15799    conn.execute_batch(&format!(
15800        "CREATE TABLE _fathomdb_vector_reshape_stage (
15801             rowid         INTEGER PRIMARY KEY,
15802             embedding     BLOB NOT NULL,
15803             embedding_bin BLOB NOT NULL,
15804             source_type   TEXT,
15805             kind          TEXT,
15806             created_at    INTEGER,
15807             status        TEXT{stage_defs}
15808         );
15809         INSERT INTO _fathomdb_vector_reshape_stage({stage_names})
15810             SELECT {stage_names} FROM {DEFAULT_VECTOR_PARTITION};
15811         DROP TABLE {DEFAULT_VECTOR_PARTITION};"
15812    ))?;
15813
15814    // Recreate at the DESIRED shape (plain TEXT attr columns — condition #2).
15815    conn.execute_batch(&vector_partition_create_sql(dimension, false, desired_cols))?;
15816
15817    // Re-insert. `rowid` explicit (condition #1); `vec_bit(embedding_bin)`
15818    // verbatim, never re-quantized (condition #4); `status` and each surviving
15819    // attribute column carried forward; each NEW desired column back-fills from
15820    // `canonical_attributes` (the `''` sentinel only where genuinely absent —
15821    // condition #3).
15822    let mut insert_cols =
15823        String::from("rowid, embedding, embedding_bin, source_type, kind, created_at, status");
15824    let mut select_exprs = String::from(
15825        "rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, status",
15826    );
15827    for col in desired_cols {
15828        insert_cols.push_str(&format!(", {col}"));
15829        if actual_cols.iter().any(|a| a == col) && !refresh_values {
15830            // Surviving column — carry its value forward (never NULL: vec0 TEXT
15831            // metadata is `''`-sentinelled, but COALESCE defends the stage table).
15832            select_exprs.push_str(&format!(", COALESCE({col}, '')"));
15833        } else {
15834            // New column — back-fill from the ALREADY-populated `canonical_attributes`
15835            // (fix-1 finding 2 [P2]). `configure_projections` runs `backfill_attribute`
15836            // (which fills `canonical_attributes` from each active row's body) BEFORE
15837            // this reshape, so a pre-existing row whose body carries the attribute is
15838            // immediately filterable — no false negative until a re-embed. The `''`
15839            // sentinel (condition #3) is used ONLY where the attribute is genuinely
15840            // ABSENT for that row (no `canonical_attributes` row ⇒ COALESCE → '').
15841            // The EAV value equals the vec0 write-time value by construction (both go
15842            // through `extract_scalar_attribute`), so pre-existing and freshly-written
15843            // rows share one filter semantics.
15844            match decode_attr_vec0_column(col) {
15845                Some(name) => {
15846                    // vec0/execute_batch takes no bind params; embed the decoded name
15847                    // as a SQL string literal, escaping single quotes.
15848                    //
15849                    // fix-3 [P2] — a PRESENT row (a canonical_attributes row exists)
15850                    // encodes its RAW `attr_value` as `\x01 || attr_value` (`char(1) ||
15851                    // ca.attr_value`), matching the write-time vec0 encoding so a
15852                    // pre-existing present-empty row (attr_value='') becomes the bare
15853                    // marker, NOT `''`. An ABSENT row (no canonical_attributes row) is
15854                    // the COALESCE default `''` (condition #3). `canonical_attributes`
15855                    // itself stays RAW — only this vec0 column is encoded.
15856                    let escaped = name.replace('\'', "''");
15857                    select_exprs.push_str(&format!(
15858                        ", COALESCE((SELECT char(1) || ca.attr_value FROM canonical_attributes ca \
15859                         WHERE ca.write_cursor = _fathomdb_vector_reshape_stage.rowid \
15860                           AND ca.attr_name = '{escaped}' LIMIT 1), '')"
15861                    ));
15862                }
15863                // A desired column always decodes (built by `attr_vec0_column`); if it
15864                // somehow does not, fall back to the sentinel rather than panic.
15865                None => select_exprs.push_str(", ''"),
15866            }
15867        }
15868    }
15869    conn.execute_batch(&format!(
15870        "INSERT INTO {DEFAULT_VECTOR_PARTITION}({insert_cols})
15871             SELECT {select_exprs} FROM _fathomdb_vector_reshape_stage;
15872         DROP TABLE _fathomdb_vector_reshape_stage;"
15873    ))?;
15874    Ok(())
15875}
15876
15877/// Slice 10 / G10 — stage + recreate + back-fill upgrade of an existing
15878/// **Pack-1** `vector_default` (has `embedding_bin`, lacks `status`) to the
15879/// Pack-2 shape. The existing `embedding_bin` blob is preserved verbatim (it may
15880/// be mean-centered; re-quantizing from `embedding` would drop the centering),
15881/// and `status` back-fills NULL. Same transactional discipline as
15882/// `migrate_vector_partition_to_pack1`: a single `Connection::transaction()`;
15883/// reader handles are not opened until `ensure_vector_partition` returns, and
15884/// cross-process access is serialized by the sidecar lock, so readers never see
15885/// a partial reshape.
15886fn migrate_vector_partition_pack1_to_pack2(
15887    connection: &mut Connection,
15888    dimension: u32,
15889) -> rusqlite::Result<()> {
15890    let tx = connection.transaction()?;
15891    tx.execute_batch(
15892        "CREATE TABLE _fathomdb_vector_pack2_stage (
15893             rowid         INTEGER PRIMARY KEY,
15894             embedding     BLOB NOT NULL,
15895             embedding_bin BLOB NOT NULL,
15896             source_type   TEXT,
15897             kind          TEXT,
15898             created_at    INTEGER
15899         );
15900         INSERT INTO _fathomdb_vector_pack2_stage(
15901             rowid, embedding, embedding_bin, source_type, kind, created_at
15902         )
15903             SELECT rowid, embedding, embedding_bin, source_type, kind, created_at
15904             FROM vector_default;
15905         DROP TABLE vector_default;",
15906    )?;
15907    tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15908    // `vec_bit(...)` re-tags the staged blob with the BIT subtype vec0's bit
15909    // column requires (a raw blob loses the subtype and fails the type check).
15910    // This preserves the existing (possibly mean-centered) bits verbatim — no
15911    // re-quantize, so centering survives the upgrade. `status` back-fills the
15912    // empty-string sentinel (vec0 TEXT metadata is NOT NULL-able; reserved-gap
15913    // candidate 13).
15914    tx.execute_batch(
15915        "INSERT INTO vector_default(
15916             rowid, embedding, embedding_bin, source_type, kind, created_at, status
15917         )
15918             SELECT rowid, embedding, vec_bit(embedding_bin), source_type, kind, created_at, ''
15919             FROM _fathomdb_vector_pack2_stage;
15920         DROP TABLE _fathomdb_vector_pack2_stage;",
15921    )?;
15922    tx.commit()
15923}
15924
15925/// SQL fragment implementing the D3 `kind -> source_type` map.
15926/// Used both by the Pack 1 reshape migration and by the drift-detection
15927/// unit test that pins it to [`resolve_source_type`].
15928const KIND_TO_SOURCE_TYPE_CASE_SQL: &str = "CASE s.kind
15929    WHEN 'email'   THEN 'email'
15930    WHEN 'article' THEN 'article'
15931    WHEN 'paper'   THEN 'paper'
15932    WHEN 'meeting' THEN 'meeting'
15933    WHEN 'note'    THEN 'note'
15934    WHEN 'todo'    THEN 'todo'
15935    WHEN 'doc'     THEN 'article'
15936    ELSE 'article'
15937END";
15938
15939/// Pack 1 in-place reshape of `vector_default`. Stages the existing
15940/// f32 corpus + each row's `kind`, drops the old single-column vec0
15941/// table, recreates at the runtime `dimension` with the Pack 1
15942/// columns, then repopulates with SQL-side `vec_quantize_binary` +
15943/// the D3 `kind -> source_type` mapping. The preflight CHECK on
15944/// unknown kinds has already run as migration step 9 by the time we
15945/// get here.
15946///
15947/// Atomicity: the DROP+CREATE+repopulate sequence runs inside a
15948/// rusqlite `Connection::transaction()` (DEFERRED begin per rusqlite
15949/// `transaction.rs:417`). Cross-process serialization is provided by
15950/// the engine's sidecar `acquire_lock` at `open_with_migrations`
15951/// (`lib.rs:1127` area); reader handles are not opened until
15952/// `ensure_vector_partition` returns (`lib.rs:1241` area), so readers
15953/// never observe a partial reshape.
15954fn migrate_vector_partition_to_pack1(
15955    connection: &mut Connection,
15956    dimension: u32,
15957) -> rusqlite::Result<()> {
15958    let tx = connection.transaction()?;
15959    tx.execute_batch(
15960        "CREATE TABLE _fathomdb_vector_migration_v0_7_0 (
15961             rowid     INTEGER PRIMARY KEY,
15962             embedding BLOB NOT NULL,
15963             kind      TEXT NOT NULL
15964         );
15965         INSERT INTO _fathomdb_vector_migration_v0_7_0(rowid, embedding, kind)
15966             SELECT v.rowid, v.embedding, r.kind
15967             FROM vector_default v
15968             JOIN _fathomdb_vector_rows r ON r.rowid = v.rowid;
15969         DROP TABLE vector_default;",
15970    )?;
15971    // Slice 10 / G10 — recreate directly at the Pack-2 shape (adds `status`), so
15972    // a legacy single-column DB lands the current shape in one reshape.
15973    tx.execute_batch(&vector_partition_create_sql(dimension, false, &[]))?;
15974    // `status` back-fills the empty-string sentinel (vec0 TEXT metadata is NOT
15975    // NULL-able; reserved-gap candidate 13). Legacy single-column DBs predate
15976    // mean-centering, so re-quantizing from the un-centered `embedding` is
15977    // correct here.
15978    let repopulate_sql = format!(
15979        "INSERT INTO vector_default(
15980             rowid, embedding, embedding_bin, source_type, kind, created_at, status
15981         )
15982         SELECT
15983             s.rowid,
15984             s.embedding,
15985             vec_quantize_binary(s.embedding),
15986             {KIND_TO_SOURCE_TYPE_CASE_SQL},
15987             s.kind,
15988             strftime('%s', 'now'),
15989             ''
15990         FROM _fathomdb_vector_migration_v0_7_0 s;
15991         DROP TABLE _fathomdb_vector_migration_v0_7_0;"
15992    );
15993    tx.execute_batch(&repopulate_sql)?;
15994    tx.commit()
15995}
15996
15997fn encode_vector_blob(vector: &[f32]) -> Vec<u8> {
15998    vector.iter().flat_map(|value| value.to_le_bytes()).collect()
15999}
16000
16001fn decode_vector_blob(bytes: &[u8]) -> Vec<f32> {
16002    debug_assert_eq!(bytes.len() % 4, 0, "f32 BLOB length must be multiple of 4");
16003    bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()
16004}
16005
16006/// EU-5a2 — does the live embedder identity request mean-centering?
16007/// Identity-name compare per EU-5a1's BGE_SMALL_EMBEDDER_NAME constant
16008/// (`dev/design/embedder.md` §0.6). NoopEmbedder returns `false`.
16009fn identity_requires_mean_centering(identity: &EmbedderIdentity) -> bool {
16010    identity.name == BGE_SMALL_EMBEDDER_NAME
16011}
16012
16013/// EU-5a2 — read the pinned mean vector from
16014/// `_fathomdb_embedder_profiles.mean_vec` for the default profile.
16015/// Returns `Ok(None)` when the column is NULL or the row is missing;
16016/// returns `Err(EngineError::Storage)` on dimension drift (the open-time
16017/// `check_embedder_profile` already fails closed for this, so a runtime
16018/// drift here would be an internal-inconsistency signal).
16019fn read_pinned_mean_vec(
16020    connection: &Connection,
16021    dimension: u32,
16022) -> Result<Option<Vec<f32>>, EngineError> {
16023    let bytes: Option<Vec<u8>> = connection
16024        .query_row(
16025            "SELECT mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
16026            [],
16027            |row| row.get::<_, Option<Vec<u8>>>(0),
16028        )
16029        .or_else(|err| match err {
16030            rusqlite::Error::QueryReturnedNoRows => Ok(None),
16031            other => Err(other),
16032        })
16033        .map_err(|_| EngineError::Storage)?;
16034    let Some(bytes) = bytes else { return Ok(None) };
16035    let expected_len = (dimension as usize).saturating_mul(4);
16036    if bytes.len() != expected_len {
16037        return Err(EngineError::Storage);
16038    }
16039    let mut out = Vec::with_capacity(dimension as usize);
16040    for chunk in bytes.chunks_exact(4) {
16041        let arr = [chunk[0], chunk[1], chunk[2], chunk[3]];
16042        out.push(f32::from_le_bytes(arr));
16043    }
16044    Ok(Some(out))
16045}
16046
16047/// EU-5a2 — pointwise `v - mean`. Length-checked debug-assert; caller
16048/// guarantees equal length via `read_pinned_mean_vec` + dimension check.
16049fn subtract_mean(v: &[f32], mean: &[f32]) -> Vec<f32> {
16050    debug_assert_eq!(v.len(), mean.len(), "subtract_mean dim mismatch");
16051    v.iter().zip(mean.iter()).map(|(a, b)| *a - *b).collect()
16052}
16053
16054/// 0.8.18 Slice 5 (#5 vector-equivalence probe) — parse the committed 45-probe
16055/// fixture into an ordered `Vec<&str>` (one probe per non-empty, non-`#`-comment
16056/// line). Order is stable so `probe_ordinal` is deterministic across opens.
16057fn vector_equivalence_probes() -> Vec<&'static str> {
16058    VECTOR_EQUIVALENCE_PROBE_FIXTURE
16059        .lines()
16060        .map(str::trim_end)
16061        .filter(|line| {
16062            let t = line.trim_start();
16063            !t.is_empty() && !t.starts_with('#')
16064        })
16065        .collect()
16066}
16067
16068/// 0.8.18 Slice 5 — outcome of the open-time #5 self-check.
16069struct VectorEquivalenceOutcome {
16070    dense_disabled: bool,
16071    reason: Option<String>,
16072}
16073
16074/// A dense runtime can schedule, repair, and report readiness only when an
16075/// embedder is attached and the open-time equivalence guard accepted it.
16076fn usable_dense_runtime(embedder: Option<&dyn Embedder>, dense_disabled: bool) -> bool {
16077    embedder.is_some() && !dense_disabled
16078}
16079
16080/// 0.8.18 Slice 5 — embed one probe under panic isolation. The probe runs at
16081/// open time on the writer connection BEFORE the projection workers spawn, so a
16082/// caller-supplied embedder that PANICS (or returns an error / a wrong-dimension
16083/// vector) must never wedge `Engine::open`. A panic/error/shape-mismatch yields
16084/// `None`; the CALLERS then fail-SAFE (fix-1 DEFECT #1) — a `None` at population
16085/// or check time means the vector arm cannot be established/verified, so dense is
16086/// REFUSED (`dense_disabled=true`), never silently served. `Engine::open` still
16087/// succeeds (no wedge; ADR-0.6.0 Invariant-5 posture, mirrored open-side).
16088fn probe_embed(embedder: &dyn Embedder, text: &str, dimension: usize) -> Option<Vec<f32>> {
16089    let embedded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| embedder.embed(text)));
16090    match embedded {
16091        Ok(Ok(vector)) if vector.len() == dimension => Some(vector),
16092        _ => None,
16093    }
16094}
16095
16096/// 0.8.18 Slice 5 (#5 vector-equivalence probe KEYSTONE) — the open-time
16097/// self-check. Per `dev/design/0.8.18-slice-0-vector-equivalence-publish-design.md`
16098/// §U1 + `dev/adr/ADR-0.8.18-vector-equivalence-self-check.md`.
16099///
16100/// Runs AFTER open-time mean-recovery/requantize + `ensure_vector_partition`
16101/// (U1-b) so it reads the FINAL live `mean_vec`. Two paths:
16102///
16103///  - **First accepted vector arm** (probe table empty): re-embed the 45
16104///    committed probes with the LIVE embedder and verify the in-memory
16105///    **UN-centered f32 reference vectors** + embedder identity (R-VEQ-1)
16106///    before persisting them. Store f32 ONLY — the P1 bits are NEVER persisted
16107///    (U1-d). A rejected prospective arm writes neither the baseline nor cache.
16108///  - **Subsequent open** (probe table populated): re-embed the 45 probes and
16109///    assert BOTH dense-pipeline representations against the stored references:
16110///    **(P1)** the Phase-1 mean-centered `embedding_bin` sign-flip count via the
16111///    SAME `vec_quantize_binary(sign(x − mean_vec))` path as
16112///    `build_vector_phase1_sql` (floor = 0, exact); **(P2)** the un-centered
16113///    Phase-2 L2 (`vec_distance_l2` semantics) within `VECTOR_EQUIVALENCE_L2_EPSILON`.
16114///    Divergence beyond EITHER floor ⇒ `dense_disabled=true` (R-VEQ-2/3).
16115///
16116/// Mean-centering is gated by `identity_requires_mean_centering(identity)` ∧
16117/// `mean_pinned`, applied symmetrically to reference + reembed (un-centered
16118/// fallback otherwise; NoopEmbedder no-op) — R-VEQ-3c.
16119///
16120/// Fail-SAFE, never fail-open (0.8.18 Slice 5 fix-1, DEFECT #1): any inability to
16121/// RUN or VERIFY the probe — a probe embed that panics/errors/returns wrong-dim, a
16122/// malformed/missing reference row, an unreadable pinned mean, or a
16123/// `vec_quantize_binary`/L2 SQL failure — yields `dense_disabled=true` with a clear
16124/// reason (refuse the un-verifiable dense/fused arm; the text-only/FTS path still
16125/// serves). `Engine::open` still SUCCEEDS (never wedges on a panicking caller
16126/// embedder). The distinct-identity cross-vendor refusal (`check_embedder_profile`)
16127/// remains the PRIMARY gate; this probe is ADDITIVE-ONLY (R-VEQ-5), but on the
16128/// vector arm it fails CLOSED, not open — same-identity backend drift on an
16129/// un-verifiable arm is exactly what #5 must catch (R-VEQ-4 "loud typed refuse,
16130/// never silent").
16131fn run_vector_equivalence_probe(
16132    connection: &Connection,
16133    embedder: Option<&dyn Embedder>,
16134    identity: &EmbedderIdentity,
16135    mean_pinned: bool,
16136    prospective_dense_arm: bool,
16137) -> VectorEquivalenceOutcome {
16138    let not_disabled = VectorEquivalenceOutcome { dense_disabled: false, reason: None };
16139
16140    // No runtime embedder means no dense arm to guard (EmbedderChoice::None).
16141    // The probe is inert; dense writes/queries already fail with
16142    // EmbedderNotConfigured.
16143    let Some(embedder) = embedder else { return not_disabled };
16144
16145    // Gate: the probe engages once the workspace has either a REGISTERED vector
16146    // kind (`_fathomdb_vector_kinds` non-empty) or a durable prospective vector
16147    // declaration that the safe boot graft would otherwise enrol. A workspace
16148    // with neither has no dense arm to guard, so the probe does ZERO embed work
16149    // at that open — this keeps `Engine::open` free of the 45-probe re-embed on
16150    // empty/vector-less workspaces (and inert for the pathological
16151    // single-session hang/panic embedder tests, which register their kind AFTER
16152    // open and never reopen).
16153    //
16154    // fix-1 DEFECT #4 — the baseline is established at OPEN, at the first open
16155    // where a vector kind already exists (population path below). This covers BOTH:
16156    //   (b) the v18→v19 UPGRADE with pre-existing vector kinds: the baseline is
16157    //       captured here, at the first v19 open, from the identity-matched
16158    //       embedder (identity is already gated by `check_embedder_profile`, so the
16159    //       baseline is the same *claimed* embedder; future backend drift is caught);
16160    //   (a) a vector kind registered POST-OPEN in a prior session: the baseline is
16161    //       captured at the NEXT open (this gate + population), again identity-gated.
16162    // It is deliberately NOT captured in the registering session's write path: a
16163    // write must NEVER block on the embedder (the async-projection invariant —
16164    // `ac_029_canonical_writes_complete_under_projection_stall` and the PR-9 embed
16165    // watchdog/thread-leak bounds), and 45 synchronous probe embeds there would
16166    // violate it and hang/degrade under a stalling embedder. Serving vector queries
16167    // in the registering session is SAFE regardless: the serving backend IS the
16168    // backend that built those vectors, so there is nothing to diverge from. The
16169    // residual — a same-*identity* backend that drifted between the registering
16170    // session and the next open is not retroactively caught — is IDENTICAL to the
16171    // accepted upgrade residual (R-VEQ-5 additive-only; U3 same-identity candle
16172    // CPU↔CUDA = 0/17280). See `dev/design/0.8.18-slice-5-vector-equivalence-probe.md`.
16173    let vector_kind_registered: bool = connection
16174        .query_row("SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds)", [], |r| r.get(0))
16175        .unwrap_or(false);
16176    if !vector_kind_registered && !prospective_dense_arm {
16177        return not_disabled;
16178    }
16179
16180    // A cold declared arm must earn its durable enrolment only after a successful
16181    // probe. Its preflight is read-only until acceptance: a refusal must leave no
16182    // reference baseline or verdict-cache mutation behind for a later open.
16183    let prospective_preflight = prospective_dense_arm && !vector_kind_registered;
16184    match probe_populate_or_check(
16185        connection,
16186        embedder,
16187        identity,
16188        mean_pinned,
16189        prospective_preflight,
16190    ) {
16191        Ok(()) => not_disabled,
16192        Err(reason) => VectorEquivalenceOutcome { dense_disabled: true, reason: Some(reason) },
16193    }
16194}
16195
16196/// 0.8.18 Slice 5 — either PERSIST the baseline (probe table empty) or CHECK
16197/// against it (probe table populated). `Err(reason)` ⇒ refuse the dense arm
16198/// (`dense_disabled=true`); `Ok(())` ⇒ dense served. Fail-SAFE throughout.
16199fn probe_populate_or_check(
16200    connection: &Connection,
16201    embedder: &dyn Embedder,
16202    identity: &EmbedderIdentity,
16203    mean_pinned: bool,
16204    prospective_preflight: bool,
16205) -> Result<(), String> {
16206    let probes = vector_equivalence_probes();
16207    if probes.is_empty() {
16208        // Fail-SAFE: the compiled-in probe fixture is empty ⇒ nothing to verify
16209        // the vector arm against. (Defensive; the fixture is drift-guarded
16210        // non-empty at 45 probes.)
16211        return Err(
16212            "vector-equivalence probe fixture is empty; cannot verify the dense arm".to_string()
16213        );
16214    }
16215
16216    let existing: i64 = connection
16217        .query_row("SELECT COUNT(*) FROM _fathomdb_embed_probe", [], |r| r.get(0))
16218        .map_err(|e| format!("could not read the probe reference table: {e}; cannot verify"))?;
16219
16220    if existing == 0 {
16221        let pending_baseline = collect_probe_baseline(embedder, identity, &probes)?;
16222        if prospective_preflight {
16223            // A declaration alone is not an enrolled arm. Verify the in-memory
16224            // reference set first; on every refusal path this has performed reads
16225            // and embed calls only. Persist both the baseline and its cache marker
16226            // only after the verdict has been accepted.
16227            let cache_update = probe_check_stored_baseline(
16228                connection,
16229                embedder,
16230                identity,
16231                mean_pinned,
16232                &probes,
16233                &pending_baseline,
16234            )?;
16235            persist_probe_baseline(connection, &pending_baseline)?;
16236            if let Some(fingerprint) = cache_update {
16237                record_probe_verification(connection, &fingerprint);
16238            }
16239            Ok(())
16240        } else {
16241            // A registered arm preserves the established Slice 5 behavior:
16242            // persist its first baseline, then confirm that durable baseline
16243            // before serving dense.
16244            persist_probe_baseline(connection, &pending_baseline)?;
16245            probe_check_against_baseline(connection, embedder, identity, mean_pinned, &probes, true)
16246        }
16247    } else {
16248        // A prospective refusal must preserve even a stale prior marker: the
16249        // proposed arm was never accepted, so it cannot mutate durable state.
16250        probe_check_against_baseline(
16251            connection,
16252            embedder,
16253            identity,
16254            mean_pinned,
16255            &probes,
16256            !prospective_preflight,
16257        )
16258    }
16259}
16260
16261/// Capture the 45 un-centered f32 reference vectors as rows not yet made durable.
16262/// This collection is deliberately side-effect free so a prospective dense arm can
16263/// be refused without changing the workspace it was merely considering joining.
16264fn collect_probe_baseline(
16265    embedder: &dyn Embedder,
16266    identity: &EmbedderIdentity,
16267    probes: &[&str],
16268) -> Result<Vec<StoredProbeRow>, String> {
16269    let dimension = identity.dimension as usize;
16270    let mut rows = Vec::with_capacity(probes.len());
16271    for (ordinal, probe) in probes.iter().enumerate() {
16272        match probe_embed(embedder, probe, dimension) {
16273            Some(vector) => rows.push((
16274                ordinal as i64,
16275                (*probe).to_string(),
16276                encode_vector_blob(&vector),
16277                identity.name.clone(),
16278                identity.revision.clone(),
16279                identity.dimension as i64,
16280            )),
16281            None => {
16282                return Err(format!(
16283                    "embedder failed to produce a reference vector for probe {ordinal}; \
16284                     cannot establish a vector-equivalence baseline (dense arm refused)"
16285                ));
16286            }
16287        }
16288    }
16289    Ok(rows)
16290}
16291
16292/// Persist a complete accepted baseline atomically. A failed transaction leaves no
16293/// partial reference set for a future open to trust.
16294fn persist_probe_baseline(connection: &Connection, rows: &[StoredProbeRow]) -> Result<(), String> {
16295    let tx = connection
16296        .unchecked_transaction()
16297        .map_err(|e| format!("could not open the probe-baseline transaction: {e}"))?;
16298    for (ordinal, probe, blob, name, revision, dim) in rows {
16299        tx.execute(
16300            "INSERT OR REPLACE INTO _fathomdb_embed_probe(
16301                 probe_ordinal, probe_text, reference_vec,
16302                 embedder_name, embedder_revision, dim
16303             ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
16304            params![ordinal, probe, blob, name, revision, dim],
16305        )
16306        .map_err(|e| format!("could not persist the probe baseline: {e}"))?;
16307    }
16308    tx.commit().map_err(|e| format!("could not commit the probe baseline: {e}"))?;
16309    Ok(())
16310}
16311
16312/// 0.8.20 Slice 22 (TC-68) — one row of the stored probe baseline:
16313/// `(probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim)`.
16314type StoredProbeRow = (i64, String, Vec<u8>, String, String, i64);
16315
16316/// 0.8.20 Slice 22 (TC-68) — length-prefixed field feed for the verdict
16317/// fingerprint. The `u64` length prefix makes the concatenation UNAMBIGUOUS: two
16318/// different input tuples can never produce the same byte stream by sliding a
16319/// delimiter (e.g. name `"ab"` + revision `"c"` vs name `"a"` + revision `"bc"`).
16320fn hash_fingerprint_field(hasher: &mut Sha256, bytes: &[u8]) {
16321    hasher.update((bytes.len() as u64).to_le_bytes());
16322    hasher.update(bytes);
16323}
16324
16325/// 0.8.20 Slice 22 (TC-68) — the **embedder-identity fingerprint** the cached
16326/// equivalence verdict is keyed on: a SHA-256 over EVERY input the probe's verdict
16327/// depends on. Two opens sharing a fingerprint would, by construction, compute the
16328/// same P1/P2 answer, so the second may reuse the first's.
16329///
16330/// The inputs, and why each is load-bearing:
16331///
16332/// - **the recipe tag** ([`VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE`]) — bumping it
16333///   invalidates every cached verdict in the field at once;
16334/// - **`identity.{name, revision, dimension}`** — the nominal embedder. This is
16335///   *defence in depth only*: `check_embedder_profile` already REFUSES the open
16336///   with `EmbedderIdentityMismatch`/`EmbedderDimensionMismatch` before the probe
16337///   is reached, so an identity change is never observed here in practice;
16338/// - **the live pinned `mean_vec`** (and whether centering is applied at all) —
16339///   this one is NOT optional. P1 quantizes through
16340///   `vec_quantize_binary(sign(x − mean_vec))`, so rewriting the pinned mean
16341///   changes the verdict *for the same embedder and the same baseline*. A
16342///   fingerprint over the identity triple alone would be stale by construction,
16343///   because open-time mean-recovery/requantize and the operator `recompute_mean`
16344///   verb both rewrite it;
16345/// - **the committed probe fixture** — a cached verdict computed over a different
16346///   probe set means nothing. (`vector_equivalence_probe_fixture_drift.rs` does
16347///   NOT make this redundant: it pins the engine's copy equal to the embedder
16348///   crate's copy — it guards COPY drift between two committed files, not the
16349///   fixture's content across releases. The stored-baseline completeness check
16350///   below does fail closed first on a fixture edit, so this input is belt-and-
16351///   braces rather than the only guard; it is one hash of a ~3 KB constant.);
16352/// - **both D4 floors** — a verdict that passed under a loose ε must not be
16353///   inherited by a build that tightened it;
16354/// - **the STORED baseline rows, reference blobs included** — the 0.8.18 fix-2
16355///   completeness check pins each row's shape (count, ordinal, text, blob LENGTH,
16356///   identity) but not the blob CONTENT, which the re-embed comparison used to
16357///   catch. Hashing the blobs keeps that external-tamper closure intact at
16358///   negligible cost (~69 KB of SHA-256 against 45 model invocations).
16359fn probe_verification_fingerprint(
16360    identity: &EmbedderIdentity,
16361    mean_vec: Option<&[f32]>,
16362    stored: &[StoredProbeRow],
16363) -> String {
16364    let mut hasher = Sha256::new();
16365    hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_FINGERPRINT_RECIPE.as_bytes());
16366    hash_fingerprint_field(&mut hasher, identity.name.as_bytes());
16367    hash_fingerprint_field(&mut hasher, identity.revision.as_bytes());
16368    hash_fingerprint_field(&mut hasher, &identity.dimension.to_le_bytes());
16369    match mean_vec {
16370        Some(mean) => {
16371            hash_fingerprint_field(&mut hasher, b"mean-centered");
16372            hash_fingerprint_field(&mut hasher, &encode_vector_blob(mean));
16373        }
16374        None => hash_fingerprint_field(&mut hasher, b"un-centered"),
16375    }
16376    hash_fingerprint_field(&mut hasher, VECTOR_EQUIVALENCE_PROBE_FIXTURE.as_bytes());
16377    hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_P1_FLIP_FLOOR.to_le_bytes());
16378    hash_fingerprint_field(&mut hasher, &VECTOR_EQUIVALENCE_L2_EPSILON.to_le_bytes());
16379    hash_fingerprint_field(&mut hasher, &(stored.len() as u64).to_le_bytes());
16380    for (ordinal, probe_text, reference_vec, name, revision, dim) in stored {
16381        hash_fingerprint_field(&mut hasher, &ordinal.to_le_bytes());
16382        hash_fingerprint_field(&mut hasher, probe_text.as_bytes());
16383        hash_fingerprint_field(&mut hasher, reference_vec);
16384        hash_fingerprint_field(&mut hasher, name.as_bytes());
16385        hash_fingerprint_field(&mut hasher, revision.as_bytes());
16386        hash_fingerprint_field(&mut hasher, &dim.to_le_bytes());
16387    }
16388    hasher.finalize().iter().map(|b| format!("{b:02x}")).collect()
16389}
16390
16391/// 0.8.20 Slice 22 (TC-68) — is `fingerprint` the fingerprint under which the
16392/// probe last RAN and PASSED on this workspace?
16393///
16394/// Fail-SAFE against ACCIDENT (R-VEQ-4): **every** failure mode answers `false`,
16395/// which means "run the probe". A missing `_fathomdb_open_state` table, an absent
16396/// row, a non-TEXT value, a truncated or garbled value, a stale fingerprint, any
16397/// SQL error — none of them can be mistaken for a pass.
16398///
16399/// # What a `true` does and does not mean (fix-1, codex §9 round 2 [P1])
16400///
16401/// `true` means: **the fingerprint inputs are unchanged since *some* engine
16402/// recorded a pass.** It does NOT mean "this engine verified this backend", and it
16403/// cannot: the fingerprint is a SHA-256 over deterministic, publicly derivable DB
16404/// and build inputs, so an actor with write access to the file can compute the
16405/// current digest and write it here, skipping the 45-probe verification. This
16406/// marker is not — and cannot be — an authenticated attestation; an embedded
16407/// local-first engine holds no secret with which to authenticate one, and a salt
16408/// would be readable by the same actor.
16409///
16410/// The same actor also defeats the same arm through the **pre-slice** path, by
16411/// re-baselining `_fathomdb_embed_probe`'s `reference_vec` blobs to their drifted
16412/// backend's own output — the probe then runs in full and verifies the drifted
16413/// backend against itself. Measured by
16414/// `tests/tc68_probe_fingerprint_cache.rs::a_forged_stored_baseline_defeats_the_probe_even_when_it_fully_runs`
16415/// (marker deleted, all 45 embeds performed, dense still enabled), with the
16416/// un-forged control caught.
16417///
16418/// **That is the same actor, NOT the same cost, and fix-2 struck the claim that it
16419/// was.** Forging this marker needs only a publicly computable digest — usually the
16420/// value already sitting in the row. Re-baselining additionally needs the target
16421/// backend's 45 exact embeddings, encoded into every row. **So the cache IS a
16422/// cheaper bypass** for a writer of the database file.
16423///
16424/// What bounds it is the ruled residual, not this marker. A same-identity backend
16425/// drift moves no fingerprint input, so a marker recorded by an **honest** earlier
16426/// open already skips the probe and already serves the drifted backend, with no
16427/// forgery anywhere
16428/// (`residual_same_identity_backend_drift_is_not_caught_on_a_cached_open`). Forgery
16429/// adds capability only on an open where no valid marker exists for the *current*
16430/// fingerprint — and a digest is valid only for the state it was computed over, so
16431/// it stops working at the next change to any fingerprint input.
16432///
16433/// The equivalence probe is a **correctness self-check against backend drift, not
16434/// tamper evidence**; `dense_disabled` is not a tamper signal. Threat model, with
16435/// the concession and the bound: §8.4/§8.5 of
16436/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
16437fn probe_verification_is_cached(connection: &Connection, fingerprint: &str) -> bool {
16438    connection
16439        .query_row(
16440            "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
16441            [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
16442            |row| row.get::<_, String>(0),
16443        )
16444        .map(|cached| cached == fingerprint)
16445        .unwrap_or(false)
16446}
16447
16448/// 0.8.20 Slice 22 (TC-68) — record that the probe RAN and PASSED under
16449/// `fingerprint`.
16450///
16451/// A write failure is deliberately SWALLOWED rather than turned into a verdict
16452/// failure. The arm has just been verified, so refusing dense because a marker
16453/// could not be persisted (read-only file, disk full) would be a false refusal;
16454/// and the consequence of the missing marker is simply that the next open re-runs
16455/// the probe — more work, never less. That is the fail-safe direction.
16456fn record_probe_verification(connection: &Connection, fingerprint: &str) {
16457    let _ = connection.execute(
16458        "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
16459         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
16460        params![VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY, fingerprint],
16461    );
16462}
16463
16464/// 0.8.20 Slice 22 (TC-68) — drop any cached verdict.
16465///
16466/// Called on every failure path of an already registered arm, so that arm cannot
16467/// carry a marker a later registered open might match. A prospective arm has not
16468/// joined durable state yet and deliberately preserves its snapshot on refusal.
16469fn clear_probe_verification(connection: &Connection) {
16470    let _ = connection.execute(
16471        "DELETE FROM _fathomdb_open_state WHERE key = ?1",
16472        [VECTOR_EQUIVALENCE_VERDICT_CACHE_KEY],
16473    );
16474}
16475
16476/// 0.8.18 Slice 5 — SUBSEQUENT open: re-embed the 45 probes and assert BOTH
16477/// dense-pipeline representations against the stored references — **(P1)** the
16478/// mean-centered `embedding_bin` sign-flip count (floor 0, exact) and **(P2)** the
16479/// un-centered Phase-2 L2 (within `VECTOR_EQUIVALENCE_L2_EPSILON`).
16480///
16481/// 0.8.20 Slice 22 (TC-68) — this wrapper adds the failure half of the verdict
16482/// cache for an already registered arm: ANY `Err` drops the cached marker, so a
16483/// workspace that could not be verified never leaves a stale "verified" marker
16484/// behind for a later registered open to match. A prospective preflight passes
16485/// `false` for `clear_cache_on_error`: it has not joined the durable arm yet, so
16486/// rejection must remain globally mutation-free.
16487fn probe_check_against_baseline(
16488    connection: &Connection,
16489    embedder: &dyn Embedder,
16490    identity: &EmbedderIdentity,
16491    mean_pinned: bool,
16492    probes: &[&str],
16493    clear_cache_on_error: bool,
16494) -> Result<(), String> {
16495    let outcome = load_stored_probe_baseline(connection).and_then(|stored| {
16496        probe_check_stored_baseline(connection, embedder, identity, mean_pinned, probes, &stored)
16497    });
16498    match outcome {
16499        Ok(Some(fingerprint)) => {
16500            record_probe_verification(connection, &fingerprint);
16501            Ok(())
16502        }
16503        Ok(None) => Ok(()),
16504        Err(reason) => {
16505            if clear_cache_on_error {
16506                clear_probe_verification(connection);
16507            }
16508            Err(reason)
16509        }
16510    }
16511}
16512
16513fn load_stored_probe_baseline(connection: &Connection) -> Result<Vec<StoredProbeRow>, String> {
16514    let mut stmt = connection
16515        .prepare(
16516            "SELECT probe_ordinal, probe_text, reference_vec, embedder_name, embedder_revision, dim \
16517             FROM _fathomdb_embed_probe ORDER BY probe_ordinal",
16518        )
16519        .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))?;
16520    stmt.query_map([], |row| {
16521        Ok((
16522            row.get::<_, i64>(0)?,
16523            row.get::<_, String>(1)?,
16524            row.get::<_, Vec<u8>>(2)?,
16525            row.get::<_, String>(3)?,
16526            row.get::<_, String>(4)?,
16527            row.get::<_, i64>(5)?,
16528        ))
16529    })
16530    .and_then(|rows| rows.collect::<rusqlite::Result<Vec<_>>>())
16531    .map_err(|e| format!("could not read the stored probe references: {e}; cannot verify"))
16532}
16533
16534/// 0.8.18 Slice 5 — the check proper. Fail-SAFE (fix-1 DEFECT #1): a probe embed
16535/// that panics/errors/returns wrong-dim, a malformed/missing reference row, an
16536/// unreadable pinned mean, or a `vec_quantize_binary`/L2 SQL failure each ⇒ `Err`
16537/// (cannot verify ⇒ refuse dense), never a silent skip-and-serve.
16538///
16539/// fix-2 (DEFECT #1 residual): BEFORE the divergence check, the STORED baseline is
16540/// validated to be EXACTLY the committed probe set — the expected row count, a
16541/// contiguous 0-based `probe_ordinal` per committed probe, each `probe_text` equal
16542/// to the committed fixture text at that ordinal, each `reference_vec` a well-formed
16543/// `4 * dim` f32 blob, and the stored embedder identity/dim matching the current
16544/// one. This closes the partial-baseline / external-tamper fail-open (a 44-of-45
16545/// table, or a re-attributed/mangled row, previously verified only the rows present
16546/// or re-embedded a tampered `probe_text` against itself). Any mismatch ⇒ `Err`.
16547///
16548/// 0.8.20 Slice 22 (TC-68) — the 45 re-embeds are CACHED against
16549/// [`probe_verification_fingerprint`]. Note WHERE the cache check sits: after the
16550/// mean resolution and after the fix-2 completeness validation, before the
16551/// re-embed loop. That split is deliberate — everything cheap keeps running on
16552/// EVERY open (so a short, re-attributed, mangled or fixture-mismatched baseline
16553/// still fails closed immediately), and only the expensive part, the 45 model
16554/// invocations, is skipped. The residual this buys is recorded in
16555/// `dev/design/0.8.20-tc68-equivalence-probe-fingerprint-cache.md`.
16556fn probe_check_stored_baseline(
16557    connection: &Connection,
16558    embedder: &dyn Embedder,
16559    identity: &EmbedderIdentity,
16560    mean_pinned: bool,
16561    probes: &[&str],
16562    stored: &[StoredProbeRow],
16563) -> Result<Option<String>, String> {
16564    let dimension = identity.dimension as usize;
16565
16566    // Resolve the live mean. Fail-SAFE: if centering is required + pinned but the
16567    // mean cannot be read, we cannot reproduce `embedding_bin` ⇒ refuse (P1
16568    // un-verifiable). NoopEmbedder / no-pin ⇒ un-centered on BOTH sides (R-VEQ-3c).
16569    let mean_vec = if identity_requires_mean_centering(identity) && mean_pinned {
16570        match read_pinned_mean_vec(connection, identity.dimension) {
16571            Ok(Some(mean)) => Some(mean),
16572            Ok(None) => {
16573                return Err("mean-centering is required and pinned but mean_vec is absent; \
16574                     cannot verify P1 (dense arm refused)"
16575                    .to_string());
16576            }
16577            Err(_) => {
16578                return Err(
16579                    "could not read the pinned mean_vec; cannot verify P1 (dense arm refused)"
16580                        .to_string(),
16581                );
16582            }
16583        }
16584    } else {
16585        None
16586    };
16587
16588    // fix-2 (DEFECT #1 residual) — COMPLETENESS validation of the STORED baseline.
16589    // `COUNT(*) > 0` is NOT proof of a complete, trustworthy baseline: a partially
16590    // populated or externally-tampered probe table (44 of 45 rows, a gap/dupe in the
16591    // ordinals, a mangled reference blob, a mismatched probe_text, or a foreign
16592    // embedder identity) is UNVERIFIABLE stored state. The prior code re-embedded
16593    // the STORED probe_text and compared it to its OWN reference, so a tampered
16594    // probe_text verified against itself and a short table verified only the rows
16595    // present — both fail-OPEN. Atomic population stops the ENGINE from writing a
16596    // partial set; this closes external corruption, a manual edit, and a future
16597    // migration bug the engine did not author. Any mismatch ⇒ fail CLOSED (dense
16598    // refused); the text-only/FTS path still serves. The stored baseline must be
16599    // EXACTLY the committed probe set, in order, under the current identity.
16600    if stored.len() != probes.len() {
16601        return Err(format!(
16602            "the probe reference table has {} rows but the committed fixture defines {}; \
16603             the stored baseline is incomplete or corrupt — cannot verify the dense arm (refused)",
16604            stored.len(),
16605            probes.len()
16606        ));
16607    }
16608    for (idx, (ordinal, probe_text, ref_blob, name, revision, dim)) in stored.iter().enumerate() {
16609        // Contiguous 0-based ordinals, one per committed probe (no gaps/dupes).
16610        if *ordinal != idx as i64 {
16611            return Err(format!(
16612                "probe reference ordinals are non-contiguous (row {idx} carries ordinal {ordinal}); \
16613                 the stored baseline is corrupt — cannot verify the dense arm (refused)"
16614            ));
16615        }
16616        // The stored text MUST be the committed fixture text at this ordinal —
16617        // otherwise a tampered probe_text re-embeds and verifies against ITSELF,
16618        // masking drift (the exact fail-open this fix closes).
16619        if probe_text != probes[idx] {
16620            return Err(format!(
16621                "probe reference {ordinal} text does not match the committed fixture; \
16622                 the stored baseline is tampered or corrupt — cannot verify the dense arm (refused)"
16623            ));
16624        }
16625        // Well-formed f32[dim] reference (4*dim little-endian bytes).
16626        if ref_blob.len() != dimension * 4 {
16627            return Err(format!(
16628                "probe reference {ordinal} is malformed (len {} != {}); \
16629                 cannot verify the dense arm (refused)",
16630                ref_blob.len(),
16631                dimension * 4
16632            ));
16633        }
16634        // The stored embedder identity/dim must match the CURRENT expected identity
16635        // (defence-in-depth beyond `check_embedder_profile`: catches a baseline row
16636        // re-attributed to a foreign embedder by external edit/migration).
16637        if *dim != identity.dimension as i64
16638            || name != &identity.name
16639            || revision != &identity.revision
16640        {
16641            return Err(format!(
16642                "probe reference {ordinal} was captured under embedder {name}/{revision}/dim={dim} \
16643                 but the current embedder is {}/{}/dim={}; the stored baseline does not match — \
16644                 cannot verify the dense arm (refused)",
16645                identity.name, identity.revision, identity.dimension
16646            ));
16647        }
16648    }
16649
16650    // 0.8.20 Slice 22 (TC-68) — the CACHE gate. Everything above this line ran on
16651    // this open and still fails closed; everything below it is the 45 model
16652    // invocations that made `Engine::open` cost a flat 45 embeds FOREVER (measured
16653    // at `94bb33ef`: 0 with no enrolled kind, 90 on the one-time population open,
16654    // 45 on every open thereafter — independent of the enrolled-kind count, since
16655    // the probe gate is an `EXISTS` and the body never iterates kinds).
16656    //
16657    // If the probe already RAN and PASSED under this exact fingerprint, re-running
16658    // it is a pure re-computation of a known answer, so the verdict is reused.
16659    // Fail-SAFE: `probe_verification_is_cached` answers `false` for every failure
16660    // mode — missing table, absent row, garbled value, SQL error — so an
16661    // unreadable cache RUNS the probe, it never short-circuits to trusting it.
16662    let fingerprint = probe_verification_fingerprint(identity, mean_vec.as_deref(), stored);
16663    if probe_verification_is_cached(connection, &fingerprint) {
16664        return Ok(None);
16665    }
16666
16667    let mut total_flips: u64 = 0;
16668    let mut max_l2: f32 = 0.0;
16669    let mut worst_probe: Option<String> = None;
16670
16671    for (ordinal, probe_text, ref_blob, _, _, _) in stored {
16672        let reference = decode_vector_blob(ref_blob);
16673        let reembed = probe_embed(embedder, probe_text, dimension).ok_or_else(|| {
16674            format!(
16675                "embedder failed/panicked re-embedding probe {ordinal}; \
16676                 cannot verify the dense arm (refused)"
16677            )
16678        })?;
16679
16680        // (P2) un-centered L2 — `vec_distance_l2(embedding, vec_f32(query))`.
16681        let l2 = l2_distance(&reembed, &reference);
16682        if l2 > max_l2 {
16683            max_l2 = l2;
16684            worst_probe = Some(probe_text.to_string());
16685        }
16686
16687        // (P1) mean-centered Phase-1 flip count — same
16688        // `vec_quantize_binary(sign(x − mean_vec))` path as build_vector_phase1_sql.
16689        let (ref_c, reembed_c) = match &mean_vec {
16690            Some(mean) => (subtract_mean(&reference, mean), subtract_mean(&reembed, mean)),
16691            None => (reference.clone(), reembed.clone()),
16692        };
16693        let ref_bits = quantize_binary_via_sql(connection, &ref_c).ok_or_else(|| {
16694            format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16695        })?;
16696        let reembed_bits = quantize_binary_via_sql(connection, &reembed_c).ok_or_else(|| {
16697            format!("vec_quantize_binary SQL failed for probe {ordinal}; cannot verify P1")
16698        })?;
16699        total_flips = total_flips.saturating_add(hamming_bytes(&ref_bits, &reembed_bits));
16700    }
16701
16702    let p1_tripped = total_flips > VECTOR_EQUIVALENCE_P1_FLIP_FLOOR;
16703    let p2_tripped = max_l2 > VECTOR_EQUIVALENCE_L2_EPSILON;
16704    if p1_tripped || p2_tripped {
16705        let probe_hint = worst_probe.as_deref().unwrap_or("<unknown>");
16706        return Err(format!(
16707            "P1 mean-centered embedding_bin flips={total_flips} (floor={VECTOR_EQUIVALENCE_P1_FLIP_FLOOR}), \
16708             P2 max un-centered L2={max_l2:.3e} (epsilon={VECTOR_EQUIVALENCE_L2_EPSILON:.3e}); \
16709             worst probe {probe_hint:?}"
16710        ));
16711    }
16712
16713    // The caller persists this accepted fingerprint only after its enclosing arm
16714    // has become durable. That ordering keeps a rejected prospective preflight
16715    // entirely read-only while preserving cache behavior for registered arms.
16716    Ok(Some(fingerprint))
16717}
16718
16719/// 0.8.18 Slice 5 — un-centered Euclidean (L2) distance, matching the
16720/// `vec_distance_l2` semantics used by the Phase-2 rerank.
16721fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
16722    a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum::<f32>().sqrt()
16723}
16724
16725/// 0.8.18 Slice 5 — produce the packed 1-bit `embedding_bin` blob for a (possibly
16726/// mean-centered) f32 vector via the SAME SQL `vec_quantize_binary` the production
16727/// Phase-1 path uses, so the probe's bits are byte-equal to the engine's
16728/// `embedding_bin` production. `None` on any SQL/serialization error.
16729fn quantize_binary_via_sql(connection: &Connection, vector: &[f32]) -> Option<Vec<u8>> {
16730    let json = serde_json::to_string(vector).ok()?;
16731    connection
16732        .query_row("SELECT vec_quantize_binary(vec_f32(?1))", [json], |row| {
16733            row.get::<_, Vec<u8>>(0)
16734        })
16735        .ok()
16736}
16737
16738/// 0.8.18 Slice 5 — Hamming distance (differing bit count) between two equal-length
16739/// packed bit blobs. Unequal lengths ⇒ count every bit of the length delta as
16740/// differing (a shape divergence is a divergence).
16741fn hamming_bytes(a: &[u8], b: &[u8]) -> u64 {
16742    let common = a.len().min(b.len());
16743    let mut flips: u64 = 0;
16744    for i in 0..common {
16745        flips += u64::from((a[i] ^ b[i]).count_ones());
16746    }
16747    let extra = a.len().abs_diff(b.len());
16748    flips + (extra as u64) * 8
16749}
16750
16751/// Maps the writer-facing `kind` value to the locked Pack 1
16752/// `source_type` partition-key vocabulary. Must stay in lockstep with
16753/// the CASE WHEN inlined in migration step 9
16754/// (`fathomdb-schema/src/lib.rs`); the drift-detection unit test in
16755/// this module's `tests` mod enforces that. Per
16756/// `dev/design/0.7.0-vector-quant-pack1.md` D3.
16757fn resolve_source_type(kind: &str) -> Result<&'static str, EngineError> {
16758    Ok(match kind {
16759        "email" => "email",
16760        "article" => "article",
16761        "paper" => "paper",
16762        "meeting" => "meeting",
16763        "note" => "note",
16764        "todo" => "todo",
16765        // Synthetic AC-013 test fixture; coerced so the 6-value HITL lock holds.
16766        "doc" => "article",
16767        // G11 (Slice 15) — edge-body projection; separate `source_type` partition
16768        // key distinguishes edge vectors from node vectors in `vector_default`.
16769        "edge_fact" => "edge_fact",
16770        _ => return Err(EngineError::Storage),
16771    })
16772}
16773
16774/// 0.8.20 Slice 20c fix-2 (codex §9 [P1]) — **can the vector writer COMMIT a row
16775/// of this kind?** The ONE definition of the vector pipeline's kind domain, shared
16776/// by every enrolment path.
16777///
16778/// [`commit_projection_outcomes`] resolves `kind -> source_type` through
16779/// [`resolve_source_type`] and returns `Err` for anything outside its locked
16780/// vocabulary — *before* it records the row's terminal. `PreparedWrite::Node`, by
16781/// contrast, accepts ANY non-empty `kind` (`validate_write` constrains the body,
16782/// the identity and the validity window, never the kind against that vocabulary),
16783/// so a corpus can legitimately hold e.g. an `"invoice"` node.
16784///
16785/// Enrolling such a kind is therefore a permanent LIVENESS WEDGE for the whole
16786/// workspace: the scheduler picks the row up, the commit fails, no terminal is
16787/// ever written, the scanner re-enqueues it forever, `drain` burns its entire
16788/// timeout into [`EngineError::Scheduler`] and `dense_readiness` sticks on
16789/// `embedding` — starving the rows whose kinds ARE commit-able along with it.
16790///
16791/// So enrolment is RESTRICTED to this predicate rather than the vector writer
16792/// being taught arbitrary kinds (which would reach into `resolve_source_type`'s
16793/// locked Pack-1 partition-key semantics — `dev/design/0.7.0-vector-quant-pack1.md`
16794/// D3, a HITL lock). A non-commit-able kind simply gets NO dense arm, which is
16795/// precisely its pre-slice status quo; it is deliberately **not** a new typed
16796/// error and adds no governed surface.
16797///
16798/// It DELEGATES to `resolve_source_type` instead of restating the list. A
16799/// hand-copied second vocabulary is the TC-56 defect shape (a mirror that silently
16800/// drifts from its original), and here the drift would be silent in the worst
16801/// direction: a kind added to `resolve_source_type` but missing from a copied
16802/// filter would just never be embedded.
16803fn kind_is_vector_committable(kind: &str) -> bool {
16804    resolve_source_type(kind).is_ok()
16805}
16806
16807/// G11 (Slice 15) — derive a stable hex-encoded sha256 logical_id from a
16808/// `(kind, name)` pair. Both inputs are lowercased before hashing so that
16809/// entity identity is case-insensitive (`"Alice"` == `"alice"`). The
16810/// canonical form is `sha256("<kind>:<name>")` — identical to the
16811/// ADR-0.8.1-byo-llm derivation rule.
16812///
16813/// fix-34 [P1]: because `:` is the delimiter, a `:` in `kind` would let the
16814/// split point move and collide two distinct `(kind, name)` pairs onto one
16815/// identity (e.g. `("a:b","c")` and `("a","b:c")` both hash `"a:b:c"`),
16816/// silently dropping one entity via batch dedup / G0 supersession. An empty
16817/// `name` collapses every name-less entity of a kind onto `sha256("<kind>:")`.
16818/// We reject both at the boundary; this preserves the ADR derivation rule
16819/// (a colon-free `kind` makes the first `:` an unambiguous delimiter, so a `:`
16820/// in `name` stays safe — edge keys deliberately rely on that).
16821fn derive_logical_id(kind: &str, name: &str) -> Result<String, EngineError> {
16822    if kind.contains(':') || name.is_empty() {
16823        return Err(EngineError::Extractor);
16824    }
16825    let input = format!("{}:{}", kind.to_lowercase(), name.to_lowercase());
16826    let mut hasher = Sha256::new();
16827    hasher.update(input.as_bytes());
16828    // digest 0.11 returns `hybrid_array::Array`, which (unlike the old
16829    // `GenericArray`) does not implement `LowerHex`. Format the bytes
16830    // explicitly — byte-identical lowercase, zero-padded hex to the prior
16831    // `{:x}` rendering, preserving the load-bearing logical-id derivation.
16832    Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
16833}
16834
16835/// Cause-A (0.8.11.2) / C-2 (0.8.19, TC-8) — derive the typed **stable hit-id**
16836/// ([`IdSpace`]) carried on [`SearchHit::id`] for cross-session real-gold keying.
16837///
16838/// The stable id is the active canonical node's `logical_id` — the post-G0
16839/// supersession-stable identity, preserved across re-projection/re-ingest by the
16840/// tombstone-then-insert contract (whereas the engine-internal `write_cursor` is
16841/// reassigned on every re-ingest). When `logical_id` is NULL — the doc-seeded
16842/// node case, the *dominant* corpus hit type today — we fall back to a content
16843/// hash of the body so doc hits still carry a re-ingest-survivable key.
16844///
16845/// The result is a typed [`IdSpace`]; its `to_prefixed()` reproduces the pre-C-2
16846/// `stable_id` string byte-for-byte so real-gold keying is a no-op:
16847/// - [`IdSpace::logical`] (`"l:<logical_id>"`) — entities + edges (graph-arm,
16848///   vector-node, and edge hits when `logical_id` is present);
16849/// - [`IdSpace::content`] (`"h:<sha256(body)>"`) — doc nodes with NULL
16850///   `logical_id`, and any branch that cannot cheaply resolve a `logical_id`.
16851///
16852/// Behaviour-neutral: the value never participates in ranking/scoring (same
16853/// additive posture as `source_id` / `ce_score`).
16854fn derive_stable_id(logical_id: Option<&str>, body: &str) -> IdSpace {
16855    match logical_id {
16856        Some(lid) if !lid.is_empty() => IdSpace::logical(lid),
16857        _ => {
16858            let mut hasher = Sha256::new();
16859            hasher.update(body.as_bytes());
16860            IdSpace::content(
16861                hasher.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>(),
16862            )
16863        }
16864    }
16865}
16866
16867/// fix-34 [P2]: dedup a batch of [`PreparedWrite`]s by `logical_id`, keeping the
16868/// first occurrence. Shared by the entity and edge arms of the BYO-LLM ingest
16869/// path so a harness that returns the same node/edge twice in one response does
16870/// not write a row that immediately supersedes its sibling.
16871///
16872/// **TC-32 (0.8.20) — single-provenance entity dedupe is INTENTIONAL and
16873/// ACCEPTED.** Because dedupe keeps the FIRST occurrence, same-name entities
16874/// collapse onto one `logical_id` row that carries only the FIRST document's
16875/// `source_id`; erasing a later document therefore does not remove the shared
16876/// entity row. The HITL has ruled this acceptable for now and explicitly
16877/// declined a multi-source-provenance model. Tracked as TC-32 — do not "fix"
16878/// this by changing dedupe behaviour without a fresh decision.
16879fn dedup_prepared_by_logical_id(batch: Vec<PreparedWrite>) -> Vec<PreparedWrite> {
16880    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
16881    batch
16882        .into_iter()
16883        .filter(|w| match w {
16884            PreparedWrite::Node { logical_id: Some(id), .. }
16885            | PreparedWrite::Edge { logical_id: Some(id), .. } => seen.insert(id.clone()),
16886            _ => true,
16887        })
16888        .collect()
16889}
16890
16891/// 0.8.6 Slice 5 (ADR-0.8.6) — the family of caller-supplied provider tasks that
16892/// ride the one NDJSON-over-stdio transport. Each task maps to a wire protocol
16893/// string `fathomdb.<task>.v1` and a task discriminator name. `Extract` shipped
16894/// in 0.8.6; `Consolidate` (0.8.12 Slice 15, OPP-2) is the SECOND consumer of
16895/// this one transport — it adds only a variant, a payload, and an `EngineError`
16896/// leaf, WITHOUT a second handshake or a second transport.
16897#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16898enum ProviderTask {
16899    Extract,
16900    /// 0.8.12 Slice 15 (OPP-2, ADR-0.8.12) — consolidation / recency provider.
16901    Consolidate,
16902}
16903
16904impl ProviderTask {
16905    /// The wire task discriminator, e.g. `"extract"`. Used for `supported_tasks`
16906    /// negotiation and as the request envelope `type`.
16907    fn name(self) -> &'static str {
16908        match self {
16909            ProviderTask::Extract => "extract",
16910            ProviderTask::Consolidate => "consolidate",
16911        }
16912    }
16913
16914    /// The protocol string FathomDB sends in `hello`/requests and requires in
16915    /// `ready`. For `Extract` this is the UNCHANGED `fathomdb.extract.v1` —
16916    /// byte-identical back-compat for existing ELPS harnesses (ADR-0.8.6 §2.1).
16917    /// For `Consolidate` it is `fathomdb.consolidate.v1` (ADR-0.8.12 §2).
16918    fn protocol(self) -> &'static str {
16919        match self {
16920            ProviderTask::Extract => "fathomdb.extract.v1",
16921            ProviderTask::Consolidate => "fathomdb.consolidate.v1",
16922        }
16923    }
16924}
16925
16926/// 0.8.6 Slice 5 (ADR-0.8.6) — an open provider transport session: the spawned
16927/// caller subprocess, the buffered stdin writer, the detached stdout-drain
16928/// channel, the bounded-recv timeout, and the negotiated handshake state
16929/// (`model` provenance + `max_docs_per_request`). One session serves one task
16930/// family; the `request`/framing is identical across tasks. `Drop` reaps the
16931/// child (sends stdin EOF via the writer field's own drop, then kill/wait),
16932/// replacing the prior explicit outer kill/wait.
16933struct ProviderSession {
16934    task: ProviderTask,
16935    child: std::process::Child,
16936    writer: std::io::BufWriter<std::process::ChildStdin>,
16937    line_rx: Receiver<std::io::Result<String>>,
16938    io_timeout: Duration,
16939    /// `ready.model`, recorded as output-row provenance (`extractor_model_id`).
16940    model: Option<String>,
16941    max_docs_per_request: usize,
16942}
16943
16944impl Drop for ProviderSession {
16945    fn drop(&mut self) {
16946        // The detached stdout-drain thread exits when the child's stdout closes;
16947        // kill() guarantees that even for a child that ignores stdin EOF. The
16948        // `writer` field drops after this (declaration order) sending EOF too.
16949        let _ = self.child.kill();
16950        let _ = self.child.wait();
16951    }
16952}
16953
16954impl ProviderSession {
16955    /// Run the `hello` → `ready` handshake and `supported_tasks` negotiation.
16956    /// Validates protocol + schema_version (fix-23 [P2]); rejects a zero
16957    /// `max_docs_per_request` (fix-1 [P2]); and, when the harness advertises
16958    /// `supported_tasks`, refuses to proceed unless this session's task is in it.
16959    /// When `supported_tasks` is absent, the harness is assumed to serve the
16960    /// requested task (back-compat: existing extract-only harnesses unchanged).
16961    fn handshake(&mut self) -> Result<(), EngineError> {
16962        let protocol = self.task.protocol();
16963        let hello = serde_json::json!({
16964            "protocol": protocol,
16965            "type": "hello",
16966            "schema_version": 1,
16967        });
16968        let hello_line = serde_json::to_string(&hello).map_err(|_| EngineError::Extractor)?;
16969        writeln!(self.writer, "{hello_line}").map_err(|_| EngineError::Extractor)?;
16970        self.writer.flush().map_err(|_| EngineError::Extractor)?;
16971
16972        let line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
16973        let ready: Value = serde_json::from_str(line.trim()).map_err(|_| EngineError::Extractor)?;
16974        // fix-23 [P2]: validate protocol + schema_version in the ready message per ADR.
16975        if ready.get("type").and_then(|v| v.as_str()) != Some("ready")
16976            || ready.get("protocol").and_then(|v| v.as_str()) != Some(protocol)
16977            || ready.get("schema_version").and_then(|v| v.as_u64()) != Some(1)
16978        {
16979            return Err(EngineError::Extractor);
16980        }
16981
16982        // 0.8.6 Slice 5 (ADR-0.8.6 §2.2): additive, optional `supported_tasks`
16983        // negotiation. If present, the harness must advertise this session's task
16984        // or FathomDB refuses to dispatch it. If absent, default to "serves the
16985        // requested task" so extract-only harnesses keep working unchanged.
16986        if let Some(supported) = ready.get("supported_tasks").and_then(|v| v.as_array()) {
16987            let task_name = self.task.name();
16988            let advertised = supported.iter().any(|t| t.as_str() == Some(task_name));
16989            if !advertised {
16990                return Err(EngineError::Extractor);
16991            }
16992        }
16993
16994        self.model = ready.get("model").and_then(|v| v.as_str()).map(|s| s.to_string());
16995        let max_docs =
16996            ready.get("max_docs_per_request").and_then(|v| v.as_u64()).unwrap_or(8) as usize;
16997        // fix-1 [P2]: reject zero max_docs_per_request to prevent chunks(0) panic.
16998        if max_docs == 0 {
16999            return Err(EngineError::Extractor);
17000        }
17001        self.max_docs_per_request = max_docs;
17002        Ok(())
17003    }
17004
17005    /// Send one framed request for this session's task and receive its matching
17006    /// response. `payload` carries the task-specific fields; the envelope keys
17007    /// (`protocol`, `type`, `request_id`) are added here. The response must have
17008    /// `type == "result"` and a matching `request_id` (fix-24 [P2]); anything
17009    /// else (error, wrong id, missing type) is a protocol fault. For `Extract`
17010    /// the serialized request bytes are identical to the pre-0.8.6 path (serde_json
17011    /// serializes map keys sorted, independent of insertion order).
17012    fn request(
17013        &mut self,
17014        request_id: &str,
17015        payload: Vec<(String, Value)>,
17016    ) -> Result<Value, EngineError> {
17017        let mut req = serde_json::Map::new();
17018        req.insert("protocol".to_string(), Value::from(self.task.protocol()));
17019        req.insert("type".to_string(), Value::from(self.task.name()));
17020        req.insert("request_id".to_string(), Value::from(request_id));
17021        for (k, v) in payload {
17022            req.insert(k, v);
17023        }
17024        let req_line =
17025            serde_json::to_string(&Value::Object(req)).map_err(|_| EngineError::Extractor)?;
17026        writeln!(self.writer, "{req_line}").map_err(|_| EngineError::Extractor)?;
17027        self.writer.flush().map_err(|_| EngineError::Extractor)?;
17028
17029        let result_line = recv_extractor_line(&self.line_rx, self.io_timeout)?;
17030        let result: Value =
17031            serde_json::from_str(result_line.trim()).map_err(|_| EngineError::Extractor)?;
17032        let resp_type = result.get("type").and_then(|v| v.as_str());
17033        let resp_id = result.get("request_id").and_then(|v| v.as_str());
17034        if resp_type != Some("result") || resp_id != Some(request_id) {
17035            return Err(EngineError::Extractor);
17036        }
17037        Ok(result)
17038    }
17039}
17040
17041/// fix-35 [P2]: BYO-LLM extractor I/O timeout. Defaults to 300s to accommodate
17042/// slow LLM harnesses; override (in milliseconds) via
17043/// `FATHOMDB_EXTRACTOR_TIMEOUT_MS` (tests use this to exercise the hung-harness
17044/// path quickly).
17045fn extractor_io_timeout() -> Duration {
17046    std::env::var("FATHOMDB_EXTRACTOR_TIMEOUT_MS")
17047        .ok()
17048        .and_then(|s| s.parse::<u64>().ok())
17049        .map(Duration::from_millis)
17050        .unwrap_or_else(|| Duration::from_secs(300))
17051}
17052
17053/// fix-35 [P1/P2]: receive one line from the stdout reader thread, bounded by
17054/// `timeout`. A timeout, a closed channel (reader thread ended / child EOF), or
17055/// an underlying io error all map to [`EngineError::Extractor`].
17056fn recv_extractor_line(
17057    rx: &Receiver<std::io::Result<String>>,
17058    timeout: Duration,
17059) -> Result<String, EngineError> {
17060    match rx.recv_timeout(timeout) {
17061        Ok(Ok(line)) => Ok(line),
17062        _ => Err(EngineError::Extractor),
17063    }
17064}
17065
17066fn map_runtime_embedder_error(err: RuntimeEmbedderError) -> EngineError {
17067    match err {
17068        RuntimeEmbedderError::Failed { .. } | RuntimeEmbedderError::Timeout => {
17069            EngineError::Embedder
17070        }
17071    }
17072}
17073
17074fn default_embedder_identity() -> EmbedderIdentity {
17075    EmbedderIdentity::new(
17076        DEFAULT_EMBEDDER_NAME,
17077        DEFAULT_EMBEDDER_REVISION,
17078        DEFAULT_EMBEDDER_DIMENSION,
17079    )
17080}
17081
17082fn check_embedder_profile(
17083    connection: &Connection,
17084    supplied: &EmbedderIdentity,
17085) -> Result<bool, EngineOpenError> {
17086    // Returns `true` iff `_fathomdb_embedder_profiles.mean_vec IS NOT NULL`
17087    // for the default profile (and its byte length matches `4 * dimension`
17088    // per `dev/design/embedder.md` §0.2). EU-5a2: column lands in step 10.
17089    let mut statement = match connection.prepare(
17090        "SELECT name, revision, dimension, mean_vec FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
17091    ) {
17092        Ok(statement) => statement,
17093        Err(_) => return Ok(false),
17094    };
17095    let mut rows = statement.query([]).map_err(|_| {
17096        EngineOpenError::Corruption(CorruptionDetail {
17097            kind: CorruptionKind::EmbedderIdentityDrift,
17098            stage: OpenStage::EmbedderIdentity,
17099            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
17100            recovery_hint: RecoveryHint {
17101                code: "E_CORRUPT_EMBEDDER_IDENTITY",
17102                doc_anchor: "design/recovery.md#embedder-identity-drift",
17103            },
17104        })
17105    })?;
17106
17107    let Some(row) = rows.next().map_err(|_| {
17108        EngineOpenError::Corruption(CorruptionDetail {
17109            kind: CorruptionKind::EmbedderIdentityDrift,
17110            stage: OpenStage::EmbedderIdentity,
17111            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
17112            recovery_hint: RecoveryHint {
17113                code: "E_CORRUPT_EMBEDDER_IDENTITY",
17114                doc_anchor: "design/recovery.md#embedder-identity-drift",
17115            },
17116        })
17117    })?
17118    else {
17119        connection
17120            .execute(
17121                "INSERT INTO _fathomdb_embedder_profiles(profile, name, revision, dimension)
17122                 VALUES(?1, ?2, ?3, ?4)",
17123                params![
17124                    DEFAULT_VECTOR_PROFILE,
17125                    supplied.name,
17126                    supplied.revision,
17127                    supplied.dimension
17128                ],
17129            )
17130            .map_err(|_| EngineOpenError::Io {
17131                message: "could not persist embedder profile".to_string(),
17132            })?;
17133        return Ok(false);
17134    };
17135
17136    let stored_name = row.get::<_, String>(0).map_err(|_| {
17137        EngineOpenError::Corruption(CorruptionDetail {
17138            kind: CorruptionKind::EmbedderIdentityDrift,
17139            stage: OpenStage::EmbedderIdentity,
17140            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
17141            recovery_hint: RecoveryHint {
17142                code: "E_CORRUPT_EMBEDDER_IDENTITY",
17143                doc_anchor: "design/recovery.md#embedder-identity-drift",
17144            },
17145        })
17146    })?;
17147    let stored_revision = row.get::<_, String>(1).map_err(|_| {
17148        EngineOpenError::Corruption(CorruptionDetail {
17149            kind: CorruptionKind::EmbedderIdentityDrift,
17150            stage: OpenStage::EmbedderIdentity,
17151            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
17152            recovery_hint: RecoveryHint {
17153                code: "E_CORRUPT_EMBEDDER_IDENTITY",
17154                doc_anchor: "design/recovery.md#embedder-identity-drift",
17155            },
17156        })
17157    })?;
17158    let dimension = row.get::<_, u32>(2).map_err(|_| {
17159        EngineOpenError::Corruption(CorruptionDetail {
17160            kind: CorruptionKind::EmbedderIdentityDrift,
17161            stage: OpenStage::EmbedderIdentity,
17162            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
17163            recovery_hint: RecoveryHint {
17164                code: "E_CORRUPT_EMBEDDER_IDENTITY",
17165                doc_anchor: "design/recovery.md#embedder-identity-drift",
17166            },
17167        })
17168    })?;
17169
17170    let stored = EmbedderIdentity::new(stored_name, stored_revision, dimension);
17171
17172    if stored.name != supplied.name || stored.revision != supplied.revision {
17173        return Err(EngineOpenError::EmbedderIdentityMismatch {
17174            stored,
17175            supplied: supplied.clone(),
17176        });
17177    }
17178    if dimension != supplied.dimension {
17179        return Err(EngineOpenError::EmbedderDimensionMismatch {
17180            stored: dimension,
17181            supplied: supplied.dimension,
17182        });
17183    }
17184
17185    // EU-5a2 / `dev/design/embedder.md` §0.2 invariant: if `mean_vec` is
17186    // populated, byte length MUST equal `4 * dimension`. Debug builds
17187    // assert; release builds fail closed via EmbedderIdentityMismatch
17188    // (the same fail-closed channel the rest of profile drift takes).
17189    let mean_vec: Option<Vec<u8>> = row.get::<_, Option<Vec<u8>>>(3).map_err(|_| {
17190        EngineOpenError::Corruption(CorruptionDetail {
17191            kind: CorruptionKind::EmbedderIdentityDrift,
17192            stage: OpenStage::EmbedderIdentity,
17193            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
17194            recovery_hint: RecoveryHint {
17195                code: "E_CORRUPT_EMBEDDER_IDENTITY",
17196                doc_anchor: "design/recovery.md#embedder-identity-drift",
17197            },
17198        })
17199    })?;
17200    let pinned = match mean_vec {
17201        Some(bytes) => {
17202            let expected_len = (dimension as usize).saturating_mul(4);
17203            // `dev/design/embedder.md` §0.2 invariant: when populated,
17204            // `mean_vec` byte length MUST equal `4 * dimension`. Fail
17205            // closed via the existing identity-drift channel in both
17206            // debug and release builds — tests deliberately poke
17207            // malformed values to exercise this branch.
17208            if bytes.len() != expected_len {
17209                return Err(EngineOpenError::EmbedderIdentityMismatch {
17210                    stored,
17211                    supplied: supplied.clone(),
17212                });
17213            }
17214            true
17215        }
17216        None => false,
17217    };
17218
17219    Ok(pinned)
17220}
17221
17222#[derive(Clone, Debug, Eq, PartialEq)]
17223enum WritePlan {
17224    Node,
17225    Edge,
17226    AppendOnlyLog,
17227    LatestState,
17228    AdminSchema,
17229}
17230
17231fn validate_batch(
17232    connection: &Connection,
17233    batch: &[PreparedWrite],
17234) -> Result<Vec<WritePlan>, EngineError> {
17235    batch.iter().map(|write| validate_write(connection, write)).collect()
17236}
17237
17238fn collect_projection_jobs(
17239    connection: &Connection,
17240    batch: &[PreparedWrite],
17241) -> Result<Vec<ProjectionJob>, EngineError> {
17242    let mut jobs = Vec::new();
17243    for write in batch {
17244        if let PreparedWrite::Node { kind, body, .. } = write {
17245            // 0.8.20 Slice 20c — this probe decides whether `notify_new_work` is
17246            // called, so `Engine::enrol_batch_vector_kinds` MUST already have run
17247            // on this batch: a kind enrolled after this point would be enqueued in
17248            // the database with the dispatcher left asleep on
17249            // `pending_scan == false`, and because `drain` is a passive barrier
17250            // (C4 rider: never a trigger) the next `drain` would burn its ENTIRE
17251            // timeout and return `EngineError::Scheduler` on work ready to run.
17252            if kind_is_vector_indexed(connection, kind)? {
17253                jobs.push(ProjectionJob { cursor: 0, kind: kind.clone(), body: body.clone() });
17254            }
17255        }
17256    }
17257    Ok(jobs)
17258}
17259
17260fn validate_write(
17261    connection: &Connection,
17262    write: &PreparedWrite,
17263) -> Result<WritePlan, EngineError> {
17264    match write {
17265        PreparedWrite::Node { kind, body, logical_id, valid_from, valid_until, .. } => {
17266            if kind.trim().is_empty() || body.trim().is_empty() {
17267                return Err(EngineError::WriteValidation);
17268            }
17269            // 0.8.20 Slice 15b (TC-34) — the validity window is HALF-OPEN
17270            // `[valid_from, valid_until)`, so a pair with `from >= until` selects
17271            // no instant at all: the row would be written but no default read
17272            // could ever return it. Silently accepting that is a trap, so it is a
17273            // typed refusal.
17274            //
17275            // 0.8.20 Slice 22 (R-20-VC) — **decision #18, SETTLED: one family.**
17276            // This site used to return `EngineError::InvalidArgument { msg }`
17277            // carrying both bounds, which made `validate_write` — ONE function —
17278            // reject across TWO error families, so the same `write` call raised
17279            // `InvalidArgumentError` for an inverted window and
17280            // `WriteValidationError` for a non-integer bound. `dev/design/errors.md`
17281            // (status: locked) defines `WriteValidationError` as "malformed typed
17282            // write shape" / "the submitted typed write is malformed **before**
17283            // schema-sensitive payload checks run" — which is exactly this
17284            // boundary — so the code now agrees with the taxonomy of record.
17285            // `InvalidArgument` stays the family for caller-argument rejections
17286            // OUTSIDE this boundary (see the errors.md 2026-07-28 amendment).
17287            //
17288            // **The cost, stated:** `WriteValidation` is a UNIT variant and both
17289            // bindings map it to a fixed message-less string, so the offending
17290            // bounds are no longer recoverable from the error. That is a breaking
17291            // behaviour change on a published surface (CHANGELOG 0.8.20) and it is
17292            // the diagnostic the prior split existed to preserve. Restoring it
17293            // needs a message-carrying `WriteValidation { msg }`, which is a
17294            // cross-cutting change across every engine + binding raise site and
17295            // both binding payload shapes — its own slice, not this one.
17296            //
17297            // Only the PAIR can be empty. A one-sided window is unbounded on the
17298            // missing side and can never be empty, so it is never refused.
17299            if let (Some(from), Some(until)) = (valid_from, valid_until) {
17300                if from >= until {
17301                    return Err(EngineError::WriteValidation);
17302                }
17303            }
17304            // R-20-E3: `source_id` needs no emptiness check here — `SourceId`
17305            // cannot hold an empty or reserved id, so the check has moved from
17306            // this branch into the type's constructor.
17307            // G0 — an explicit logical_id must be non-empty (NULL/None is the
17308            // legacy default; an empty string is never a valid identity).
17309            // Also reject char(30) = \x1e (ASCII RS), which is the BFS cycle-guard
17310            // delimiter; allowing it would corrupt the visited-path substring test.
17311            if let Some(logical_id) = logical_id {
17312                if logical_id.is_empty() || logical_id.contains('\x1e') {
17313                    return Err(EngineError::WriteValidation);
17314                }
17315            }
17316            Ok(WritePlan::Node)
17317        }
17318        PreparedWrite::Edge { kind, from, to, logical_id, t_valid, t_invalid, .. } => {
17319            if kind.trim().is_empty() || from.trim().is_empty() || to.trim().is_empty() {
17320                return Err(EngineError::WriteValidation);
17321            }
17322            // Reject char(30) in from/to: these become from_id/to_id in canonical_edges
17323            // and appear in BFS visited strings — an \x1e there would corrupt the guard.
17324            if from.contains('\x1e') || to.contains('\x1e') {
17325                return Err(EngineError::WriteValidation);
17326            }
17327            // R-20-E3: see the Node branch — emptiness is a `SourceId` invariant.
17328            if let Some(logical_id) = logical_id {
17329                if logical_id.is_empty() || logical_id.contains('\x1e') {
17330                    return Err(EngineError::WriteValidation);
17331                }
17332            }
17333            // TC-33 fix-1 (codex §9 P2) — an epoch SQLite cannot render to
17334            // ISO-8601 must be UNSTORABLE. The governed integer surface is the
17335            // only way to reach one (inbound ISO normalisation maxes at year
17336            // 9999), so this write boundary is where it is stopped, before it
17337            // can render to a silent `null` on the consolidation wire and
17338            // resurrect an invalidated edge. Structural primary layer; the
17339            // render site keeps a defensive hard-assert as the backstop.
17340            reject_unrenderable_edge_epoch("t_valid", *t_valid)?;
17341            reject_unrenderable_edge_epoch("t_invalid", *t_invalid)?;
17342            Ok(WritePlan::Edge)
17343        }
17344        PreparedWrite::AdminSchema { name, kind, schema_json, retention_json } => {
17345            if name.trim().is_empty()
17346                || !matches!(kind.as_str(), "append_only_log" | "latest_state")
17347                || serde_json::from_str::<Value>(schema_json).is_err()
17348                || serde_json::from_str::<Value>(retention_json).is_err()
17349                || contains_external_ref(schema_json)
17350            {
17351                return Err(EngineError::SchemaValidation);
17352            }
17353            Ok(WritePlan::AdminSchema)
17354        }
17355        PreparedWrite::OpStore { collection, record_key, schema_id, body } => {
17356            if collection.trim().is_empty() || record_key.trim().is_empty() {
17357                return Err(EngineError::WriteValidation);
17358            }
17359            let (kind, schema_json) = collection_metadata(connection, collection)?;
17360            if let Some(schema_id) = schema_id {
17361                if schema_id != collection {
17362                    return Err(EngineError::SchemaValidation);
17363                }
17364                validate_payload(&schema_json, body)?;
17365            } else if serde_json::from_str::<Value>(body).is_err() {
17366                return Err(EngineError::SchemaValidation);
17367            }
17368
17369            match kind.as_str() {
17370                "append_only_log" => Ok(WritePlan::AppendOnlyLog),
17371                "latest_state" => Ok(WritePlan::LatestState),
17372                _ => Err(EngineError::OpStore),
17373            }
17374        }
17375    }
17376}
17377
17378fn collection_metadata(
17379    connection: &Connection,
17380    collection: &str,
17381) -> Result<(String, String), EngineError> {
17382    connection
17383        .query_row(
17384            "SELECT kind, schema_json FROM operational_collections WHERE name = ?1",
17385            [collection],
17386            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
17387        )
17388        .map_err(|_| EngineError::OpStore)
17389}
17390
17391fn validate_payload(schema_json: &str, body: &str) -> Result<(), EngineError> {
17392    let schema =
17393        serde_json::from_str::<Value>(schema_json).map_err(|_| EngineError::SchemaValidation)?;
17394    let payload = serde_json::from_str::<Value>(body).map_err(|_| EngineError::SchemaValidation)?;
17395
17396    let compiled = JSONSchema::compile(&schema).map_err(|_| EngineError::SchemaValidation)?;
17397    compiled.validate(&payload).map_err(|_| EngineError::SchemaValidation)?;
17398
17399    Ok(())
17400}
17401
17402fn contains_external_ref(schema_json: &str) -> bool {
17403    let Ok(value) = serde_json::from_str::<Value>(schema_json) else {
17404        return false;
17405    };
17406    value_contains_external_ref(&value)
17407}
17408
17409fn value_contains_external_ref(value: &Value) -> bool {
17410    match value {
17411        Value::Object(object) => object.iter().any(|(key, value)| {
17412            if key == "$ref" {
17413                return value.as_str().is_some_and(|uri| !uri.starts_with('#'));
17414            }
17415            value_contains_external_ref(value)
17416        }),
17417        Value::Array(values) => values.iter().any(value_contains_external_ref),
17418        _ => false,
17419    }
17420}
17421
17422// fix-30 [P2]: helpers to collect active edge write_cursors BEFORE a supersession
17423// UPDATE so the callers can prune stale vector_default rows.
17424fn prior_edge_cursors_by_logical_id(
17425    tx: &rusqlite::Transaction<'_>,
17426    logical_id: &str,
17427) -> rusqlite::Result<Vec<i64>> {
17428    let mut s = tx.prepare_cached(
17429        "SELECT write_cursor FROM canonical_edges \
17430         WHERE logical_id = ?1 AND superseded_at IS NULL",
17431    )?;
17432    let rows = s.query_map(params![logical_id], |r| r.get(0))?;
17433    rows.collect()
17434}
17435
17436/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — the active (non-superseded) NODE
17437/// cursors for a `logical_id`, collected BEFORE the tombstone-then-insert
17438/// supersession UPDATE so the caller can purge the about-to-be-superseded row's
17439/// row-owned attribute projections. Mirrors [`prior_edge_cursors_by_logical_id`].
17440/// The partial-unique-active index means this is at most one cursor; a `Vec`
17441/// keeps it robust and symmetric with the edge path.
17442fn prior_node_cursors_by_logical_id(
17443    tx: &rusqlite::Transaction<'_>,
17444    logical_id: &str,
17445) -> rusqlite::Result<Vec<i64>> {
17446    let mut s = tx.prepare_cached(
17447        "SELECT write_cursor FROM canonical_nodes \
17448         WHERE logical_id = ?1 AND superseded_at IS NULL",
17449    )?;
17450    let rows = s.query_map(params![logical_id], |r| r.get(0))?;
17451    rows.collect()
17452}
17453
17454fn prior_edge_cursors_by_triple(
17455    tx: &rusqlite::Transaction<'_>,
17456    from: &str,
17457    to: &str,
17458    kind: &str,
17459) -> rusqlite::Result<Vec<i64>> {
17460    let mut s = tx.prepare_cached(
17461        "SELECT write_cursor FROM canonical_edges \
17462         WHERE from_id = ?1 AND to_id = ?2 AND kind = ?3 AND superseded_at IS NULL",
17463    )?;
17464    let rows = s.query_map(params![from, to, kind], |r| r.get(0))?;
17465    rows.collect()
17466}
17467
17468/// EXP-S (0.8.14 Slice 5, D2) — the set of coexisting indexes a `row_kind`
17469/// projects into. `fts` = the FTS index (`search_index`), written SYNCHRONOUSLY
17470/// in the write transaction; `vector` = the vec0 vector index, written
17471/// ASYNCHRONOUSLY by the projection worker pool (and additionally gated per
17472/// doc-type `kind` by [`kind_is_vector_indexed`]).
17473#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17474struct IndexTargetSet {
17475    fts: bool,
17476    vector: bool,
17477}
17478
17479/// 0.8.20 Slice 5a (R-20-E1) — the class a row-owned projection table belongs
17480/// to, so the four maintenance sites can each truncate exactly the subset they
17481/// own without re-deriving a hand-rolled table list.
17482///
17483/// - `NodeFts` — same-txn lexical projection of a canonical NODE body.
17484/// - `EdgeFts` — same-txn lexical projection of a canonical EDGE body.
17485/// - `Vector` — the async vec0 materialization (written by the embed worker,
17486///   not by the write path — see [`project_canonical_node_row`]).
17487/// - `Readiness` — the terminal-cursor bookkeeping that lets
17488///   `advance_projection_cursor` walk past a row.
17489#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17490enum ProjectionClass {
17491    NodeFts,
17492    EdgeFts,
17493    Vector,
17494    Readiness,
17495    /// 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store (`filterable` +
17496    /// the value-at-rest for `searchable`). Same-transaction, row-owned.
17497    Attribute,
17498    /// 0.8.20 Slice 15d (R-20-EAV) — the property-FTS5 shadow of attribute
17499    /// values (`searchable→FTS`). Same-transaction, row-owned.
17500    PropertyFts,
17501}
17502
17503/// 0.8.20 Slice 5a (R-20-E1) — one ROW-OWNED projection table: a shadow whose
17504/// rows are 1:1 with a canonical row's `write_cursor` and therefore MUST die
17505/// with that row.
17506#[derive(Clone, Copy, Debug)]
17507struct RowOwnedProjection {
17508    /// Table name. `'static` and never caller-derived: safe to interpolate.
17509    table: &'static str,
17510    /// The column carrying the owning canonical row's `write_cursor`. For the
17511    /// vec0 table this is `rowid` — vec0 rowid IS the write_cursor (see the
17512    /// `_fathomdb_vector_rows.write_cursor UNIQUE` identity).
17513    cursor_column: &'static str,
17514    class: ProjectionClass,
17515}
17516
17517/// 0.8.20 Slice 5a (R-20-E1) — **the** registry of row-owned projections.
17518///
17519/// Every table here is 1:1 with a canonical `write_cursor` and is erased by
17520/// [`erase_row_projections`] whenever that canonical row is erased. Adding a
17521/// projection table WITHOUT registering it here re-opens the defect this slice
17522/// closes (`search_index_v2` was written by one site and deleted by one site,
17523/// out of five that maintain projections — so `excise_source` left the erased
17524/// body on disk in a content-storing FTS5 table). The `guard_row_owned_registry`
17525/// unit test introspects `sqlite_master` and fails if a `write_cursor`-keyed
17526/// table is missing from this list.
17527///
17528/// **NOT here, deliberately (design v5 §1.1): `_fathomdb_projection_state`.**
17529/// That table is KIND-owned — keyed by `kind`, holding a per-kind enqueue
17530/// watermark. Erasing one row must NOT rewind a whole kind's watermark, so it
17531/// must never be deleted per-cursor. A rebuild resets it deliberately; erasure
17532/// leaves it alone.
17533const ROW_OWNED_PROJECTIONS: &[RowOwnedProjection] = &[
17534    RowOwnedProjection {
17535        table: "search_index",
17536        cursor_column: "write_cursor",
17537        class: ProjectionClass::NodeFts,
17538    },
17539    RowOwnedProjection {
17540        table: "search_index_v2",
17541        cursor_column: "write_cursor",
17542        class: ProjectionClass::NodeFts,
17543    },
17544    RowOwnedProjection {
17545        table: "search_index_edges",
17546        cursor_column: "write_cursor",
17547        class: ProjectionClass::EdgeFts,
17548    },
17549    RowOwnedProjection {
17550        table: "vector_default",
17551        cursor_column: "rowid",
17552        class: ProjectionClass::Vector,
17553    },
17554    RowOwnedProjection {
17555        table: "_fathomdb_vector_rows",
17556        cursor_column: "write_cursor",
17557        class: ProjectionClass::Vector,
17558    },
17559    RowOwnedProjection {
17560        table: "_fathomdb_projection_terminal",
17561        cursor_column: "write_cursor",
17562        class: ProjectionClass::Readiness,
17563    },
17564    // 0.8.20 Slice 15d (R-20-EAV) — the EAV attribute store and its property-FTS
17565    // shadow both hold declared attribute VALUES at rest (potential PII), keyed
17566    // 1:1 with the owning node's write_cursor. They MUST be reachable by
17567    // `purge`/`excise_source`: registering them here is what makes
17568    // `erase_row_projections` delete them without a hand-rolled list (an
17569    // unregistered content-storing table is exactly the `search_index_v2` leak
17570    // class this registry closes). The `guard_row_owned_registry` unit test
17571    // FAILS if either is left unregistered.
17572    RowOwnedProjection {
17573        table: "canonical_attributes",
17574        cursor_column: "write_cursor",
17575        class: ProjectionClass::Attribute,
17576    },
17577    RowOwnedProjection {
17578        table: "property_search_index",
17579        cursor_column: "write_cursor",
17580        class: ProjectionClass::PropertyFts,
17581    },
17582];
17583
17584/// 0.8.20 Slice 5a (R-20-E1) — erase EVERY row-owned projection for one
17585/// canonical `write_cursor`. Returns the number of shadow rows deleted.
17586///
17587/// This is the single erasure primitive: `purge_inner` and `excise_source_inner`
17588/// both call it, so a new projection table becomes erasable by registering it in
17589/// [`ROW_OWNED_PROJECTIONS`] — not by remembering to patch two hand-rolled
17590/// delete lists (the omission that left erased bodies in `search_index_v2`).
17591fn erase_row_projections(tx: &Connection, write_cursor: i64) -> rusqlite::Result<u64> {
17592    let mut deleted: u64 = 0;
17593    for projection in ROW_OWNED_PROJECTIONS {
17594        deleted =
17595            saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
17596    }
17597    Ok(deleted)
17598}
17599
17600/// TC-76 — delete one row-owned projection's rows for one `write_cursor`.
17601/// The vec0 partition is routed through the shared direct-delete primitive;
17602/// every other table is the plain registry-driven statement.
17603fn delete_row_owned_projection(
17604    tx: &Connection,
17605    projection: &RowOwnedProjection,
17606    write_cursor: i64,
17607) -> rusqlite::Result<usize> {
17608    if projection.table == DEFAULT_VECTOR_PARTITION {
17609        return delete_vector_partition_row(tx, write_cursor);
17610    }
17611    let sql = format!("DELETE FROM {} WHERE {} = ?1", projection.table, projection.cursor_column);
17612    tx.execute(&sql, [write_cursor])
17613}
17614
17615fn saturating_add_u64(acc: u64, n: usize) -> u64 {
17616    acc.saturating_add(n as u64)
17617}
17618
17619/// 0.8.20 Slice 15d fix-1 finding 2 [P2] — purge the row-owned projections in
17620/// `classes` for ONE canonical `write_cursor`. Same registry-driven mechanism as
17621/// [`erase_row_projections`] (iterate [`ROW_OWNED_PROJECTIONS`], delete by the
17622/// declared cursor column) but scoped to a class SUBSET, so the write path can
17623/// drop a SUPERSEDED node's `Attribute` + `PropertyFts` rows — making the at-rest
17624/// property projection active-only — WITHOUT touching the `NodeFts`/`Vector`
17625/// shadows, whose stale rows the node read path already excludes by joining
17626/// `canonical_nodes WHERE superseded_at IS NULL`. Consistent with the erasure
17627/// model: an unregistered table is unreachable here, exactly as with erasure.
17628fn purge_row_projections_for_cursor_in(
17629    tx: &Connection,
17630    write_cursor: i64,
17631    classes: &[ProjectionClass],
17632) -> rusqlite::Result<u64> {
17633    let mut deleted: u64 = 0;
17634    for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
17635        deleted =
17636            saturating_add_u64(deleted, delete_row_owned_projection(tx, projection, write_cursor)?);
17637    }
17638    Ok(deleted)
17639}
17640
17641/// 0.8.20 Slice 5a (R-20-E1) — truncate the row-owned projections in `classes`.
17642/// Returns the number of shadow rows deleted.
17643fn truncate_row_projections_in(
17644    tx: &Connection,
17645    classes: &[ProjectionClass],
17646) -> rusqlite::Result<u64> {
17647    let mut deleted: u64 = 0;
17648    for projection in ROW_OWNED_PROJECTIONS.iter().filter(|p| classes.contains(&p.class)) {
17649        let sql = format!("DELETE FROM {}", projection.table);
17650        deleted = deleted.saturating_add(tx.execute(&sql, [])? as u64);
17651    }
17652    Ok(deleted)
17653}
17654
17655/// 0.8.20 Slice 5a (R-20-E1) — truncate EVERY row-owned projection (the full
17656/// `rebuild_projections` invalidation). Kind-owned watermark state
17657/// (`_fathomdb_projection_state`) is deliberately untouched; the rebuild resets
17658/// readiness by rewinding the projection cursor instead.
17659#[cfg(feature = "operator")]
17660fn truncate_all_row_projections(tx: &Connection) -> rusqlite::Result<u64> {
17661    truncate_row_projections_in(
17662        tx,
17663        &[
17664            ProjectionClass::NodeFts,
17665            ProjectionClass::EdgeFts,
17666            ProjectionClass::Vector,
17667            ProjectionClass::Readiness,
17668            ProjectionClass::Attribute,
17669            ProjectionClass::PropertyFts,
17670        ],
17671    )
17672}
17673
17674/// 0.8.20 Slice 5a (R-20-E1) — which half of a projector's work a call site
17675/// wants. The projectors are TOTAL (they own every row-owned projection for a
17676/// canonical row); the pass selects the subset a replay site is rebuilding, so
17677/// no call site re-implements projection SQL inline.
17678#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17679enum ProjectionPass {
17680    /// The write path: same-txn FTS **and** async vector enqueue / readiness
17681    /// termination.
17682    Write,
17683    /// Lexical replay only (the open-path tokenizer reproject). Readiness and
17684    /// vector state are already correct and must not be perturbed.
17685    FtsOnly,
17686    /// Readiness + async-vector replay only (`rebuild_vec0`, i.e. a rebuild with
17687    /// `include_fts = false`): the FTS shadows are not being rebuilt in this
17688    /// pass, so they must not be written.
17689    ///
17690    /// Only the `operator` rebuild seam constructs this pass, so the DEFAULT
17691    /// (recovery-clean) build sees it as unconstructed — same gate rationale as
17692    /// the operator methods themselves (feature = gate, not delete).
17693    #[cfg_attr(not(feature = "operator"), allow(dead_code))]
17694    VectorOnly,
17695}
17696
17697impl ProjectionPass {
17698    fn writes_fts(self) -> bool {
17699        matches!(self, ProjectionPass::Write | ProjectionPass::FtsOnly)
17700    }
17701
17702    fn writes_vector_state(self) -> bool {
17703        matches!(self, ProjectionPass::Write | ProjectionPass::VectorOnly)
17704    }
17705
17706    /// 0.8.20 Slice 15d (R-20-EAV) — whether this pass (re)projects the declared
17707    /// attribute set into the EAV store + property-FTS. Only the full `Write`
17708    /// pass does: the `FtsOnly` tokenizer-upgrade reproject predates step 24 (no
17709    /// registry/attribute tables exist at that migration point, so it must not
17710    /// touch them), and `VectorOnly` rebuilds only the async vector shadows. The
17711    /// operator FTS rebuild uses `Write`, so a full `rebuild_projections`
17712    /// re-derives attributes cleanly after `truncate_all_row_projections` clears
17713    /// the two attribute classes.
17714    fn writes_attributes(self) -> bool {
17715        matches!(self, ProjectionPass::Write)
17716    }
17717}
17718
17719/// 0.8.20 Slice 15d (R-20-PR) — the on-disk registry row for one declared
17720/// projection, read back from `_fathomdb_projection_registry`.
17721///
17722/// **On-disk encoding of the optional sub-objects.** The `fts_tokenizer` column
17723/// is tri-valued: SQL `NULL` = no `fts` sub-object; empty string `""` = `fts`
17724/// present with the engine-default tokenizer; a non-empty string = `fts` with a
17725/// custom tokenizer. This is what lets `searchable→FTS with default tokenizer`
17726/// be distinguished durably from `searchable` with no FTS sub-target. `vector`
17727/// mirrors it with an explicit `vector_declared` bit plus a nullable
17728/// `vector_embedder`.
17729#[derive(Clone, Debug, Eq, PartialEq)]
17730struct StoredProjection {
17731    roles: BTreeSet<ProjectionRole>,
17732    fts_present: bool,
17733    /// `Some(custom)` custom tokenizer; `None` = engine default (only
17734    /// meaningful when `fts_present`).
17735    fts_tokenizer: Option<String>,
17736    vector_declared: bool,
17737    vector_embedder: Option<String>,
17738    source: Option<Vec<String>>,
17739}
17740
17741impl StoredProjection {
17742    /// True iff the declared roles want the attribute VALUE stored at rest in
17743    /// the EAV store: `filterable` (the value IS the filter target) or
17744    /// `searchable` (the value is the retrievable meaning, and Slice 20's vector
17745    /// embed will read it from here). `rankable`-only wants no value at rest.
17746    fn wants_eav(&self) -> bool {
17747        self.roles.contains(&ProjectionRole::Filterable)
17748            || self.roles.contains(&ProjectionRole::Searchable)
17749    }
17750
17751    /// True iff a `searchable→FTS` property-FTS row should be written: the
17752    /// `searchable` role AND an `fts` sub-object.
17753    fn wants_property_fts(&self) -> bool {
17754        self.roles.contains(&ProjectionRole::Searchable) && self.fts_present
17755    }
17756
17757    /// 0.8.20 Slice 21c (ledger `TC-71`) — **THE `searchable→vector` predicate.**
17758    /// True iff this declaration puts the attribute on the dense arm: the
17759    /// `searchable` role AND a `vector` sub-object. The exact analogue of
17760    /// [`StoredProjection::wants_property_fts`], for the same reason — the
17761    /// sub-object SELECTS a sub-target of `searchable`; it does not confer one.
17762    ///
17763    /// [`vector_projection_declared`] — the corpus-wide predicate gating all
17764    /// three enrolment paths (declare-time backfill, its drop inverse, and the
17765    /// write path's late enrolment) — routes through this so no call site can
17766    /// re-derive the rule and drift. Before it existed, that predicate keyed off
17767    /// `vector_declared` ALONE, so `{roles: [filterable], vector: {}}` — the
17768    /// combination Slice 15d documented as inert-but-round-trippable — enrolled
17769    /// node kinds, backfilled the corpus and made every later write enqueue an
17770    /// embedding in any session with a live embedder.
17771    ///
17772    /// **0.8.20 Slice 23 (`R-20-SV`):** that combination is no longer DECLARABLE
17773    /// — [`apply_projection_config`] rejects it as an invalid spec. This
17774    /// predicate still governs, because the shape survives at rest in every
17775    /// database that declared it while the engine accepted it, and it is read
17776    /// from the registry, not from a caller's spec.
17777    ///
17778    /// **Deliberately NOT the same as [`StoredProjection::has_deferred`]**, which
17779    /// keys off `vector_declared` alone and must keep doing so: that one feeds
17780    /// `ProjectionDelta.deferred`, a REPORTING field, and the round-trip contract
17781    /// wants a stored-but-unbuilt `vector` sub-object reported however it was
17782    /// declared. TC-71 changes what the engine DOES, not what it says.
17783    fn wants_vector(&self) -> bool {
17784        self.roles.contains(&ProjectionRole::Searchable) && self.vector_declared
17785    }
17786
17787    /// The `fts_tokenizer` column value: `None` (SQL NULL) when no `fts`
17788    /// sub-object, else the custom tokenizer or `""` for engine-default.
17789    fn fts_column(&self) -> Option<String> {
17790        if self.fts_present {
17791            Some(self.fts_tokenizer.clone().unwrap_or_default())
17792        } else {
17793            None
17794        }
17795    }
17796
17797    /// Build from the public [`ProjectionSpec`].
17798    ///
17799    /// 0.8.20 Slice 20 (R-20-DR) — note what is DELIBERATELY not read here:
17800    /// `spec.vector.dense_readiness`. Readiness is engine-set READ METADATA, not
17801    /// part of the declaration, so it never reaches the durable registry. That
17802    /// is what makes a caller-supplied value INERT (the engine always reports the
17803    /// derived truth) and what keeps it out of the destructive-change diff — a
17804    /// readiness difference can never look like a projection change.
17805    fn from_spec(spec: &ProjectionSpec) -> Self {
17806        StoredProjection {
17807            roles: spec.roles.clone(),
17808            fts_present: spec.fts.is_some(),
17809            fts_tokenizer: spec
17810                .fts
17811                .as_ref()
17812                .and_then(|f| f.tokenizer.clone())
17813                .filter(|t| !t.is_empty()),
17814            vector_declared: spec.vector.is_some(),
17815            vector_embedder: spec
17816                .vector
17817                .as_ref()
17818                .and_then(|v| v.embedder.clone())
17819                .filter(|e| !e.is_empty()),
17820            source: spec.source.clone(),
17821        }
17822    }
17823
17824    /// Reconstruct the public [`ProjectionSpec`] for `read_projections`.
17825    fn to_spec(&self, name: &str) -> ProjectionSpec {
17826        ProjectionSpec {
17827            name: name.to_string(),
17828            roles: self.roles.clone(),
17829            fts: if self.fts_present {
17830                Some(ProjectionFts { tokenizer: self.fts_tokenizer.clone() })
17831            } else {
17832                None
17833            },
17834            vector: if self.vector_declared {
17835                // 0.8.20 Slice 20 (R-20-DR) — the registry knows nothing about
17836                // readiness (it is DERIVED, never stored), so the durable shape
17837                // reconstructs with `dense_readiness: None`.
17838                // [`Engine::read_projections`] fills it from
17839                // [`derive_dense_readiness`] on the way out.
17840                Some(ProjectionVector {
17841                    embedder: self.vector_embedder.clone(),
17842                    dense_readiness: None,
17843                })
17844            } else {
17845                None
17846            },
17847            source: self.source.clone(),
17848        }
17849    }
17850
17851    /// The set of ROLE spellings this declaration DEFERS rather than builds:
17852    /// `rankable` (F9 not live) and, since 15d builds no embedding, the
17853    /// `searchable→vector` sub-target. Used to populate `ProjectionDelta.deferred`.
17854    fn has_deferred(&self) -> bool {
17855        self.roles.contains(&ProjectionRole::Rankable) || self.vector_declared
17856    }
17857}
17858
17859/// 0.8.20 Slice 15d (R-20-PR) — is `name` a well-formed attribute name?
17860///
17861/// Establishes the invariant "a name that `configure_projections` ACCEPTS must be
17862/// POPULATABLE": the write-path extraction compiles the SQLite JSON path
17863/// `$."<name>"` (double-quoted key). A name must therefore round-trip through
17864/// that quoted-key form unchanged. Rejects:
17865///   - empty;
17866///   - a double-quote `"` (would terminate the quoted key early → malformed path,
17867///     ERRORing inside the write transaction);
17868///   - a BACKSLASH `\` (fix-4 finding 1 [P2]): SQLite treats `\` as an escape
17869///     introducer inside the double-quoted JSON-path key, so a body key literally
17870///     containing `\` (e.g. `a\b`) is NOT matched by `$."a\b"`. Pre-fix the name
17871///     was accepted yet the attribute silently NEVER populated
17872///     `canonical_attributes` — an accept-then-never-populate footgun. Rejecting
17873///     it keeps the accept ⟹ works contract (mirrors the TC-33 hard-reject
17874///     philosophy);
17875///   - any ASCII control char (incl. NUL): not a safe/legible key spelling and
17876///     not reliably matchable through the quoted-key form.
17877///
17878/// Projection names are app-declared identifiers, so this charset restriction is
17879/// a legitimate contract. Caller-supplied, so it is validated at
17880/// `configure_projections` time (spec names AND the `drop` list).
17881fn is_valid_attribute_name(name: &str) -> bool {
17882    !name.is_empty()
17883        && !name.contains('"')
17884        && !name.contains('\\')
17885        && !name.chars().any(|c| c.is_control())
17886}
17887
17888/// 0.8.20 Slice 15d — the SQLite JSON path that extracts attribute `name` from a
17889/// node body. `name` is pre-validated by [`is_valid_attribute_name`]; the path
17890/// is bound as a PARAMETER (never interpolated into SQL), so this is not an
17891/// injection surface even before that validation.
17892fn attribute_json_path(name: &str) -> String {
17893    format!("$.\"{name}\"")
17894}
17895
17896/// SQLite JSON path for one declared projection. A declared source is an ordered
17897/// list of literal object-member names; it is never a caller-provided JSONPath.
17898/// Every segment is validated before persistence, and the resulting path is
17899/// always bound as a parameter rather than interpolated into SQL.
17900fn projection_json_path(name: &str, stored: &StoredProjection) -> String {
17901    match &stored.source {
17902        None => attribute_json_path(name),
17903        Some(segments) => {
17904            let mut path = String::from("$");
17905            for segment in segments {
17906                path.push_str(".\"");
17907                path.push_str(segment);
17908                path.push('"');
17909            }
17910            path
17911        }
17912    }
17913}
17914
17915/// Refuse a source path that cannot be safely represented as SQLite quoted
17916/// member selectors. Empty paths would select the whole body rather than one
17917/// member and therefore do not meet the scalar-projection contract.
17918fn is_valid_projection_source(source: &[String]) -> bool {
17919    !source.is_empty() && source.iter().all(|segment| is_valid_attribute_name(segment))
17920}
17921
17922/// Reject only nested-source object/array terminals. Legacy top-level
17923/// projections retain their shipped skip-composite behaviour for compatibility.
17924fn nested_projection_terminal_is_composite(
17925    conn: &Connection,
17926    body: &str,
17927    name: &str,
17928    stored: &StoredProjection,
17929) -> rusqlite::Result<bool> {
17930    if stored.source.is_none() {
17931        return Ok(false);
17932    }
17933    let path = projection_json_path(name, stored);
17934    let terminal: Option<String> = conn.query_row(
17935        "SELECT CASE WHEN json_valid(?1) THEN json_type(?1, ?2) END",
17936        params![body, path],
17937        |row| row.get(0),
17938    )?;
17939    Ok(matches!(terminal.as_deref(), Some("object") | Some("array")))
17940}
17941
17942/// Validate nested source terminals across the active rows a declaration would
17943/// backfill. The caller owns the transaction, so `WriteValidation` aborts it
17944/// rather than leaving a partly reconfigured registry.
17945fn validate_projection_source_backfill(
17946    conn: &Connection,
17947    name: &str,
17948    stored: &StoredProjection,
17949) -> Result<(), EngineError> {
17950    if stored.source.is_none() {
17951        return Ok(());
17952    }
17953    let mut stmt = conn
17954        .prepare(
17955            "SELECT body FROM canonical_nodes
17956             WHERE superseded_at IS NULL AND state = 'active'",
17957        )
17958        .map_err(|_| EngineError::Storage)?;
17959    let bodies =
17960        stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
17961    for body in bodies {
17962        let body = body.map_err(|_| EngineError::Storage)?;
17963        if nested_projection_terminal_is_composite(conn, &body, name, stored)
17964            .map_err(|_| EngineError::Storage)?
17965        {
17966            return Err(EngineError::WriteValidation);
17967        }
17968    }
17969    Ok(())
17970}
17971
17972/// Validate every nested source present in a normal node write before the write
17973/// transaction starts. This keeps an object/array terminal in the existing
17974/// `WriteValidation` family and guarantees the whole batch is rejected.
17975fn validate_nested_projection_sources_for_write(
17976    conn: &Connection,
17977    batch: &[PreparedWrite],
17978) -> Result<(), EngineError> {
17979    let registry = load_projection_registry(conn).map_err(|_| EngineError::Storage)?;
17980    if registry.values().all(|stored| stored.source.is_none()) {
17981        return Ok(());
17982    }
17983    for write in batch {
17984        let PreparedWrite::Node { body, .. } = write else { continue };
17985        validate_nested_projection_sources_for_body_in_registry(conn, body, &registry)?;
17986    }
17987    Ok(())
17988}
17989
17990fn validate_nested_projection_sources_for_body(
17991    conn: &Connection,
17992    body: &str,
17993) -> Result<(), EngineError> {
17994    let registry = load_projection_registry(conn).map_err(|_| EngineError::Storage)?;
17995    validate_nested_projection_sources_for_body_in_registry(conn, body, &registry)
17996}
17997
17998/// Validate one body against a registry snapshot owned by the caller. Batch
17999/// writes load this snapshot once, keeping validation proportional to bodies
18000/// plus declarations rather than repeating registry I/O for every row.
18001fn validate_nested_projection_sources_for_body_in_registry(
18002    conn: &Connection,
18003    body: &str,
18004    registry: &BTreeMap<String, StoredProjection>,
18005) -> Result<(), EngineError> {
18006    for (name, stored) in registry {
18007        if nested_projection_terminal_is_composite(conn, body, name, stored)
18008            .map_err(|_| EngineError::Storage)?
18009        {
18010            return Err(EngineError::WriteValidation);
18011        }
18012    }
18013    Ok(())
18014}
18015
18016/// 0.8.20 Slice 15d (R-20-PR) — load the durable projection registry
18017/// (`_fathomdb_projection_registry`) into a name→[`StoredProjection`] map. This
18018/// is the derived-cache source (Q5) that boot re-derive and every
18019/// `configure_projections` diff read.
18020fn load_projection_registry(
18021    conn: &Connection,
18022) -> rusqlite::Result<BTreeMap<String, StoredProjection>> {
18023    let mut out = BTreeMap::new();
18024    // The registry table is created by schema step 24; a DB migrated to a
18025    // pre-24 head (e.g. a compatibility/partial-migration test open) does not
18026    // have it. Absent ⇒ no projections declared ⇒ empty registry, not an error.
18027    // This keeps boot re-derive and the write-path attribute projector safe on
18028    // every pre-24 schema.
18029    let table_exists: bool = conn
18030        .query_row(
18031            "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_fathomdb_projection_registry'",
18032            [],
18033            |_| Ok(true),
18034        )
18035        .optional()?
18036        .unwrap_or(false);
18037    if !table_exists {
18038        return Ok(out);
18039    }
18040    let mut stmt = conn.prepare(
18041        "SELECT name, roles, fts_tokenizer, vector_embedder, vector_declared, source
18042         FROM _fathomdb_projection_registry",
18043    )?;
18044    let rows = stmt.query_map([], |row| {
18045        let name: String = row.get(0)?;
18046        let roles_json: String = row.get(1)?;
18047        let fts_tokenizer: Option<String> = row.get(2)?;
18048        let vector_embedder: Option<String> = row.get(3)?;
18049        let vector_declared: i64 = row.get(4)?;
18050        let source_json: Option<String> = row.get(5)?;
18051        Ok((name, roles_json, fts_tokenizer, vector_embedder, vector_declared, source_json))
18052    })?;
18053    for row in rows {
18054        let (name, roles_json, fts_col, vector_embedder, vector_declared, source_json) = row?;
18055        let roles: BTreeSet<ProjectionRole> = parse_roles_json(&roles_json);
18056        let fts_present = fts_col.is_some();
18057        let fts_tokenizer = fts_col.filter(|t| !t.is_empty());
18058        let source = source_json
18059            .map(|encoded| serde_json::from_str::<Vec<String>>(&encoded))
18060            .transpose()
18061            .map_err(|err| {
18062                rusqlite::Error::FromSqlConversionFailure(
18063                    5,
18064                    rusqlite::types::Type::Text,
18065                    Box::new(err),
18066                )
18067            })?;
18068        out.insert(
18069            name,
18070            StoredProjection {
18071                roles,
18072                fts_present,
18073                fts_tokenizer,
18074                vector_declared: vector_declared != 0,
18075                vector_embedder,
18076                source,
18077            },
18078        );
18079    }
18080    Ok(out)
18081}
18082
18083/// Roles are persisted as a compact, sorted, comma-separated list (set
18084/// semantics; order-independent). Unknown tokens are ignored (forward-compat).
18085fn parse_roles_json(s: &str) -> BTreeSet<ProjectionRole> {
18086    s.split(',').filter_map(|t| ProjectionRole::from_str_opt(t.trim())).collect()
18087}
18088
18089fn roles_to_storage(roles: &BTreeSet<ProjectionRole>) -> String {
18090    roles.iter().map(|r| r.as_str()).collect::<Vec<_>>().join(",")
18091}
18092
18093/// 0.8.20 Slice 15d (R-20-PR) — write/overwrite one registry row.
18094fn persist_projection_row(
18095    tx: &Connection,
18096    name: &str,
18097    stored: &StoredProjection,
18098) -> rusqlite::Result<()> {
18099    let source = stored
18100        .source
18101        .as_ref()
18102        .map(serde_json::to_string)
18103        .transpose()
18104        .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?;
18105    tx.execute(
18106        "INSERT INTO _fathomdb_projection_registry
18107             (name, roles, fts_tokenizer, vector_embedder, vector_declared, source)
18108         VALUES(?1, ?2, ?3, ?4, ?5, ?6)
18109         ON CONFLICT(name) DO UPDATE SET
18110             roles = excluded.roles,
18111             fts_tokenizer = excluded.fts_tokenizer,
18112             vector_embedder = excluded.vector_embedder,
18113             vector_declared = excluded.vector_declared,
18114             source = excluded.source",
18115        params![
18116            name,
18117            roles_to_storage(&stored.roles),
18118            stored.fts_column(),
18119            stored.vector_embedder,
18120            i64::from(stored.vector_declared),
18121            source,
18122        ],
18123    )?;
18124    Ok(())
18125}
18126
18127/// 0.8.20 Slice 15d (R-20-PR) — delete one registry row.
18128fn remove_projection_row(tx: &Connection, name: &str) -> rusqlite::Result<()> {
18129    tx.execute("DELETE FROM _fathomdb_projection_registry WHERE name = ?1", params![name])?;
18130    Ok(())
18131}
18132
18133/// 0.8.20 Slice 15d (R-20-EAV) — delete every EAV + property-FTS row for one
18134/// attribute `name` (all owning nodes). The idempotent-rebuild primitive: a
18135/// changed or dropped projection clears its rows before (re)backfill.
18136fn clear_attribute_projection(tx: &Connection, name: &str) -> rusqlite::Result<()> {
18137    tx.execute("DELETE FROM property_search_index WHERE attr_name = ?1", params![name])?;
18138    tx.execute("DELETE FROM canonical_attributes WHERE attr_name = ?1", params![name])?;
18139    Ok(())
18140}
18141
18142/// 0.8.20 Slice 15d (R-20-EAV) — project ONE attribute value for ONE node row
18143/// into the EAV store and (if `searchable→FTS`) the property-FTS shadow. Skips a
18144/// NULL/absent extraction (an absent attribute means no row, so a `filterable`
18145/// equality simply never matches it — correct). Shared by the write path and
18146/// the backfill so they cannot drift.
18147fn project_one_attribute(
18148    tx: &Connection,
18149    cursor: i64,
18150    body: &str,
18151    name: &str,
18152    stored: &StoredProjection,
18153) -> rusqlite::Result<()> {
18154    if !stored.wants_eav() {
18155        return Ok(());
18156    }
18157    // json_extract over a non-JSON body would error; guard with json_valid so a
18158    // plain-text body simply yields no attribute rows. The canonical scalar
18159    // extraction is shared with the Slice-15e vec0 pre-KNN column via
18160    // [`extract_scalar_attribute`], so the EAV value and the `attr_<hex>` value
18161    // are IDENTICAL by construction.
18162    //
18163    // fix-1 finding 1 [P2] — project EVERY JSON scalar type, not just strings.
18164    // The prior form read the extraction as `Option<String>`; for a JSON number
18165    // or bool, `json_extract` returns an INTEGER/REAL, the `get::<Option<String>>`
18166    // conversion FAILED, and `.unwrap_or(None)` silently treated the attribute as
18167    // absent — so a numeric/boolean filterable value never projected. We now
18168    // render a single canonical TEXT form per JSON type, keyed on `json_type` so
18169    // the stored value is deterministic and the SAME value flows to BOTH
18170    // `canonical_attributes` and `property_search_index` (consistency by
18171    // construction — one `value` binding below):
18172    //   - string  -> the text verbatim
18173    //   - integer -> decimal text (CAST AS TEXT); e.g. 3 -> "3"
18174    //   - real    -> decimal text (CAST AS TEXT); e.g. 3.5 -> "3.5"
18175    //   - true    -> "true", false -> "false"  (preserve the JSON literal, NOT the
18176    //                SQLite `1`/`0` that a bare `CAST(json_extract(...) AS TEXT)`
18177    //                would yield — so a bool filter matches the value the caller
18178    //                wrote, and "true" never collides with the number 1).
18179    //   - null / absent path -> SQL NULL -> no row (an absent attribute correctly
18180    //                never matches a `filterable` equality).
18181    //   - object / array -> DELIBERATELY SKIPPED (SQL NULL -> no row): a composite
18182    //                value is not a scalar filter/FTS target in 15d; projecting its
18183    //                raw JSON text would be a footgun (nested-field filtering is the
18184    //                >=0.9.x multi-field work). Skipping is deliberate, not an
18185    //                accidental type-conversion drop — no scalar type is dropped.
18186    let Some(value) = extract_scalar_attribute(tx, body, name, stored)? else {
18187        return Ok(());
18188    };
18189    tx.execute(
18190        "INSERT INTO canonical_attributes(write_cursor, attr_name, attr_value)
18191         VALUES(?1, ?2, ?3)",
18192        params![cursor, name, value],
18193    )?;
18194    if stored.wants_property_fts() {
18195        tx.execute(
18196            "INSERT INTO property_search_index(attr_value, attr_name, write_cursor)
18197             VALUES(?1, ?2, ?3)",
18198            params![value, name, cursor],
18199        )?;
18200    }
18201    Ok(())
18202}
18203
18204/// 0.8.20 Slice 15e fix-3 [P2] — the leading marker byte that the vec0
18205/// `attr_<hex>` FILTER column prepends to every PRESENT scalar value, so that
18206/// PRESENT and ABSENT are DISJOINT in a NOT-NULL TEXT column.
18207///
18208/// The `''` empty-string sentinel used to mean BOTH "attribute absent" AND
18209/// "attribute present with value `''`", so a `status == ""` equality filter
18210/// false-matched every absent row. vec0 TEXT metadata is NOT-NULL-able (TC-46
18211/// condition #3), so absent cannot be `NULL`; instead absent stays `''` and every
18212/// PRESENT value `V` is encoded `enc(V) = "\x01" || V`. This is injective and
18213/// non-empty for ALL `V` (including `V=""`, whose encoding is the bare marker),
18214/// so `attr_<hex> = enc("")` matches present-empty but NEVER the `''`-absent rows.
18215///
18216/// This encoding is CONFINED to the vec0 filter column and the filter-value
18217/// lowering ([`vector_filter_values`]). `property_search_index` (the searchable→FTS
18218/// projection) and `canonical_attributes.attr_value` keep the RAW value — the FTS
18219/// arm distinguishes absent from present-empty by canonical_attributes row
18220/// EXISTENCE instead (see [`hit_attributes_pass_filter`]).
18221const ATTR_VEC0_PRESENT_MARKER: char = '\u{1}';
18222
18223/// 0.8.20 Slice 15e fix-3 — encode a PRESENT scalar value for the vec0 filter
18224/// column / filter-value lowering (see [`ATTR_VEC0_PRESENT_MARKER`]). ABSENT is
18225/// NOT encoded (it stays the bare `''` sentinel), so this is only ever called on a
18226/// value known to be present.
18227fn encode_attr_vec0_present(value: &str) -> String {
18228    let mut s = String::with_capacity(value.len() + 1);
18229    s.push(ATTR_VEC0_PRESENT_MARKER);
18230    s.push_str(value);
18231    s
18232}
18233
18234/// 0.8.20 Slice 15e — extract the canonical TEXT form of attribute `name` from a
18235/// node `body`, using the SAME `json_type` CASE as [`project_one_attribute`] so
18236/// the vec0 pre-KNN `attr_<hex>` column value equals the EAV
18237/// `canonical_attributes` value (consistency by construction — a `filterable`
18238/// filter routed pre-KNN sees exactly what the EAV path stored). Returns `None`
18239/// for an absent / null / object / array / non-JSON extraction (⇒ the `''`
18240/// sentinel at the vec0 column, ⇒ fail-to-match).
18241fn extract_scalar_attribute(
18242    conn: &Connection,
18243    body: &str,
18244    name: &str,
18245    stored: &StoredProjection,
18246) -> rusqlite::Result<Option<String>> {
18247    let path = projection_json_path(name, stored);
18248    let value: Option<String> = conn
18249        .query_row(
18250            "SELECT CASE WHEN json_valid(?1) THEN
18251                 CASE json_type(?1, ?2)
18252                     WHEN 'true'   THEN 'true'
18253                     WHEN 'false'  THEN 'false'
18254                     WHEN 'null'   THEN NULL
18255                     WHEN 'object' THEN NULL
18256                     WHEN 'array'  THEN NULL
18257                     ELSE CAST(json_extract(?1, ?2) AS TEXT)
18258                 END
18259             END",
18260            params![body, path],
18261            |row| row.get::<_, Option<String>>(0),
18262        )
18263        .unwrap_or(None);
18264    Ok(value)
18265}
18266
18267/// 0.8.20 Slice 15e — for a node `body`, build the `, attr_<hex>` column suffix,
18268/// the `, ?N` placeholder suffix (numbered from `start_idx`), and the bound TEXT
18269/// values for EVERY attribute column CURRENTLY on the live `vector_default` (read
18270/// from the table's own SQL, so the INSERT always matches the table shape exactly —
18271/// vec0 rejects a partial-column INSERT). Each value is the body's canonical
18272/// scalar extraction, or the `''` sentinel when absent. Returns empty fragments
18273/// (and no values) when the table has no attribute columns, so the INSERT stays
18274/// byte-identical to the shipped statement.
18275fn vector_attr_insert_fragments(
18276    conn: &Connection,
18277    body: &str,
18278    start_idx: usize,
18279) -> rusqlite::Result<(String, String, Vec<rusqlite::types::Value>)> {
18280    let cols = actual_vector_attr_columns(conn)?;
18281    let registry = load_projection_registry(conn)?;
18282    let mut col_sql = String::new();
18283    let mut ph_sql = String::new();
18284    let mut values: Vec<rusqlite::types::Value> = Vec::new();
18285    for (i, col) in cols.iter().enumerate() {
18286        let name = decode_attr_vec0_column(col).unwrap_or_default();
18287        // fix-3 [P2] — a PRESENT scalar value is encoded `\x01 || V` so it is
18288        // DISJOINT from the `''`-absent sentinel (present-empty ⇒ the bare marker,
18289        // never `''`). Absent stays the bare `''` sentinel.
18290        let scalar = match registry.get(&name) {
18291            Some(stored) => extract_scalar_attribute(conn, body, &name, stored)?,
18292            None => None,
18293        };
18294        let value = match scalar {
18295            Some(v) => encode_attr_vec0_present(&v),
18296            None => String::new(),
18297        };
18298        col_sql.push_str(&format!(", {col}"));
18299        ph_sql.push_str(&format!(", ?{}", start_idx + i));
18300        values.push(rusqlite::types::Value::Text(value));
18301    }
18302    Ok((col_sql, ph_sql, values))
18303}
18304
18305/// 0.8.20 Slice 15d (R-20-EAV) — the write-path attribute projector: for a
18306/// just-inserted node, project EVERY declared attribute (reading the live
18307/// registry from `tx`). Same-transaction, so the node is filter/FTS-retrievable
18308/// on commit. A no-op when the registry is empty (the pre-`configure_projections`
18309/// default), so it costs one empty-table scan per node and is behaviour-neutral
18310/// until a projection is declared.
18311fn project_node_attributes(tx: &Connection, cursor: i64, body: &str) -> rusqlite::Result<()> {
18312    let registry = load_projection_registry(tx)?;
18313    for (name, stored) in &registry {
18314        project_one_attribute(tx, cursor, body, name, stored)?;
18315    }
18316    Ok(())
18317}
18318
18319/// 0.8.20 Slice 15d (R-20-PR) — backfill ONE attribute across every ACTIVE,
18320/// non-superseded canonical node. Called by `configure_projections` when a
18321/// projection is added/changed (after `clear_attribute_projection`), and by boot
18322/// re-derive. Idempotent when paired with the clear.
18323fn backfill_attribute(
18324    tx: &Connection,
18325    name: &str,
18326    stored: &StoredProjection,
18327) -> rusqlite::Result<()> {
18328    if !stored.wants_eav() {
18329        return Ok(());
18330    }
18331    let rows: Vec<(i64, String)> = {
18332        let mut stmt = tx.prepare(
18333            "SELECT write_cursor, body FROM canonical_nodes
18334             WHERE superseded_at IS NULL AND state = 'active'",
18335        )?;
18336        let collected = stmt
18337            .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))?
18338            .collect::<rusqlite::Result<Vec<_>>>()?;
18339        collected
18340    };
18341    for (cursor, body) in rows {
18342        project_one_attribute(tx, cursor, &body, name, stored)?;
18343    }
18344    Ok(())
18345}
18346
18347/// 0.8.20 Slice 15d (R-20-PR) — is `desired` an INCOMPATIBLE/DESTRUCTIVE change
18348/// to a live `existing` projection? A destructive change discards an
18349/// expensive-to-rebuild resource and so REQUIRES an explicit `drop` (C3): a role
18350/// REMOVAL, dropping the `fts`/`vector` sub-target, or changing the tokenizer /
18351/// embedder. Purely ADDITIVE changes (adding a role, adding an `fts`/`vector`
18352/// sub-object) are non-destructive and applied in place.
18353fn is_destructive_projection_change(
18354    existing: &StoredProjection,
18355    desired: &StoredProjection,
18356) -> bool {
18357    if existing.roles.iter().any(|r| !desired.roles.contains(r)) {
18358        return true;
18359    }
18360    if existing.fts_present
18361        && (!desired.fts_present || existing.fts_tokenizer != desired.fts_tokenizer)
18362    {
18363        return true;
18364    }
18365    if existing.vector_declared
18366        && (!desired.vector_declared || existing.vector_embedder != desired.vector_embedder)
18367    {
18368        return true;
18369    }
18370    if existing.source != desired.source {
18371        return true;
18372    }
18373    false
18374}
18375
18376/// Human-readable summary of the destructive delta, surfaced in
18377/// [`EngineError::ProjectionDestructive`] so the caller sees WHAT it must drop.
18378fn describe_projection_delta(existing: &StoredProjection, desired: &StoredProjection) -> String {
18379    let mut parts: Vec<String> = Vec::new();
18380    for r in &existing.roles {
18381        if !desired.roles.contains(r) {
18382            parts.push(format!("role '{}' removed", r.as_str()));
18383        }
18384    }
18385    if existing.fts_present && !desired.fts_present {
18386        parts.push("fts sub-target removed".to_string());
18387    } else if existing.fts_present && existing.fts_tokenizer != desired.fts_tokenizer {
18388        parts.push("fts tokenizer changed".to_string());
18389    }
18390    if existing.vector_declared && !desired.vector_declared {
18391        parts.push("vector sub-target removed".to_string());
18392    } else if existing.vector_declared && existing.vector_embedder != desired.vector_embedder {
18393        parts.push("vector embedder changed".to_string());
18394    }
18395    if existing.source != desired.source {
18396        parts.push("source path changed".to_string());
18397    }
18398    if parts.is_empty() {
18399        "incompatible change".to_string()
18400    } else {
18401        parts.join("; ")
18402    }
18403}
18404
18405/// 0.8.20 Slice 15d (R-20-PR) — the declarative, idempotent diff+backfill apply
18406/// that backs [`Engine::configure_projections`]. Runs inside the caller's write
18407/// transaction `tx`. Order: apply `drop`s first (so a drop+re-declare in one
18408/// call rebuilds fresh), then diff each spec. Idempotent re-registration diffs to
18409/// an empty delta (`unchanged`). A destructive change without an explicit drop is
18410/// refused with [`EngineError::ProjectionDestructive`].
18411///
18412/// 0.8.20 Slice 20c (R-20-DR remainder) — returns `(delta, enqueued_backfill)`.
18413/// The second member is `true` iff [`enqueue_declared_vector_backfill`] put
18414/// deferred embed work on the queue, in which case the CALLER must
18415/// `notify_new_work()` after committing (the flag cannot ride on
18416/// [`ProjectionDelta`]: that is the caller-facing diff, and this is a runtime
18417/// signal, not part of the declaration's result).
18418///
18419/// 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — and the symmetric inverse: a call
18420/// that removes the LAST `searchable→vector` declaration un-enrols the node kinds
18421/// the forward path enrolled ([`unenrol_registry_vector_node_kinds`]), on this
18422/// same transaction. It deletes no embedding.
18423///
18424/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 [P2]) — and, beside that transition, a
18425/// state-keyed RECONCILIATION ([`registry_governs_an_inert_dense_arm`]) so that a
18426/// database already carrying an inert enrolment from before the Slice-21c role
18427/// gate is healed by any `configure_projections` call, not only by a
18428/// searchable-vector-to-none transition it may never perform. The boot arm is
18429/// [`reconcile_inert_vector_enrolments_on_boot`].
18430fn apply_projection_config(
18431    tx: &Connection,
18432    specs: &[ProjectionSpec],
18433    drop: &[String],
18434    dense_arm_live: bool,
18435) -> Result<(ProjectionDelta, bool), EngineError> {
18436    // Validate up-front so a bad name aborts before any write.
18437    //
18438    // fix-6 finding [P2] — REJECT a duplicate projection `name` within `specs`
18439    // (and a duplicate entry within `drop`) up front. The diff loop below diffs
18440    // every spec against the ONE pre-loop registry snapshot, so a name repeated
18441    // in `specs` diffed the SECOND spec against state that never saw the first
18442    // spec's just-persisted row: on a fresh DB `[status(searchable+fts),
18443    // status(rankable-only)]` reported `built=[status]` in the delta while the
18444    // registry ended rankable-only (which builds nothing) — the returned delta
18445    // DIVERGED from the persisted registry, breaking the fix-4 "accept ⟹ correct"
18446    // contract. A duplicate `drop` entry likewise reported the drop twice though
18447    // the row was removed once. A single request naming the same projection twice
18448    // is ambiguous/malformed, so we refuse it (rejection, not last-wins coalesce)
18449    // — a rejected request is a total no-op, keeping the registry and delta
18450    // consistent with the accepted input. A name that appears in BOTH `specs` and
18451    // `drop` is NOT a duplicate: that is the documented drop-then-rebuild-fresh
18452    // pattern (drops apply first, then the fresh spec builds), so it is allowed.
18453    let mut seen_spec_names: BTreeSet<&str> = BTreeSet::new();
18454    for spec in specs {
18455        if !is_valid_attribute_name(&spec.name) {
18456            return Err(EngineError::InvalidArgument {
18457                msg: format!("invalid projection attribute name: {:?}", spec.name),
18458            });
18459        }
18460        if spec.roles.is_empty() {
18461            return Err(EngineError::InvalidArgument {
18462                msg: format!("projection '{}' declares no roles", spec.name),
18463            });
18464        }
18465        if let Some(source) = &spec.source {
18466            if !is_valid_projection_source(source) {
18467                return Err(EngineError::InvalidArgument {
18468                    msg: format!("invalid projection source path for {:?}", spec.name),
18469                });
18470            }
18471        }
18472        if !seen_spec_names.insert(spec.name.as_str()) {
18473            return Err(EngineError::InvalidArgument {
18474                msg: format!("duplicate projection name in one request: '{}'", spec.name),
18475            });
18476        }
18477    }
18478    let mut seen_drop_names: BTreeSet<&str> = BTreeSet::new();
18479    for name in drop {
18480        if !is_valid_attribute_name(name) {
18481            return Err(EngineError::InvalidArgument {
18482                msg: format!("invalid projection drop name: {name:?}"),
18483            });
18484        }
18485        if !seen_drop_names.insert(name.as_str()) {
18486            return Err(EngineError::InvalidArgument {
18487                msg: format!("duplicate projection drop in one request: '{name}'"),
18488            });
18489        }
18490    }
18491
18492    // 0.8.20 Slice 23 (`R-20-SV`) — REJECT an `fts` or `vector` sub-object
18493    // declared WITHOUT the `searchable` role.
18494    //
18495    // HITL ruling 2026-07-24 (`dev/plans/plan-0.8.20.md` §11 item 4, option (b)):
18496    // *"it is a meaningless config; fail-fast matches the hard-reject philosophy,
18497    // and additive strictness is safe pre-1.0"*, to be implemented "at the next
18498    // `configure_projections` slice". This OVERTURNS the shipped 15d fix-4
18499    // position, which accepted the shape because it round-tripped faithfully.
18500    //
18501    // WHY it is meaningless: `searchable→FTS` and `searchable→vector` are TIER
18502    // LABELS, not roles ([`ProjectionRole`] has exactly three members). The
18503    // sub-objects SELECT a sub-target of `searchable`; they do not CONFER one —
18504    // both build predicates ([`StoredProjection::wants_property_fts`] and
18505    // [`StoredProjection::wants_vector`]) are conjunctions with
18506    // `roles.contains(Searchable)`. So without the role the declaration builds no
18507    // property-FTS, enrols no kind and embeds nothing: it names a sub-target of a
18508    // projection that does not exist. The reject is therefore keyed on the
18509    // ABSENCE of `searchable` and on nothing else — `filterable` / `rankable` are
18510    // orthogonal axes that neither supply nor substitute for it.
18511    //
18512    // FAMILY: [`EngineError::WriteValidation`], per decision #18 (0.8.20 Slice 22)
18513    // — the write-SHAPE boundary is ONE family, and this is a shape rejection.
18514    // Deliberately a SEPARATE loop from the name checks above: those are NAME
18515    // rejections that keep `InvalidArgument { msg }` because the message naming
18516    // the offending value is the caller's only handle on it. `dev/design/errors.md`
18517    // ("Validation boundary") states that split; keeping the two loops apart keeps
18518    // the split visible in the code and this change one-line-reversible.
18519    //
18520    // KNOWN COST (TC-95/TC-98, HITL-deferred): `WriteValidation` is a UNIT
18521    // variant, so this refusal cannot name WHICH spec in `specs` was invalid —
18522    // strictly worse than the name rejections above. Recorded, not worked around.
18523    for spec in specs {
18524        if spec.roles.contains(&ProjectionRole::Searchable) {
18525            continue;
18526        }
18527        if spec.fts.is_some() || spec.vector.is_some() {
18528            return Err(EngineError::WriteValidation);
18529        }
18530    }
18531
18532    // A destructive source change retains the normal drop-first error precedence.
18533    // Check the pre-drop registry before inspecting a proposed source's backfill
18534    // rows; otherwise a composite at that source could mask ProjectionDestructive.
18535    let pre_drop = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18536    let mut refresh_vector_attributes = false;
18537    for spec in specs {
18538        let desired = StoredProjection::from_spec(spec);
18539        if let Some(existing) = pre_drop.get(&spec.name) {
18540            let replacing = drop.iter().any(|name| name == &spec.name);
18541            if !replacing && is_destructive_projection_change(existing, &desired) {
18542                return Err(EngineError::ProjectionDestructive {
18543                    name: spec.name.clone(),
18544                    delta: describe_projection_delta(existing, &desired),
18545                });
18546            }
18547            if replacing
18548                && existing.source != desired.source
18549                && desired.roles.contains(&ProjectionRole::Filterable)
18550            {
18551                refresh_vector_attributes = true;
18552            }
18553        }
18554    }
18555
18556    // A declared nested source is scalar-only. Validate the complete backfill
18557    // set before any registry mutation so a composite terminal rolls the whole
18558    // configuration request back with the existing write-validation family.
18559    for spec in specs {
18560        let desired = StoredProjection::from_spec(spec);
18561        validate_projection_source_backfill(tx, &spec.name, &desired)?;
18562    }
18563
18564    let mut delta = ProjectionDelta::default();
18565
18566    // 0.8.20 Slice 20c fix-1 (codex §9 [P2]) — snapshot "is the dense arm
18567    // declared?" BEFORE any registry mutation. Together with the same read taken
18568    // after them it identifies the ONE transition that owns the inverse of this
18569    // slice's enrolment: declared -> not-declared. See
18570    // [`unenrol_registry_vector_node_kinds`] for why the inverse is keyed to that
18571    // TRANSITION rather than to the bare post-state.
18572    let vector_declared_before =
18573        vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
18574
18575    // (1) Explicit drops. Omission never drops (C3); only this list does.
18576    let before_drop = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18577    for name in drop {
18578        if before_drop.contains_key(name) {
18579            clear_attribute_projection(tx, name).map_err(|_| EngineError::Storage)?;
18580            remove_projection_row(tx, name).map_err(|_| EngineError::Storage)?;
18581            delta.dropped.push(name.clone());
18582        }
18583        // dropping an absent projection is an idempotent no-op, not an error.
18584    }
18585
18586    // (2) Diff each spec against the post-drop registry.
18587    let current = load_projection_registry(tx).map_err(|_| EngineError::Storage)?;
18588    for spec in specs {
18589        let desired = StoredProjection::from_spec(spec);
18590        match current.get(&spec.name) {
18591            Some(existing) if existing == &desired => {
18592                // Idempotent re-registration — no-op (the keystone acceptance).
18593            }
18594            Some(existing) => {
18595                if is_destructive_projection_change(existing, &desired) {
18596                    return Err(EngineError::ProjectionDestructive {
18597                        name: spec.name.clone(),
18598                        delta: describe_projection_delta(existing, &desired),
18599                    });
18600                }
18601                persist_projection_row(tx, &spec.name, &desired)
18602                    .map_err(|_| EngineError::Storage)?;
18603                clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
18604                backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
18605                if desired.wants_eav() {
18606                    delta.built.push(spec.name.clone());
18607                }
18608                // 0.8.20 Slice 15e fix-2 finding 2 [P2] — this arm ONLY runs when
18609                // the registry row actually CHANGED (`existing != desired`), so the
18610                // delta MUST reflect that change; otherwise an accepted mutation
18611                // reports `unchanged = true` — a no-op lie to SDK callers. The prior
18612                // `&& !existing.has_deferred()` guard suppressed a deferred-ONLY
18613                // change (e.g. `rankable` → `rankable + vector`, which builds no EAV
18614                // so `built` stays empty): the row persisted but `delta` came back
18615                // empty. Mirror the fresh-registration push (`if
18616                // desired.has_deferred()`). Every valid non-empty spec has
18617                // `wants_eav()` OR `has_deferred()`, so on a real change at least one
18618                // of `built`/`deferred` is now populated ⇒ `unchanged` can never be
18619                // `true` on a persisted change. A genuine no-op (identical spec)
18620                // takes the idempotent arm above and is untouched.
18621                if desired.has_deferred() {
18622                    delta.deferred.push(spec.name.clone());
18623                }
18624            }
18625            None => {
18626                persist_projection_row(tx, &spec.name, &desired)
18627                    .map_err(|_| EngineError::Storage)?;
18628                clear_attribute_projection(tx, &spec.name).map_err(|_| EngineError::Storage)?;
18629                backfill_attribute(tx, &spec.name, &desired).map_err(|_| EngineError::Storage)?;
18630                if desired.wants_eav() {
18631                    delta.built.push(spec.name.clone());
18632                }
18633                if desired.has_deferred() {
18634                    delta.deferred.push(spec.name.clone());
18635                }
18636            }
18637        }
18638    }
18639
18640    // 0.8.20 Slice 15e — after the registry mutations, reconcile the live vec0
18641    // shape with the (possibly changed) `filterable` set: a NON-DESTRUCTIVE
18642    // reshape adds/removes the `attr_<hex>` pre-KNN columns preserving every
18643    // row's embedding (TC-46, HITL Option 1). Runs on the caller's write
18644    // transaction, so the reshape commits atomically with the registry row. On an
18645    // idempotent re-registration the desired set equals the live set, so this is a
18646    // no-op (vec0 untouched) and `delta.unchanged` above is unaffected. Skipped
18647    // when there is no embedder profile (⇒ no `vector_default` to reshape).
18648    if let Ok(dimension) = default_profile_dimension(tx) {
18649        if refresh_vector_attributes {
18650            refresh_vector_attr_values(tx, dimension).map_err(|_| EngineError::Storage)?;
18651        } else {
18652            reconcile_vector_attr_columns(tx, dimension).map_err(|_| EngineError::Storage)?;
18653        }
18654    }
18655
18656    delta.unchanged =
18657        delta.built.is_empty() && delta.dropped.is_empty() && delta.deferred.is_empty();
18658
18659    // 0.8.20 Slice 20c (R-20-DR remainder) — THE C4 RIDER. Everything above has
18660    // only *persisted* the `searchable→vector` declaration and pushed its name
18661    // onto `delta.deferred`. Acknowledging deferred work and then dropping it on
18662    // the floor is what made `drain` a FALSE-READY barrier; this call is where the
18663    // deferred work is actually enqueued onto the runtime `drain` waits on.
18664    //
18665    // fix-1 (codex §9 [P2]) — and its SYMMETRIC INVERSE, on the same
18666    // transaction. If this call removed the last `searchable→vector` declaration,
18667    // un-enrol the node kinds the forward path enrols; otherwise enrolment is a
18668    // one-way door and the write path keeps embedding for a projection the
18669    // registry no longer declares.
18670    let vector_declared_after = vector_projection_declared(tx).map_err(|_| EngineError::Storage)?;
18671    let enqueued = if vector_declared_after {
18672        // 0.8.20 Slice 22 (R-20-VC / TC-67) — THE REPORT. Scoped to a live dense
18673        // -arm declaration (with no `searchable→vector` projection there is
18674        // nothing for a kind to be unsupported FOR, so reporting would be noise
18675        // on every `filterable`/FTS-only call), but deliberately OUTSIDE the
18676        // `dense_arm_live` gate below — see [`unsupported_vector_kinds`].
18677        //
18678        // Placed AFTER `delta.unchanged` is computed, and it does not feed it:
18679        // this is a STATE report, not a diff, so an idempotent re-apply still
18680        // carries it (that is also the documented refresh path for the
18681        // declare-time residual).
18682        delta.vector_unsupported_kinds =
18683            unsupported_vector_kinds(tx).map_err(|_| EngineError::Storage)?;
18684        if dense_arm_live {
18685            enqueue_declared_vector_backfill(tx).map_err(|_| EngineError::Storage)?
18686        } else {
18687            false
18688        }
18689    } else {
18690        // fix-1 (codex §9 round 1 [P2], ledger `TC-71`) — the transition arm is
18691        // KEPT as-is and a state-keyed reconciliation is added BESIDE it; neither
18692        // subsumes the other. The transition fires when this very call removed the
18693        // last dense-arm declaration, including the case where it removed the last
18694        // `vector` sub-object with it (which leaves
18695        // `registry_governs_an_inert_dense_arm` false). The reconciliation covers
18696        // the ALREADY-AFFECTED database whose user calls `configure_projections`
18697        // again with anything at all: there `before` is already `false`, so the
18698        // transition arm is inert and the inert enrolment used to survive
18699        // indefinitely. `||` short-circuits, so the transition case pays nothing
18700        // extra; the other case pays two cached `EXISTS` probes on a governed call,
18701        // never on the hot write path.
18702        if vector_declared_before
18703            || registry_governs_an_inert_dense_arm(tx).map_err(|_| EngineError::Storage)?
18704        {
18705            unenrol_registry_vector_node_kinds(tx).map_err(|_| EngineError::Storage)?;
18706        }
18707        false
18708    };
18709    Ok((delta, enqueued))
18710}
18711
18712/// 0.8.20 Slice 20c fix-1 (codex §9 [P2] "Stop embedding after vector projection
18713/// drops") — **the inverse of [`enqueue_declared_vector_backfill`]'s enrolment.**
18714///
18715/// Slice 20c gave `_fathomdb_vector_kinds` its first governed-call-reachable
18716/// writer for a NODE kind (before it, the only one was the `#[doc(hidden)]`
18717/// `configure_vector_kind_for_test` hook). Forward without reverse is the defect:
18718/// after `drop`ping the last `searchable→vector` declaration,
18719/// [`project_canonical_node_row`]'s `kind_is_vector_indexed` gate and
18720/// [`connection_has_pending_projection_work`] both still see the enrolment, so
18721/// subsequent writes keep enqueueing embeds and `drain` keeps waiting on work for
18722/// a projection [`Engine::read_projections`] no longer reports.
18723///
18724/// # It DELETES NO EMBEDDING — that is the point
18725///
18726/// The shipped drop arm ([`clear_attribute_projection`] +
18727/// [`remove_projection_row`]) has never touched vec0, `_fathomdb_vector_rows` or
18728/// `_fathomdb_vector_kinds`, so "vectors already at rest survive a drop" is
18729/// ALREADY the shipped contract. Removing one registry row PRESERVES it; deleting
18730/// embeddings would be the destructive delta, and is not done here.
18731///
18732/// # Why keyed to the TRANSITION, not to the bare post-state
18733///
18734/// The rule is "this call removed the last vector declaration"
18735/// (`declared_before && !declared_after`), not "no vector declaration exists
18736/// now". A workspace can hold enrolments this registry never made — the test hook
18737/// does exactly that, and several shipped suites enrol a kind through it and then
18738/// declare an unrelated `filterable`-only projection (e.g.
18739/// `slice15e_prekn_filterable`). Firing on the bare post-state would un-enrol
18740/// those and silently kill a dense arm the registry never owned. In production
18741/// the two readings coincide: before this slice
18742/// `production_vector_kind_surface=[]`, so a node kind can only be enrolled
18743/// because a `searchable→vector` declaration existed.
18744///
18745/// It is still STATE-keyed, not delta-keyed: both members are reads of the
18746/// registry, never "was this spec new". Re-applying the same drop finds
18747/// `declared_before == false` and is a total no-op, and nothing re-enrols it
18748/// ([`Engine::enrol_vector_kind_if_declared`] is gated on
18749/// [`vector_projection_declared`]).
18750///
18751/// # 0.8.20 Slice 21 fix-1 — a SECOND, narrower authorisation now exists
18752///
18753/// The reasoning above is why the bare post-state cannot authorise this DELETE,
18754/// and it still stands. What it does not cover is a database that ran the
18755/// PRE-Slice-21c code and enrolled node kinds off a `{filterable, vector}`
18756/// declaration: there the registry DID own the enrolment, and no transition will
18757/// ever fire for it. [`registry_governs_an_inert_dense_arm`] adds exactly that
18758/// case — positively conditioned on the registry existing AND declaring a
18759/// `vector` sub-object AND declaring no `searchable→vector` projection, which is
18760/// strictly narrower than the bare post-state and in particular excludes every
18761/// workspace whose enrolment the registry never made. Its callers are
18762/// [`reconcile_inert_vector_enrolments_on_boot`] and the drop arm of
18763/// [`apply_projection_config`].
18764///
18765/// # `'edge_fact'` is excluded, deliberately
18766///
18767/// [`project_canonical_edge_row`] (G11) auto-registers `'edge_fact'` off the
18768/// presence of an edge BODY, unconditionally and independently of the projection
18769/// registry. That lifecycle predates this slice and is not the registry's to end,
18770/// so a node-projection drop must not take the edge dense arm down with it.
18771///
18772/// # What it deliberately does NOT do
18773///
18774/// It touches no `_fathomdb_projection_terminal` row and no readiness watermark.
18775/// A row enqueued-but-not-yet-embedded when the drop lands keeps its absent
18776/// terminal, which pins the watermark below it — harmless, because both the
18777/// scheduler and the pending-work probe join `_fathomdb_vector_kinds` and so no
18778/// longer see it, and it is precisely what lets a later RE-declaration pick the
18779/// row up again instead of stranding it.
18780fn unenrol_registry_vector_node_kinds(tx: &Connection) -> rusqlite::Result<()> {
18781    tx.execute("DELETE FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact'", [])?;
18782    Ok(())
18783}
18784
18785/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`, ledger `TC-71`) — **does the
18786/// registry GOVERN the dense arm while declaring none?** The narrow,
18787/// positively-conditioned predicate that authorises
18788/// [`unenrol_registry_vector_node_kinds`] on a bare STATE rather than on the
18789/// `declared_before && !declared_after` transition.
18790///
18791/// # Why a state-keyed authorisation exists at all
18792///
18793/// Slice 21c gated the dense arm on the `searchable` ROLE, which closes the three
18794/// FORWARD doors. It cannot reach a database that already ran the old code: those
18795/// node kinds are already in `_fathomdb_vector_kinds`, and
18796///
18797/// - [`Engine::vector_kind_needs_enrolment`] returns early the moment
18798///   [`kind_is_vector_indexed`] is true, so it never consults the new role-aware
18799///   predicate for an EXISTING registration; and
18800/// - [`project_canonical_node_row`] gates the embed enqueue solely on registry
18801///   membership (deliberately — that is the hot write path, and the decision is
18802///   meant to live upstream).
18803///
18804/// So without this, upgrading does not actually stop the billable, unexpected
18805/// embeddings for exactly the population TC-71 was raised for — the finding's
18806/// whole harm survives the fix unless the user happens to perform a
18807/// searchable-vector-to-none transition later.
18808///
18809/// # THE TRAP: why it is not `!vector_projection_declared`
18810///
18811/// [`vector_projection_declared`] answers `false` when the registry table is
18812/// ABSENT (pre-step-24) or merely EMPTY — which is every LEGACY database, many of
18813/// which have a legitimately working dense arm enrolled by other means (the
18814/// `#[doc(hidden)]` `configure_vector_kind_for_test` hook is one; before this
18815/// slice `production_vector_kind_surface=[]`, but a workspace is not obliged to
18816/// have reached its enrolment through the registry). Un-enrolling on that bare
18817/// negative would silently switch vector search OFF for all of them — a far worse
18818/// regression than TC-71 itself. So the rule is POSITIVE on all three counts:
18819///
18820///   1. `_fathomdb_projection_registry` EXISTS; **and**
18821///   2. at least one row carries a `vector` sub-object (`vector_declared = 1`) —
18822///      someone actually asked for a dense arm through the registry, which is
18823///      precisely what identifies the affected population; **and**
18824///   3. NO projection satisfies [`StoredProjection::wants_vector`], i.e. none of
18825///      them is `searchable`.
18826///
18827/// Condition 2 is the load-bearing one. It leaves untouched a registry-governed
18828/// database that declares no `vector` sub-object at all but holds enrolments from
18829/// a pre-registry era (`slice15e_prekn_filterable` is exactly that shape). Being
18830/// conservative here is the correct direction: never destroy a working dense arm.
18831///
18832/// Conditions 1+2 are the SAME two `prepare_cached` `EXISTS` probes
18833/// [`vector_projection_declared`] opens with, so a workspace that never declared
18834/// a `vector` sub-object — the overwhelmingly common shape — pays nothing beyond
18835/// them and never reaches the typed [`load_projection_registry`] read. Condition 3
18836/// is delegated to [`vector_projection_declared`] verbatim rather than re-derived,
18837/// so the authorisation and the gate cannot drift.
18838fn registry_governs_an_inert_dense_arm(conn: &Connection) -> rusqlite::Result<bool> {
18839    // (1) the registry must EXIST. A pre-step-24 database has no registry at all
18840    // and is therefore not registry-governed — hands off.
18841    let table_exists: bool = conn
18842        .prepare_cached(
18843            "SELECT EXISTS(
18844                 SELECT 1 FROM sqlite_master
18845                 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18846             )",
18847        )?
18848        .query_row([], |row| row.get(0))?;
18849    if !table_exists {
18850        return Ok(false);
18851    }
18852    // (2) …and it must actually DECLARE a `vector` sub-object somewhere. An empty
18853    // or vector-less registry governs no dense arm, so any enrolment present came
18854    // from outside it and is not ours to remove.
18855    let any_vector_subobject: bool = conn
18856        .prepare_cached(
18857            "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18858        )?
18859        .query_row([], |row| row.get(0))?;
18860    if !any_vector_subobject {
18861        return Ok(false);
18862    }
18863    // (3) …while declaring no `searchable→vector` projection. THE predicate,
18864    // reused, so this can never disagree with the gate the write path applies.
18865    Ok(!vector_projection_declared(conn)?)
18866}
18867
18868/// 0.8.20 Slice 21 fix-1 (codex §9 round 1 `[P2]`) — the BOOT arm of the
18869/// reconciliation: on every open, bring an already-enrolled inert vector kind
18870/// into agreement with the role-aware decision, so an affected database
18871/// self-heals without the user calling anything. Returns `true` iff it un-enrolled
18872/// something.
18873///
18874/// Authorised by [`registry_governs_an_inert_dense_arm`] (read that for the trap
18875/// this must not fall into), and performed by
18876/// [`unenrol_registry_vector_node_kinds`] — the SAME writer the drop inverse uses,
18877/// so `'edge_fact'` is excluded (G11 auto-registers it off the presence of an edge
18878/// body, independently of the projection registry) and **no embedding is deleted**.
18879///
18880/// # It mirrors the drop inverse exactly, because that inverse does nothing else
18881///
18882/// `apply_projection_config`'s drop arm is a single call to
18883/// [`unenrol_registry_vector_node_kinds`]: no terminal record is touched, no
18884/// readiness watermark is rewound, no row is un-stranded, and nothing is notified
18885/// (it returns `enqueued = false`). So leaving the database in "the state a drop
18886/// transition would have left it in" is exactly that one `DELETE`, and there is
18887/// no second half to mirror.
18888///
18889/// # Cheap when there is nothing to do, and idempotent
18890///
18891/// A workspace with no `vector` sub-object pays only the two cached `EXISTS`
18892/// probes the authorisation opens with. When the authorisation DOES fire, a third
18893/// cached `EXISTS` checks whether any node kind is actually enrolled, so the
18894/// steady state after the first healing open is a pure READ — no write
18895/// transaction, no `DELETE`, nothing to oscillate. `DELETE … WHERE kind <>
18896/// 'edge_fact'` is a single statement, hence atomic on its own; no explicit
18897/// transaction is opened around it.
18898///
18899/// # Placement
18900///
18901/// Runs inside `open_locked` on the writer connection, single-threaded, before
18902/// readers and the projection workers spawn — alongside the other boot
18903/// reconciliations ([`rederive_projections_on_boot`],
18904/// [`reconcile_vector_attr_columns`]) and therefore BEFORE
18905/// [`run_vector_equivalence_probe`], which is deliberate: on a database whose only
18906/// enrolment was the inert one, reconciling first leaves `_fathomdb_vector_kinds`
18907/// empty, so the probe correctly finds no dense arm to guard and the healing open
18908/// spends no embed calls at all.
18909///
18910/// # Not a data migration
18911///
18912/// It removes a registration row inside ONE live database to match that
18913/// database's own declarations. It converts no row across a version step; the
18914/// reconciliation itself introduces no migration.
18915fn reconcile_inert_vector_enrolments_on_boot(conn: &Connection) -> rusqlite::Result<bool> {
18916    if !registry_governs_an_inert_dense_arm(conn)? {
18917        return Ok(false);
18918    }
18919    // Nothing enrolled beyond the G11 edge arm ⇒ nothing to do. Keeps the steady
18920    // state a pure read instead of a no-op write transaction on every open.
18921    let any_node_kind: bool = conn
18922        .prepare_cached(
18923            "SELECT EXISTS(SELECT 1 FROM _fathomdb_vector_kinds WHERE kind <> 'edge_fact')",
18924        )?
18925        .query_row([], |row| row.get(0))?;
18926    if !any_node_kind {
18927        return Ok(false);
18928    }
18929    unenrol_registry_vector_node_kinds(conn)?;
18930    Ok(true)
18931}
18932
18933/// 0.8.20 Slice 20c (R-20-DR remainder) — is ANY `searchable→vector` projection
18934/// declared in the durable registry?
18935///
18936/// This is the corpus-wide "the dense arm is live" predicate. It is corpus-wide
18937/// rather than per-attribute for the same reason [`derive_dense_readiness`] is:
18938/// Slice 15d persists the `searchable→vector` sub-object but defers building any
18939/// per-attribute embedding, so every declared vector projection is served by the
18940/// ONE engine vector pipeline. When per-attribute embedding lands, this is where
18941/// the scoping goes — the same seam as readiness.
18942///
18943/// Safe on a pre-step-24 schema (the registry table is created by step 24): an
18944/// absent table means nothing is declared, not an error. Mirrors the guard in
18945/// [`load_projection_registry`], and uses `prepare_cached` because the write path
18946/// calls this once per un-registered-kind row.
18947///
18948/// # 0.8.20 Slice 21c (ledger `TC-71`) — it requires the `searchable` ROLE
18949///
18950/// This used to answer `EXISTS(… WHERE vector_declared = 1)`, reading the stored
18951/// `vector` sub-object and never the `roles` column. But the sub-object SELECTS
18952/// a sub-target of `searchable`; it does not confer one (exactly as `fts` does
18953/// not — see [`StoredProjection::wants_property_fts`]). So
18954/// `{roles: [filterable], vector: {}}`, which Slice 15d documents as
18955/// inert-but-round-trippable, turned the dense arm ON in any session with a live
18956/// embedder: it enrolled node kinds, backfilled the corpus, and made every later
18957/// write of those kinds enqueue an embedding. Wasted embed work and unexpected
18958/// vectors at rest for a projection meant to do nothing. The answer now comes
18959/// from [`StoredProjection::wants_vector`], the ONE predicate, so the three
18960/// gated paths cannot drift.
18961///
18962/// **This flips the forward AND inverse arms of [`apply_projection_config`] at
18963/// once**, which is a real semantic consequence and not an accident: demoting
18964/// the last `{searchable, vector}` projection to `{filterable, vector}` (or
18965/// dropping it while an inert `{filterable, vector}` sibling survives) now reads
18966/// `declared → not-declared` and therefore UN-ENROLS, where before the surviving
18967/// `vector_declared = 1` row masked the transition and the write path kept
18968/// embedding. Pinned in `tests/slice21c_vector_role_gate.rs`.
18969///
18970/// # Why the cheap `EXISTS` survives as a pre-filter
18971///
18972/// The write path calls this once per un-registered-kind row, and the
18973/// overwhelmingly common shape is a workspace that declared no `vector`
18974/// sub-object at all. `EXISTS(… vector_declared = 1)` is a NECESSARY condition
18975/// for [`StoredProjection::wants_vector`], so keeping it as a fast negative
18976/// leaves that workspace paying exactly the two cached `EXISTS` probes it paid
18977/// before — no typed load, no `BTreeMap`, no uncached `prepare`. Only a
18978/// workspace that HAS a `vector` sub-object somewhere pays the
18979/// [`load_projection_registry`] read, and there the registry is a handful of
18980/// app-declared rows; in the ordinary `searchable→vector` case the kind is
18981/// enrolled after the first probe and `kind_is_vector_indexed` short-circuits
18982/// this call entirely from then on.
18983fn vector_projection_declared(conn: &Connection) -> rusqlite::Result<bool> {
18984    let table_exists: bool = conn
18985        .prepare_cached(
18986            "SELECT EXISTS(
18987                 SELECT 1 FROM sqlite_master
18988                 WHERE type = 'table' AND name = '_fathomdb_projection_registry'
18989             )",
18990        )?
18991        .query_row([], |row| row.get(0))?;
18992    if !table_exists {
18993        return Ok(false);
18994    }
18995    // Fast negative: no `vector` sub-object anywhere ⇒ certainly no dense arm.
18996    let any_vector_subobject: bool = conn
18997        .prepare_cached(
18998            "SELECT EXISTS(SELECT 1 FROM _fathomdb_projection_registry WHERE vector_declared = 1)",
18999        )?
19000        .query_row([], |row| row.get(0))?;
19001    if !any_vector_subobject {
19002        return Ok(false);
19003    }
19004    // `roles` is persisted as a comma-joined sorted string, so it is not a
19005    // trustworthy SQL predicate (a `LIKE` would match a forward-compat token that
19006    // merely CONTAINS a role spelling). Answer through the typed registry and the
19007    // ONE predicate instead.
19008    Ok(load_projection_registry(conn)?.values().any(StoredProjection::wants_vector))
19009}
19010
19011/// 0.8.20 Slice 20c (R-20-DR remainder) — enrol `kind` in the vector pipeline.
19012///
19013/// `INSERT OR IGNORE`, so it is idempotent and never disturbs an existing
19014/// registration's `profile`/`created_at`. Same statement shape the G11 edge path
19015/// uses for `'edge_fact'` ([`project_canonical_edge_row`]).
19016fn register_vector_kind(tx: &Connection, kind: &str) -> rusqlite::Result<()> {
19017    tx.execute(
19018        "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
19019         VALUES(?1, ?2, 0)",
19020        params![kind, DEFAULT_VECTOR_PROFILE],
19021    )?;
19022    Ok(())
19023}
19024
19025/// 0.8.20 Slice 20c (R-20-DR remainder) — **the flush barrier's enqueue half**
19026/// (`api-surface.md` **C4** rider: `drain` is a barrier, not a trigger, so
19027/// deferred/backfill rows must be enqueued on the same projection runtime `drain`
19028/// waits on).
19029///
19030/// Runs on the caller's `configure_projections` write transaction, AFTER the
19031/// registry mutations, so the enrolment + re-enqueue commit atomically with the
19032/// declaration that caused them. Returns `true` iff work was enqueued — the
19033/// caller must then `notify_new_work()` (after the commit; the dispatcher opens
19034/// its own connection).
19035///
19036/// # The defect this closes
19037///
19038/// `project_canonical_node_row` writes a PERMANENT `'up_to_date'` terminal for
19039/// any row whose kind was not vector-registered *at write time*, and before this
19040/// slice NOTHING but the `#[doc(hidden)]` test hook ever registered a node kind
19041/// (`slice-G0-design.md`: `production_vector_kind_surface=[]`). So the ordinary
19042/// "turn the dense arm on over an existing corpus" flow — write rows, then
19043/// declare `searchable→vector` — left every row terminally marked done with no
19044/// vector and no way to get one short of an operator `rebuild`. Both
19045/// `drain`/`wait_for_idle` and `derive_dense_readiness` read that terminal
19046/// through [`connection_has_pending_projection_work`], so the corpus reported
19047/// `ready` while nothing would ever embed it.
19048///
19049/// # Shape (deliberately the `run_rebuild` shape, scoped)
19050///
19051/// `run_rebuild` truncates the readiness terminals and rewinds the projection
19052/// cursor so the scheduler re-walks the corpus. This does the same, but scoped to
19053/// the rows the declaration newly covers, and it does NOT truncate anything else:
19054///
19055/// 1. enrol every vector-eligible node kind present in `canonical_nodes`
19056///    (`row_kind IN ('leaf','coverage')` — the `index_targets_for_row_kind`
19057///    vector-eligibility predicate; `graph` rows are lexically searchable but
19058///    never embedded, so enrolling on them would silently start embedding
19059///    structural rows) **that the vector writer can commit**
19060///    ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]);
19061/// 2. (and 3.) un-strand the rows that enrolment now covers, via
19062///    [`reenqueue_stranded_vector_rows`] — shared verbatim with the write path's
19063///    late enrolment.
19064///
19065/// # Why it is IDEMPOTENT (R-20-PR: "re-registration is a no-op")
19066///
19067/// Every step keys off *state*, not off "was this declaration new": step 1 is
19068/// `INSERT OR IGNORE`; steps 2-3 act only on rows that are stranded RIGHT NOW.
19069/// Once the backfill has been drained those rows carry vectors, so a re-apply
19070/// finds an empty stranded set, returns `false`, and touches neither the
19071/// terminals nor the cursor. No rewind, no re-embed, no spurious `embedding`
19072/// window.
19073///
19074/// # Not a data migration
19075///
19076/// This re-enqueues embed work inside ONE live database at the caller's request.
19077/// It converts no rows across a version step and introduces no migration (HITL
19078/// 2026-07-21; cf. TC-46's in-place vec0 reshape).
19079/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — the ONE scan of "which node kinds in
19080/// this corpus are candidates for the dense arm?".
19081///
19082/// `row_kind IN ('leaf', 'coverage')` is the `index_targets_for_row_kind` vector
19083/// -eligibility predicate: `graph` rows are lexically searchable but NEVER
19084/// embedded, so they are excluded here on a ROW-KIND axis that has nothing to do
19085/// with the `kind` vocabulary — including them would make TC-67 report structural
19086/// rows as "unsupported kinds", which is a different (and false) statement.
19087///
19088/// Extracted so [`enqueue_declared_vector_backfill`] (which enrols the
19089/// commit-able half) and [`unsupported_vector_kinds`] (which reports the other
19090/// half) partition ONE list rather than running two hand-copied queries that
19091/// could drift — the same TC-56 anti-drift discipline that made
19092/// [`kind_is_vector_committable`] delegate to [`resolve_source_type`].
19093///
19094/// `SELECT DISTINCT … ORDER BY kind` gives the caller a sorted, de-duplicated
19095/// list for free, which is the reported ordering.
19096fn vector_eligible_node_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
19097    let mut stmt = tx.prepare(
19098        "SELECT DISTINCT kind FROM canonical_nodes
19099         WHERE row_kind IN ('leaf', 'coverage')
19100         ORDER BY kind",
19101    )?;
19102    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
19103    rows.collect::<rusqlite::Result<Vec<String>>>()
19104}
19105
19106/// 0.8.20 Slice 22 (R-20-VC / **TC-67**) — **the report that replaces the
19107/// silence.** The vector-eligible node kinds present in the corpus that
19108/// [`kind_is_vector_committable`] excludes, i.e. the exact complement of the set
19109/// [`enqueue_declared_vector_backfill`] enrols.
19110///
19111/// Populates [`ProjectionDelta::vector_unsupported_kinds`]. Read that field's
19112/// doc-comment for the naming, the state-not-diff semantics and the residual;
19113/// what belongs HERE is the one thing the call SITE decides:
19114///
19115/// **It is deliberately NOT gated on `dense_arm_live`.** The enrolment it mirrors
19116/// is (`apply_projection_config` only calls `enqueue_declared_vector_backfill`
19117/// with a usable dense runtime, the Q6a graceful-absent path), but this answer does not
19118/// depend on the session: [`resolve_source_type`]'s vocabulary is a compile-time
19119/// constant, so "this kind can never be embedded" is equally true with no
19120/// embedder attached. Gating it would hide the permanent fact behind the
19121/// transient one, which is the very conflation TC-67 exists to end — a
19122/// no-embedder caller is exactly the caller who most needs to know that
19123/// attaching an embedder later will still not embed these kinds.
19124fn unsupported_vector_kinds(tx: &Connection) -> rusqlite::Result<Vec<String>> {
19125    Ok(vector_eligible_node_kinds(tx)?
19126        .into_iter()
19127        .filter(|kind| !kind_is_vector_committable(kind))
19128        .collect())
19129}
19130
19131fn enqueue_declared_vector_backfill(tx: &Connection) -> rusqlite::Result<bool> {
19132    if !vector_projection_declared(tx)? {
19133        return Ok(false);
19134    }
19135
19136    // (1) Enrol the vector-eligible kinds the live corpus actually contains —
19137    // RESTRICTED to the ones the vector writer can actually commit
19138    // ([`kind_is_vector_committable`], fix-2 / codex §9 [P1]). Enrolling a kind
19139    // outside `resolve_source_type`'s locked vocabulary wedges the projection
19140    // worker forever and starves every other kind with it.
19141    //
19142    // 0.8.20 Slice 22 (TC-67) — the kinds this filter DROPS are what
19143    // [`unsupported_vector_kinds`] reports; both read the same scan through
19144    // [`vector_eligible_node_kinds`] so the report can never describe a
19145    // different set from the one actually excluded.
19146    let kinds = vector_eligible_node_kinds(tx)?;
19147    for kind in kinds.iter().filter(|kind| kind_is_vector_committable(kind)) {
19148        register_vector_kind(tx, kind)?;
19149    }
19150
19151    // (2)+(3) Un-strand the rows the new enrolment now covers.
19152    reenqueue_stranded_vector_rows(tx)
19153}
19154
19155/// Enrol and requeue a durable vector declaration during a safe open. The
19156/// prospective-equivalence guard runs before this function; this function owns
19157/// the one durable transaction that makes a crash converge to either the old
19158/// state or a fully queued repair.
19159fn boot_graft_declared_vector_backfill(connection: &Connection) -> rusqlite::Result<bool> {
19160    if !vector_projection_declared(connection)? {
19161        return Ok(false);
19162    }
19163    connection.execute_batch("BEGIN IMMEDIATE")?;
19164    match enqueue_declared_vector_backfill(connection) {
19165        Ok(enqueued) => {
19166            connection.execute_batch("COMMIT")?;
19167            Ok(enqueued)
19168        }
19169        Err(error) => {
19170            let _ = connection.execute_batch("ROLLBACK");
19171            Err(error)
19172        }
19173    }
19174}
19175
19176/// 0.8.20 Slice 20c — steps (2) and (3) of the declared-backfill above, as their
19177/// own function because **both** enrolment doors owe this treatment.
19178///
19179/// fix-2 (codex §9 [P2]): [`enqueue_declared_vector_backfill`] is the DECLARE-time
19180/// door; [`Engine::enrol_batch_vector_kinds`] is the WRITE-time one, and it used to
19181/// enrol a kind while enqueueing only the row in its own batch. A database that
19182/// persisted a `searchable→vector` declaration while opened WITHOUT an embedder
19183/// (Q6a graceful-absent: it defers, enrolling nothing), then reopened WITH one and
19184/// wrote the same kind BEFORE re-applying the projection, therefore drained the new
19185/// row and reported `ready` while every row from the no-embedder session kept its
19186/// permanent `'up_to_date'` terminal and no vector. That is a FALSE READY — the
19187/// exact defect class R-20-DR exists to eliminate — so the two doors share ONE
19188/// implementation rather than one of them carrying a partial copy.
19189///
19190/// Returns `true` iff work was re-enqueued; the caller must then `notify_new_work()`
19191/// (after its commit — the dispatcher opens its own connection).
19192///
19193///   2. find the STRANDED rows — vector-eligible, now vector-kind-registered,
19194///      carrying an `'up_to_date'` terminal, and carrying NO `_fathomdb_vector_rows`
19195///      row — and delete their terminals so the scheduler's `terminal IS NULL`
19196///      predicate sees them again;
19197///   3. rewind the readiness watermark to just below the lowest stranded cursor, so
19198///      the scheduler's `write_cursor > cursor` filter reaches them.
19199///
19200/// The `_fathomdb_vector_kinds` join is what scopes this to the dense arm: a kind
19201/// that is not enrolled (including one that is not commit-able, per
19202/// [`kind_is_vector_committable`]) is not stranded — it has no dense arm to be
19203/// behind on.
19204///
19205/// Idempotent by construction: it acts only on rows that are stranded RIGHT NOW, so
19206/// once drained the set is empty, it returns `false`, and neither the terminals nor
19207/// the cursor are touched. A `'failed'` terminal is deliberately NOT re-enqueued
19208/// (the filter is `'up_to_date'`): re-enqueueing it would loop a permanently-failing
19209/// row forever, and the documented failure boundary is that a terminally-failed
19210/// embed stops being outstanding work (see [`derive_dense_readiness`]).
19211fn reenqueue_stranded_vector_rows(tx: &Connection) -> rusqlite::Result<bool> {
19212    // (2) The stranded set: covered by the dense arm, terminally marked done, no
19213    // vector. `MIN` first so a no-op apply costs one indexed probe and stops.
19214    let lowest_stranded: Option<u64> = tx.query_row(
19215        "SELECT MIN(n.write_cursor)
19216         FROM canonical_nodes n
19217         JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
19218         JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
19219         LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
19220         WHERE n.row_kind IN ('leaf', 'coverage')
19221           AND t.state = 'up_to_date'
19222           AND v.write_cursor IS NULL",
19223        [],
19224        |row| row.get::<_, Option<u64>>(0),
19225    )?;
19226    let Some(lowest_stranded) = lowest_stranded else {
19227        return Ok(false);
19228    };
19229
19230    tx.execute(
19231        "DELETE FROM _fathomdb_projection_terminal
19232         WHERE write_cursor IN (
19233             SELECT n.write_cursor
19234             FROM canonical_nodes n
19235             JOIN _fathomdb_vector_kinds k ON k.kind = n.kind
19236             JOIN _fathomdb_projection_terminal t ON t.write_cursor = n.write_cursor
19237             LEFT JOIN _fathomdb_vector_rows v ON v.write_cursor = n.write_cursor
19238             WHERE n.row_kind IN ('leaf', 'coverage')
19239               AND t.state = 'up_to_date'
19240               AND v.write_cursor IS NULL
19241         )",
19242        [],
19243    )?;
19244
19245    // (3) Rewind the readiness watermark just below the lowest stranded row so the
19246    // scheduler's `write_cursor > cursor` filter reaches it. Never move it
19247    // FORWARD: rows above the watermark that still hold their terminals are
19248    // skipped by the scheduler's `terminal IS NULL` predicate, and
19249    // `advance_projection_cursor` walks the watermark back up over them.
19250    let rewind_to = lowest_stranded.saturating_sub(1);
19251    if load_projection_cursor(tx)? > rewind_to {
19252        store_projection_cursor(tx, rewind_to)?;
19253    }
19254    Ok(true)
19255}
19256
19257/// 0.8.20 Slice 15d (R-20-PR, Q5) — BOOT re-derive: the engine `ProjectionSpec`
19258/// is a DERIVED cache, re-driven idempotently on boot. For every persisted
19259/// registry declaration, clear + backfill its EAV / property-FTS rows from the
19260/// canonical nodes — so a DB whose registry row survives but whose projection
19261/// rows are missing/partial (a crash window, a restored registry) CONVERGES on
19262/// the next open. A no-op (single empty-table read) when no projections are
19263/// declared — which is every pre-`configure_projections` DB. Runs on the writer
19264/// connection, single-threaded, before readers spawn.
19265fn rederive_projections_on_boot(conn: &Connection) -> rusqlite::Result<()> {
19266    let registry = load_projection_registry(conn)?;
19267    if registry.is_empty() {
19268        return Ok(());
19269    }
19270    conn.execute_batch("BEGIN IMMEDIATE")?;
19271    let result = (|| {
19272        for (name, stored) in &registry {
19273            clear_attribute_projection(conn, name)?;
19274            backfill_attribute(conn, name, stored)?;
19275        }
19276        Ok(())
19277    })();
19278    match result {
19279        Ok(()) => conn.execute_batch("COMMIT"),
19280        Err(err) => {
19281            let _ = conn.execute_batch("ROLLBACK");
19282            Err(err)
19283        }
19284    }
19285}
19286
19287/// EXP-S (0.8.14 Slice 5) — the `row_kind -> index-target set` dispatch
19288/// (ADR-0.8.14 §D2), and the OPP-12 forward-compat seam (ADR-0.8.14 §D5(a) /
19289/// ledger `TC-1`).
19290///
19291/// This is deliberately a per-kind LOOKUP rather than branching inlined at each
19292/// write call-site: it is the single seam a later declarative OPP-12 projection
19293/// registry (`dev/design/projection-registry-and-async-embed.md`) would wrap to
19294/// populate `row_kind -> {filterable, searchable->FTS (same-txn), searchable->
19295/// vector (async)}` without reshaping the substrate. Per D5, EXP-S implements
19296/// NO OPP-12 surface here (OPP-12 lands >=0.9.x; re-check at its scheduling) —
19297/// this function only records the index-target intent so the async-vs-sync split
19298/// (D5(b)) and the per-kind-extensible terminal-cursor readiness (D5(c)) stay
19299/// wrappable.
19300///
19301/// `Leaf` MUST preserve today's behavior exactly: FTS (sync) + vector (async,
19302/// gated by `kind_is_vector_indexed`).
19303fn index_targets_for_row_kind(row_kind: RowKind) -> IndexTargetSet {
19304    match row_kind {
19305        // Normal record — identical to pre-EXP-S behavior.
19306        RowKind::Leaf => IndexTargetSet { fts: true, vector: true },
19307        // Coverage/summary rows — searchable and embeddable.
19308        RowKind::Coverage => IndexTargetSet { fts: true, vector: true },
19309        // Graph structural rows — lexically searchable, not embedded.
19310        RowKind::Graph => IndexTargetSet { fts: true, vector: false },
19311    }
19312}
19313
19314/// EXP-S (0.8.14 Slice 5, D2/D5) — apply the per-`row_kind` index-target
19315/// dispatch for one just-inserted canonical node row (write_cursor `cursor`).
19316///
19317/// Preserves the OPP-12-shaped split (D5(b)): FTS is written in THIS
19318/// transaction (same-txn `searchable->FTS`); vector work is only *enqueued*
19319/// here into `_fathomdb_projection_state` and embedded later, asynchronously,
19320/// by the projection worker pool (`searchable->vector`). When the row projects
19321/// into no async vector index, its readiness is terminated up-front (D5(c),
19322/// per-kind-extensible) so `advance_projection_cursor` can walk past it.
19323///
19324/// Returns `true` iff async vector work was enqueued (the caller must then
19325/// `notify_new_work`). For `RowKind::Leaf` this is behavior-identical to the
19326/// pre-EXP-S inline node path.
19327fn project_canonical_node_row(
19328    tx: &Connection,
19329    cursor: u64,
19330    kind: &str,
19331    body: &str,
19332    row_kind: RowKind,
19333    pass: ProjectionPass,
19334    node_active: bool,
19335) -> rusqlite::Result<bool> {
19336    let targets = index_targets_for_row_kind(row_kind);
19337    if targets.fts && pass.writes_fts() {
19338        tx.execute(
19339            "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
19340            params![body, kind, cursor],
19341        )?;
19342        // F5 (0.8.14 Slice 10) — same coexisting `searchable->FTS` target also
19343        // populates the multi-column `search_index_v2` (kind/body/status) so a
19344        // BM25F query can field-weight the lexical arm. Written SYNCHRONOUSLY in
19345        // THIS transaction, exactly like `search_index` (rowid==write_cursor
19346        // identity preserved). The `status` field mirrors the migration-17
19347        // O(N) re-index: `$.status` from a JSON body, guarded by `json_valid` so
19348        // non-JSON bodies index an empty status. NOTE (codex fix-1 finding 2):
19349        // this is F5's OWN `$.status`-derived field for the BM25F `status`
19350        // column — it is NOT (yet) the value the shipped G10 SearchFilter reads.
19351        // G10 filtering reads the vec0 `status` column, which is still hardwired
19352        // to the empty-string sentinel; wiring G10 onto this field is out of
19353        // scope for F5. Determinism (R-SUB-2) is preserved: the derivation is
19354        // a pure function of `body`, evaluated in-SQL identically on every run.
19355        tx.execute(
19356            "INSERT INTO search_index_v2(kind, body, status, write_cursor)
19357             VALUES(
19358                 ?1,
19359                 ?2,
19360                 CASE WHEN json_valid(?2)
19361                      THEN COALESCE(json_extract(?2, '$.status'), '')
19362                      ELSE '' END,
19363                 ?3
19364             )",
19365            params![kind, body, cursor],
19366        )?;
19367    }
19368    // 0.8.20 Slice 15d (R-20-EAV) — same-transaction attribute projection. Only
19369    // the full `Write` pass re-derives attributes (see `writes_attributes`): the
19370    // FtsOnly tokenizer reproject predates step 24 and must not touch the
19371    // registry/attribute tables; VectorOnly rebuilds only vector shadows. A full
19372    // operator FTS rebuild uses `Write`, so it re-derives attributes after the
19373    // truncate.
19374    //
19375    // fix-2 [P2]: gated on `node_active`. The at-rest attribute projection tracks
19376    // EXACTLY the backfill's row set — `state = 'active' AND superseded_at IS NULL`
19377    // (see `backfill_attribute`). Unlike node-FTS / vector shadows (whose stale
19378    // versions are excluded by the canonical read path's `superseded_at IS NULL`
19379    // / `state = 'active'` join), the property tables carry NO read-side lifecycle
19380    // filter (`property_search_index` is an FTS5 table that cannot), so a pending
19381    // or superseded node's attribute values would otherwise LEAK into a
19382    // same-session property filter / property-FTS. The write path passes
19383    // `state == Active`; a projector-replay rebuild passes `active ∧ non-superseded`
19384    // per row. Lifecycle transitions maintain the store directly (see
19385    // `Engine::transition`). Passes where `writes_attributes()` is false ignore the
19386    // flag entirely.
19387    if pass.writes_attributes() && node_active {
19388        project_node_attributes(tx, cursor as i64, body)?;
19389    }
19390    // 0.8.20 Slice 20c (R-20-DR remainder) — UNCHANGED, deliberately. Late
19391    // enrolment of a kind first written AFTER a `searchable→vector` declaration
19392    // happens in [`Engine::enrol_vector_kind_if_declared`], upstream of this
19393    // transaction, NOT here: the decision needs the engine's usable-runtime
19394    // predicate, which a free function holding only a `Connection` cannot see.
19395    // Enrolling without one would queue embeds that cannot safely run.
19396    let enqueue_vector = targets.vector && kind_is_vector_indexed(tx, kind).unwrap_or(false);
19397    if pass.writes_vector_state() {
19398        if enqueue_vector {
19399            tx.execute(
19400                "INSERT INTO _fathomdb_projection_state(kind, last_enqueued_cursor, updated_at)
19401                 VALUES(?1, ?2, 0)
19402                 ON CONFLICT(kind) DO UPDATE SET last_enqueued_cursor = excluded.last_enqueued_cursor",
19403                params![kind, cursor],
19404            )?;
19405        } else {
19406            // Never-vector-projected rows terminate the cursor up-front so
19407            // `advance_projection_cursor` can advance the readiness watermark.
19408            record_projection_terminal(tx, cursor, "up_to_date")?;
19409        }
19410    }
19411    Ok(enqueue_vector)
19412}
19413
19414/// 0.8.20 Slice 5a (R-20-E1, work item 1) — the EDGE half of the total
19415/// projector, extracted verbatim from the inlined `commit_batch` edge arm.
19416///
19417/// Before this extraction there was NO edge projector function: `commit_batch`
19418/// inlined the edge FTS insert + the edge vector enqueue, and
19419/// `rebuild_shadow_state` re-implemented a SUBSET of it (edge FTS only, and only
19420/// for body-carrying edges), so a projector-replay rebuild silently dropped the
19421/// rest — notably the `up_to_date` readiness terminal that the write path
19422/// records for a body-less structural edge. With both sites now calling this one
19423/// function, the write path and the rebuild path produce identical edge
19424/// projections by construction.
19425///
19426/// Mirrors [`project_canonical_node_row`]'s split (ADR-0.8.14 §D5(b)): FTS in
19427/// THIS transaction; vector work only ENQUEUED, embedded later by the worker
19428/// pool. Edge bodies enqueue under the fixed kind `"edge_fact"` so
19429/// `resolve_source_type` maps them to `source_type = "edge_fact"` in
19430/// `vector_default` (partition correctness); that kind is auto-registered in
19431/// `_fathomdb_vector_kinds` (idempotent).
19432///
19433/// Returns `true` iff async vector work was enqueued.
19434fn project_canonical_edge_row(
19435    tx: &Connection,
19436    cursor: u64,
19437    kind: &str,
19438    body: Option<&str>,
19439    pass: ProjectionPass,
19440) -> rusqlite::Result<bool> {
19441    // G11 — edge FTS projection into `search_index_edges` (separate table from
19442    // node-body `search_index` — Option B partition). Body-less structural
19443    // edges carry no lexical content and project no FTS row.
19444    if pass.writes_fts() {
19445        if let Some(edge_body) = body {
19446            tx.execute(
19447                "INSERT INTO search_index_edges(body, kind, write_cursor)
19448                 VALUES(?1, ?2, ?3)",
19449                params![edge_body, kind, cursor],
19450            )?;
19451        }
19452    }
19453    let enqueue_vector = body.is_some();
19454    if pass.writes_vector_state() {
19455        if enqueue_vector {
19456            let now_unix =
19457                SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
19458            tx.execute(
19459                "INSERT OR IGNORE INTO _fathomdb_vector_kinds(kind, profile, created_at)
19460                 VALUES('edge_fact', 'default', ?1)",
19461                params![now_unix],
19462            )?;
19463            tx.execute(
19464                "INSERT INTO _fathomdb_projection_state(
19465                     kind, last_enqueued_cursor, updated_at
19466                 ) VALUES('edge_fact', ?1, 0)
19467                 ON CONFLICT(kind) DO UPDATE
19468                     SET last_enqueued_cursor = excluded.last_enqueued_cursor",
19469                params![cursor],
19470            )?;
19471            // Do NOT call record_projection_terminal — let the scheduler embed
19472            // the body and mark it terminal after projection.
19473        } else {
19474            record_projection_terminal(tx, cursor, "up_to_date")?;
19475        }
19476    }
19477    Ok(enqueue_vector)
19478}
19479
19480/// F5 (0.8.14 Slice 10, fix-1) — tokenizer for the in-engine BM25F scorer.
19481///
19482/// Tokenizes `text` through the SAME FTS5 tokenizer that `search_index_v2` uses
19483/// for candidate recall (`porter unicode61 remove_diacritics 2`), so the scorer
19484/// measures term-frequency, document-frequency, field length, and average field
19485/// length under the index's own tokenization — porter stemming + unicode61
19486/// case-fold + diacritic folding. The previous implementation hand-rolled a
19487/// second lowercase-alnum splitter; a stemmed/diacritic variant recalled by
19488/// `MATCH` (e.g. query `run` vs indexed `running`, or `cafe` vs `café`) was then
19489/// scored as if the term were absent, so ranking was wrong for exactly those
19490/// variants (codex §9 fix-1 finding 1). Reusing FTS5 itself makes scoring
19491/// tokenization-faithful without re-implementing porter/unicode61 in Rust.
19492///
19493/// Mechanism: round-trip `text` through a temp single-column FTS5 table with the
19494/// identical tokenizer, then read the emitted token instances back via the
19495/// `fts5vocab(..., 'instance')` companion. The token multiset is returned in
19496/// index order (duplicates kept) so callers count tf and field length directly.
19497/// Query terms and every candidate field go through this one path, so all four
19498/// statistics are consistent with each other and with the FTS5 index the scorer
19499/// ranks.
19500fn fts5_tokenize(connection: &Connection, text: &str) -> rusqlite::Result<Vec<String>> {
19501    connection.execute_batch(
19502        "CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok
19503             USING fts5(t, tokenize = 'porter unicode61 remove_diacritics 2');
19504         CREATE VIRTUAL TABLE IF NOT EXISTS temp.bm25f_tok_vocab
19505             USING fts5vocab('bm25f_tok', 'instance');
19506         DELETE FROM temp.bm25f_tok;",
19507    )?;
19508    connection.execute("INSERT INTO temp.bm25f_tok(t) VALUES(?1)", params![text])?;
19509    let mut stmt =
19510        connection.prepare("SELECT term FROM temp.bm25f_tok_vocab ORDER BY \"offset\"")?;
19511    let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
19512    rows.collect()
19513}
19514
19515/// F5 (0.8.14 Slice 10) — build the FTS5 `MATCH` expression for candidate
19516/// recall from the query's tokens: each token is a double-quoted FTS5 string
19517/// (tokens are FTS5-emitted stems — unicode61 alnum, no embedded quotes),
19518/// OR-joined.
19519fn bm25f_match_expression(terms: &[String]) -> String {
19520    terms.iter().map(|t| format!("\"{t}\"")).collect::<Vec<_>>().join(" OR ")
19521}
19522
19523/// F5 (0.8.14 Slice 10) — the BM25F score for one candidate document.
19524///
19525/// Standard BM25F: per query term, accumulate a length-normalized,
19526/// field-weighted pseudo term-frequency across the fields, then apply the BM25
19527/// saturation once. `norm_f = 1 - b + b*(len_f/avglen_f)` is the per-field
19528/// length normalization (this is where tunable `b` bites); `weight_f` is the
19529/// field boost (this is where the R-F5-1 field weighting bites).
19530fn bm25f_score_doc(
19531    plan: &Bm25fQueryPlan,
19532    query_terms: &[String],
19533    // (weight, doc field length, corpus avg field length, per-term tf in field)
19534    fields: &[(f64, f64, f64, &HashMap<String, u32>)],
19535    doc_count: usize,
19536    df: &HashMap<String, usize>,
19537) -> f64 {
19538    let mut score = 0.0_f64;
19539    for term in query_terms {
19540        let mut weighted_tf = 0.0_f64;
19541        for (weight, len_f, avglen_f, tf_map) in fields {
19542            if *weight == 0.0 || *avglen_f <= 0.0 {
19543                continue;
19544            }
19545            let tf = *tf_map.get(term).unwrap_or(&0) as f64;
19546            if tf == 0.0 {
19547                continue;
19548            }
19549            let norm = 1.0 - plan.b + plan.b * (len_f / avglen_f);
19550            if norm <= 0.0 {
19551                continue;
19552            }
19553            weighted_tf += weight * tf / norm;
19554        }
19555        if weighted_tf <= 0.0 {
19556            continue;
19557        }
19558        let dfq = *df.get(term).unwrap_or(&0);
19559        if dfq == 0 {
19560            continue;
19561        }
19562        let n = doc_count as f64;
19563        let idf = ((n - dfq as f64 + 0.5) / (dfq as f64 + 0.5) + 1.0).ln();
19564        score += idf * (weighted_tf * (plan.k1 + 1.0)) / (plan.k1 + weighted_tf);
19565    }
19566    score
19567}
19568
19569/// F5 (0.8.14 Slice 10) — connection-level implementation of the BM25F lexical
19570/// arm. See [`Engine::bm25f_search`].
19571fn bm25f_search_inner(
19572    connection: &Connection,
19573    query: &str,
19574    plan: &Bm25fQueryPlan,
19575) -> rusqlite::Result<Vec<(u64, f64)>> {
19576    let query_terms: Vec<String> = {
19577        let mut seen = BTreeSet::new();
19578        fts5_tokenize(connection, query)?.into_iter().filter(|t| seen.insert(t.clone())).collect()
19579    };
19580    if query_terms.is_empty() {
19581        return Ok(Vec::new());
19582    }
19583
19584    // Corpus pass over ACTIVE rows (superseded versions excluded): accumulate
19585    // N, total field length per field (for avg field length), and per-term
19586    // document frequency — all under the SAME FTS5 tokenization the index uses.
19587    let mut doc_count: usize = 0;
19588    let mut total_len = [0.0_f64; 3]; // kind, body, status
19589    let mut df: HashMap<String, usize> = HashMap::new();
19590    {
19591        let mut stmt = connection.prepare(
19592            "SELECT v.kind, v.body, v.status
19593             FROM search_index_v2 v
19594             JOIN canonical_nodes cn ON cn.write_cursor = v.write_cursor
19595             WHERE cn.superseded_at IS NULL AND cn.state = 'active'",
19596        )?;
19597        let mut rows = stmt.query([])?;
19598        while let Some(row) = rows.next()? {
19599            let fields =
19600                [row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?];
19601            doc_count += 1;
19602            let mut present: BTreeSet<String> = BTreeSet::new();
19603            for (i, field) in fields.iter().enumerate() {
19604                let toks = fts5_tokenize(connection, field)?;
19605                total_len[i] += toks.len() as f64;
19606                for tok in toks {
19607                    if query_terms.contains(&tok) {
19608                        present.insert(tok);
19609                    }
19610                }
19611            }
19612            for term in present {
19613                *df.entry(term).or_insert(0) += 1;
19614            }
19615        }
19616    }
19617    if doc_count == 0 {
19618        return Ok(Vec::new());
19619    }
19620    let avglen = [
19621        total_len[0] / doc_count as f64,
19622        total_len[1] / doc_count as f64,
19623        total_len[2] / doc_count as f64,
19624    ];
19625
19626    // Active write_cursor set, to filter FTS5 MATCH candidates (search_index_v2
19627    // retains superseded versions, exactly like search_index).
19628    let active: BTreeSet<i64> = {
19629        let mut stmt = connection
19630            .prepare("SELECT write_cursor FROM canonical_nodes WHERE superseded_at IS NULL AND state = 'active'")?;
19631        let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
19632        rows.collect::<rusqlite::Result<BTreeSet<i64>>>()?
19633    };
19634
19635    // Candidate recall through the FTS5 index (this is what makes the v2 index
19636    // load-bearing), then score each candidate with the in-engine BM25F.
19637    let match_expr = bm25f_match_expression(&query_terms);
19638    let mut scored: Vec<(u64, f64)> = Vec::new();
19639    {
19640        let mut stmt = connection.prepare(
19641            "SELECT write_cursor, kind, body, status
19642             FROM search_index_v2
19643             WHERE search_index_v2 MATCH ?1",
19644        )?;
19645        let mut rows = stmt.query([match_expr.as_str()])?;
19646        while let Some(row) = rows.next()? {
19647            let wc = row.get::<_, i64>(0)?;
19648            if !active.contains(&wc) {
19649                continue;
19650            }
19651            let kind = row.get::<_, String>(1)?;
19652            let body = row.get::<_, String>(2)?;
19653            let status = row.get::<_, String>(3)?;
19654
19655            let mut tf_kind: HashMap<String, u32> = HashMap::new();
19656            let mut len_kind = 0.0_f64;
19657            for tok in fts5_tokenize(connection, &kind)? {
19658                len_kind += 1.0;
19659                *tf_kind.entry(tok).or_insert(0) += 1;
19660            }
19661            let mut tf_body: HashMap<String, u32> = HashMap::new();
19662            let mut len_body = 0.0_f64;
19663            for tok in fts5_tokenize(connection, &body)? {
19664                len_body += 1.0;
19665                *tf_body.entry(tok).or_insert(0) += 1;
19666            }
19667            let mut tf_status: HashMap<String, u32> = HashMap::new();
19668            let mut len_status = 0.0_f64;
19669            for tok in fts5_tokenize(connection, &status)? {
19670                len_status += 1.0;
19671                *tf_status.entry(tok).or_insert(0) += 1;
19672            }
19673
19674            let fields = [
19675                (plan.weights.kind, len_kind, avglen[0], &tf_kind),
19676                (plan.weights.body, len_body, avglen[1], &tf_body),
19677                (plan.weights.status, len_status, avglen[2], &tf_status),
19678            ];
19679            let score = bm25f_score_doc(plan, &query_terms, &fields, doc_count, &df);
19680            scored.push((wc as u64, score));
19681        }
19682    }
19683
19684    // Descending score; write_cursor ascending as the deterministic tiebreak.
19685    scored.sort_by(|a, b| {
19686        b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))
19687    });
19688    Ok(scored)
19689}
19690
19691fn commit_batch(
19692    connection: &mut Connection,
19693    batch: &[PreparedWrite],
19694    plans: &[WritePlan],
19695    base_cursor: u64,
19696    provenance_row_cap: u64,
19697) -> rusqlite::Result<u64> {
19698    // 0.8.20 Slice 21a-2 (TC-57) — `BEGIN IMMEDIATE`, not rusqlite's `BEGIN
19699    // DEFERRED` default. Take the WAL write lock AT `BEGIN`, before the
19700    // supersession SELECT below, so this transaction never has to PROMOTE a read
19701    // lock to a write lock.
19702    //
19703    // The defect this closes (characterized in
19704    // `dev/design/0.8.20-tc57-write-race-characterization.md`, repro 10/10 at
19705    // baseline `41a81c17`): for a GOVERNED write (`logical_id: Some`) the first
19706    // statement in this transaction is a read —
19707    // `prior_node_cursors_by_logical_id` — and the second is the supersession
19708    // UPDATE. When the async projection worker holds the write lock on its own
19709    // connection at that instant, SQLite refuses the promotion with plain
19710    // `SQLITE_BUSY` (5) and SKIPS the busy handler entirely, for deadlock
19711    // avoidance (`sqlite3_busy_handler`: "if SQLite determines that invoking the
19712    // busy handler could result in a deadlock, it will go ahead and return
19713    // SQLITE_BUSY"). MEASURED: handler invoked ZERO times, error returned in 0 ms
19714    // against rusqlite's 5 000 ms default timeout. So NO `busy_timeout` value
19715    // could ever have absorbed it, and the caller saw an opaque, un-retryable
19716    // `EngineError::Storage` mid-ingest. The same shape also has a second,
19717    // narrower exit — `SQLITE_BUSY_SNAPSHOT` (517) when the WAL advances past the
19718    // read snapshot — which this closes too, by construction.
19719    //
19720    // UNCONDITIONAL rather than gated on `logical_id`, deliberately: an anonymous
19721    // batch's first statement is already the INSERT below, so it takes the write
19722    // lock essentially immediately anyway and the delta is microseconds, whereas a
19723    // content-dependent transaction behaviour would be a NEW correctness surface
19724    // (mixed batches, edge arms, future write kinds) with a place to be wrong in
19725    // each. MEASURED cost on the anonymous arm: none detectable
19726    // (`tc57_worker_commit_pressure.rs`).
19727    //
19728    // `BEGIN IMMEDIATE` can itself return `SQLITE_BUSY` — but WITH the busy
19729    // handler consulted, i.e. absorbed by the existing 5 s default instead of
19730    // surfaced (pinned by `tc57_mechanism_control_write_first_is_retryable`).
19731    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
19732
19733    for (i, (write, plan)) in batch.iter().zip(plans).enumerate() {
19734        // Per-row cursor: row i gets `base_cursor + i + 1`. See the
19735        // comment in `Engine::write_inner`.
19736        let cursor = base_cursor.saturating_add((i as u64).saturating_add(1));
19737        match (write, plan) {
19738            (
19739                PreparedWrite::Node {
19740                    kind,
19741                    body,
19742                    source_id,
19743                    logical_id,
19744                    state,
19745                    reason,
19746                    valid_from,
19747                    valid_until,
19748                },
19749                WritePlan::Node,
19750            ) => {
19751                // G0 — supersession is tombstone-then-insert in this same txn:
19752                // mark the prior active version superseded BEFORE inserting the
19753                // new active row, so the partial-unique-active index never sees
19754                // two active rows for one logical_id. Scoped to logical_id ALONE
19755                // (Decision 5, HITL-SIGNED 2026-06-05): a kind-change re-ingest of
19756                // the same logical_id SUPERSEDES, never forks. No-op when logical_id
19757                // is None (legacy/own-identity insert, behavior-identical to 0.7.x).
19758                if let Some(logical_id) = logical_id {
19759                    // fix-1 finding 2 [P2]: collect the prior active cursor(s)
19760                    // BEFORE tombstoning so we can purge the superseded row's
19761                    // row-owned attribute projections and keep the at-rest EAV /
19762                    // property-FTS store ACTIVE-ONLY. Without this, a same-session
19763                    // property filter / property-FTS saw BOTH the stale and the
19764                    // current value until a boot re-derive/reconfigure cleared the
19765                    // table — a stale read that violates the active-only invariant.
19766                    let prior_g0 = prior_node_cursors_by_logical_id(&tx, logical_id)?;
19767                    tx.execute(
19768                        "UPDATE canonical_nodes SET superseded_at = ?1
19769                         WHERE logical_id = ?2 AND superseded_at IS NULL",
19770                        params![cursor, logical_id],
19771                    )?;
19772                    // Purge only the Attribute + PropertyFts classes: those tables
19773                    // have NO `superseded_at IS NULL` read-side filter (the FTS5
19774                    // `property_search_index` cannot carry one), so their stale rows
19775                    // MUST be deleted at rest. The NodeFts (`search_index` /
19776                    // `search_index_v2`) + Vector shadows are left intact — the node
19777                    // read path already excludes their superseded rows via the
19778                    // `canonical_nodes WHERE superseded_at IS NULL` join, so purging
19779                    // them here would be a behaviour change outside this fix's scope.
19780                    for sc in &prior_g0 {
19781                        purge_row_projections_for_cursor_in(
19782                            &tx,
19783                            *sc,
19784                            &[ProjectionClass::Attribute, ProjectionClass::PropertyFts],
19785                        )?;
19786                    }
19787                }
19788                // EXP-S (0.8.14 Slice 5, D1) — a `PreparedWrite::Node` is the
19789                // `leaf` structural row_kind (a normal record). coverage/graph
19790                // rows are written via internal paths (row_kind is a SEPARATE
19791                // axis from the doc-type `kind`, and there is no public SDK
19792                // surface for it this release). Writing `leaf` explicitly is
19793                // value-identical to the column DEFAULT.
19794                // OPP-12 Phase-1 (0.8.19 Slice 5) — persist the create-time
19795                // existence state + advisory reason. `InitialState::Active`
19796                // (the default) writes `state = 'active'`, value-identical to the
19797                // migration step-20 column DEFAULT; `Pending` quarantines the node
19798                // out of default retrieval (the `state = 'active'` read exclusion).
19799                // 0.8.20 Slice 15b (TC-34) — persist the world-time validity
19800                // window. A `None` binds SQL NULL, which is what the migration
19801                // step-22 columns already hold for every pre-existing row and what
19802                // `ReadView::validity_sql` reads as UNBOUNDED on that side. So a
19803                // write that omits the window is byte-identical on disk to a
19804                // pre-slice write, and default-view visibility cannot drift.
19805                tx.execute(
19806                    "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id, logical_id, row_kind, state, reason, valid_from, valid_until)
19807                     VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
19808                    params![cursor, kind, body, source_id.as_str(), logical_id, RowKind::Leaf.as_str(), state.as_str(), reason, valid_from, valid_until],
19809                )?;
19810                // EXP-S (D2/D5) — per-row_kind index-target dispatch. For `leaf`
19811                // this is behavior-identical to the pre-EXP-S inline path: FTS
19812                // (sync, in-tx) + vector (async, gated by kind_is_vector_indexed);
19813                // else the cursor is terminated up-front.
19814                // fix-2 [P2]: gate the attribute projection on the create-time
19815                // state. A fresh insert is always non-superseded, so the backfill
19816                // predicate (`state = 'active' AND superseded_at IS NULL`) reduces
19817                // to `state == Active` here. A `Pending` node is quarantined out of
19818                // the canonical read model — its declared attributes must NOT reach
19819                // the property store until a `transition(pending → active)` promotes
19820                // it (which projects them then). Node-FTS / vector shadows are left
19821                // to their read-side lifecycle filter, exactly as for supersession.
19822                project_canonical_node_row(
19823                    &tx,
19824                    cursor,
19825                    kind,
19826                    body,
19827                    RowKind::Leaf,
19828                    ProjectionPass::Write,
19829                    matches!(state, InitialState::Active),
19830                )?;
19831            }
19832            (
19833                PreparedWrite::Edge {
19834                    kind,
19835                    from,
19836                    to,
19837                    source_id,
19838                    logical_id,
19839                    body,
19840                    t_valid,
19841                    t_invalid,
19842                    confidence,
19843                    extractor_model_id,
19844                    temporal_fallback,
19845                },
19846                WritePlan::Edge,
19847            ) => {
19848                // G0 — identical tombstone-then-insert supersession on edges,
19849                // keyed by logical_id ALONE (Decision 5, HITL-SIGNED 2026-06-05;
19850                // edge `kind` is relationship-type, not identity — a kind-change
19851                // re-ingest of the same edge logical_id SUPERSEDES, never forks).
19852                // No-op when logical_id is None.
19853                if let Some(logical_id) = logical_id {
19854                    // fix-30 [P2]: collect prior active cursors BEFORE tombstoning
19855                    // so stale vector_default rows can be pruned.
19856                    let prior_g0 = prior_edge_cursors_by_logical_id(&tx, logical_id)?;
19857                    tx.execute(
19858                        "UPDATE canonical_edges SET superseded_at = ?1
19859                         WHERE logical_id = ?2 AND superseded_at IS NULL",
19860                        params![cursor, logical_id],
19861                    )?;
19862                    for sc in &prior_g0 {
19863                        delete_vector_partition_row(&tx, *sc)?;
19864                        tx.execute(
19865                            "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
19866                            [sc],
19867                        )?;
19868                        // fix-32 [P2]: record terminal so advance_projection_cursor
19869                        // can walk past this now-superseded cursor.
19870                        // TC-45: the token MUST be 'up_to_date', NOT 'superseded'.
19871                        // The terminal table (schema step 7) carries
19872                        // CHECK(state IN ('failed','up_to_date')) and the writer is
19873                        // INSERT OR IGNORE, which SILENTLY SKIPS a CHECK-violating
19874                        // row — so 'superseded' was dropped without error and this
19875                        // cursor stalled forever (nothing backfills it: the job
19876                        // query and the pending-work probe both exclude superseded
19877                        // edges). 'up_to_date' is the CHECK-valid, non-'failed'
19878                        // terminal and is semantically exact here: the row is
19879                        // tombstoned and its vector shadow just deleted, so there is
19880                        // no further projection work for this cursor. Same reasoning
19881                        // and same token as the step-23 backfill (fix-4, TC-33).
19882                        record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19883                    }
19884                }
19885                // G11 — invalidate-not-accumulate: for fact-edges (body IS NOT NULL),
19886                // tombstone any prior active edge on the same (from_id, to_id, kind)
19887                // BEFORE inserting the new row. This is DIFFERENT from the G0
19888                // logical_id tombstone: it is keyed on the triple, not the identity.
19889                // Regular edges (body=None) skip this path — they retain G0 semantics.
19890                if body.is_some() {
19891                    // fix-30 [P2]: collect and prune vector shadow for the superseded edge.
19892                    let prior_g11 = prior_edge_cursors_by_triple(&tx, from, to, kind)?;
19893                    tx.execute(
19894                        "UPDATE canonical_edges SET superseded_at = ?1
19895                         WHERE from_id = ?2 AND to_id = ?3 AND kind = ?4 AND superseded_at IS NULL",
19896                        params![cursor, from, to, kind],
19897                    )?;
19898                    for sc in &prior_g11 {
19899                        delete_vector_partition_row(&tx, *sc)?;
19900                        tx.execute(
19901                            "DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1",
19902                            [sc],
19903                        )?;
19904                        // fix-32 [P2]: mark terminal so projection cursor can advance.
19905                        // TC-45: 'up_to_date', NOT 'superseded' — see the identical
19906                        // note on the G0 prune loop above. The step-7 CHECK admits
19907                        // only ('failed','up_to_date') and INSERT OR IGNORE swallows
19908                        // a violating row, so 'superseded' never landed and wedged
19909                        // the shared readiness watermark.
19910                        record_projection_terminal(&tx, *sc as u64, "up_to_date")?;
19911                    }
19912                }
19913                let temporal_fallback_i: Option<i64> =
19914                    temporal_fallback.and_then(|f| if f { Some(1) } else { None });
19915                tx.execute(
19916                    "INSERT INTO canonical_edges(
19917                         write_cursor, kind, from_id, to_id, source_id, logical_id,
19918                         body, t_valid, t_invalid, confidence, extractor_model_id,
19919                         temporal_fallback
19920                     ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
19921                    params![
19922                        cursor,
19923                        kind,
19924                        from,
19925                        to,
19926                        source_id.as_str(),
19927                        logical_id,
19928                        body,
19929                        t_valid,
19930                        t_invalid,
19931                        confidence,
19932                        extractor_model_id,
19933                        temporal_fallback_i
19934                    ],
19935                )?;
19936                // 0.8.20 Slice 5a (R-20-E1, work item 1) — edge projection is no
19937                // longer inlined here: the write path and the rebuild replay
19938                // share ONE projector, so they cannot drift.
19939                project_canonical_edge_row(
19940                    &tx,
19941                    cursor,
19942                    kind,
19943                    body.as_deref(),
19944                    ProjectionPass::Write,
19945                )?;
19946            }
19947            (
19948                PreparedWrite::AdminSchema { name, kind, schema_json, retention_json },
19949                WritePlan::AdminSchema,
19950            ) => {
19951                tx.execute(
19952                    "INSERT INTO operational_collections(
19953                        name, kind, schema_json, retention_json, format_version, created_at
19954                     ) VALUES(?1, ?2, ?3, ?4, 1, 0)
19955                     ON CONFLICT(name) DO UPDATE SET
19956                        schema_json = excluded.schema_json,
19957                        retention_json = excluded.retention_json",
19958                    params![name, kind, schema_json, retention_json],
19959                )?;
19960                record_projection_terminal(&tx, cursor, "up_to_date")?;
19961            }
19962            (
19963                PreparedWrite::OpStore { collection, record_key, schema_id, body },
19964                WritePlan::AppendOnlyLog,
19965            ) => {
19966                tx.execute(
19967                    "INSERT INTO operational_mutations(
19968                        collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
19969                     ) VALUES(?1, ?2, 'append', ?3, ?4, ?5)",
19970                    params![collection, record_key, body, schema_id, cursor],
19971                )?;
19972                record_projection_terminal(&tx, cursor, "up_to_date")?;
19973            }
19974            (
19975                PreparedWrite::OpStore { collection, record_key, schema_id, body },
19976                WritePlan::LatestState,
19977            ) => {
19978                tx.execute(
19979                    "INSERT INTO operational_state(
19980                        collection_name, record_key, payload_json, schema_id, write_cursor
19981                     ) VALUES(?1, ?2, ?3, ?4, ?5)
19982                     ON CONFLICT(collection_name, record_key) DO UPDATE SET
19983                        payload_json = excluded.payload_json,
19984                        schema_id = excluded.schema_id,
19985                        write_cursor = excluded.write_cursor",
19986                    params![collection, record_key, body, schema_id, cursor],
19987                )?;
19988                record_projection_terminal(&tx, cursor, "up_to_date")?;
19989            }
19990            _ => return Err(rusqlite::Error::InvalidQuery),
19991        }
19992    }
19993
19994    // G8 (Slice 20 / F10) — cross-row dangling-edge flag-and-count. This runs
19995    // AFTER the batch loop (so every same-batch node is already on disk in `tx`
19996    // and a same-batch later-inserted endpoint is visible) and BEFORE retention /
19997    // projection-cursor / commit. It is the cross-row reason this lives here and
19998    // not in single-row pre-insert `validate_write`. Default is FLAG-AND-COUNT:
19999    // we only COUNT, never roll back (strict-mode rollback is deferred to
20000    // reserved-gap band 22 — adding a write-options surface is out of scope).
20001    //
20002    // Probe is `logical_id`-alone against the step-12 partial index
20003    // `canonical_nodes_logical_active_idx ON canonical_nodes(logical_id)
20004    // WHERE superseded_at IS NULL` (its leading column + partial predicate), so
20005    // it SEARCHes the index with no SCAN (see `tests/pr_g8_dangling_edges.rs`
20006    // case (f)). There is no node-kind to match: `canonical_edges` stores only
20007    // the edge's own kind, not the endpoint node's kind.
20008    let dangling_edge_endpoints = {
20009        // O(N) pre-pass: record, per `logical_id`, the LAST (highest) index at
20010        // which an `Edge { logical_id: Some(_), .. }` with that id appears. Keyed
20011        // by `logical_id` ALONE (Decision 5, HITL-SIGNED 2026-06-05) to match the
20012        // supersession UPDATE, which keys by logical_id alone: a kind-change
20013        // re-ingest of the same edge logical_id SUPERSEDES the earlier one.
20014        // Iterating front-to-back and overwriting means the stored value ends up
20015        // as the final index for each id. An edge at index `i` with that id is
20016        // then in-batch-superseded iff `last_index[lid] > i`. This is
20017        // behavior-identical to the prior per-edge `batch[i+1..]` `.any(..)` scan
20018        // (which was O(N²) under the single-writer txn) — same skip-set, same count.
20019        let mut last_index: HashMap<&str, usize> = HashMap::new();
20020        for (i, write) in batch.iter().enumerate() {
20021            if let PreparedWrite::Edge { logical_id: Some(lid), .. } = write {
20022                last_index.insert(lid.as_str(), i);
20023            }
20024        }
20025
20026        let mut probe = tx.prepare(
20027            "SELECT 1 FROM canonical_nodes WHERE logical_id = ?1 AND superseded_at IS NULL LIMIT 1",
20028        )?;
20029        let mut count: u64 = 0;
20030        for (i, write) in batch.iter().enumerate() {
20031            if let PreparedWrite::Edge { from, to, logical_id, .. } = write {
20032                // Honor `edge.superseded_at IS NULL`: an edge inserted in this
20033                // batch is active unless a LATER same-batch edge with the same
20034                // `Some(logical_id)` tombstoned it (the loop's supersession
20035                // UPDATE). Skip such an in-batch-superseded edge. Edges with
20036                // `logical_id: None` are never superseded-in-batch.
20037                if let Some(lid) = logical_id {
20038                    let superseded_in_batch =
20039                        last_index.get(lid.as_str()).is_some_and(|&last| last > i);
20040                    if superseded_in_batch {
20041                        continue;
20042                    }
20043                }
20044                // Probe `from_id` and `to_id` independently (0, 1, or 2 per edge).
20045                for endpoint in [from, to] {
20046                    if !probe.exists(params![endpoint])? {
20047                        count = count.saturating_add(1);
20048                    }
20049                }
20050            }
20051        }
20052        count
20053    };
20054
20055    enforce_provenance_retention(&tx, provenance_row_cap)?;
20056    advance_projection_cursor(&tx)?;
20057
20058    tx.commit()?;
20059    Ok(dangling_edge_endpoints)
20060}
20061
20062fn load_next_cursor(connection: &Connection) -> u64 {
20063    let nodes = max_cursor(connection, "canonical_nodes").unwrap_or(0);
20064    let edges = max_cursor(connection, "canonical_edges").unwrap_or(0);
20065    let mutations = max_cursor(connection, "operational_mutations").unwrap_or(0);
20066    let state = max_cursor(connection, "operational_state").unwrap_or(0);
20067    // TC-33: schema step 23 RECREATES `canonical_edges` (no data migration), so
20068    // the edge rows that used to hold the high-water mark are gone. Without this
20069    // term the allocator can hand out a cursor a PREVIOUS edge already used —
20070    // and stale `_fathomdb_projection_terminal` / `_fathomdb_vector_rows` / vec0
20071    // rows still key on it, so a brand-new row would be treated as
20072    // already-projected and never get indexed. Step 23 stashes the pre-drop
20073    // maximum here; folding it in keeps cursors monotonic across the migration.
20074    let reserved = reserved_write_cursor(connection);
20075    nodes.max(edges).max(mutations).max(state).max(reserved)
20076}
20077
20078/// The write-cursor high-water mark reserved by schema step 23, or 0 when the
20079/// key is absent (fresh DB, or a DB that never had edges). Never fails the
20080/// caller: a missing/unparseable value degrades to 0, which is the pre-TC-33
20081/// behaviour.
20082fn reserved_write_cursor(connection: &Connection) -> u64 {
20083    connection
20084        .query_row(
20085            "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
20086            params![fathomdb_schema::RESERVED_WRITE_CURSOR_KEY],
20087            |row| row.get::<_, String>(0),
20088        )
20089        .ok()
20090        .and_then(|raw| raw.parse::<u64>().ok())
20091        .unwrap_or(0)
20092}
20093
20094fn max_cursor(connection: &Connection, table: &str) -> rusqlite::Result<u64> {
20095    let sql = format!("SELECT COALESCE(MAX(write_cursor), 0) FROM {table}");
20096    connection.query_row(&sql, [], |row| row.get::<_, u64>(0))
20097}
20098
20099/// Map a rusqlite error to its stable SQLite extended-code name.
20100///
20101/// Returns `None` for non-`SqliteFailure` variants (e.g. JSON conversion
20102/// failures, type mismatches at the rusqlite layer) — those are not
20103/// SQLite-internal events and should not be surfaced under
20104/// `EventSource::SqliteInternal`. The names returned here are the
20105/// canonical `SQLITE_*` symbol names from `sqlite3.h` and are stable
20106/// dispatch keys for AC-021 / AC-006 binding adapters.
20107///
20108/// Only the subset of codes the engine can reach in 0.6.0 is enumerated
20109/// — bare-extended-code matching covers the rest with a stable
20110/// `"SQLITE_UNKNOWN"` fallback so subscribers always see a typed code.
20111///
20112/// Diagnostic completeness for unmapped codes — **corrected 0.8.20 Slice 21a-2
20113/// (TC-57)**. This comment used to claim that when the helper returns
20114/// `"SQLITE_UNKNOWN"` the numeric extended code "is not lost — it remains on the
20115/// underlying `rusqlite::Error::SqliteFailure` carried in the engine error chain
20116/// that subscribers can inspect via `EngineError`'s `source()`". **That is
20117/// false.** There is no such chain: `EngineError::Storage` is a UNIT variant with
20118/// no payload and no `source()`, and `write_inner` drops the `rusqlite::Error`
20119/// immediately after emitting the lifecycle event. So for an unmapped code the
20120/// numeric value IS lost, and the only signal a host receives is the string
20121/// `"SQLITE_UNKNOWN"`.
20122///
20123/// Concretely: `SQLITE_BUSY_SNAPSHOT` (517) matches none of the PRIMARY constants
20124/// below — the match is on the EXTENDED value — so it reaches subscribers as
20125/// `"SQLITE_UNKNOWN"` and is unrecoverable from the public API. Restructuring the
20126/// error path so busy codes are distinguishable (and surfacing the numeric code as
20127/// a typed payload field) is candidate R2 of
20128/// `dev/design/0.8.20-tc57-write-race-characterization.md` §7, explicitly OUT of
20129/// scope for the 21a-2 fix and recorded here rather than silently carried.
20130fn sqlite_extended_code_name(err: &rusqlite::Error) -> Option<&'static str> {
20131    let sqlite_error = err.sqlite_error()?;
20132    let extended = sqlite_error.extended_code;
20133    Some(match extended {
20134        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
20135        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
20136        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
20137        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
20138        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
20139        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
20140        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
20141        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
20142        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
20143        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
20144        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
20145        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
20146        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
20147        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
20148        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
20149        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
20150        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
20151        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
20152        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
20153        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
20154        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
20155        _ => "SQLITE_UNKNOWN",
20156    })
20157}
20158
20159fn sqlite_extended_code_name_from_int(extended: i32) -> &'static str {
20160    match extended {
20161        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
20162        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
20163        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
20164        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
20165        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
20166        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
20167        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
20168        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
20169        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
20170        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
20171        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
20172        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
20173        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
20174        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
20175        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
20176        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
20177        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
20178        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
20179        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
20180        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
20181        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
20182        _ => "SQLITE_UNKNOWN",
20183    }
20184}
20185
20186fn map_open_sqlite_error(err: rusqlite::Error, stage: OpenStage) -> EngineOpenError {
20187    let Some(sqlite_error) = err.sqlite_error() else {
20188        return EngineOpenError::Io { message: "could not open database".to_string() };
20189    };
20190    match sqlite_error.extended_code {
20191        rusqlite::ffi::SQLITE_CORRUPT | rusqlite::ffi::SQLITE_NOTADB => {
20192            EngineOpenError::Corruption(CorruptionDetail {
20193                kind: match stage {
20194                    OpenStage::WalReplay => CorruptionKind::WalReplayFailure,
20195                    OpenStage::HeaderProbe => CorruptionKind::HeaderMalformed,
20196                    OpenStage::SchemaProbe => CorruptionKind::SchemaInconsistent,
20197                    OpenStage::EmbedderIdentity => CorruptionKind::EmbedderIdentityDrift,
20198                },
20199                stage,
20200                locator: CorruptionLocator::OpaqueSqliteError {
20201                    sqlite_extended_code: sqlite_error.extended_code,
20202                },
20203                recovery_hint: RecoveryHint {
20204                    code: match stage {
20205                        OpenStage::WalReplay => "E_CORRUPT_WAL_REPLAY",
20206                        OpenStage::HeaderProbe => "E_CORRUPT_HEADER",
20207                        OpenStage::SchemaProbe => "E_CORRUPT_SCHEMA",
20208                        OpenStage::EmbedderIdentity => "E_CORRUPT_EMBEDDER_IDENTITY",
20209                    },
20210                    doc_anchor: match stage {
20211                        OpenStage::WalReplay => "design/recovery.md#wal-replay-failures",
20212                        OpenStage::HeaderProbe => "design/recovery.md#header-malformed",
20213                        OpenStage::SchemaProbe => "design/recovery.md#schema-inconsistent",
20214                        OpenStage::EmbedderIdentity => "design/recovery.md#embedder-identity-drift",
20215                    },
20216                },
20217            })
20218        }
20219        _ => EngineOpenError::Io { message: "could not open database".to_string() },
20220    }
20221}
20222
20223fn emit_open_error_event(subscriber: &Arc<dyn lifecycle::Subscriber>, err: &EngineOpenError) {
20224    if let EngineOpenError::Corruption(detail) = err {
20225        let code = match detail.locator {
20226            CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
20227                Some(sqlite_extended_code_name_from_int(sqlite_extended_code))
20228            }
20229            _ => None,
20230        };
20231        let event = lifecycle::Event {
20232            phase: lifecycle::Phase::Failed,
20233            source: lifecycle::EventSource::SqliteInternal,
20234            category: lifecycle::EventCategory::Corruption,
20235            code,
20236        };
20237        subscriber.on_event(&event);
20238    }
20239}
20240
20241/// Install a `sqlite3_profile` callback on `connection` that dispatches
20242/// per-statement profile records and slow-statement signals to the
20243/// engine's subscriber registry.
20244///
20245/// Why FFI rather than `rusqlite::Connection::profile`: the safe API
20246/// (rusqlite 0.31) accepts only a `fn(&str, Duration)` with no
20247/// environment, so it cannot carry a per-engine subscriber-registry
20248/// pointer. We use `sqlite3_profile` directly with a leaked-into-`Box`
20249/// context whose pointer is tied to the engine's lifetime via
20250/// `Engine::profile_contexts`.
20251///
20252/// `sqlite3_profile` is documented as deprecated in favor of
20253/// `sqlite3_trace_v2`, but it remains supported and is sufficient for
20254/// the wall-clock + SQL-text payload required by AC-005a/b.
20255#[allow(clippy::vec_box)]
20256fn install_profile_callback(
20257    connection: &Connection,
20258    subscribers: &Arc<lifecycle::SubscriberRegistry>,
20259    profiling_enabled: &Arc<AtomicBool>,
20260    slow_threshold_ms: &Arc<AtomicU64>,
20261    contexts: &mut Vec<Box<ProfileContext>>,
20262) {
20263    let mut ctx = Box::new(ProfileContext {
20264        subscribers: Arc::clone(subscribers),
20265        profiling_enabled: Arc::clone(profiling_enabled),
20266        slow_threshold_ms: Arc::clone(slow_threshold_ms),
20267    });
20268    let ctx_ptr: *mut ProfileContext = &mut *ctx;
20269
20270    // SAFETY: the Box outlives the connection. Rust drops struct fields
20271    // in declaration order. `connection` and `reader_pool` are declared
20272    // before `profile_contexts`. `ReaderWorkerPool::Drop` joins every
20273    // reader worker, and each worker uninstalls and drops its owned
20274    // connection inside `reader_worker_loop` before the worker thread
20275    // returns. Therefore all connections — and SQLite's internal
20276    // profile-callback state with them — are torn down before the
20277    // `Box<ProfileContext>` allocations are freed. `Engine::close`
20278    // additionally clears the callback via
20279    // `sqlite3_profile(handle, None, NULL)` before connection close to
20280    // drain any in-flight callback dispatch.
20281    unsafe {
20282        rusqlite::ffi::sqlite3_profile(
20283            connection.handle(),
20284            Some(profile_callback_trampoline),
20285            ctx_ptr.cast::<std::ffi::c_void>(),
20286        );
20287    }
20288    contexts.push(ctx);
20289}
20290
20291/// Uninstall the profile callback so SQLite stops calling into our
20292/// freed `Box<ProfileContext>` pointer once a connection is being torn
20293/// down. Call before dropping `profile_contexts`.
20294fn uninstall_profile_callback(connection: &Connection) {
20295    // SAFETY: passing `None` as the callback unregisters the previous
20296    // callback; SQLite documents this as legal and idempotent.
20297    unsafe {
20298        rusqlite::ffi::sqlite3_profile(connection.handle(), None, std::ptr::null_mut());
20299    }
20300}
20301
20302/// Pack 6.G G.1 — configure SQLite per-connection lookaside on a reader
20303/// worker connection. Must be called BEFORE any statement is prepared
20304/// or any PRAGMA is run on `connection`; per the SQLite docs
20305/// (https://www.sqlite.org/malloc.html §3) lookaside is silently
20306/// ignored if reconfigured after the first allocation on the
20307/// connection. Passing `NULL` for the buffer pointer lets SQLite
20308/// allocate the lookaside backing memory itself.
20309///
20310/// rusqlite 0.31's `set_db_config` only handles the boolean
20311/// `DbConfig::*` variants; `SQLITE_DBCONFIG_LOOKASIDE` is not surfaced
20312/// (it is commented out in `rusqlite/src/config.rs`), so we call the
20313/// raw FFI directly.
20314///
20315/// Returns the rc of `sqlite3_db_config` so callers can debug-assert
20316/// `SQLITE_OK` and surface configuration failure under
20317/// `debug_assertions` test builds without expanding the public surface.
20318/// 0.7.0 perf-experiments hook: apply caller-supplied reader PRAGMAs
20319/// from the `FATHOMDB_PERF_READER_PRAGMAS` env var. Format:
20320/// comma-separated `name=value` pairs (e.g.
20321/// `cache_size=-262144,mmap_size=268435456,temp_store=MEMORY`).
20322///
20323/// **Gated on `FATHOMDB_PERF_EXPERIMENTS=1`.** No-op if the gate env
20324/// var is unset, so production paths are never affected. Failures to
20325/// apply individual PRAGMAs are logged to stderr (via `eprintln!`) but
20326/// do not error the connection open — experiments are best-effort,
20327/// not contract.
20328///
20329/// Scope: 0.7.0 perf-experiment campaign per
20330/// `dev/plans/0.7.0-perf-experiments.md`. Once Wave 5 picks the
20331/// landing combination, the chosen PRAGMAs are hardcoded as the new
20332/// reader-open default and this hook is removed.
20333/// 0.7.0 perf-experiments hook: apply writer-side PRAGMAs from
20334/// `FATHOMDB_PERF_WRITER_PRAGMAS` (same format as reader hook).
20335/// **Runs BEFORE migrations** so PRAGMAs like `page_size` that must
20336/// precede any table creation take effect on a fresh DB.
20337///
20338/// Gated on `FATHOMDB_PERF_EXPERIMENTS=1`. No-op otherwise.
20339fn apply_perf_experiment_writer_pragmas(connection: &Connection) {
20340    if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
20341        return;
20342    }
20343    let raw = match std::env::var("FATHOMDB_PERF_WRITER_PRAGMAS") {
20344        Ok(s) if !s.is_empty() => s,
20345        _ => return,
20346    };
20347    for entry in raw.split(',') {
20348        let entry = entry.trim();
20349        if entry.is_empty() {
20350            continue;
20351        }
20352        let (name, value) = match entry.split_once('=') {
20353            Some((n, v)) => (n.trim(), v.trim()),
20354            None => {
20355                eprintln!("perf-experiment: bad writer pragma entry (expect name=value): {entry}");
20356                continue;
20357            }
20358        };
20359        if name.is_empty() {
20360            eprintln!("perf-experiment: empty pragma name in writer entry: {entry}");
20361            continue;
20362        }
20363        match connection.pragma_update(None, name, value) {
20364            Ok(()) => {
20365                eprintln!(
20366                    "perf-experiment: applied PRAGMA {name}={value} on writer (pre-migration)"
20367                );
20368            }
20369            Err(err) => {
20370                eprintln!("perf-experiment: writer PRAGMA {name}={value} failed: {err}");
20371            }
20372        }
20373    }
20374}
20375
20376fn apply_perf_experiment_reader_pragmas(connection: &Connection) {
20377    if std::env::var_os("FATHOMDB_PERF_EXPERIMENTS").is_none() {
20378        return;
20379    }
20380    let raw = match std::env::var("FATHOMDB_PERF_READER_PRAGMAS") {
20381        Ok(s) if !s.is_empty() => s,
20382        _ => return,
20383    };
20384    for entry in raw.split(',') {
20385        let entry = entry.trim();
20386        if entry.is_empty() {
20387            continue;
20388        }
20389        let (name, value) = match entry.split_once('=') {
20390            Some((n, v)) => (n.trim(), v.trim()),
20391            None => {
20392                eprintln!("perf-experiment: bad pragma entry (expect name=value): {entry}");
20393                continue;
20394            }
20395        };
20396        if name.is_empty() {
20397            eprintln!("perf-experiment: empty pragma name in entry: {entry}");
20398            continue;
20399        }
20400        match connection.pragma_update(None, name, value) {
20401            Ok(()) => {
20402                eprintln!("perf-experiment: applied PRAGMA {name}={value} on reader");
20403            }
20404            Err(err) => {
20405                eprintln!("perf-experiment: PRAGMA {name}={value} failed: {err}");
20406            }
20407        }
20408    }
20409}
20410
20411fn configure_reader_lookaside(connection: &Connection) -> std::os::raw::c_int {
20412    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
20413    // the lifetime of `connection`. The variadic
20414    // `sqlite3_db_config(LOOKASIDE)` call expects three trailing
20415    // arguments of types `void*`, `int`, `int` — the prototype shape
20416    // documented in `sqlite3.h`. We pass a null buffer so SQLite owns
20417    // the lookaside backing allocation, and the slot size / count from
20418    // the G.1 constants. No allocations happen on the connection
20419    // before this call (reader open path is `Connection::open` ->
20420    // `configure_reader_lookaside` -> first PRAGMA).
20421    unsafe {
20422        rusqlite::ffi::sqlite3_db_config(
20423            connection.handle(),
20424            rusqlite::ffi::SQLITE_DBCONFIG_LOOKASIDE,
20425            std::ptr::null_mut::<std::ffi::c_void>(),
20426            READER_LOOKASIDE_SLOT_SIZE,
20427            READER_LOOKASIDE_SLOT_COUNT,
20428        )
20429    }
20430}
20431
20432/// Read the high-water-mark for `SQLITE_DBSTATUS_LOOKASIDE_USED` on
20433/// `connection`. The `current` out-param is the live checked-out slot
20434/// count and decays as transactions finalize, so it is unreliable as
20435/// post-warmup evidence. The `hiwtr` out-param latches the largest
20436/// observed `current` value since the last reset and is the right
20437/// signal that lookaside was honored at any point on this connection.
20438/// Reset flag is `0` so reading does not clear the high-water mark.
20439#[cfg(debug_assertions)]
20440fn read_lookaside_used_hiwtr(connection: &Connection) -> std::os::raw::c_int {
20441    let mut current: std::os::raw::c_int = 0;
20442    let mut hiwtr: std::os::raw::c_int = 0;
20443    // SAFETY: handle is valid; both out pointers are to local stack
20444    // ints; reset flag 0 is documented as legal.
20445    unsafe {
20446        rusqlite::ffi::sqlite3_db_status(
20447            connection.handle(),
20448            rusqlite::ffi::SQLITE_DBSTATUS_LOOKASIDE_USED,
20449            &mut current,
20450            &mut hiwtr,
20451            0,
20452        );
20453    }
20454    hiwtr
20455}
20456
20457/// Pack 6.G G.3.5 — read the three page-cache pressure counters on
20458/// `connection`: `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and
20459/// `_CACHE_USED`. Returns `(hit, miss, used_bytes)`. Hit/miss are
20460/// monotonic counters (reset flag = 0 here); used_bytes is the live
20461/// page-cache memory footprint at call time. The caller is expected to
20462/// take pre/post snapshots and do delta arithmetic explicitly.
20463#[cfg(debug_assertions)]
20464fn read_cache_status(
20465    connection: &Connection,
20466) -> (std::os::raw::c_int, std::os::raw::c_int, std::os::raw::c_int) {
20467    let mut hit_current: std::os::raw::c_int = 0;
20468    let mut hit_hiwtr: std::os::raw::c_int = 0;
20469    let mut miss_current: std::os::raw::c_int = 0;
20470    let mut miss_hiwtr: std::os::raw::c_int = 0;
20471    let mut used_current: std::os::raw::c_int = 0;
20472    let mut used_hiwtr: std::os::raw::c_int = 0;
20473    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
20474    // the lifetime of `connection`. All out-pointers are to local stack
20475    // ints. Reset flag 0 is documented as legal (no counter is reset).
20476    unsafe {
20477        rusqlite::ffi::sqlite3_db_status(
20478            connection.handle(),
20479            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_HIT,
20480            &mut hit_current,
20481            &mut hit_hiwtr,
20482            0,
20483        );
20484        rusqlite::ffi::sqlite3_db_status(
20485            connection.handle(),
20486            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_MISS,
20487            &mut miss_current,
20488            &mut miss_hiwtr,
20489            0,
20490        );
20491        rusqlite::ffi::sqlite3_db_status(
20492            connection.handle(),
20493            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_USED,
20494            &mut used_current,
20495            &mut used_hiwtr,
20496            0,
20497        );
20498    }
20499    // CACHE_HIT / CACHE_MISS are monotonic counters reported in the
20500    // `current` out-param; CACHE_USED is the live byte count, also in
20501    // `current`. The hiwtr values are unused for this telemetry.
20502    (hit_current, miss_current, used_current)
20503}
20504
20505/// FFI trampoline for `sqlite3_profile`.
20506///
20507/// Invoked by SQLite at statement-finish with the SQL text and the
20508/// statement's wall-clock cost in nanoseconds. We dispatch a
20509/// `ProfileRecord` (when profiling is enabled) and a `SlowStatement`
20510/// signal (when `wall_clock_ms` exceeds the configured slow threshold).
20511///
20512/// Per `dev/design/lifecycle.md` § Public record shape, the public
20513/// payload exposes `wall_clock_ms`, `step_count`, and `cache_delta`.
20514/// `sqlite3_profile` does not surface per-statement step counts or
20515/// cache-hit deltas in its callback; we emit `0` for those fields and
20516/// document the hazard. AC-005b requires the fields be typed numeric,
20517/// not that they carry non-zero values for every backend.
20518unsafe extern "C" fn profile_callback_trampoline(
20519    user_data: *mut std::ffi::c_void,
20520    sql: *const std::os::raw::c_char,
20521    nanoseconds: u64,
20522) {
20523    if user_data.is_null() || sql.is_null() {
20524        return;
20525    }
20526    let ctx = unsafe { &*(user_data.cast::<ProfileContext>()) };
20527    let sql_text = match unsafe { std::ffi::CStr::from_ptr(sql) }.to_str() {
20528        Ok(s) => s,
20529        Err(_) => return,
20530    };
20531
20532    let wall_clock_ms = nanoseconds / 1_000_000;
20533
20534    if ctx.profiling_enabled.load(Ordering::Relaxed) {
20535        let record = lifecycle::ProfileRecord {
20536            wall_clock_ms,
20537            // step_count / cache_delta are not surfaced by
20538            // sqlite3_profile; placeholder 0 satisfies AC-005b's
20539            // "typed numeric" contract. A future profiling refactor
20540            // around sqlite3_stmt_status + sqlite3_db_status would
20541            // populate them with non-zero deltas.
20542            step_count: 0,
20543            cache_delta: 0,
20544        };
20545        ctx.subscribers.dispatch_profile(&record);
20546    }
20547
20548    let threshold = ctx.slow_threshold_ms.load(Ordering::Relaxed);
20549    if wall_clock_ms > threshold {
20550        let signal = lifecycle::SlowStatement { statement: sql_text.to_string(), wall_clock_ms };
20551        ctx.subscribers.dispatch_slow_statement(&signal);
20552    }
20553}
20554
20555#[cfg(test)]
20556mod tests {
20557    use super::{
20558        derive_stable_id, resolve_source_type, Engine, IdSpace, IdSpaceKind, PreparedWrite,
20559        KIND_TO_SOURCE_TYPE_CASE_SQL, ROW_OWNED_PROJECTIONS,
20560    };
20561    use rusqlite::Connection;
20562    use tempfile::TempDir;
20563
20564    /// 0.8.20 Slice 5a (R-20-E1, work item 2) — the registry GUARD.
20565    ///
20566    /// Introspects `sqlite_master` on a freshly migrated database and asserts
20567    /// that EVERY `write_cursor`-keyed table is accounted for: either it is a
20568    /// registered row-owned projection, or it is one of the explicitly named
20569    /// canonical / operational tables that are sources of truth, not shadows.
20570    /// A future projection table therefore cannot be added without either
20571    /// registering it in [`ROW_OWNED_PROJECTIONS`] (making it erasable at every
20572    /// maintenance site at once) or consciously failing this test.
20573    ///
20574    /// **`_fathomdb_projection_state` is allowlisted as KIND-owned** (design v5
20575    /// §1.1): it is keyed by `kind`, not by `write_cursor`, and holds a per-kind
20576    /// enqueue watermark. Erasing one row must not rewind a whole kind's
20577    /// watermark, so it must NEVER be deleted per-cursor. The test asserts both
20578    /// halves of that claim — that it carries no `write_cursor` column, and that
20579    /// it is absent from the row-owned registry.
20580    #[test]
20581    fn guard_row_owned_registry() {
20582        /// Canonical + operational tables: `write_cursor`-carrying SOURCES OF
20583        /// TRUTH, never row-owned projections of another row.
20584        const NON_PROJECTION_CURSOR_TABLES: &[&str] =
20585            &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
20586
20587        let dir = TempDir::new().unwrap();
20588        let path = dir.path().join("registry_guard.fathomdb");
20589        Engine::open(&path).expect("open").engine.close().expect("close");
20590        let conn = Connection::open(&path).expect("open sqlite");
20591
20592        let table_names: Vec<String> = conn
20593            .prepare(
20594                "SELECT name FROM sqlite_master
20595                 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
20596            )
20597            .expect("prepare")
20598            .query_map([], |row| row.get::<_, String>(0))
20599            .expect("query")
20600            .collect::<rusqlite::Result<Vec<_>>>()
20601            .expect("collect");
20602        assert!(table_names.len() > 5, "sqlite_master introspection returned nothing useful");
20603
20604        let has_write_cursor = |table: &str| -> bool {
20605            conn.prepare(&format!("PRAGMA table_info({table})"))
20606                .and_then(|mut stmt| {
20607                    let names = stmt
20608                        .query_map([], |row| row.get::<_, String>(1))?
20609                        .collect::<rusqlite::Result<Vec<_>>>()?;
20610                    Ok(names.iter().any(|n| n == "write_cursor"))
20611                })
20612                .unwrap_or(false)
20613        };
20614
20615        let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
20616
20617        // (1) Every write_cursor-keyed table is registered or explicitly excused.
20618        for table in &table_names {
20619            if !has_write_cursor(table) {
20620                continue;
20621            }
20622            assert!(
20623                registered.contains(&table.as_str())
20624                    || NON_PROJECTION_CURSOR_TABLES.contains(&table.as_str()),
20625                "table `{table}` is keyed by write_cursor but is neither registered in \
20626                 ROW_OWNED_PROJECTIONS nor listed as a non-projection source of truth. \
20627                 If it is a projection, register it — otherwise erasure will leave its \
20628                 rows on disk (the `search_index_v2` defect)."
20629            );
20630        }
20631
20632        // (2) Every registered projection actually exists and is erasable by its
20633        //     declared cursor column (vec0's `rowid` included).
20634        for projection in ROW_OWNED_PROJECTIONS {
20635            assert!(
20636                table_names.iter().any(|t| t == projection.table),
20637                "registered projection `{}` does not exist in the schema",
20638                projection.table
20639            );
20640            conn.query_row(
20641                &format!(
20642                    "SELECT COUNT(*) FROM {} WHERE {} = 0",
20643                    projection.table, projection.cursor_column
20644                ),
20645                [],
20646                |row| row.get::<_, u64>(0),
20647            )
20648            .unwrap_or_else(|err| {
20649                panic!(
20650                    "registered projection `{}` is not erasable by `{}`: {err}",
20651                    projection.table, projection.cursor_column
20652                )
20653            });
20654        }
20655
20656        // (3) `_fathomdb_projection_state` is KIND-owned, not row-owned.
20657        assert!(
20658            !has_write_cursor("_fathomdb_projection_state"),
20659            "_fathomdb_projection_state gained a write_cursor column — re-decide its ownership \
20660             class before treating it as kind-owned"
20661        );
20662        assert!(
20663            !registered.contains(&"_fathomdb_projection_state"),
20664            "_fathomdb_projection_state is KIND-owned (per-kind enqueue watermark) and must \
20665             never be deleted per-cursor: erasing one row would rewind a whole kind's watermark"
20666        );
20667    }
20668
20669    /// 0.8.20 Slice 15d (R-20-EAV) — PROVE THE GUARD BITES. The two net-new
20670    /// content-storing projection tables (`canonical_attributes`,
20671    /// `property_search_index`) are `write_cursor`-keyed and hold attribute
20672    /// values at rest. This test asserts (1) they ARE registered in
20673    /// `ROW_OWNED_PROJECTIONS` (so `erase_row_projections` reaches them), and (2)
20674    /// the guard's core predicate — "registered OR a named source of truth" —
20675    /// FAILS for either table if it is (hypothetically) removed from the
20676    /// registry. This is what makes forgetting to register a future
20677    /// content-storing projection a red test, not a silent erasure leak.
20678    #[test]
20679    fn slice15d_attribute_projections_registered_and_guard_bites() {
20680        const NON_PROJECTION_CURSOR_TABLES: &[&str] =
20681            &["canonical_nodes", "canonical_edges", "operational_mutations", "operational_state"];
20682
20683        let registered: Vec<&str> = ROW_OWNED_PROJECTIONS.iter().map(|p| p.table).collect();
20684
20685        // (1) Both new content-storing projections are registered as row-owned.
20686        for table in ["canonical_attributes", "property_search_index"] {
20687            assert!(
20688                registered.contains(&table),
20689                "{table} holds attribute values at rest and MUST be in ROW_OWNED_PROJECTIONS \
20690                 so purge/excise_source reach it"
20691            );
20692        }
20693
20694        // (2) The guard predicate BITES: pretend one of them was never
20695        //     registered — the guard's "registered OR source-of-truth" check must
20696        //     reject it (the exact assertion `guard_row_owned_registry` runs).
20697        for hidden in ["canonical_attributes", "property_search_index"] {
20698            let as_if_unregistered: Vec<&str> =
20699                registered.iter().copied().filter(|t| *t != hidden).collect();
20700            let accepted = as_if_unregistered.contains(&hidden)
20701                || NON_PROJECTION_CURSOR_TABLES.contains(&hidden);
20702            assert!(
20703                !accepted,
20704                "if {hidden} were unregistered the guard would still (incorrectly) accept it — \
20705                 the guard does not actually bite"
20706            );
20707        }
20708    }
20709
20710    /// Cause-A (0.8.11.2) / C-2 (0.8.19) — `derive_stable_id` id-space contract:
20711    /// a present `logical_id` yields a `Logical` (`"l:"`) [`IdSpace`]; a NULL or
20712    /// empty `logical_id` falls back to a deterministic `Content` (`"h:"`) sha256
20713    /// content-hash of the body. The typed spaces are prefix-distinguishable and
20714    /// the value is behaviour-neutral (never used in ranking). Post-C-2 the helper
20715    /// returns a typed [`IdSpace`] whose `to_prefixed()` reproduces the pre-swap
20716    /// string byte-for-byte (eu7 no-op basis).
20717    #[test]
20718    fn derive_stable_id_id_space_contract() {
20719        // logical_id present → Logical space, body-independent.
20720        assert_eq!(derive_stable_id(Some("alice-1"), "any body"), IdSpace::logical("alice-1"));
20721        assert_eq!(
20722            derive_stable_id(Some("alice-1"), "a different body"),
20723            IdSpace::logical("alice-1")
20724        );
20725        // Byte-identical prefixed form to the pre-C-2 `stable_id` string.
20726        assert_eq!(derive_stable_id(Some("alice-1"), "any body").to_prefixed(), "l:alice-1");
20727
20728        // NULL logical_id → Content space, deterministic on body.
20729        let h1 = derive_stable_id(None, "stable body text");
20730        let h2 = derive_stable_id(None, "stable body text");
20731        assert_eq!(h1, h2, "content-hash is deterministic");
20732        assert_eq!(h1.space, IdSpaceKind::Content);
20733        let h1s = h1.to_prefixed();
20734        assert!(h1s.starts_with("h:"));
20735        assert_eq!(h1s.len(), 2 + 64, "h: + sha256 hex");
20736        assert!(h1s["h:".len()..].chars().all(|c| c.is_ascii_hexdigit()));
20737
20738        // Empty logical_id is treated as absent (falls back to content-hash).
20739        assert_eq!(derive_stable_id(Some(""), "stable body text"), h1);
20740
20741        // Distinct bodies → distinct content-hashes (no collision).
20742        assert_ne!(derive_stable_id(None, "body A"), derive_stable_id(None, "body B"));
20743    }
20744
20745    /// C-2 (0.8.19 / TC-8) — [`IdSpace`] parse/format round-trip is stable across
20746    /// all three spaces, including a value that itself contains `":"`.
20747    #[test]
20748    fn id_space_parse_format_round_trip() {
20749        let cases = [
20750            IdSpace::logical("alice-1"),
20751            IdSpace::content("a".repeat(64)),
20752            IdSpace::passage("7"),
20753            IdSpace::logical("l:weird:value"), // value contains the delimiter
20754        ];
20755        for id in cases {
20756            assert_eq!(IdSpace::parse(&id.to_prefixed()), Some(id.clone()), "round-trip {id:?}");
20757        }
20758        assert_eq!(IdSpace::logical("x").to_prefixed(), "l:x");
20759        assert_eq!(IdSpace::content("y").to_prefixed(), "h:y");
20760        assert_eq!(IdSpace::passage("3").to_prefixed(), "p:3");
20761        assert_eq!(IdSpace::parse("untagged"), None);
20762    }
20763
20764    // Pack 1 drift-detection: the Rust helper used by the two writer
20765    // sites must agree with the CASE WHEN used by the Pack 1 reshape
20766    // migration in `migrate_vector_partition_to_pack1`. The CASE SQL
20767    // is exported as `KIND_TO_SOURCE_TYPE_CASE_SQL`; this test
20768    // exercises it against an in-memory SQLite (no sqlite-vec extension
20769    // required — only the CASE) and asserts byte-equal output with the
20770    // Rust helper for every kind in the locked Pack 1 vocabulary
20771    // (incl. the synthetic `doc` -> `article` coercion). See
20772    // `dev/design/0.7.0-vector-quant-pack1.md` D3 / D4.
20773    #[test]
20774    fn resolve_source_type_drift_check() {
20775        let kinds = ["email", "article", "paper", "meeting", "note", "todo", "doc"];
20776
20777        // 1. Rust helper return values (table is the contract: changes
20778        //    here must be reflected in the SQL CASE or this test fails).
20779        let want: &[(&str, &str)] = &[
20780            ("email", "email"),
20781            ("article", "article"),
20782            ("paper", "paper"),
20783            ("meeting", "meeting"),
20784            ("note", "note"),
20785            ("todo", "todo"),
20786            ("doc", "article"),
20787        ];
20788        for (kind, expected) in want {
20789            let got = resolve_source_type(kind).unwrap_or_else(|_| {
20790                panic!("resolve_source_type({kind}) returned Err; want Ok({expected})")
20791            });
20792            assert_eq!(got, *expected, "Rust helper drift for kind={kind}");
20793        }
20794        assert!(
20795            resolve_source_type("banana").is_err(),
20796            "unknown kind must surface as writer error"
20797        );
20798
20799        // 2. SQL CASE evaluated against the same kinds. Build a
20800        //    one-row staging row per kind and SELECT through
20801        //    KIND_TO_SOURCE_TYPE_CASE_SQL; assert each row equals the
20802        //    Rust helper's output. Drift in either direction fails.
20803        let conn = Connection::open_in_memory().expect("in-memory sqlite");
20804        conn.execute_batch("CREATE TABLE s(kind TEXT NOT NULL)").expect("create s");
20805        for kind in &kinds {
20806            conn.execute("INSERT INTO s(kind) VALUES (?1)", [kind]).expect("insert kind");
20807        }
20808        let sql = format!("SELECT s.kind, {KIND_TO_SOURCE_TYPE_CASE_SQL} FROM s");
20809        let mut stmt = conn.prepare(&sql).expect("prepare CASE");
20810        let rows: Vec<(String, String)> = stmt
20811            .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
20812            .expect("query")
20813            .map(|r| r.expect("row"))
20814            .collect();
20815        assert_eq!(rows.len(), kinds.len(), "row count drift");
20816        for (kind, sql_result) in &rows {
20817            let rust_result = resolve_source_type(kind).expect("known kind");
20818            assert_eq!(
20819                sql_result, rust_result,
20820                "SQL CASE vs Rust helper drift for kind={kind}: SQL={sql_result}, Rust={rust_result}"
20821            );
20822        }
20823    }
20824
20825    #[test]
20826    fn write_advances_cursor() {
20827        let dir = TempDir::new().unwrap();
20828        let opened = Engine::open(dir.path().join("rewrite.sqlite")).expect("engine should open");
20829        let receipt = opened
20830            .engine
20831            .write(&[PreparedWrite::Node {
20832                kind: "doc".to_string(),
20833                body: "hello".to_string(),
20834                source_id: crate::SourceId::new("test:fixture").expect("test source id"),
20835                logical_id: None,
20836                state: crate::InitialState::Active,
20837                reason: None,
20838                valid_from: None,
20839                valid_until: None,
20840            }])
20841            .expect("write should succeed");
20842
20843        assert_eq!(receipt.cursor, 1);
20844    }
20845}