Skip to main content

khive_db/
namespace_move.rs

1//! Moving records between namespaces (ADR-189).
2//!
3//! Namespace is attribution-only and an open string, so moving a record between
4//! namespaces is sound in principle. It is not a `UPDATE ... SET namespace`
5//! sweep, for reasons that are properties of this schema rather than matters of
6//! taste: six of the affected tables are fts5 virtual tables that accept no
7//! `UPDATE` of an indexed column, the vector tables are created at runtime and
8//! appear in no static list, and the ANN bookkeeping has ordering semantics an
9//! `UPDATE` violates silently.
10//!
11//! Three scoping facts decide the shape of everything below.
12//!
13//! **One backend.** A pack can be assigned its own backend, and then its records
14//! live in a different SQLite file; the live configuration on a development
15//! machine puts comm's notes and the knowledge atoms in two files beside the
16//! main one. SQLite has no transaction across unattached databases, so this
17//! operates on the connection it is given and a store with three backends is
18//! the same route map applied three times. That composes because a class routed
19//! with no rows here succeeds reporting zero. Atomicity does not compose, and
20//! this module does not pretend otherwise.
21//!
22//! **Re-runnable, so a partial application is a resume point.** A backend whose
23//! move did not run holds exactly the state that existed before anyone asked:
24//! records under the source namespace. That is not a new failure mode, it is the
25//! one the move was called to fix, still present for the unmoved subset. Because
26//! a routed class with no rows succeeds reporting zero, a second run over an
27//! already-moved backend is a no-op and a second run over the failed one is a
28//! first run. So what a multi-backend caller owes is not all-or-nothing across
29//! files, which SQLite cannot give it, but per-backend atomicity, a per-backend
30//! report so a partial outcome is known rather than silent, and the willingness
31//! to run the same request again.
32//!
33//! **No transaction of its own.** `WriterTaskHandle::send` hands its closure a
34//! connection already inside the `BEGIN IMMEDIATE` it opened and owns the commit
35//! or rollback, and a nested bare `BEGIN IMMEDIATE` is an error. So the entry
36//! point here is DML-only, and the enumerating `SELECT`s run inside the caller's
37//! transaction with the writes they feed — the same TOCTOU reason
38//! `Fts5TextSearch::rename_namespace` records.
39//!
40//! **Refuse rather than resolve.** A collision means two rows the caller wrote
41//! claim one identity in the target namespace. There is no conflict policy: any
42//! `ON CONFLICT` form picks a winner over a caller's data while satisfying a
43//! counts-in-equals-counts-out assertion, which is the worst available pairing —
44//! the destructive outcome and the reassuring receipt arrive together.
45
46use std::collections::{BTreeMap, BTreeSet};
47
48use rusqlite::{Connection, OptionalExtension};
49
50use crate::namespace_census::{self, NamespaceCensus, NamespaceConstraint};
51
52#[path = "namespace_move_knowledge.rs"]
53mod knowledge_move;
54#[path = "namespace_move_pack_tables.rs"]
55mod pack_tables;
56#[path = "namespace_move_routes.rs"]
57mod subject_routes;
58use knowledge_move::{move_knowledge_atoms, ordinary_atom_count};
59use pack_tables::{move_task_audit, settle_pack_tables};
60use subject_routes::routed_subjects;
61
62/// A routable subject class: a record that exists in its own right.
63///
64/// Notes and entities qualify by kind; edges optionally qualify by relation.
65/// Nothing in the schema
66/// stops a note kind and an entity kind sharing a spelling, so an unqualified
67/// key would route both on a store that has them, and a refusal naming
68/// `note:observation` is one a caller can act on where `observation` is not.
69#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
70pub enum SubjectClass {
71    Note(String),
72    Entity(String),
73    Edge,
74    EdgeRelation(String),
75    Atom,
76    Domain,
77}
78
79impl SubjectClass {
80    /// Parse a route key. The error names what was given, because a typo here
81    /// is the most likely caller mistake and the least likely to be obvious.
82    pub fn parse(key: &str) -> Result<Self, MoveError> {
83        match key {
84            "edge" => return Ok(Self::Edge),
85            "atom" => return Ok(Self::Atom),
86            "domain" => return Ok(Self::Domain),
87            _ => {}
88        }
89        match key.split_once(':') {
90            Some(("note", kind)) if !kind.is_empty() => Ok(Self::Note(kind.to_string())),
91            Some(("entity", kind)) if !kind.is_empty() => Ok(Self::Entity(kind.to_string())),
92            Some(("edge", relation))
93                if khive_types::EdgeRelation::VALID_NAMES.contains(&relation) =>
94            {
95                Ok(Self::EdgeRelation(relation.to_string()))
96            }
97            _ => Err(MoveError::UnknownSubjectClass {
98                key: key.to_string(),
99            }),
100        }
101    }
102
103    pub fn render(&self) -> String {
104        match self {
105            Self::Note(kind) => format!("note:{kind}"),
106            Self::Entity(kind) => format!("entity:{kind}"),
107            Self::Edge => "edge".to_string(),
108            Self::EdgeRelation(relation) => format!("edge:{relation}"),
109            Self::Atom => "atom".to_string(),
110            Self::Domain => "domain".to_string(),
111        }
112    }
113}
114
115/// One `(subject class, target namespace)` pair.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct MoveRoute {
118    pub class: SubjectClass,
119    pub target: String,
120}
121
122/// A whole move, validated before anything is written.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct MoveRequest {
125    pub source: String,
126    pub routes: Vec<MoveRoute>,
127    /// The instant, in microseconds since the epoch, at which grant expiry is
128    /// judged when authorization rows stay behind. `None` reads the wall clock
129    /// once, at the call. Set it with [`MoveRequest::at`] so a test, or a caller
130    /// replaying a move, controls it.
131    pub now_micros: Option<i64>,
132}
133
134impl MoveRequest {
135    /// The route map as data, validated whole before the first write. A list can
136    /// be checked, logged, replayed and tested against a fixture; a callback
137    /// can do none of those.
138    pub fn new(source: impl Into<String>, routes: Vec<MoveRoute>) -> Self {
139        Self {
140            source: source.into(),
141            routes,
142            now_micros: None,
143        }
144    }
145
146    /// Judge grant expiry at `now_micros` instead of at the wall clock.
147    pub fn at(mut self, now_micros: i64) -> Self {
148        self.now_micros = Some(now_micros);
149        self
150    }
151
152    fn route_for(&self, class: &SubjectClass) -> Option<&MoveRoute> {
153        self.routes
154            .iter()
155            .find(|route| &route.class == class)
156            .or_else(|| match class {
157                SubjectClass::EdgeRelation(_) => self
158                    .routes
159                    .iter()
160                    .find(|route| route.class == SubjectClass::Edge),
161                _ => None,
162            })
163    }
164
165    /// Every route names the same target. That every class and relation present
166    /// in the source is routed is not checked here: `validate` refuses any other
167    /// request before this is consulted. Only then can a per-namespace aggregate
168    /// with no subject be carried anywhere.
169    fn single_target(&self) -> Option<&str> {
170        let mut targets = self.routes.iter().map(|r| r.target.as_str());
171        let first = targets.next()?;
172        targets.all(|t| t == first).then_some(first)
173    }
174}
175
176/// What a move did, per route and per table.
177///
178/// `left_behind` is not an error column. A per-namespace aggregate with no
179/// subject cannot be split across a partitioning move; separately identified
180/// operational records have no subject route at all. They stay, and the caller
181/// is told rather than left to discover them.
182#[derive(Clone, Debug, Default, PartialEq, Eq)]
183pub struct MoveCounts {
184    /// Subjects moved, keyed by the rendered route key. A routed class with no
185    /// rows appears here with zero — that is what makes the map verifiable by
186    /// its caller, and what makes the same map re-runnable against each backend
187    /// of a split store.
188    pub subjects: BTreeMap<String, u64>,
189    /// Rows written, keyed by table. Derived rows appear here and in no route.
190    pub rows: BTreeMap<String, u64>,
191    /// Rows left where they were, keyed by table, with the reason in the docs
192    /// above rather than in the data.
193    pub left_behind: BTreeMap<String, u64>,
194    /// Entries appended to `ann_write_log` for the source and target namespaces.
195    pub ann_log_appended: u64,
196    /// Tool policy rows left in the source that are not soft-deleted. Authorization
197    /// state is never carried, so these are the rows an operator still has to
198    /// decide about; `left_behind` counts every row that stayed.
199    pub live_policies_left_behind: u64,
200    /// Tool grant rows left in the source that are in force at the instant the
201    /// move judged them (see [`MoveRequest::now_micros`]): granted, not
202    /// invalidated by a registration, and not expired.
203    pub grants_in_force_left_behind: u64,
204}
205
206/// A note that cannot move, and its position in the stream that pins it.
207#[derive(Clone, Debug, PartialEq, Eq)]
208pub struct StreamMember {
209    pub note_id: String,
210    pub stream: String,
211    pub seq: i64,
212}
213
214/// A row that would claim an identity already taken in the target namespace.
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct Collision {
217    pub table: String,
218    pub constraint: String,
219    pub target: String,
220    /// The key values that clash, rendered in the constraint's own column order.
221    ///
222    /// This identifies both rows and needs no second field: the row already in
223    /// the target holds this key there, and the row that would have moved holds
224    /// the same key in the source namespace.
225    pub key: String,
226}
227
228/// Why a move refused, or how it failed.
229#[derive(Debug)]
230pub enum MoveError {
231    /// A route key that names no subject class.
232    UnknownSubjectClass {
233        key: String,
234    },
235    /// The same class routed twice. Ambiguous rather than redundant: the two
236    /// targets may differ, and picking one would be a guess.
237    DuplicateRoute {
238        class: String,
239    },
240    /// A route whose target is the source. A no-op written as an instruction is
241    /// more likely a mistake than an intent.
242    TargetIsSource {
243        class: String,
244    },
245    /// A namespace-bearing table holding rows here that this code has no rule
246    /// for.
247    ///
248    /// The census finds tables; only a reader of the code can say what a move
249    /// does with one. So a table arriving in a migration after this was written
250    /// refuses the move by name, rather than letting the subjects around it move
251    /// and leaving the new table pointing at a namespace nothing else is in.
252    UnknownTable {
253        table: String,
254        rows: u64,
255    },
256    /// A subject class with rows in the source namespace and no route.
257    ///
258    /// Distinct from a routed class with no rows, which succeeds reporting zero.
259    /// A host that binds a pack writing nothing needs "routed, nothing there" to
260    /// be distinguishable from "you forgot this one".
261    UnroutedClass {
262        class: String,
263        rows: u64,
264    },
265    /// Notes the stream schema pins to their namespace.
266    ///
267    /// Four triggers make this absolute: an `UPDATE` of a member note naming
268    /// `namespace` aborts, the delete aborts, and both writes to the ledger row
269    /// abort. So the refusal comes from a read taken before any write, and names
270    /// the notes, rather than from a trigger's abort string from somewhere in
271    /// the middle of the transaction.
272    StreamMembers {
273        notes: Vec<StreamMember>,
274    },
275    /// A partitioned vector has no unique destination among its source subjects.
276    /// Single-target moves retain their existing whole-namespace behavior.
277    UnroutableVector {
278        subject_id: String,
279        table: String,
280        destinations: u64,
281    },
282    /// Rows that would collide in a target namespace.
283    Collisions {
284        collisions: Vec<Collision>,
285    },
286    Sqlite(rusqlite::Error),
287}
288
289impl From<rusqlite::Error> for MoveError {
290    fn from(error: rusqlite::Error) -> Self {
291        Self::Sqlite(error)
292    }
293}
294
295impl std::fmt::Display for MoveError {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        match self {
298            Self::UnknownSubjectClass { key } => write!(
299                f,
300                "no subject class named {key:?}; expected note:<kind>, entity:<kind>, edge:<relation>, edge, atom or domain"
301            ),
302            Self::DuplicateRoute { class } => {
303                write!(f, "{class} is routed more than once")
304            }
305            Self::TargetIsSource { class } => {
306                write!(f, "{class} is routed to the namespace it is already in")
307            }
308            Self::UnknownTable { table, rows } => write!(
309                f,
310                "{table} holds {rows} row(s) in the source namespace and this build has no rule \
311                 for it; a table added by a later migration refuses a move rather than being \
312                 left behind by one"
313            ),
314            Self::UnroutedClass { class, rows } => write!(
315                f,
316                "{class} has {rows} row(s) in the source namespace and no route; \
317                 a class routed with zero rows succeeds reporting zero, an unrouted one refuses"
318            ),
319            Self::StreamMembers { notes } => {
320                write!(f, "{} note(s) belong to a stream and cannot change namespace: ", notes.len())?;
321                for (i, member) in notes.iter().enumerate() {
322                    if i > 0 {
323                        f.write_str(", ")?;
324                    }
325                    write!(f, "{} ({} seq {})", member.note_id, member.stream, member.seq)?;
326                }
327                Ok(())
328            }
329            Self::UnroutableVector { subject_id, table, destinations } => write!(
330                f,
331                "unroutable_vector: {table:?} subject {subject_id:?} has {destinations} source-subject destinations; a partitioned move requires exactly one"
332            ),
333            Self::Collisions { collisions } => {
334                write!(f, "{} collision(s): ", collisions.len())?;
335                for (i, collision) in collisions.iter().enumerate() {
336                    if i > 0 {
337                        f.write_str(", ")?;
338                    }
339                    write!(
340                        f,
341                        "{}.{} in {} already holds {}",
342                        collision.table, collision.constraint, collision.target, collision.key
343                    )?;
344                }
345                Ok(())
346            }
347            Self::Sqlite(error) => write!(f, "{error}"),
348        }
349    }
350}
351
352impl std::error::Error for MoveError {}
353
354/// Tables holding rows keyed by namespace with no subject to be carried by.
355///
356/// `brain_profile_snapshots` is `(profile_id, namespace)` and `brain_event_log`
357/// likewise: one row per profile per namespace, describing an aggregate. A move
358/// routing five classes to five namespaces has no target to carry a single
359/// profile snapshot to, and splitting it would invent numbers. They move only
360/// when the move is total and single-target.
361const NAMESPACE_SCOPED_TABLES: &[&str] = &[
362    "brain_profile_snapshots",
363    "brain_event_log",
364    "proposals_open",
365];
366
367/// Tables keyed by a subject the caller routes, which therefore follow it.
368///
369/// Both are `(namespace, target_id, …)` shapes in the brain pack, and `target_id`
370/// names a note, an entity or an atom. That is why the follow query below is a
371/// union over the three subject tables rather than a per-route join: the column
372/// does not say which kind of subject it points at, and a wrong guess would
373/// leave learned state under a namespace its subject has left.
374const SUBJECT_KEYED_TABLES: &[&str] = &["brain_implicit_mass", "brain_serve_ledger"];
375
376/// A memory visibility receipt belongs to its note. Its fences name the
377/// receipt by `(namespace, note_id)`, so they must follow it in dependency order.
378const MEMORY_VISIBILITY_RECEIPTS: &str = "memory_visibility_receipts";
379const MEMORY_VISIBILITY_FENCES: &str = "memory_visibility_fences";
380const MEMORY_VISIBILITY_EPOCHS: &str = "memory_visibility_epochs";
381
382/// Sender envelopes have a logical-message/device/epoch identity and survive
383/// deletion of their outbound note. The session mirror uses provider session
384/// identities, not the IDs of caller-authored `session` notes. Neither has a
385/// route in `MoveRequest`, so retain their source attribution and report it.
386const LEAVE_BEHIND_TABLES: &[&str] = &["comm_sender_transport", "sessions", "session_messages"];
387
388/// What a move does with one namespace-bearing table.
389///
390/// This is the half of the design that cannot be derived, because it is a
391/// statement about meaning rather than about schema. The census answers which
392/// tables carry the column; only a reader of the code can say whether a row in
393/// one of them is a subject that moves, a projection that follows, an append-only
394/// record of something that already happened, or an aggregate that cannot be
395/// split. So the list is written down — and the guard is that a table the census
396/// finds and this function does not name is a REFUSAL, not a default.
397#[derive(Clone, Copy, Debug, PartialEq, Eq)]
398pub enum TableDisposition {
399    /// Rows the caller routes by subject class.
400    Subject,
401    /// A projection rebuilt from its subject, never routed on its own.
402    ///
403    /// Split two ways on purpose. `fts_knowledge` and `fts_sections` are
404    /// maintained by triggers that fire on `UPDATE OF ... namespace`
405    /// (`sql/026-knowledge-fts-repair.sql:49` and
406    /// `sql/002-narrow-fts-sections-update-trigger.sql:10`), so writing the base
407    /// row carries them. Both live declarations are column-scoped, and
408    /// `fts_sections_au` reached that shape by being narrowed: `sql/schema.sql`
409    /// declares it `AFTER UPDATE` unconditioned and V2 drops and recreates it
410    /// over a named column list, to stop reindex-only updates from paying an
411    /// fts5 delete-and-reinsert. So this disposition depends on `namespace`
412    /// staying in that list, which a later narrowing could shorten without
413    /// touching anything here. `fts_notes` and `fts_entities` have no triggers at all —
414    /// their contents are written from Rust — so the move writes them itself, and
415    /// because fts5 refuses an `UPDATE` of an indexed column that write is a
416    /// delete followed by an insert.
417    Derived { trigger_maintained: bool },
418    /// The rowid map beside an fts5 table, keyed `(namespace, subject_id)`.
419    DerivedRowidMap,
420    /// Appended to, never rewritten: new entries land under the target namespace
421    /// and the entries already there stay where they are, because they record
422    /// writes that happened under the old name.
423    Appended,
424    /// A record of what happened. Rewriting it would make the audit trail
425    /// describe a past that did not occur.
426    History,
427    /// Consumer bookkeeping whose ordering semantics an `UPDATE` violates.
428    ConsumerWatermark,
429    /// The schema itself refuses: `sql/029-note-streams.sql` installs four
430    /// triggers that abort any namespace change to a member note or its ledger.
431    RefusedBySchema,
432    /// Keyed by `(profile_id, namespace)` with no subject, so a partitioning
433    /// move has no target to carry it to. Moves only when the request is total
434    /// and single-target; otherwise reported as left behind.
435    NamespaceScopedAggregate,
436    /// Keyed by a subject the caller routes, so it follows that subject.
437    SubjectKeyed { subject_column: &'static str },
438    /// Owns an identity outside the routed subject set. Keep its namespace and
439    /// report the retained rows so a caller can handle them separately.
440    LeaveBehind,
441}
442
443/// The disposition of a table, or `None` if this code has never seen it.
444///
445/// `None` is the whole point. A migration that adds a namespace-bearing table
446/// after this was written lands in no branch here, and a move that finds rows in
447/// it refuses by name rather than moving the subjects around it and leaving the
448/// new table pointing at a namespace nothing else is in.
449pub fn disposition(table: &namespace_census::NamespaceTable) -> Option<TableDisposition> {
450    use TableDisposition::*;
451    Some(match table.name.as_str() {
452        "notes" | "entities" | "graph_edges" | "knowledge_atoms" | "knowledge_domains" => Subject,
453
454        // Partitioned sections follow their source atom. A routed single-target
455        // knowledge class carries every source section with its source vectors.
456        "knowledge_sections" => SubjectKeyed {
457            subject_column: "atom_id",
458        },
459
460        // An open proposal is keyed by `proposal_id` and references no subject,
461        // so it belongs to the namespace rather than to anything inside it. In a
462        // move routing several classes to several targets there is no namespace
463        // for it to belong to afterwards, which is the same problem the two brain
464        // aggregates have and takes the same answer.
465        "proposals_open" => NamespaceScopedAggregate,
466
467        "fts_knowledge" | "fts_sections" => Derived {
468            trigger_maintained: true,
469        },
470        "fts_notes" | "fts_entities" => Derived {
471            trigger_maintained: false,
472        },
473        "fts_notes_rowids" | "fts_entities_rowids" => DerivedRowidMap,
474        // `move_vectors` clears these rows using the staged vec0 subjects.
475        "vector_provenance" => Derived {
476            trigger_maintained: false,
477        },
478
479        "ann_write_log" => Appended,
480        "events" => History,
481        "ann_consumer_watermark" | "ann_consumer_pending" => ConsumerWatermark,
482        "note_streams" => RefusedBySchema,
483
484        "brain_profile_snapshots" | "brain_event_log" => NamespaceScopedAggregate,
485        "brain_implicit_mass" | "brain_serve_ledger" => SubjectKeyed {
486            subject_column: "target_id",
487        },
488        "memory_visibility_receipts" => SubjectKeyed {
489            subject_column: "note_id",
490        },
491        "memory_visibility_fences" => SubjectKeyed {
492            subject_column: "note_id",
493        },
494        "memory_visibility_epochs" => SubjectKeyed {
495            subject_column: "note_id",
496        },
497
498        // The next eight are created by a pack, not by a migration, so a store
499        // may not have them. Their rules live in `namespace_move_pack_tables.rs`.
500        //
501        // Readers of a task's audit rows key on `note_id` alone, so a transition
502        // record belongs to its task note and follows it. `namespace` is nullable
503        // here, and a NULL row is outside every namespace.
504        "gtd_lifecycle_audit" => SubjectKeyed {
505            subject_column: "note_id",
506        },
507        // One summary row per evaluation run, read per namespace, naming no
508        // subject: the same problem the brain aggregates have.
509        "knowledge_eval_runs" => NamespaceScopedAggregate,
510        // Receipts of work done in the namespace, keyed to no routed subject, so
511        // they follow the evaluation runs: a total single-target move carries
512        // them and a partitioning move leaves them. An `exec_events` row belongs
513        // to an `exec_runs` row of the same namespace and both take the same
514        // branch, so the two tables always move or stay together.
515        "exec_runs" | "exec_events" | "git_receipts" => NamespaceScopedAggregate,
516        // Authorization state. A row carried into a target would grant, or deny,
517        // there what the target never decided, so no move carries either table.
518        // The rows stay in the source and the move reports them.
519        "tool_policy" | "tool_grants" => LeaveBehind,
520        // A cache of an index over the namespace it names, keyed by a bare
521        // namespace or by `{namespace}::vamana::{model}`. The move deletes the
522        // source's rows and writes nothing for the target.
523        "retrieval_snapshots" => Derived {
524            trigger_maintained: false,
525        },
526        _ if LEAVE_BEHIND_TABLES.contains(&table.name.as_str()) => LeaveBehind,
527
528        // Created at runtime, one per embedding model, and in no source file, so
529        // the live store is the only place they can be identified from.
530        //
531        // The NAME is not enough to identify one, and treating it as enough put a
532        // hole straight through the refusal above: a migration adding an ordinary
533        // namespace-bearing table called `vec_audit` would be classed here, handed
534        // to `move_vectors`, and die on `no such column: embedding` in the middle
535        // of the caller's transaction -- a bare SQLite error in place of the
536        // refusal by name that every other unnamed table gets. A vector table is a
537        // `CREATE VIRTUAL TABLE`, which an ordinary migration's table is not, so
538        // the census's own reading of that is the second half of the test.
539        _ if is_runtime_vector_table(table) => Derived {
540            trigger_maintained: false,
541        },
542
543        _ => return None,
544    })
545}
546
547/// A vector table created at runtime by an embedding model.
548///
549/// One predicate rather than two spellings of `starts_with("vec_")`: the
550/// disposition and the loop that moves them have to agree, or a table one of
551/// them admits reaches code the other never cleared.
552fn is_runtime_vector_table(table: &namespace_census::NamespaceTable) -> bool {
553    table.name.starts_with("vec_") && table.virtual_table
554}
555
556/// What the source namespace actually holds, read inside the caller's
557/// transaction with the writes it feeds.
558#[derive(Debug, Default)]
559struct SourceInventory {
560    /// `notes` and `entities` rows per `kind`, which is what a route key names.
561    note_kinds: BTreeMap<String, u64>,
562    entity_kinds: BTreeMap<String, u64>,
563    edge_relations: BTreeMap<String, u64>,
564    atoms: u64,
565    domains: u64,
566    /// Namespace-bearing tables holding rows here that [`disposition`] has no
567    /// rule for. Non-empty means refuse.
568    unknown: Vec<(String, u64)>,
569}
570
571fn count_in_namespace(conn: &Connection, table: &str, namespace: &str) -> rusqlite::Result<u64> {
572    let sql = format!(
573        "SELECT COUNT(*) FROM {} WHERE namespace = ?1",
574        namespace_census::quote_ident(table)
575    );
576    conn.query_row(&sql, [namespace], |row| row.get::<_, i64>(0))
577        .map(|n| n as u64)
578}
579
580fn kinds_in_namespace(
581    conn: &Connection,
582    table: &str,
583    namespace: &str,
584) -> rusqlite::Result<BTreeMap<String, u64>> {
585    let sql = format!(
586        "SELECT kind, COUNT(*) FROM {} WHERE namespace = ?1 GROUP BY kind",
587        namespace_census::quote_ident(table)
588    );
589    let mut stmt = conn.prepare(&sql)?;
590    let rows = stmt.query_map([namespace], |row| {
591        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
592    })?;
593    let mut out = BTreeMap::new();
594    for row in rows {
595        let (kind, count) = row?;
596        out.insert(kind, count);
597    }
598    Ok(out)
599}
600
601fn edge_relations_in_namespace(
602    conn: &Connection,
603    namespace: &str,
604) -> rusqlite::Result<BTreeMap<String, u64>> {
605    conn.prepare(
606        "SELECT relation, COUNT(*) FROM graph_edges WHERE namespace = ?1 GROUP BY relation",
607    )?
608    .query_map([namespace], |row| {
609        Ok((row.get(0)?, row.get::<_, i64>(1)? as u64))
610    })?
611    .collect()
612}
613
614/// Soft-deleted rows are counted and moved with the rest, deliberately.
615///
616/// They are rows, they carry the namespace, and several of the partial unique
617/// indexes exclude them, so they cannot collide. Leaving them behind would
618/// strand a record's tombstone in a namespace its subject no longer occupies,
619/// which is the state the delete path reads when it decides whether a later
620/// write is a resurrection.
621fn read_source(
622    conn: &Connection,
623    census: &NamespaceCensus,
624    source: &str,
625) -> Result<SourceInventory, MoveError> {
626    let mut inventory = SourceInventory {
627        note_kinds: kinds_in_namespace(conn, "notes", source)?,
628        entity_kinds: kinds_in_namespace(conn, "entities", source)?,
629        edge_relations: edge_relations_in_namespace(conn, source)?,
630        atoms: ordinary_atom_count(conn, source)?,
631        domains: count_in_namespace(conn, "knowledge_domains", source)?,
632        unknown: Vec::new(),
633    };
634
635    for table in &census.tables {
636        if disposition(table).is_some() {
637            continue;
638        }
639        let count = count_in_namespace(conn, &table.name, source)?;
640        if count > 0 {
641            inventory.unknown.push((table.name.clone(), count));
642        }
643    }
644    Ok(inventory)
645}
646
647/// Notes the stream schema pins where they are.
648///
649/// Read before any write, so the refusal names the notes rather than arriving as
650/// a trigger's abort string from the middle of the transaction. Four triggers in
651/// `sql/029-note-streams.sql` make it absolute: a namespace-naming `UPDATE` of a
652/// member note aborts, the delete aborts, and both writes to the ledger abort.
653fn stream_members(conn: &Connection, source: &str) -> rusqlite::Result<Vec<StreamMember>> {
654    let mut stmt = conn.prepare(
655        "SELECT note_id, stream, seq FROM note_streams \
656         WHERE namespace = ?1 ORDER BY stream, seq",
657    )?;
658    let rows = stmt.query_map([source], |row| {
659        Ok(StreamMember {
660            note_id: row.get(0)?,
661            stream: row.get(1)?,
662            seq: row.get(2)?,
663        })
664    })?;
665    rows.collect()
666}
667
668/// Everything that can refuse before a single row is written.
669///
670/// Ordered by how much the caller can do about it: a malformed request first,
671/// then a request the store refuses, then a request the store would corrupt.
672pub fn validate(
673    conn: &Connection,
674    census: &NamespaceCensus,
675    request: &MoveRequest,
676) -> Result<(), MoveError> {
677    let mut seen = BTreeSet::new();
678    for route in &request.routes {
679        if let SubjectClass::EdgeRelation(relation) = &route.class {
680            if !khive_types::EdgeRelation::VALID_NAMES.contains(&relation.as_str()) {
681                return Err(MoveError::UnknownSubjectClass {
682                    key: route.class.render(),
683                });
684            }
685        }
686        if !seen.insert(route.class.clone()) {
687            return Err(MoveError::DuplicateRoute {
688                class: route.class.render(),
689            });
690        }
691        if route.target == request.source {
692            return Err(MoveError::TargetIsSource {
693                class: route.class.render(),
694            });
695        }
696    }
697
698    let inventory = read_source(conn, census, &request.source)?;
699
700    if let Some((table, rows)) = inventory.unknown.first() {
701        return Err(MoveError::UnknownTable {
702            table: table.clone(),
703            rows: *rows,
704        });
705    }
706
707    let unrouted = |class: SubjectClass, rows: u64| -> Result<(), MoveError> {
708        if rows > 0 && request.route_for(&class).is_none() {
709            return Err(MoveError::UnroutedClass {
710                class: class.render(),
711                rows,
712            });
713        }
714        Ok(())
715    };
716    for (kind, rows) in &inventory.note_kinds {
717        unrouted(SubjectClass::Note(kind.clone()), *rows)?;
718    }
719    for (kind, rows) in &inventory.entity_kinds {
720        unrouted(SubjectClass::Entity(kind.clone()), *rows)?;
721    }
722    for (relation, rows) in &inventory.edge_relations {
723        unrouted(SubjectClass::EdgeRelation(relation.clone()), *rows)?;
724    }
725    unrouted(SubjectClass::Atom, inventory.atoms)?;
726    unrouted(SubjectClass::Domain, inventory.domains)?;
727
728    let pinned = stream_members(conn, &request.source)?;
729    if !pinned.is_empty() {
730        return Err(MoveError::StreamMembers { notes: pinned });
731    }
732
733    validate_partitioned_vector_moves(conn, census, request)?;
734    Ok(())
735}
736
737fn validate_partitioned_vector_moves(
738    conn: &Connection,
739    census: &NamespaceCensus,
740    request: &MoveRequest,
741) -> Result<(), MoveError> {
742    if request.single_target().is_some() {
743        return Ok(());
744    }
745    let (selector, parameters) = routed_subjects(request);
746    for table in census
747        .tables
748        .iter()
749        .filter(|table| is_runtime_vector_table(table))
750    {
751        let unresolved = conn
752            .query_row(
753                &format!(
754                    "WITH routed AS ({selector}) \
755                     SELECT vector.subject_id, COUNT(DISTINCT routed.target) \
756                     FROM {} AS vector LEFT JOIN routed ON routed.subject_id = vector.subject_id \
757                     WHERE vector.namespace = ?1 GROUP BY vector.subject_id \
758                     HAVING COUNT(DISTINCT routed.target) != 1 \
759                     ORDER BY vector.subject_id LIMIT 1",
760                    namespace_census::quote_ident(&table.name)
761                ),
762                rusqlite::params_from_iter(&parameters),
763                |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64)),
764            )
765            .optional()?;
766        if let Some((subject_id, destinations)) = unresolved {
767            return Err(MoveError::UnroutableVector {
768                subject_id,
769                table: table.name.clone(),
770                destinations,
771            });
772        }
773    }
774    Ok(())
775}
776
777fn edge_collision_target_predicate(
778    request: &MoveRequest,
779    target: &str,
780    parameters: &mut Vec<rusqlite::types::Value>,
781) -> String {
782    let fallback = request
783        .route_for(&SubjectClass::Edge)
784        .is_some_and(|route| route.target == target);
785    let mut matching = Vec::new();
786    let mut specific = Vec::new();
787    for route in &request.routes {
788        if let SubjectClass::EdgeRelation(relation) = &route.class {
789            if route.target != target && !fallback {
790                continue;
791            }
792            parameters.push(relation.clone().into());
793            let parameter = format!("?{}", parameters.len());
794            if route.target == target {
795                matching.push(parameter.clone());
796            }
797            if fallback {
798                specific.push(parameter);
799            }
800        }
801    }
802    let mut alternatives = Vec::new();
803    if !matching.is_empty() {
804        alternatives.push(format!("source.relation IN ({})", matching.join(", ")));
805    }
806    if fallback {
807        alternatives.push(if specific.is_empty() {
808            "1".to_owned()
809        } else {
810            format!("source.relation NOT IN ({})", specific.join(", "))
811        });
812    }
813    if alternatives.is_empty() {
814        "0".to_owned()
815    } else {
816        alternatives.join(" OR ")
817    }
818}
819
820/// Collisions the pre-flight can enumerate exactly, for one constraint.
821///
822/// Exactness is the admission criterion, not coverage. A constraint qualifies
823/// only when every key column has a name and the index is not partial, because
824/// those are the two cases where the query below means precisely what the
825/// constraint means:
826///
827/// - an expression key column is reported by `PRAGMA index_xinfo` with a null
828///   name, so there is nothing to join on;
829/// - a partial index carries a `WHERE` clause that `index_xinfo` does not
830///   report, so a join ignoring it reports clashes the constraint would not have
831///   raised.
832///
833/// The second rule covers two cases that are not alike, and saying so here keeps
834/// the comment from presenting one reason for both.
835/// `idx_comm_message_external_id` (V42's channel-scoped successor to the V5
836/// index) is unreachable either way: its third key column is
837/// `json_extract(properties, '$.external_id')`, followed by two channel
838/// expressions, and its predicate also reads the external ID. Neither the key
839/// nor the filter can be expressed without evaluating it on both sides.
840/// `idx_notes_namespace_kind_key` (`sql/028-notes-key.sql:4`) is not like that at
841/// all: three plain column names and `WHERE key IS NOT NULL AND deleted_at IS
842/// NULL`, which a source/target join CAN express exactly. It is excluded only
843/// because `index_xinfo` does not hand over the `WHERE`, and it is the collision
844/// a consolidation of two namespaces is most likely to hit, since two notes
845/// sharing a key under one kind is the ordinary case rather than the exotic one.
846/// Admitting it means deciding which predicate shapes a parse may accept, and a
847/// predicate read permissively would refuse moves SQLite allows, so it is left
848/// out until that rule exists rather than guessed at here.
849///
850/// What the excluded constraints get instead is the constraint itself: the move
851/// issues plain statements, they error, and the caller's transaction rolls back.
852/// So this function decides the QUALITY of a refusal, never whether one happens.
853/// A collision it cannot enumerate still aborts the move.
854///
855/// That refusal is thinner than it sounds, and it is worth writing down because
856/// it is the reason the exclusion above costs something. SQLite names the
857/// COLUMNS, not the index: a caller whose note-key move is refused receives
858/// `UNIQUE constraint failed: notes.namespace, notes.kind, notes.key` and gets
859/// no index name to look up and no rows. Measured.
860///
861/// The reverse also happens, and it does not show up here at all: a constraint
862/// this function DOES enumerate can be unreachable because of one the census
863/// never reported. `graph_edges` is `PRIMARY KEY (namespace, id)`, which names
864/// `namespace` and so arrives here as a live key — but
865/// `sql/014-graph-edges-id-unique.sql:25` puts a UNIQUE index on `id` alone,
866/// globally, so no two rows in the database can share an `id` and the clash this
867/// arm looks for cannot exist in any store that reached V13. That index names no
868/// namespace, so a census keyed on the column cannot see it, and nothing in the
869/// enumerated set says the arm is dead. `idx_graph_edges_unique_triple`
870/// (`namespace, source_id, target_id, relation`) is the constraint that actually
871/// refuses a graph edge move, and it is enumerated here.
872fn collisions_for(
873    conn: &Connection,
874    constraint: &NamespaceConstraint,
875    request: &MoveRequest,
876    target: &str,
877) -> rusqlite::Result<Vec<Collision>> {
878    if constraint.partial || !constraint.columns_are_nameable() {
879        return Ok(Vec::new());
880    }
881    let names: Vec<&str> = constraint
882        .columns
883        .iter()
884        .filter_map(|c| c.as_deref())
885        .collect();
886    let others: Vec<&str> = names
887        .iter()
888        .copied()
889        .filter(|c| !c.eq_ignore_ascii_case("namespace"))
890        .collect();
891    if others.len() != names.len() - 1 {
892        // `namespace` is not in this constraint, so moving cannot collide on it.
893        return Ok(Vec::new());
894    }
895    if others.is_empty() {
896        // A uniqueness constraint on `namespace` alone: one row per namespace,
897        // and a second one arriving is a collision whatever its other columns.
898        // Handled by the same query with an empty key rendering.
899        return collisions_on_namespace_alone(conn, constraint, &request.source, target);
900    }
901
902    let table = namespace_census::quote_ident(&constraint.table);
903    let join = others
904        .iter()
905        .map(|c| {
906            let q = namespace_census::quote_ident(c);
907            // `IS` rather than `=` so two NULLs in a nullable key column compare
908            // equal, which is what a UNIQUE index does NOT do. This over-reports
909            // in exactly one direction and the direction is the safe one.
910            format!("target.{q} IS source.{q}")
911        })
912        .collect::<Vec<_>>()
913        .join(" AND ");
914    let select = others
915        .iter()
916        .map(|c| format!("source.{}", namespace_census::quote_ident(c)))
917        .collect::<Vec<_>>()
918        .join(", ");
919    let mut parameters = vec![request.source.clone().into(), target.to_owned().into()];
920    let mut sql = format!(
921        "SELECT {select} FROM {table} AS source \
922         JOIN {table} AS target ON target.namespace = ?2 AND {join} \
923         WHERE source.namespace = ?1"
924    );
925    if constraint.table == "graph_edges" && constraint.index == "idx_graph_edges_unique_triple" {
926        let predicate = edge_collision_target_predicate(request, target, &mut parameters);
927        sql.push_str(&format!(" AND ({predicate})"));
928    }
929
930    let mut stmt = conn.prepare(&sql)?;
931    let column_count = others.len();
932    let rows = stmt.query_map(rusqlite::params_from_iter(parameters), move |row| {
933        let mut parts = Vec::with_capacity(column_count);
934        for i in 0..column_count {
935            parts.push(match row.get_ref(i)? {
936                rusqlite::types::ValueRef::Null => "NULL".to_string(),
937                rusqlite::types::ValueRef::Integer(v) => v.to_string(),
938                rusqlite::types::ValueRef::Real(v) => v.to_string(),
939                rusqlite::types::ValueRef::Text(v) => String::from_utf8_lossy(v).into_owned(),
940                rusqlite::types::ValueRef::Blob(_) => "<blob>".to_string(),
941            });
942        }
943        Ok(parts.join(", "))
944    })?;
945
946    let mut found = Vec::new();
947    for key in rows {
948        found.push(Collision {
949            table: constraint.table.clone(),
950            constraint: constraint.index.clone(),
951            target: target.to_string(),
952            key: key?,
953        });
954    }
955    Ok(found)
956}
957
958fn collisions_on_namespace_alone(
959    conn: &Connection,
960    constraint: &NamespaceConstraint,
961    source: &str,
962    target: &str,
963) -> rusqlite::Result<Vec<Collision>> {
964    let table = namespace_census::quote_ident(&constraint.table);
965    let sql = format!(
966        "SELECT (SELECT COUNT(*) FROM {table} WHERE namespace = ?1) \
967              * (SELECT COUNT(*) FROM {table} WHERE namespace = ?2)"
968    );
969    let product: i64 = conn.query_row(&sql, [source, target], |row| row.get(0))?;
970    Ok(if product > 0 {
971        vec![Collision {
972            table: constraint.table.clone(),
973            constraint: constraint.index.clone(),
974            target: target.to_string(),
975            key: "(namespace alone)".to_string(),
976        }]
977    } else {
978        Vec::new()
979    })
980}
981
982/// Move one note or entity kind, and the rows derived from it.
983///
984/// The derived writes run AFTER the base update and select through it, so no id
985/// list is ever held in memory and a large namespace costs the same as a small
986/// one. They are still exact: the `WHERE namespace = :source` on the derived
987/// table excludes rows that were already in the target before this ran.
988/// The three tables a note or entity kind is spread across.
989///
990/// Grouped rather than passed as three strings because they are one fact: the
991/// map is keyed on the rowid the fts table holds, so naming them apart invites
992/// a call site that pairs a base with the wrong map.
993struct KindedTables {
994    base: &'static str,
995    fts: &'static str,
996    rowids: &'static str,
997}
998
999const NOTE_TABLES: KindedTables = KindedTables {
1000    base: "notes",
1001    fts: "fts_notes",
1002    rowids: "fts_notes_rowids",
1003};
1004
1005const ENTITY_TABLES: KindedTables = KindedTables {
1006    base: "entities",
1007    fts: "fts_entities",
1008    rowids: "fts_entities_rowids",
1009};
1010
1011fn move_kinded_subject(
1012    conn: &Connection,
1013    tables: &KindedTables,
1014    source: &str,
1015    target: &str,
1016    kind: &str,
1017    rows: &mut BTreeMap<String, u64>,
1018) -> rusqlite::Result<u64> {
1019    let KindedTables { base, fts, rowids } = *tables;
1020    // Entity writers advance revisions explicitly; note revisions belong to
1021    // their update trigger. Keep the entity target and both assignment lists
1022    // literal so the two writer contracts can be inspected independently.
1023    let statement = if base == "entities" {
1024        "UPDATE entities SET namespace = ?2, version = version + 1 \
1025         WHERE namespace = ?1 AND kind = ?3"
1026            .to_owned()
1027    } else {
1028        format!(
1029            "UPDATE {} SET namespace = ?2 WHERE namespace = ?1 AND kind = ?3",
1030            namespace_census::quote_ident(base)
1031        )
1032    };
1033    let moved = conn.execute(&statement, rusqlite::params![source, target, kind])? as u64;
1034    *rows.entry(base.to_string()).or_default() += moved;
1035
1036    // An ordinary fts5 table accepts this and preserves the rowid, which is what
1037    // the map below is keyed on. Measured; the arm lives in the tests.
1038    let selector = format!(
1039        "SELECT id FROM {} WHERE namespace = ?2 AND kind = ?3",
1040        namespace_census::quote_ident(base)
1041    );
1042    for derived in [fts, rowids] {
1043        let n = conn.execute(
1044            &format!(
1045                "UPDATE {} SET namespace = ?2 \
1046                 WHERE namespace = ?1 AND subject_id IN ({selector})",
1047                namespace_census::quote_ident(derived)
1048            ),
1049            rusqlite::params![source, target, kind],
1050        )? as u64;
1051        *rows.entry(derived.to_string()).or_default() += n;
1052    }
1053    Ok(moved)
1054}
1055
1056/// Move a whole table's rows out of the source namespace.
1057fn move_whole_table(
1058    conn: &Connection,
1059    table: &str,
1060    source: &str,
1061    target: &str,
1062    rows: &mut BTreeMap<String, u64>,
1063) -> rusqlite::Result<u64> {
1064    let moved = conn.execute(
1065        &format!(
1066            "UPDATE {} SET namespace = ?2 WHERE namespace = ?1",
1067            namespace_census::quote_ident(table)
1068        ),
1069        rusqlite::params![source, target],
1070    )? as u64;
1071    *rows.entry(table.to_string()).or_default() += moved;
1072    Ok(moved)
1073}
1074
1075fn move_edges(
1076    conn: &Connection,
1077    request: &MoveRequest,
1078    route: &MoveRoute,
1079    rows: &mut BTreeMap<String, u64>,
1080) -> rusqlite::Result<u64> {
1081    let mut parameters: Vec<rusqlite::types::Value> =
1082        vec![request.source.clone().into(), route.target.clone().into()];
1083    let predicate = if let SubjectClass::EdgeRelation(relation) = &route.class {
1084        parameters.push(relation.clone().into());
1085        "relation = ?3".to_owned()
1086    } else {
1087        let mut excluded = Vec::new();
1088        for specific in &request.routes {
1089            if let SubjectClass::EdgeRelation(relation) = &specific.class {
1090                parameters.push(relation.clone().into());
1091                excluded.push(format!("?{}", parameters.len()));
1092            }
1093        }
1094        if excluded.is_empty() {
1095            return move_whole_table(conn, "graph_edges", &request.source, &route.target, rows);
1096        }
1097        format!("relation NOT IN ({})", excluded.join(", "))
1098    };
1099    let moved = conn.execute(
1100        &format!("UPDATE graph_edges SET namespace = ?2 WHERE namespace = ?1 AND {predicate}"),
1101        rusqlite::params_from_iter(parameters),
1102    )? as u64;
1103    *rows.entry("graph_edges".into()).or_default() += moved;
1104    Ok(moved)
1105}
1106
1107/// Carry a note's visibility receipt and fences without breaking their composite
1108/// foreign key. Copy the receipt under the target first, re-key its fences, then
1109/// remove the source receipt. The counts describe rows carried, not the extra
1110/// insert and delete needed to preserve the reference at each statement.
1111fn move_memory_visibility(
1112    conn: &Connection,
1113    source: &str,
1114    target: &str,
1115    rows: &mut BTreeMap<String, u64>,
1116) -> rusqlite::Result<()> {
1117    let receipts = conn.execute(
1118        "INSERT INTO memory_visibility_receipts (namespace, note_id, model_count) \
1119         SELECT ?2, receipt.note_id, receipt.model_count \
1120         FROM memory_visibility_receipts AS receipt \
1121         JOIN notes AS note ON note.id = receipt.note_id \
1122         WHERE receipt.namespace = ?1 AND note.namespace = ?2",
1123        rusqlite::params![source, target],
1124    )? as u64;
1125    *rows.entry(MEMORY_VISIBILITY_RECEIPTS.into()).or_default() += receipts;
1126
1127    let fences = conn.execute(
1128        "UPDATE memory_visibility_fences SET namespace = ?2 \
1129         WHERE namespace = ?1 AND note_id IN (\
1130           SELECT note_id FROM memory_visibility_receipts WHERE namespace = ?2)",
1131        rusqlite::params![source, target],
1132    )? as u64;
1133    *rows.entry(MEMORY_VISIBILITY_FENCES.into()).or_default() += fences;
1134
1135    let removed = conn.execute(
1136        "DELETE FROM memory_visibility_receipts \
1137         WHERE namespace = ?1 AND note_id IN (\
1138           SELECT note_id FROM memory_visibility_receipts WHERE namespace = ?2)",
1139        rusqlite::params![source, target],
1140    )? as u64;
1141    debug_assert_eq!(removed, receipts);
1142    Ok(())
1143}
1144
1145/// A vec0 row moves by delete and re-insert, carrying the stored embedding.
1146///
1147/// No `UPDATE` against vec0 exists anywhere in the tree, and re-embedding would
1148/// be wasted work rather than a safer alternative: namespace is not an input to
1149/// an embedding, so a re-index pass recomputes byte-identical vectors.
1150///
1151/// The order is forced by the key. `subject_id` is declared `PRIMARY KEY` and
1152/// does not change, so an insert issued before the delete would collide with the
1153/// row it is replacing. The moving rows therefore land in a temporary table
1154/// first. That table is `TEMP`, so it is invisible to the store and dropped with
1155/// the connection, and it is created and dropped inside the caller's
1156/// transaction with everything else.
1157fn move_vectors(
1158    conn: &Connection,
1159    table: &str,
1160    source: &str,
1161    target: Option<&str>,
1162) -> rusqlite::Result<VectorMove> {
1163    let quoted = namespace_census::quote_ident(table);
1164    let columns = "subject_id, namespace, kind, field, embedding_model, embedding";
1165
1166    conn.execute_batch("DROP TABLE IF EXISTS temp.namespace_move_vectors")?;
1167    let staged = if let Some(target) = target {
1168        conn.execute(
1169            &format!(
1170                "CREATE TEMP TABLE namespace_move_vectors AS \
1171                 SELECT {columns}, ?2 AS target FROM {quoted} WHERE namespace = ?1"
1172            ),
1173            rusqlite::params![source, target],
1174        )
1175    } else {
1176        conn.execute(
1177            &format!(
1178                "CREATE TEMP TABLE namespace_move_vectors AS \
1179                 SELECT vector.subject_id, vector.namespace, vector.kind, vector.field, \
1180                        vector.embedding_model, vector.embedding, routed.target \
1181                 FROM {quoted} AS vector JOIN (\
1182                   SELECT DISTINCT subject_id, target FROM temp.namespace_move_subject_targets\
1183                 ) AS routed ON routed.subject_id = vector.subject_id \
1184                 WHERE vector.namespace = ?1"
1185            ),
1186            [source],
1187        )
1188    };
1189    // `CREATE TABLE ... AS SELECT` reports no row count, so the count comes from
1190    // the staging table itself rather than from the statement.
1191    staged?;
1192    let staged_rows: i64 = conn.query_row(
1193        "SELECT COUNT(*) FROM temp.namespace_move_vectors",
1194        [],
1195        |r| r.get(0),
1196    )?;
1197
1198    conn.execute(
1199        &format!("DELETE FROM {quoted} WHERE namespace = ?1"),
1200        [source],
1201    )?;
1202    let inserted = conn.execute(
1203        &format!(
1204            "INSERT INTO {quoted} ({columns}) \
1205             SELECT subject_id, target, kind, field, embedding_model, embedding \
1206             FROM temp.namespace_move_vectors"
1207        ),
1208        [],
1209    )? as u64;
1210    debug_assert_eq!(
1211        inserted, staged_rows as u64,
1212        "every staged vector is re-inserted or the move is losing embeddings"
1213    );
1214
1215    // The sidecar is optional on databases predating vector provenance. The
1216    // move re-inserts vec0 bytes without capturing a new prepared input or
1217    // attributable write time, so discard the old provenance for the exact
1218    // staged subject set in the same transaction. An equal BLOB would not
1219    // invalidate the old digest on its own.
1220    let has_provenance: bool = conn.query_row(
1221        "SELECT EXISTS(SELECT 1 FROM sqlite_master \
1222         WHERE type = 'table' AND name = 'vector_provenance')",
1223        [],
1224        |row| row.get(0),
1225    )?;
1226    if has_provenance {
1227        let model_key = table
1228            .strip_prefix("vec_")
1229            .expect("runtime vector tables use the vec_ prefix");
1230        conn.execute(
1231            "DELETE FROM vector_provenance \
1232             WHERE model_key = ?1 \
1233               AND subject_id IN (SELECT subject_id FROM temp.namespace_move_vectors)",
1234            [model_key],
1235        )?;
1236    }
1237
1238    // The staging table is still here because THIS is what the write log has to
1239    // be built from. It holds the moved rows and nothing else; the live table now
1240    // holds them beside whatever the target already had, and a log built by
1241    // reading the target back cannot tell the two apart. Measured against the
1242    // read-back form: a target already holding one other subject's vector
1243    // produced a `delete` under the source for a subject the source never held,
1244    // a second `upsert` for a vector that never moved, and an appended count of
1245    // four where two vectors' worth of instructions were owed.
1246    let deleted = conn.execute(
1247        "INSERT INTO ann_write_log (namespace, embedding_model, kind, field, subject_id, op) \
1248         SELECT ?1, embedding_model, kind, field, subject_id, 'delete' \
1249         FROM temp.namespace_move_vectors",
1250        [source],
1251    )? as u64;
1252    let upserts = conn
1253        .prepare(
1254            "INSERT INTO ann_write_log \
1255             (namespace, embedding_model, kind, field, subject_id, op) \
1256             SELECT target, embedding_model, kind, field, subject_id, 'upsert' \
1257             FROM temp.namespace_move_vectors \
1258             RETURNING seq, subject_id, embedding_model, kind, field",
1259        )?
1260        .query_map([], |row| {
1261            Ok((
1262                row.get::<_, i64>(0)?,
1263                row.get::<_, String>(1)?,
1264                row.get::<_, String>(2)?,
1265                row.get::<_, String>(3)?,
1266                row.get::<_, String>(4)?,
1267            ))
1268        })?
1269        .collect::<rusqlite::Result<Vec<_>>>()?;
1270    for (seq, subject, model, kind, field) in &upserts {
1271        if kind == "note" && field == "note.content" {
1272            refresh_moved_memory_fence(conn, source, subject, model, *seq)?;
1273        }
1274    }
1275    let appended = deleted + upserts.len() as u64;
1276
1277    conn.execute_batch("DROP TABLE temp.namespace_move_vectors")?;
1278
1279    Ok(VectorMove {
1280        moved: inserted,
1281        ann_appended: appended,
1282    })
1283}
1284
1285/// The receipt has not been re-keyed yet. Preserve its model set and refresh
1286/// only a fence whose vector was actually published by this move transaction.
1287fn refresh_moved_memory_fence(
1288    conn: &Connection,
1289    source: &str,
1290    subject: &str,
1291    model: &str,
1292    seq: i64,
1293) -> rusqlite::Result<()> {
1294    conn.execute(
1295        "UPDATE memory_visibility_fences SET ann_write_log_seq = ?4 \
1296         WHERE namespace = ?1 AND note_id = ?2 AND model = ?3",
1297        rusqlite::params![source, subject, model, seq],
1298    )?;
1299    Ok(())
1300}
1301
1302/// What one vector table's move did: the rows carried, and the instructions
1303/// appended for the ANN consumers on BOTH sides.
1304///
1305/// The write log is appended to and never rewritten: its existing entries record
1306/// writes that happened under the old name and are true. What a move adds is two
1307/// entries per moved vector, not one.
1308///
1309/// One entry is not enough and the asymmetry is easy to miss. A consumer builds
1310/// its index per `(namespace, embedding_model)` and advances a watermark over
1311/// this log. An `upsert` under the target tells the target's consumer to take
1312/// the vector. Nothing tells the SOURCE's consumer to drop it, so without the
1313/// paired `delete` that index keeps answering searches with a subject that is no
1314/// longer in its namespace — the same silent outcome as doing nothing to the
1315/// vectors at all, moved one layer out.
1316struct VectorMove {
1317    moved: u64,
1318    ann_appended: u64,
1319}
1320
1321/// Move records out of one namespace, per the route map, inside the caller's
1322/// transaction.
1323///
1324/// DML only. The caller opened `BEGIN IMMEDIATE` and owns the commit or the
1325/// rollback; a nested one here is a SQLite error. Every refusal happens before
1326/// the first write, except the ones only a constraint can raise, and those abort
1327/// the caller's transaction whole.
1328pub fn move_namespace(conn: &Connection, request: &MoveRequest) -> Result<MoveCounts, MoveError> {
1329    let census = namespace_census::census(conn)?;
1330    validate(conn, &census, request)?;
1331
1332    // A collision names a target rather than a route. Routes sharing that
1333    // target must not repeat the same collision, including relation routes.
1334    let mut targets: BTreeSet<&str> = BTreeSet::new();
1335    for route in &request.routes {
1336        targets.insert(route.target.as_str());
1337    }
1338    let mut collisions = Vec::new();
1339    for constraint in namespace_census::reachable_constraints(&census) {
1340        for target in &targets {
1341            collisions.extend(collisions_for(conn, constraint, request, target)?);
1342        }
1343    }
1344    if !collisions.is_empty() {
1345        return Err(MoveError::Collisions { collisions });
1346    }
1347
1348    let mut counts = MoveCounts::default();
1349    let source = request.source.as_str();
1350
1351    // Capture the source identities before any base row moves, excluding every
1352    // target resident even when it has the same vector kind or route class.
1353    if request.single_target().is_none() {
1354        let (selector, parameters) = routed_subjects(request);
1355        conn.execute_batch("DROP TABLE IF EXISTS temp.namespace_move_subject_targets")?;
1356        conn.execute(
1357            &format!("CREATE TEMP TABLE namespace_move_subject_targets AS {selector}"),
1358            rusqlite::params_from_iter(parameters),
1359        )?;
1360    }
1361
1362    // Audit rows are selected by the routed note kind, so they go before the
1363    // notes do.
1364    move_task_audit(conn, &census, request, &mut counts.rows)?;
1365
1366    for route in &request.routes {
1367        let target = route.target.as_str();
1368        let moved = match &route.class {
1369            SubjectClass::Note(kind) => {
1370                move_kinded_subject(conn, &NOTE_TABLES, source, target, kind, &mut counts.rows)?
1371            }
1372            SubjectClass::Entity(kind) => {
1373                move_kinded_subject(conn, &ENTITY_TABLES, source, target, kind, &mut counts.rows)?
1374            }
1375            SubjectClass::Edge | SubjectClass::EdgeRelation(_) => {
1376                move_edges(conn, request, route, &mut counts.rows)?
1377            }
1378            SubjectClass::Atom => {
1379                move_knowledge_atoms(conn, request, target, false, &mut counts.rows)?
1380            }
1381            SubjectClass::Domain => {
1382                // A domain and its same-ID mirror atom form one logical subject.
1383                move_knowledge_atoms(conn, request, target, true, &mut counts.rows)?;
1384                move_whole_table(conn, "knowledge_domains", source, target, &mut counts.rows)?
1385            }
1386        };
1387        counts.subjects.insert(route.class.render(), moved);
1388    }
1389
1390    // Single-target vectors carry every source section's vector, including
1391    // historical sections with a missing or differently attributed parent.
1392    if let Some(target) = request.single_target() {
1393        move_whole_table(conn, "knowledge_sections", source, target, &mut counts.rows)?;
1394    }
1395
1396    // Vectors are enumerated from the live store, never from a constant list: a
1397    // store using a model this build was never compiled against still has its
1398    // `vec_*` table found here.
1399    for table in &census.tables {
1400        if !is_runtime_vector_table(table) {
1401            continue;
1402        }
1403        let moved = move_vectors(conn, &table.name, source, request.single_target())?;
1404        *counts.rows.entry(table.name.clone()).or_default() += moved.moved;
1405        counts.ann_log_appended += moved.ann_appended;
1406    }
1407    if request.single_target().is_none() {
1408        conn.execute_batch("DROP TABLE temp.namespace_move_subject_targets")?;
1409    }
1410    if census
1411        .tables
1412        .iter()
1413        .any(|table| table.name == "vector_provenance")
1414    {
1415        let left = count_in_namespace(conn, "vector_provenance", source)?;
1416        if left > 0 {
1417            counts.left_behind.insert("vector_provenance".into(), left);
1418        }
1419    }
1420
1421    // Learned state follows the subject it is about. Run per distinct target, so
1422    // a partitioning move sends each row after the subject it names. The set is
1423    // the one the pre-flight above already built, for the same reason.
1424    for table in SUBJECT_KEYED_TABLES {
1425        for target in &targets {
1426            let moved = conn.execute(
1427                &format!(
1428                    "UPDATE {} SET namespace = ?2 WHERE namespace = ?1 AND target_id IN (\
1429                       SELECT id FROM notes WHERE namespace = ?2 \
1430                       UNION ALL SELECT id FROM entities WHERE namespace = ?2 \
1431                       UNION ALL SELECT id FROM knowledge_atoms WHERE namespace = ?2)",
1432                    namespace_census::quote_ident(table)
1433                ),
1434                rusqlite::params![source, target],
1435            )? as u64;
1436            *counts.rows.entry((*table).to_string()).or_default() += moved;
1437        }
1438        let left = count_in_namespace(conn, table, source)?;
1439        if left > 0 {
1440            counts.left_behind.insert((*table).to_string(), left);
1441        }
1442    }
1443
1444    // Epochs belong to the note even when its receipt is absent.
1445    for target in &targets {
1446        let moved = conn.execute(
1447            "UPDATE memory_visibility_epochs SET namespace = ?2 \
1448             WHERE namespace = ?1 AND note_id IN (SELECT id FROM notes WHERE namespace = ?2)",
1449            rusqlite::params![source, target],
1450        )? as u64;
1451        *counts
1452            .rows
1453            .entry(MEMORY_VISIBILITY_EPOCHS.into())
1454            .or_default() += moved;
1455    }
1456    let left = count_in_namespace(conn, MEMORY_VISIBILITY_EPOCHS, source)?;
1457    if left > 0 {
1458        counts
1459            .left_behind
1460            .insert(MEMORY_VISIBILITY_EPOCHS.into(), left);
1461    }
1462
1463    // Visibility receipts follow notes, but their fences reference the receipt
1464    // including its namespace. A plain UPDATE of either table first would fail
1465    // with foreign keys enabled. The helper keeps the reference valid throughout.
1466    for target in &targets {
1467        move_memory_visibility(conn, source, target, &mut counts.rows)?;
1468    }
1469    for table in [MEMORY_VISIBILITY_RECEIPTS, MEMORY_VISIBILITY_FENCES] {
1470        let left = count_in_namespace(conn, table, source)?;
1471        if left > 0 {
1472            counts.left_behind.insert((*table).to_string(), left);
1473        }
1474    }
1475
1476    for table in LEAVE_BEHIND_TABLES {
1477        let left = count_in_namespace(conn, table, source)?;
1478        if left > 0 {
1479            counts.left_behind.insert((*table).to_string(), left);
1480        }
1481    }
1482
1483    // Per-namespace aggregates with no subject. A partitioning move has no
1484    // target to carry them to, so they stay and the caller is told, rather than
1485    // being left to find out.
1486    for table in NAMESPACE_SCOPED_TABLES {
1487        match request.single_target() {
1488            Some(target) => {
1489                move_whole_table(conn, table, source, target, &mut counts.rows)?;
1490            }
1491            None => {
1492                let left = count_in_namespace(conn, table, source)?;
1493                if left > 0 {
1494                    counts.left_behind.insert((*table).to_string(), left);
1495                }
1496            }
1497        }
1498    }
1499
1500    settle_pack_tables(conn, &census, request, &mut counts)?;
1501
1502    Ok(counts)
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use super::*;
1508    use crate::migrations::run_migrations_for_test as run_migrations;
1509    use rusqlite::Connection;
1510
1511    pub(super) fn migrated() -> Connection {
1512        let mut conn = Connection::open_in_memory().expect("open");
1513        run_migrations(&mut conn).expect("migrate");
1514        conn
1515    }
1516
1517    /// Seeds through raw SQL, which is enough for every arm below and is NOT
1518    /// enough for the ones that are deliberately absent.
1519    ///
1520    /// `fts_notes` and `fts_entities` have no triggers: their contents are
1521    /// written from Rust, so a SQL seed leaves them empty and an arm asserting
1522    /// the move carried them would pass against a store where there was nothing
1523    /// to carry. Those arms need a fixture built through the store's own
1524    /// writers. `fts_knowledge` and `fts_sections` ARE trigger-maintained, so
1525    /// they are reachable from here and are exercised.
1526    pub(super) fn seed_note(conn: &Connection, id: &str, namespace: &str, kind: &str) {
1527        conn.execute(
1528            "INSERT INTO notes (id, namespace, kind, name, content, created_at, updated_at) \
1529             VALUES (?1, ?2, ?3, 'a name', 'some content', 1, 1)",
1530            rusqlite::params![id, namespace, kind],
1531        )
1532        .expect("seed note");
1533    }
1534
1535    pub(super) fn route(key: &str, target: &str) -> MoveRoute {
1536        MoveRoute {
1537            class: SubjectClass::parse(key).expect("route key"),
1538            target: target.to_string(),
1539        }
1540    }
1541
1542    /// The property the whole multi-backend story rests on. A store split across
1543    /// several SQLite files runs the same request once per backend, and that
1544    /// composes only because a backend holding none of a routed class is a
1545    /// SUCCESS reporting zero rather than a refusal.
1546    #[test]
1547    fn a_routed_class_with_no_rows_succeeds_reporting_zero() {
1548        let conn = migrated();
1549        let request = MoveRequest::new(
1550            "empty-source",
1551            vec![route("note:observation", "target"), route("atom", "target")],
1552        );
1553        let counts = move_namespace(&conn, &request).expect("a backend with nothing routed here");
1554        assert_eq!(counts.subjects.get("note:observation"), Some(&0));
1555        assert_eq!(counts.subjects.get("atom"), Some(&0));
1556        assert_eq!(counts.rows.get(MEMORY_VISIBILITY_RECEIPTS), Some(&0));
1557        assert_eq!(counts.rows.get(MEMORY_VISIBILITY_FENCES), Some(&0));
1558    }
1559
1560    #[test]
1561    fn a_memory_visibility_receipt_follows_its_note_and_reports_count() {
1562        let conn = migrated();
1563        conn.pragma_update(None, "foreign_keys", "ON").unwrap();
1564        seed_note(&conn, "n1", "source", "observation");
1565        conn.execute(
1566            "INSERT INTO memory_visibility_receipts (namespace, note_id, model_count) \
1567             VALUES ('source', 'n1', 0)",
1568            [],
1569        )
1570        .unwrap();
1571
1572        let request = MoveRequest::new("source", vec![route("note:observation", "target")]);
1573        let counts = move_namespace(&conn, &request).expect("the receipt follows its note");
1574        assert_eq!(counts.subjects.get("note:observation"), Some(&1));
1575        assert_eq!(counts.rows.get(MEMORY_VISIBILITY_RECEIPTS), Some(&1));
1576        assert_eq!(counts.rows.get(MEMORY_VISIBILITY_FENCES), Some(&0));
1577        let stored: (String, i64) = conn
1578            .query_row(
1579                "SELECT namespace, model_count FROM memory_visibility_receipts \
1580                 WHERE note_id = 'n1'",
1581                [],
1582                |row| Ok((row.get(0)?, row.get(1)?)),
1583            )
1584            .unwrap();
1585        assert_eq!(stored, ("target".into(), 0));
1586        let source_rows: i64 = conn
1587            .query_row(
1588                "SELECT COUNT(*) FROM memory_visibility_receipts WHERE namespace = 'source'",
1589                [],
1590                |row| row.get(0),
1591            )
1592            .unwrap();
1593        assert_eq!(source_rows, 0);
1594    }
1595
1596    #[test]
1597    fn memory_visibility_fences_follow_their_note_with_foreign_keys_enabled() {
1598        let conn = migrated();
1599        conn.pragma_update(None, "foreign_keys", "ON").unwrap();
1600        seed_note(&conn, "n1", "source", "observation");
1601        seed_note(&conn, "n2", "source", "decision");
1602        conn.execute(
1603            "INSERT INTO memory_visibility_receipts (namespace, note_id, model_count) \
1604             VALUES ('source', 'n1', 2), ('source', 'n2', 0)",
1605            [],
1606        )
1607        .unwrap();
1608        conn.execute(
1609            "INSERT INTO memory_visibility_fences \
1610             (namespace, note_id, model, ann_write_log_seq) VALUES \
1611             ('source', 'n1', 'model-a', 10), ('source', 'n1', 'model-b', 11)",
1612            [],
1613        )
1614        .unwrap();
1615
1616        let request = MoveRequest::new(
1617            "source",
1618            vec![
1619                route("note:observation", "target-a"),
1620                route("note:decision", "target-b"),
1621            ],
1622        );
1623        let counts = move_namespace(&conn, &request).expect("both receipts follow their notes");
1624        assert_eq!(counts.rows.get(MEMORY_VISIBILITY_RECEIPTS), Some(&2));
1625        assert_eq!(counts.rows.get(MEMORY_VISIBILITY_FENCES), Some(&2));
1626        let receipt_places: Vec<(String, String)> = conn
1627            .prepare("SELECT note_id, namespace FROM memory_visibility_receipts ORDER BY note_id")
1628            .unwrap()
1629            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1630            .unwrap()
1631            .collect::<rusqlite::Result<_>>()
1632            .unwrap();
1633        assert_eq!(
1634            receipt_places,
1635            vec![
1636                ("n1".into(), "target-a".into()),
1637                ("n2".into(), "target-b".into())
1638            ]
1639        );
1640        let fences: Vec<(String, String, i64)> = conn
1641            .prepare(
1642                "SELECT namespace, model, ann_write_log_seq \
1643                 FROM memory_visibility_fences ORDER BY model",
1644            )
1645            .unwrap()
1646            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1647            .unwrap()
1648            .collect::<rusqlite::Result<_>>()
1649            .unwrap();
1650        assert_eq!(
1651            fences,
1652            vec![
1653                ("target-a".into(), "model-a".into(), 10),
1654                ("target-a".into(), "model-b".into(), 11)
1655            ]
1656        );
1657        let foreign_key_errors: i64 = conn
1658            .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
1659                row.get(0)
1660            })
1661            .unwrap();
1662        assert_eq!(foreign_key_errors, 0);
1663    }
1664
1665    #[cfg(feature = "vectors")]
1666    #[test]
1667    fn moved_visibility_fences_use_each_exact_upsert_and_preserve_unrelated_receipts() {
1668        crate::extension::ensure_extensions_loaded();
1669        let mut conn = migrated();
1670        conn.pragma_update(None, "foreign_keys", "ON").unwrap();
1671        for (id, namespace) in [
1672            ("moved", "source"),
1673            ("zero", "source"),
1674            ("unfenced", "source"),
1675            ("existing", "target"),
1676        ] {
1677            seed_note(&conn, id, namespace, "memory");
1678        }
1679        conn.execute_batch(
1680            "INSERT INTO memory_visibility_receipts (namespace, note_id, model_count) VALUES \
1681             ('source', 'moved', 2), ('source', 'zero', 0), ('target', 'existing', 1); \
1682             INSERT INTO memory_visibility_fences (namespace, note_id, model, ann_write_log_seq) VALUES \
1683             ('source', 'moved', 'model-a', 17), ('source', 'moved', 'model-b', 18), \
1684             ('target', 'existing', 'model-a', 99);",
1685        ).unwrap();
1686        for (table, model) in [("vec_model_a", "model-a"), ("vec_model_b", "model-b")] {
1687            conn.execute_batch(&format!(
1688                "CREATE VIRTUAL TABLE {table} USING vec0(\
1689                 subject_id TEXT PRIMARY KEY, namespace TEXT NOT NULL, \
1690                 kind TEXT NOT NULL, field TEXT NOT NULL, embedding_model TEXT NOT NULL, \
1691                 embedding float[2] distance_metric=cosine)"
1692            ))
1693            .unwrap();
1694            conn.execute(&format!(
1695                "INSERT INTO {table} (subject_id, namespace, kind, field, embedding_model, embedding) \
1696                 VALUES ('moved', 'source', 'note', 'note.content', ?1, '[0.1, 0.2]')"
1697            ), [model]).unwrap();
1698        }
1699        conn.execute_batch(
1700            "INSERT INTO vec_model_a (subject_id, namespace, kind, field, embedding_model, embedding) VALUES \
1701             ('unfenced', 'source', 'note', 'note.content', 'model-a', '[0.1, 0.2]'), \
1702             ('existing', 'target', 'note', 'note.content', 'model-a', '[0.1, 0.2]'); \
1703             INSERT INTO ann_write_log (seq, namespace, embedding_model, kind, field, subject_id, op) \
1704             VALUES (100, 'target', 'model-a', 'note', 'note.content', 'existing', 'upsert');",
1705        ).unwrap();
1706        let transaction = conn.transaction().unwrap();
1707        let counts = move_namespace(
1708            &transaction,
1709            &MoveRequest::new("source", vec![route("note:memory", "target")]),
1710        )
1711        .expect("move memory receipts and both vector models");
1712        assert_eq!(counts.ann_log_appended, 6);
1713        for model in ["model-a", "model-b"] {
1714            let fence: i64 = transaction
1715                .query_row(
1716                    "SELECT ann_write_log_seq FROM memory_visibility_fences \
1717                 WHERE namespace = 'target' AND note_id = 'moved' AND model = ?1",
1718                    [model],
1719                    |row| row.get(0),
1720                )
1721                .unwrap();
1722            let upsert: i64 = transaction
1723                .query_row(
1724                    "SELECT seq FROM ann_write_log WHERE namespace = 'target' \
1725                 AND subject_id = 'moved' AND embedding_model = ?1 AND op = 'upsert'",
1726                    [model],
1727                    |row| row.get(0),
1728                )
1729                .unwrap();
1730            assert!(upsert > 100);
1731            assert_eq!(fence, upsert, "each model owes its own destination upsert");
1732        }
1733        let untouched: i64 = transaction
1734            .query_row(
1735                "SELECT ann_write_log_seq FROM memory_visibility_fences \
1736             WHERE namespace = 'target' AND note_id = 'existing' AND model = 'model-a'",
1737                [],
1738                |row| row.get(0),
1739            )
1740            .unwrap();
1741        assert_eq!(untouched, 99);
1742        let zero_count: i64 = transaction
1743            .query_row(
1744                "SELECT model_count FROM memory_visibility_receipts \
1745             WHERE namespace = 'target' AND note_id = 'zero'",
1746                [],
1747                |row| row.get(0),
1748            )
1749            .unwrap();
1750        assert_eq!(zero_count, 0);
1751        let invented: i64 = transaction.query_row(
1752            "SELECT COUNT(*) FROM memory_visibility_fences WHERE note_id IN ('zero', 'unfenced')",
1753            [], |row| row.get(0),
1754        ).unwrap();
1755        assert_eq!(invented, 0);
1756        transaction.commit().unwrap();
1757    }
1758
1759    /// And the case it must stay distinguishable from. A host binding a pack
1760    /// that writes nothing needs "routed, nothing there" to read differently
1761    /// from "you forgot this one".
1762    #[test]
1763    fn a_class_with_rows_and_no_route_refuses_and_says_how_many() {
1764        let conn = migrated();
1765        seed_note(&conn, "n1", "source", "observation");
1766        seed_note(&conn, "n2", "source", "decision");
1767
1768        let request = MoveRequest::new("source", vec![route("note:observation", "target")]);
1769        let error = move_namespace(&conn, &request).expect_err("decision notes are unrouted");
1770        match error {
1771            MoveError::UnroutedClass { class, rows } => {
1772                assert_eq!(class, "note:decision");
1773                assert_eq!(rows, 1);
1774            }
1775            other => panic!("expected an unrouted class, got {other}"),
1776        }
1777
1778        let still_here: i64 = conn
1779            .query_row(
1780                "SELECT COUNT(*) FROM notes WHERE namespace = 'source'",
1781                [],
1782                |r| r.get(0),
1783            )
1784            .expect("count");
1785        assert_eq!(still_here, 2, "a refusal writes nothing");
1786    }
1787
1788    /// The drift guard, and the reason `disposition` returning `None` is a
1789    /// refusal rather than a default. This arm mints a namespace-bearing table
1790    /// the way a migration would, so it passes only if the refusal is derived
1791    /// from the census rather than from a list somebody remembered to edit.
1792    #[test]
1793    fn a_namespace_table_this_build_has_no_rule_for_refuses_the_move() {
1794        let conn = migrated();
1795        seed_note(&conn, "n1", "source", "observation");
1796        conn.execute_batch(
1797            "CREATE TABLE later_migration_added_this (\
1798               id TEXT PRIMARY KEY, namespace TEXT NOT NULL);\
1799             INSERT INTO later_migration_added_this VALUES ('x', 'source');",
1800        )
1801        .expect("a migration lands");
1802
1803        let request = MoveRequest::new("source", vec![route("note:observation", "target")]);
1804        let error = move_namespace(&conn, &request).expect_err("an unknown table refuses");
1805        match error {
1806            MoveError::UnknownTable { table, rows } => {
1807                assert_eq!(table, "later_migration_added_this");
1808                assert_eq!(rows, 1);
1809            }
1810            other => panic!("expected an unknown table, got {other}"),
1811        }
1812    }
1813
1814    /// The same refusal, for a table whose NAME says it is a vector table.
1815    ///
1816    /// The vector tables are created at runtime by embedding models and appear in
1817    /// no source file, so they are recognised from the live store. Recognising
1818    /// them by name alone puts a hole through the refusal above: a migration
1819    /// adding an ordinary table called `vec_audit` would be classed as a vector
1820    /// table, handed to the vector move, and die on `no such column: embedding`
1821    /// somewhere inside the caller's transaction. That is the one outcome the
1822    /// refusal exists to prevent -- a bare SQLite error in place of a named
1823    /// refusal. A real vector table is a `CREATE VIRTUAL TABLE` and this one is
1824    /// not, which is what separates them here.
1825    #[test]
1826    fn a_table_named_like_a_vector_table_but_not_one_refuses_by_name() {
1827        let conn = migrated();
1828        seed_note(&conn, "n1", "source", "observation");
1829        conn.execute_batch(
1830            "CREATE TABLE vec_audit (\
1831               id TEXT PRIMARY KEY, namespace TEXT NOT NULL);\
1832             INSERT INTO vec_audit VALUES ('x', 'source');",
1833        )
1834        .expect("a migration lands a table whose name starts with the prefix");
1835
1836        let request = MoveRequest::new("source", vec![route("note:observation", "target")]);
1837        let error = move_namespace(&conn, &request).expect_err("the prefix is not enough");
1838        match error {
1839            MoveError::UnknownTable { table, rows } => {
1840                assert_eq!(table, "vec_audit");
1841                assert_eq!(rows, 1);
1842            }
1843            other => panic!("expected an unknown table, got {other}"),
1844        }
1845    }
1846
1847    /// Control for the arm above: the same table with no rows in the source
1848    /// namespace does NOT refuse, because a move that never touches it has
1849    /// nothing to be wrong about.
1850    #[test]
1851    fn an_unknown_table_holding_nothing_here_does_not_refuse() {
1852        let conn = migrated();
1853        seed_note(&conn, "n1", "source", "observation");
1854        conn.execute_batch(
1855            "CREATE TABLE later_migration_added_this (\
1856               id TEXT PRIMARY KEY, namespace TEXT NOT NULL);\
1857             INSERT INTO later_migration_added_this VALUES ('x', 'somewhere-else');",
1858        )
1859        .expect("a migration lands");
1860
1861        let request = MoveRequest::new("source", vec![route("note:observation", "target")]);
1862        let counts = move_namespace(&conn, &request).expect("nothing of ours is in that table");
1863        assert_eq!(counts.subjects.get("note:observation"), Some(&1));
1864    }
1865
1866    #[test]
1867    fn a_route_to_the_namespace_it_is_already_in_refuses() {
1868        let conn = migrated();
1869        let request = MoveRequest::new("source", vec![route("atom", "source")]);
1870        match move_namespace(&conn, &request).expect_err("a no-op written as an instruction") {
1871            MoveError::TargetIsSource { class } => assert_eq!(class, "atom"),
1872            other => panic!("expected target-is-source, got {other}"),
1873        }
1874    }
1875
1876    #[test]
1877    fn the_same_class_routed_twice_refuses_rather_than_picking_one() {
1878        let conn = migrated();
1879        let request = MoveRequest::new(
1880            "source",
1881            vec![route("atom", "one"), route("atom", "another")],
1882        );
1883        match move_namespace(&conn, &request).expect_err("two targets, no rule to choose") {
1884            MoveError::DuplicateRoute { class } => assert_eq!(class, "atom"),
1885            other => panic!("expected a duplicate route, got {other}"),
1886        }
1887    }
1888
1889    #[test]
1890    fn an_unknown_route_key_names_what_it_was_given() {
1891        match SubjectClass::parse("notes:observation").expect_err("plural is a typo") {
1892            MoveError::UnknownSubjectClass { key } => assert_eq!(key, "notes:observation"),
1893            other => panic!("expected an unknown class, got {other}"),
1894        }
1895        assert_eq!(
1896            SubjectClass::parse("note:observation").expect("singular"),
1897            SubjectClass::Note("observation".into())
1898        );
1899    }
1900
1901    /// A partitioning move has no target to carry a per-namespace aggregate to,
1902    /// so it stays AND is reported. Silence here would be the failure: the seat
1903    /// that eventually binds brain would discover the rows instead of reading a
1904    /// line about them.
1905    #[test]
1906    fn a_partitioning_move_reports_the_aggregates_it_leaves_behind() {
1907        let conn = migrated();
1908        seed_note(&conn, "n1", "source", "observation");
1909        seed_note(&conn, "n2", "source", "decision");
1910        conn.execute(
1911            "INSERT INTO brain_profile_snapshots (profile_id, namespace, snapshot_json, updated_at) \
1912             VALUES ('p', 'source', '{}', 1)",
1913            [],
1914        )
1915        .expect("seed a snapshot");
1916
1917        let request = MoveRequest::new(
1918            "source",
1919            vec![
1920                route("note:observation", "one"),
1921                route("note:decision", "another"),
1922            ],
1923        );
1924        let counts = move_namespace(&conn, &request).expect("a partitioning move");
1925        assert_eq!(counts.left_behind.get("brain_profile_snapshots"), Some(&1));
1926
1927        let stayed: i64 = conn
1928            .query_row(
1929                "SELECT COUNT(*) FROM brain_profile_snapshots WHERE namespace = 'source'",
1930                [],
1931                |r| r.get(0),
1932            )
1933            .expect("count");
1934        assert_eq!(stayed, 1);
1935    }
1936
1937    /// The same aggregate DOES move when every route names one target, because
1938    /// then there is somewhere for it to belong.
1939    #[test]
1940    fn a_total_single_target_move_carries_the_aggregates() {
1941        let conn = migrated();
1942        seed_note(&conn, "n1", "source", "observation");
1943        conn.execute(
1944            "INSERT INTO brain_profile_snapshots (profile_id, namespace, snapshot_json, updated_at) \
1945             VALUES ('p', 'source', '{}', 1)",
1946            [],
1947        )
1948        .expect("seed a snapshot");
1949
1950        let request = MoveRequest::new("source", vec![route("note:observation", "target")]);
1951        let counts = move_namespace(&conn, &request).expect("a total move");
1952        assert!(counts.left_behind.is_empty(), "{:?}", counts.left_behind);
1953
1954        let moved: i64 = conn
1955            .query_row(
1956                "SELECT COUNT(*) FROM brain_profile_snapshots WHERE namespace = 'target'",
1957                [],
1958                |r| r.get(0),
1959            )
1960            .expect("count");
1961        assert_eq!(moved, 1);
1962    }
1963    /// The half of a vector move that is invisible from the side it leaves.
1964    ///
1965    /// An ANN consumer builds its index per `(namespace, embedding_model)` by
1966    /// advancing a watermark over `ann_write_log`. Moving the row in the `vec_*`
1967    /// table and appending only the target's `upsert` leaves the source's index
1968    /// intact and still answering searches with a subject that is no longer in
1969    /// its namespace, which is the same observable as never having touched the
1970    /// vectors at all. This arm fails if nothing tells the source side to drop
1971    /// what left.
1972    ///
1973    /// The consumer itself lives above this crate, so what is asserted here is
1974    /// the instruction it reads, not the index it builds from it.
1975    #[cfg(feature = "vectors")]
1976    #[test]
1977    fn a_vector_move_tells_the_source_side_to_drop_what_left() {
1978        // Registration is an auto-extension, so it only reaches connections
1979        // opened after it. This has to come before `migrated`.
1980        crate::extension::ensure_extensions_loaded();
1981        let conn = migrated();
1982        seed_note(&conn, "n1", "source", "observation");
1983        conn.execute_batch(
1984            "CREATE VIRTUAL TABLE vec_test_model USING vec0(\
1985               subject_id TEXT PRIMARY KEY, \
1986               namespace TEXT NOT NULL, \
1987               kind TEXT NOT NULL, \
1988               field TEXT NOT NULL, \
1989               embedding_model TEXT NOT NULL, \
1990               embedding float[4] distance_metric=cosine\
1991             )",
1992        )
1993        .expect("the vector table an embedding model creates at runtime");
1994        conn.execute(
1995            "INSERT INTO vec_test_model \
1996             (subject_id, namespace, kind, field, embedding_model, embedding) \
1997             VALUES ('n1', 'source', 'observation', 'content', 'test-model', \
1998                     '[0.1, 0.2, 0.3, 0.4]')",
1999            [],
2000        )
2001        .expect("seed a vector");
2002
2003        let request = MoveRequest::new("source", vec![route("note:observation", "target")]);
2004        let counts = move_namespace(&conn, &request).expect("a total move");
2005
2006        assert_eq!(
2007            counts.rows.get("vec_test_model"),
2008            Some(&1),
2009            "the vector itself moved"
2010        );
2011        let left_in_source: i64 = conn
2012            .query_row(
2013                "SELECT COUNT(*) FROM vec_test_model WHERE namespace = 'source'",
2014                [],
2015                |r| r.get(0),
2016            )
2017            .expect("count");
2018        assert_eq!(left_in_source, 0);
2019
2020        // Two entries per moved vector, and the one that matters here is the
2021        // first: without it the source's index is never told anything.
2022        assert_eq!(counts.ann_log_appended, 2);
2023        let dropped_from_source: i64 = conn
2024            .query_row(
2025                "SELECT COUNT(*) FROM ann_write_log \
2026                 WHERE namespace = 'source' AND op = 'delete' \
2027                   AND subject_id = 'n1' AND embedding_model = 'test-model'",
2028                [],
2029                |r| r.get(0),
2030            )
2031            .expect("count");
2032        assert_eq!(
2033            dropped_from_source, 1,
2034            "the source consumer is never told to drop the vector, so its index \
2035             keeps answering with a subject that has left the namespace"
2036        );
2037        let taken_by_target: i64 = conn
2038            .query_row(
2039                "SELECT COUNT(*) FROM ann_write_log \
2040                 WHERE namespace = 'target' AND op = 'upsert' \
2041                   AND subject_id = 'n1' AND embedding_model = 'test-model'",
2042                [],
2043                |r| r.get(0),
2044            )
2045            .expect("count");
2046        assert_eq!(taken_by_target, 1);
2047    }
2048
2049    #[cfg(feature = "vectors")]
2050    #[tokio::test]
2051    async fn vector_provenance_namespace_move_clears_sidecar() {
2052        use std::sync::Arc;
2053
2054        use khive_storage::VectorStore;
2055
2056        use crate::pool::{ConnectionPool, PoolConfig};
2057        use crate::stores::vectors::SqliteVecStore;
2058
2059        crate::extension::ensure_extensions_loaded();
2060        let dir = tempfile::tempdir().expect("tempdir");
2061        let path = dir.path().join("namespace-move-provenance.db");
2062        let mut conn = Connection::open(&path).expect("open database");
2063        run_migrations(&mut conn).expect("migrate database");
2064        let subject_id = uuid::Uuid::new_v4();
2065        let subject = subject_id.to_string();
2066        seed_note(&conn, &subject, "source", "observation");
2067        conn.execute_batch(
2068            "CREATE VIRTUAL TABLE vec_test_model USING vec0(\
2069             subject_id TEXT PRIMARY KEY, namespace TEXT NOT NULL, \
2070             kind TEXT NOT NULL, field TEXT NOT NULL, \
2071             embedding_model TEXT NOT NULL, embedding float[2] distance_metric=cosine)",
2072        )
2073        .unwrap();
2074        conn.execute(
2075            "INSERT INTO vec_test_model \
2076             (subject_id, namespace, kind, field, embedding_model, embedding) \
2077             VALUES (?1, 'source', 'observation', 'content', 'test-model', '[0.1, 0.2]')",
2078            [&subject],
2079        )
2080        .unwrap();
2081        let before_blob: Vec<u8> = conn
2082            .query_row(
2083                "SELECT embedding FROM vec_test_model WHERE subject_id = ?1",
2084                [&subject],
2085                |row| row.get(0),
2086            )
2087            .unwrap();
2088        let digest = blake3::hash(&before_blob).to_hex().to_string();
2089        conn.execute(
2090            "INSERT INTO vector_provenance \
2091             (model_key, subject_id, namespace, embedding_digest, text_fingerprint, updated_at) \
2092             VALUES ('test_model', ?1, 'source', ?2, ?3, '2026-09-25T12:34:56Z')",
2093            rusqlite::params![&subject, &digest, "a".repeat(64)],
2094        )
2095        .unwrap();
2096
2097        // A target-scoped stale sidecar with equal BLOB bytes would become
2098        // apparently current after the move if clearing were source-scoped.
2099        let stale_target_id = uuid::Uuid::new_v4();
2100        let stale_target_subject = stale_target_id.to_string();
2101        seed_note(&conn, &stale_target_subject, "source", "observation");
2102        conn.execute(
2103            "INSERT INTO vec_test_model \
2104             (subject_id, namespace, kind, field, embedding_model, embedding) \
2105             VALUES (?1, 'source', 'observation', 'content', 'test-model', '[0.1, 0.2]')",
2106            [&stale_target_subject],
2107        )
2108        .unwrap();
2109        conn.execute(
2110            "INSERT INTO vector_provenance \
2111             (model_key, subject_id, namespace, embedding_digest, text_fingerprint, updated_at) \
2112             VALUES ('test_model', ?1, 'target', ?2, ?3, '2026-09-25T12:34:56Z')",
2113            rusqlite::params![&stale_target_subject, &digest, "b".repeat(64)],
2114        )
2115        .unwrap();
2116
2117        conn.execute_batch("BEGIN IMMEDIATE").unwrap();
2118        move_namespace(
2119            &conn,
2120            &MoveRequest::new("source", vec![route("note:observation", "target")]),
2121        )
2122        .unwrap();
2123        conn.execute_batch("COMMIT").unwrap();
2124        let sidecars: i64 = conn
2125            .query_row(
2126                "SELECT COUNT(*) FROM vector_provenance \
2127                 WHERE model_key = 'test_model' AND subject_id IN (?1, ?2)",
2128                rusqlite::params![&subject, &stale_target_subject],
2129                |row| row.get(0),
2130            )
2131            .unwrap();
2132        assert_eq!(
2133            sidecars, 0,
2134            "a move must invalidate even an equal-BLOB sidecar"
2135        );
2136        let after_blob: Vec<u8> = conn
2137            .query_row(
2138                "SELECT embedding FROM vec_test_model \
2139                 WHERE subject_id = ?1 AND namespace = 'target'",
2140                [&subject],
2141                |row| row.get(0),
2142            )
2143            .unwrap();
2144        assert_eq!(after_blob, before_blob, "the vector itself must survive");
2145        drop(conn);
2146
2147        let pool = Arc::new(
2148            ConnectionPool::new(PoolConfig {
2149                path: Some(path),
2150                write_queue_enabled: Some(false),
2151                ..PoolConfig::for_test()
2152            })
2153            .expect("reopen moved database"),
2154        );
2155        let vectors = SqliteVecStore::new(
2156            pool,
2157            true,
2158            "test_model".into(),
2159            "test-model".into(),
2160            2,
2161            "target".into(),
2162        )
2163        .expect("open target vector store");
2164        let observed = vectors
2165            .provenance(subject_id)
2166            .await
2167            .expect("read moved vector")
2168            .expect("moved vector remains present");
2169        assert_eq!(observed.text_fingerprint, None);
2170        assert_eq!(observed.updated_at, None);
2171        let stale_target = vectors
2172            .provenance(stale_target_id)
2173            .await
2174            .expect("read formerly stale target vector")
2175            .expect("second moved vector remains present");
2176        assert_eq!(stale_target.text_fingerprint, None);
2177        assert_eq!(stale_target.updated_at, None);
2178    }
2179
2180    /// The instructions are about the vectors that MOVED, and a target is
2181    /// allowed to have vectors of its own already.
2182    ///
2183    /// Built by reading the live table back after the insert, the source side is
2184    /// "everything now under the target", which is the moved rows plus whatever
2185    /// was already there. That tells the source's consumer to drop a subject the
2186    /// source never held, re-upserts a vector that did not move, and reports an
2187    /// appended count of four where two instructions were owed. The staged rows
2188    /// are the only reading of "what moved" that survives the insert, which is
2189    /// why the log is built before they are dropped.
2190    #[cfg(feature = "vectors")]
2191    #[test]
2192    fn a_vector_the_target_already_held_is_not_in_the_instructions() {
2193        crate::extension::ensure_extensions_loaded();
2194        let conn = migrated();
2195        seed_note(&conn, "n1", "source", "observation");
2196        conn.execute_batch(
2197            "CREATE VIRTUAL TABLE vec_test_model USING vec0(\
2198               subject_id TEXT PRIMARY KEY, \
2199               namespace TEXT NOT NULL, \
2200               kind TEXT NOT NULL, \
2201               field TEXT NOT NULL, \
2202               embedding_model TEXT NOT NULL, \
2203               embedding float[4] distance_metric=cosine\
2204             )",
2205        )
2206        .expect("the vector table an embedding model creates at runtime");
2207        conn.execute(
2208            "INSERT INTO vec_test_model \
2209             (subject_id, namespace, kind, field, embedding_model, embedding) \
2210             VALUES ('n1', 'source', 'observation', 'content', 'test-model', \
2211                     '[0.1, 0.2, 0.3, 0.4]')",
2212            [],
2213        )
2214        .expect("the vector that moves");
2215        conn.execute(
2216            "INSERT INTO vec_test_model \
2217             (subject_id, namespace, kind, field, embedding_model, embedding) \
2218             VALUES ('already-there', 'target', 'observation', 'content', 'test-model', \
2219                     '[0.5, 0.6, 0.7, 0.8]')",
2220            [],
2221        )
2222        .expect("a vector the target already holds");
2223
2224        let request = MoveRequest::new("source", vec![route("note:observation", "target")]);
2225        let counts = move_namespace(&conn, &request).expect("a total move");
2226
2227        assert_eq!(
2228            counts.ann_log_appended, 2,
2229            "two instructions are owed for the one vector that moved"
2230        );
2231        let about_the_resident: i64 = conn
2232            .query_row(
2233                "SELECT COUNT(*) FROM ann_write_log WHERE subject_id = 'already-there'",
2234                [],
2235                |r| r.get(0),
2236            )
2237            .expect("count");
2238        assert_eq!(
2239            about_the_resident, 0,
2240            "a vector that did not move is told nothing, and is certainly not \
2241             dropped from a namespace it was never in"
2242        );
2243        // The control, so the arm cannot pass on a move that logged nothing at
2244        // all: the vector that did move still has both of its instructions.
2245        let about_the_mover: i64 = conn
2246            .query_row(
2247                "SELECT COUNT(*) FROM ann_write_log WHERE subject_id = 'n1'",
2248                [],
2249                |r| r.get(0),
2250            )
2251            .expect("count");
2252        assert_eq!(about_the_mover, 2);
2253    }
2254    /// Two routes bound for one target report a shared collision once, not twice.
2255    ///
2256    /// The pre-flight reads a constraint and two namespaces and never reads the
2257    /// route's class, so iterating routes asked the same question once per route
2258    /// and pushed byte-identical rows. A `Collision` carries no route, so a
2259    /// repeat says nothing a reader can act on: it inflates the list in
2260    /// proportion to how finely the caller partitioned its request, which is the
2261    /// one thing the refusal should be independent of.
2262    ///
2263    /// The arm fails on the unfixed code by reporting the same collision three
2264    /// times, once per route. Restoring the route-keyed loop is the control.
2265    #[test]
2266    fn two_routes_to_one_target_report_a_shared_collision_once() {
2267        let conn = migrated();
2268        // The clash is on the atom slug, which is a plain two-column unique
2269        // index and the one collision a SQL seed can plant honestly. Every class
2270        // present in the source must be routed or `validate` refuses first, so
2271        // the source holds exactly what these three routes name.
2272        seed_note(&conn, "n1", "source", "observation");
2273        seed_note(&conn, "n2", "source", "insight");
2274        for (id, namespace) in [("a1", "source"), ("a2", "target")] {
2275            conn.execute(
2276                "INSERT INTO knowledge_atoms \
2277                 (id, namespace, slug, name, created_at, updated_at) \
2278                 VALUES (?1, ?2, 'shared-slug', 'an atom', 1, 1)",
2279                rusqlite::params![id, namespace],
2280            )
2281            .expect("seed an atom on each side of the move");
2282        }
2283
2284        let request = MoveRequest::new(
2285            "source",
2286            vec![
2287                route("note:observation", "target"),
2288                route("note:insight", "target"),
2289                route("atom", "target"),
2290            ],
2291        );
2292        let error = move_namespace(&conn, &request).expect_err("the pre-flight refuses");
2293        let MoveError::Collisions { collisions } = error else {
2294            panic!("expected a named collision list, got {error:?}");
2295        };
2296
2297        assert_eq!(
2298            collisions.len(),
2299            1,
2300            "three routes share one target, so the one blocking row is reported \
2301             once: {collisions:?}"
2302        );
2303        assert_eq!(collisions[0].table, "knowledge_atoms");
2304        assert_eq!(collisions[0].constraint, "idx_knowledge_atoms_ns_slug");
2305        assert_eq!(collisions[0].key, "shared-slug");
2306    }
2307}
2308
2309#[cfg(test)]
2310#[test]
2311fn issue2673_namespace_move_advances_entity_version_without_changing_timestamp() {
2312    let mut conn = Connection::open_in_memory().unwrap();
2313    crate::migrations::run_migrations(&mut conn).unwrap();
2314    conn.execute("INSERT INTO entities(id,namespace,kind,name,created_at,updated_at) VALUES('versioned','source','concept','moved',7,7)", []).unwrap();
2315    conn.execute(
2316        "INSERT INTO notes(id,namespace,kind,content,created_at,updated_at) \
2317         VALUES('note-moved','source','observation','moved',11,11), \
2318               ('note-control','unrelated','observation','unchanged',13,13)",
2319        [],
2320    )
2321    .unwrap();
2322    let request = MoveRequest::new(
2323        "source",
2324        vec![
2325            MoveRoute {
2326                class: SubjectClass::Entity("concept".into()),
2327                target: "target".into(),
2328            },
2329            MoveRoute {
2330                class: SubjectClass::Note("observation".into()),
2331                target: "target".into(),
2332            },
2333        ],
2334    );
2335    let tx = conn.transaction().unwrap();
2336    let moved = move_namespace(&tx, &request).unwrap();
2337    assert_eq!(moved.subjects.get("entity:concept"), Some(&1));
2338    assert_eq!(moved.subjects.get("note:observation"), Some(&1));
2339    tx.commit().unwrap();
2340    let stored = conn
2341        .query_row(
2342            "SELECT namespace,updated_at,version FROM entities WHERE id='versioned'",
2343            [],
2344            |row| {
2345                Ok((
2346                    row.get::<_, String>(0)?,
2347                    row.get::<_, i64>(1)?,
2348                    row.get::<_, i64>(2)?,
2349                ))
2350            },
2351        )
2352        .unwrap();
2353    assert_eq!(stored, ("target".into(), 7, 2));
2354    for (id, namespace, timestamp, version) in [
2355        ("note-moved", "target", 11_i64, 2_i64),
2356        ("note-control", "unrelated", 13_i64, 1_i64),
2357    ] {
2358        let stored = conn
2359            .query_row(
2360                "SELECT namespace,updated_at,version FROM notes WHERE id=?1",
2361                [id],
2362                |row| {
2363                    Ok((
2364                        row.get::<_, String>(0)?,
2365                        row.get::<_, i64>(1)?,
2366                        row.get::<_, i64>(2)?,
2367                    ))
2368                },
2369            )
2370            .unwrap();
2371        assert_eq!(stored, (namespace.into(), timestamp, version));
2372    }
2373
2374    let tx = conn.transaction().unwrap();
2375    let repeated = move_namespace(&tx, &request).unwrap();
2376    assert_eq!(repeated.subjects.get("entity:concept"), Some(&0));
2377    assert_eq!(repeated.subjects.get("note:observation"), Some(&0));
2378    tx.commit().unwrap();
2379    for (sql, expected) in [
2380        ("SELECT version FROM entities WHERE id='versioned'", 2_i64),
2381        ("SELECT version FROM notes WHERE id='note-moved'", 2_i64),
2382        ("SELECT version FROM notes WHERE id='note-control'", 1_i64),
2383    ] {
2384        assert_eq!(
2385            conn.query_row(sql, [], |row| row.get::<_, i64>(0)).unwrap(),
2386            expected
2387        );
2388    }
2389}
2390
2391#[cfg(all(test, feature = "vectors"))]
2392#[path = "namespace_move_partition_tests.rs"]
2393mod partition_tests;
2394
2395#[cfg(test)]
2396#[path = "namespace_move_edge_tests.rs"]
2397mod edge_tests;
2398
2399#[cfg(test)]
2400#[path = "namespace_move_pack_tables_tests.rs"]
2401mod pack_table_tests;
2402
2403#[cfg(all(test, feature = "vectors"))]
2404#[path = "namespace_move_orphan_section_tests.rs"]
2405mod orphan_section_tests;