grit-core 0.2.4

Embedded, bi-temporal property graph for agent memory: one SQLite file, in-process, deterministic
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
//! The single-writer actor (see AGENTS.md "Write path"). SQLite has one
//! writer; we embrace it: all mutations flow through one thread owning the
//! write connection. Callers talk to it over an mpsc channel.
//!
//! Op application is **idempotent** (an op id already in the oplog is skipped)
//! and **commutative for concurrent ops** (conflicting row versions resolve by
//! HLC, invalidations converge to the minimum) — the sync guarantee of Design
//! Invariant 5, property-tested in `tests/convergence.rs`.

use std::sync::Arc;
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
use std::thread::JoinHandle;

use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
use uuid::Uuid;

use crate::clock::Clock;
use crate::error::{Error, Result};
use crate::ops::{GraphOp, OplogEntry};
use crate::vecext::f32s_as_bytes;

// Apply is by far the most common message; boxing OplogEntry to appease the
// variant-size lint would add an allocation to every write for a transient
// message that lives microseconds.
#[allow(clippy::large_enum_variant)]
pub(crate) enum WriteMsg {
    Apply {
        entry: OplogEntry,
        reply: SyncSender<Result<bool>>,
    },
    RegisterModel {
        model_id: String,
        dim: usize,
        version: String,
        reply: SyncSender<Result<()>>,
    },
    SetEmbedding {
        table: VecTable,
        id: Uuid,
        vector: Vec<f32>,
        reply: SyncSender<Result<()>>,
    },
    Shutdown,
}

#[derive(Debug, Clone, Copy)]
pub(crate) enum VecTable {
    Nodes,
    Edges,
}

impl VecTable {
    fn name(self) -> &'static str {
        match self {
            VecTable::Nodes => "vec_nodes",
            VecTable::Edges => "vec_edges",
        }
    }

    /// The base graph table whose row owns the vector (and its group).
    fn base(self) -> &'static str {
        match self {
            VecTable::Nodes => "nodes",
            VecTable::Edges => "edges",
        }
    }
}

pub(crate) fn spawn(
    mut conn: Connection,
    clock: Arc<dyn Clock>,
    rx: Receiver<WriteMsg>,
) -> JoinHandle<()> {
    std::thread::Builder::new()
        .name("grit-writer".into())
        .spawn(move || {
            while let Ok(msg) = rx.recv() {
                match msg {
                    WriteMsg::Apply { entry, reply } => {
                        let _ = reply.send(apply_entry(&mut conn, clock.as_ref(), &entry));
                    }
                    WriteMsg::RegisterModel {
                        model_id,
                        dim,
                        version,
                        reply,
                    } => {
                        let _ = reply.send(register_model(&mut conn, &model_id, dim, &version));
                    }
                    WriteMsg::SetEmbedding {
                        table,
                        id,
                        vector,
                        reply,
                    } => {
                        let _ = reply.send(set_embedding(&mut conn, table, id, &vector));
                    }
                    WriteMsg::Shutdown => break,
                }
            }
        })
        .expect("failed to spawn grit-writer thread")
}

/// Blocking round-trip to the writer thread.
pub(crate) fn call<T>(
    tx: &std::sync::mpsc::Sender<WriteMsg>,
    make: impl FnOnce(SyncSender<Result<T>>) -> WriteMsg,
) -> Result<T> {
    let (reply_tx, reply_rx) = sync_channel(1);
    tx.send(make(reply_tx)).map_err(|_| Error::Closed)?;
    reply_rx.recv().map_err(|_| Error::Closed)?
}

/// Append `entry` to the oplog and apply its derived state in one transaction.
/// Returns `false` (and changes nothing) if the op id was already applied.
fn apply_entry(conn: &mut Connection, clock: &dyn Clock, entry: &OplogEntry) -> Result<bool> {
    let op_json = serde_json::to_string(&entry.op)?;
    let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
    let inserted = tx.execute(
        "INSERT OR IGNORE INTO oplog (id, hlc, device_id, op, applied_at)
         VALUES (?1, ?2, ?3, ?4, ?5)",
        params![
            entry.id.to_string(),
            entry.hlc.encode(),
            entry.device_id,
            op_json,
            clock.now_ms(),
        ],
    )?;
    if inserted == 0 {
        // Idempotency: this op has already been applied here.
        return Ok(false);
    }
    apply_op(&tx, entry)?;
    tx.commit()?;
    Ok(true)
}

fn apply_op(tx: &Transaction<'_>, entry: &OplogEntry) -> Result<()> {
    let hlc = entry.hlc.encode();
    // Derived-state timestamps come from the op's HLC wall time, not the local
    // clock, so replaying an oplog reproduces identical rows on every device.
    let wall = entry.hlc.wall_ms;
    match &entry.op {
        GraphOp::AddNode {
            id,
            kind,
            name,
            summary,
            attrs,
            group_id,
        } => {
            // On id collision (two devices minting the same UUIDv7 is
            // practically impossible, but replays make it reachable), the
            // lowest HLC wins deterministically regardless of apply order.
            // A tombstoned id stays dead (Purge commutes with adds).
            tx.execute(
                "INSERT INTO nodes (id, kind, name, summary, attrs, group_id, created_at, hlc)
                 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8
                 WHERE NOT EXISTS (SELECT 1 FROM purged WHERE id = ?1)
                 ON CONFLICT(id) DO UPDATE SET
                     kind = excluded.kind, name = excluded.name,
                     summary = excluded.summary, attrs = excluded.attrs,
                     group_id = excluded.group_id, created_at = excluded.created_at,
                     hlc = excluded.hlc
                 WHERE excluded.hlc < nodes.hlc",
                params![
                    id.to_string(),
                    kind,
                    name,
                    summary,
                    serde_json::to_string(attrs)?,
                    group_id,
                    wall,
                    hlc,
                ],
            )?;
            // An UpdateNode may have been applied before this row arrived
            // (concurrent ops), and a lower-HLC AddNode replay rewrites row
            // content wholesale — re-fold the per-field winners either way.
            fold_node_updates(tx, &id.to_string())?;
        }
        GraphOp::UpdateNode {
            id,
            name,
            summary,
            kind,
            attrs,
        } => {
            // Append-only per-field record first (survives even if the node
            // row hasn't arrived yet), then fold into the row. Concurrent
            // updates of the same field converge to the highest HLC —
            // commutative and idempotent. A tombstoned id stays dead.
            let id_s = id.to_string();
            let attrs_s = attrs.as_ref().map(serde_json::to_string).transpose()?;
            let fields: [(&str, Option<&str>); 4] = [
                ("name", name.as_deref()),
                ("summary", summary.as_deref()),
                ("kind", kind.as_deref()),
                ("attrs", attrs_s.as_deref()),
            ];
            for (field, value) in fields {
                let Some(value) = value else { continue };
                let mut stmt = tx.prepare_cached(
                    "INSERT INTO node_updates (node_id, field, value, hlc)
                     SELECT ?1, ?2, ?3, ?4
                     WHERE NOT EXISTS (SELECT 1 FROM purged WHERE id = ?1)
                     ON CONFLICT(node_id, field) DO UPDATE SET
                         value = excluded.value, hlc = excluded.hlc
                     WHERE excluded.hlc > node_updates.hlc",
                )?;
                stmt.execute(params![id_s, field, value, hlc])?;
            }
            fold_node_updates(tx, &id_s)?;
        }
        GraphOp::AddEdge {
            id,
            src,
            dst,
            rel,
            fact,
            attrs,
            group_id,
            valid_at,
            invalid_at,
        } => {
            // Endpoints resolve through any already-applied merges, and every
            // merge re-points existing edges when it applies — so add/merge
            // interleavings converge on the same endpoints either way.
            let src = resolve_canonical(tx, &src.to_string())?;
            let dst = resolve_canonical(tx, &dst.to_string())?;
            tx.execute(
                "INSERT INTO edges (id, src, dst, rel, fact, attrs, group_id, valid_at,
                                    invalid_at, created_at, hlc)
                 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11
                 WHERE NOT EXISTS (SELECT 1 FROM purged WHERE id = ?1)
                 ON CONFLICT(id) DO UPDATE SET
                     src = excluded.src, dst = excluded.dst, rel = excluded.rel,
                     fact = excluded.fact, attrs = excluded.attrs,
                     group_id = excluded.group_id, valid_at = excluded.valid_at,
                     invalid_at = excluded.invalid_at,
                     created_at = excluded.created_at, hlc = excluded.hlc
                 WHERE excluded.hlc < edges.hlc",
                params![
                    id.to_string(),
                    src,
                    dst,
                    rel,
                    fact,
                    serde_json::to_string(attrs)?,
                    group_id,
                    valid_at,
                    invalid_at,
                    wall,
                    hlc,
                ],
            )?;
            // An InvalidateEdge for this edge may have been applied before the
            // edge itself arrived (concurrent ops, arbitrary merge order):
            // re-fold the append-only invalidation records over the row.
            fold_edge_invalidations(tx, &id.to_string())?;
        }
        GraphOp::AddEpisode {
            id,
            source,
            kind,
            content,
            occurred_at,
            group_id,
            mentions,
        } => {
            tx.execute(
                "INSERT INTO episodes (id, source, kind, content, occurred_at, group_id,
                                       created_at, hlc)
                 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8
                 WHERE NOT EXISTS (SELECT 1 FROM purged WHERE id = ?1)
                 ON CONFLICT(id) DO UPDATE SET
                     source = excluded.source, kind = excluded.kind,
                     content = excluded.content,
                     occurred_at = excluded.occurred_at, group_id = excluded.group_id,
                     created_at = excluded.created_at, hlc = excluded.hlc
                 WHERE excluded.hlc < episodes.hlc",
                params![
                    id.to_string(),
                    source,
                    kind,
                    content,
                    occurred_at,
                    group_id,
                    wall,
                    hlc
                ],
            )?;
            for target in mentions {
                // Mentions keep the ORIGINAL target id, are never re-pointed
                // by merges (reads expand merge aliases instead), and purge of
                // either endpoint deletes + tombstones them. Storing anything
                // resolution-dependent here would make the final row depend on
                // which merges had already been applied — order-dependent.
                let mut stmt = tx.prepare_cached(
                    "INSERT OR IGNORE INTO mentions (episode_id, target_id)
                     SELECT ?1, ?2
                     WHERE NOT EXISTS (SELECT 1 FROM purged WHERE id IN (?1, ?2))",
                )?;
                stmt.execute(params![id.to_string(), target.to_string()])?;
            }
        }
        GraphOp::InvalidateEdge {
            edge_id,
            invalid_at,
        } => {
            // Append-only record first (survives even if the edge row hasn't
            // arrived yet), then fold into the edge. Concurrent invalidations
            // converge to MIN(invalid_at) — commutative and idempotent. Two
            // devices invalidating the same edge at the same event time
            // collide on the (edge_id, invalid_at) PK: field-wise MIN keeps
            // the earliest recording deterministically in every apply order
            // (recorded_at is the HLC wall, so the two fields stay coherent).
            tx.execute(
                "INSERT INTO edge_invalidations
                     (edge_id, invalid_at, recorded_at, hlc)
                 SELECT ?1, ?2, ?3, ?4
                 WHERE NOT EXISTS (SELECT 1 FROM purged WHERE id = ?1)
                 ON CONFLICT(edge_id, invalid_at) DO UPDATE SET
                     recorded_at = MIN(recorded_at, excluded.recorded_at),
                     hlc = MIN(hlc, excluded.hlc)",
                params![edge_id.to_string(), invalid_at, wall, hlc],
            )?;
            fold_edge_invalidations(tx, &edge_id.to_string())?;
        }
        GraphOp::MergeNodes { from, into } => {
            if from == into {
                // Defensive: Grit::apply rejects this before it reaches the
                // oplog, but a remote entry could still carry it.
                return Ok(());
            }
            let (from_s, into_s) = (from.to_string(), into.to_string());
            // If `from` hasn't arrived yet (concurrent op order), record the
            // merge on a stub row; the max-HLC sentinel guarantees the real
            // AddNode's content wins the upsert when it lands, while the
            // merge marker survives (AddNode never touches merged_into).
            tx.execute(
                "INSERT INTO nodes (id, kind, name, summary, attrs, group_id,
                                    created_at, hlc)
                 SELECT ?1, '', '', '', '{}', '', ?2, ?3
                 WHERE NOT EXISTS (SELECT 1 FROM nodes WHERE id = ?1)
                   AND NOT EXISTS (SELECT 1 FROM purged WHERE id = ?1)",
                params![from_s, wall, MAX_HLC_SENTINEL],
            )?;
            // First merge of a node wins the audit pointer; concurrent merges
            // of the SAME `from` node into different targets are a Layer 2
            // protocol violation (it is the single merge decider) and are not
            // part of the convergence contract.
            tx.execute(
                "UPDATE nodes
                 SET merged_into = ?2,
                     expired_at = CASE
                         WHEN expired_at IS NULL OR expired_at > ?3 THEN ?3
                         ELSE expired_at END
                 WHERE id = ?1 AND merged_into IS NULL",
                params![from_s, into_s, wall],
            )?;
            // A merge that lands after `from` was purged still records its
            // pointer on the tombstone — resolution stays order-independent.
            tx.execute(
                "UPDATE purged SET merged_into = ?2
                 WHERE id = ?1 AND merged_into IS NULL",
                params![from_s, into_s],
            )?;
            // A stub row created above must still carry any already-applied
            // updates, or Merge-then-Update and Update-then-Merge would
            // leave different rows until the real AddNode lands.
            fold_node_updates(tx, &from_s)?;
            repoint_aliases(tx, &into_s)?;
        }
        GraphOp::Purge { ids } => {
            purge(tx, ids)?;
        }
    }
    Ok(())
}

/// A sentinel that sorts after every real HLC (real wall clocks are far below
/// `0xffff_ffff_ffff_ffff` ms). Used for merge stub rows so any real op's
/// content wins the min-HLC upsert.
const MAX_HLC_SENTINEL: &str = "ffffffffffffffff-ffffffff-";

/// Fold the append-only invalidation records over an edge row: event-time
/// `invalid_at` converges to the earliest of the AddEdge-provided bound
/// (the row value the upsert just wrote) and every recorded invalidation.
/// `expired_at` is deliberately NOT touched — in grit's read model an
/// expired row is retracted from belief entirely, whereas an invalidation
/// only bounds the fact's event time; the belief history of invalidations
/// lives in `edge_invalidations.recorded_at`.
fn fold_edge_invalidations(tx: &Transaction<'_>, edge_id: &str) -> Result<()> {
    let mut stmt = tx.prepare_cached(
        "UPDATE edges SET
             invalid_at = MIN(COALESCE(invalid_at, 9223372036854775807),
                              (SELECT MIN(invalid_at) FROM edge_invalidations
                               WHERE edge_id = ?1))
         WHERE id = ?1
           AND EXISTS (SELECT 1 FROM edge_invalidations WHERE edge_id = ?1)",
    )?;
    stmt.execute(params![edge_id])?;
    Ok(())
}

/// Fold the per-field `node_updates` winners over the node row's AddNode
/// base content. An update wins over base unconditionally (HLC causality:
/// a device can only update a node it has seen, so update HLCs follow the
/// creation HLC); among updates per field, the highest HLC won at insert.
/// No-op when the row hasn't arrived yet — AddNode re-folds on arrival.
fn fold_node_updates(tx: &Transaction<'_>, node_id: &str) -> Result<()> {
    let mut stmt = tx.prepare_cached(
        "UPDATE nodes SET
             name    = COALESCE((SELECT value FROM node_updates
                                 WHERE node_id = ?1 AND field = 'name'), name),
             summary = COALESCE((SELECT value FROM node_updates
                                 WHERE node_id = ?1 AND field = 'summary'), summary),
             kind    = COALESCE((SELECT value FROM node_updates
                                 WHERE node_id = ?1 AND field = 'kind'), kind),
             attrs   = COALESCE((SELECT value FROM node_updates
                                 WHERE node_id = ?1 AND field = 'attrs'), attrs)
         WHERE id = ?1
           AND EXISTS (SELECT 1 FROM node_updates WHERE node_id = ?1)",
    )?;
    stmt.execute(params![node_id])?;
    Ok(())
}

/// Follow `merged_into` pointers to the canonical node id. Concurrent merges
/// can form a cycle (A→B on one device, B→A on another); the canonical member
/// of a cycle is the minimum id — deterministic on every device.
fn resolve_canonical(tx: &Transaction<'_>, id: &str) -> Result<String> {
    let mut chain: Vec<String> = vec![id.to_owned()];
    let mut seen: std::collections::HashSet<String> = chain.iter().cloned().collect();
    loop {
        let current = chain.last().expect("chain never empty");
        // A purged node keeps its merge pointer on the tombstone.
        let mut stmt = tx.prepare_cached(
            "SELECT merged_into FROM nodes WHERE id = ?1
             UNION ALL
             SELECT merged_into FROM purged WHERE id = ?1
             LIMIT 1",
        )?;
        let next: Option<Option<String>> =
            stmt.query_row(params![current], |r| r.get(0)).optional()?;
        match next {
            None | Some(None) => return Ok(chain.pop().expect("chain never empty")),
            Some(Some(next)) => {
                if seen.contains(&next) {
                    let pos = chain
                        .iter()
                        .position(|x| x == &next)
                        .expect("seen implies present");
                    return Ok(chain[pos..]
                        .iter()
                        .min()
                        .expect("cycle slice non-empty")
                        .clone());
                }
                seen.insert(next.clone());
                chain.push(next);
            }
        }
    }
}

/// After a merge lands, re-point every edge/mention endpoint that resolves to
/// `into`'s canonical node directly at it. Runs on every merge apply, so an
/// edge added before the merge (on any device) ends up identical to one added
/// after it.
fn repoint_aliases(tx: &Transaction<'_>, into: &str) -> Result<()> {
    let canon = resolve_canonical(tx, into)?;
    // All ids whose merged_into chain reaches the canonical node (cycle-safe:
    // UNION, not UNION ALL).
    const ALIASES: &str = "WITH RECURSIVE aliases (id) AS (
         SELECT ?1
         UNION
         SELECT n.id FROM nodes AS n JOIN aliases AS a ON n.merged_into = a.id
         UNION
         SELECT p.id FROM purged AS p JOIN aliases AS a ON p.merged_into = a.id
     )";
    tx.execute(
        &format!(
            "{ALIASES}
             UPDATE edges SET src = ?1
             WHERE src IN (SELECT id FROM aliases) AND src != ?1"
        ),
        params![canon],
    )?;
    tx.execute(
        &format!(
            "{ALIASES}
             UPDATE edges SET dst = ?1
             WHERE dst IN (SELECT id FROM aliases) AND dst != ?1"
        ),
        params![canon],
    )?;
    // Mentions are deliberately NOT re-pointed: they keep the original target
    // id (reads expand merge aliases). Re-pointing them here while Purge
    // deletes them by original id would make the final row set depend on
    // whether the merge or the purge applied first.
    Ok(())
}

/// Hard-delete exactly the listed ids and tombstone them forever. No cascade:
/// purging a node does NOT delete its incident edges (a cascade would make
/// Purge order-dependent against concurrent AddEdge ops); Layer 2 lists edge
/// ids explicitly — `node_history` hands them over. Ghost edges whose
/// endpoint was purged are filtered out of every read. The purge op itself
/// remains in the oplog as the audit record.
fn purge(tx: &Transaction<'_>, ids: &[Uuid]) -> Result<()> {
    let has_vec_nodes = table_exists(tx, "vec_nodes")?;
    let has_vec_edges = table_exists(tx, "vec_edges")?;
    for id in ids {
        let id_s = id.to_string();
        // Capture the node's merge pointer (if any) into the tombstone so
        // resolution through this id keeps working after the row is gone.
        tx.execute(
            "INSERT OR IGNORE INTO purged (id, merged_into)
             VALUES (?1, (SELECT merged_into FROM nodes WHERE id = ?1))",
            params![id_s],
        )?;
        tx.execute("DELETE FROM nodes WHERE id = ?1", params![id_s])?;
        tx.execute("DELETE FROM edges WHERE id = ?1", params![id_s])?;
        tx.execute("DELETE FROM episodes WHERE id = ?1", params![id_s])?;
        tx.execute(
            "DELETE FROM edge_invalidations WHERE edge_id = ?1",
            params![id_s],
        )?;
        tx.execute("DELETE FROM node_updates WHERE node_id = ?1", params![id_s])?;
        tx.execute(
            "DELETE FROM mentions WHERE episode_id = ?1 OR target_id = ?1",
            params![id_s],
        )?;
        if has_vec_nodes {
            tx.execute("DELETE FROM vec_nodes WHERE id = ?1", params![id_s])?;
        }
        if has_vec_edges {
            tx.execute("DELETE FROM vec_edges WHERE id = ?1", params![id_s])?;
        }
    }
    Ok(())
}

fn table_exists(tx: &Transaction<'_>, name: &str) -> Result<bool> {
    let n: i64 = tx.query_row(
        "SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?1",
        params![name],
        |r| r.get(0),
    )?;
    Ok(n > 0)
}

fn register_model(conn: &mut Connection, model_id: &str, dim: usize, version: &str) -> Result<()> {
    if dim == 0 || dim > 8192 {
        return Err(Error::Embedding(format!("unsupported embedding dim {dim}")));
    }
    let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
    let existing: Option<(String, i64, String)> = tx
        .query_row(
            "SELECT model_id, dim, model_version FROM embedding_meta WHERE id = 1",
            [],
            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
        )
        .map(Some)
        .or_else(|e| match e {
            rusqlite::Error::QueryReturnedNoRows => Ok(None),
            other => Err(other),
        })?;
    if let Some((_, existing_dim, _)) = existing {
        if existing_dim != dim as i64 {
            return Err(Error::Embedding(format!(
                "a model with dim {existing_dim} is already registered; \
                 changing dimensions requires a re-embedding flow (not in v0.1)"
            )));
        }
        tx.execute(
            "UPDATE embedding_meta SET model_id = ?1, model_version = ?2 WHERE id = 1",
            params![model_id, version],
        )?;
    } else {
        tx.execute(
            "INSERT INTO embedding_meta (id, model_id, dim, model_version)
             VALUES (1, ?1, ?2, ?3)",
            params![model_id, dim as i64, version],
        )?;
    }
    // Dimension is model-dependent, so vec tables are created here, not in
    // schema.sql — and on BOTH branches: an imported file carries
    // embedding_meta but no vec tables (vectors are recomputable local state
    // and never exported), so re-registering must be able to recreate them.
    // `dim` is validated numeric above; no user strings are interpolated.
    // group_id is a PARTITION KEY so the KNN legs scan only the query's
    // group instead of post-filtering a global top-k (schema v5).
    tx.execute_batch(&format!(
        "CREATE VIRTUAL TABLE IF NOT EXISTS vec_nodes USING vec0(
             id TEXT PRIMARY KEY, group_id TEXT PARTITION KEY,
             embedding FLOAT[{dim}] distance_metric=cosine);
         CREATE VIRTUAL TABLE IF NOT EXISTS vec_edges USING vec0(
             id TEXT PRIMARY KEY, group_id TEXT PARTITION KEY,
             embedding FLOAT[{dim}] distance_metric=cosine);"
    ))?;
    tx.commit()?;
    Ok(())
}

fn set_embedding(conn: &mut Connection, table: VecTable, id: Uuid, vector: &[f32]) -> Result<()> {
    let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
    // Right-to-forget: a vector is derived from its row's content, so an
    // embed racing a purge must not re-materialize purged data. No-op, like
    // every other write against a tombstone.
    let is_purged: i64 = tx.query_row(
        "SELECT COUNT(*) FROM purged WHERE id = ?1",
        params![id.to_string()],
        |r| r.get(0),
    )?;
    if is_purged > 0 {
        return Ok(());
    }
    let dim: i64 = tx
        .query_row("SELECT dim FROM embedding_meta WHERE id = 1", [], |r| {
            r.get(0)
        })
        .map_err(|_| Error::Embedding("no embedding model registered".into()))?;
    if vector.len() as i64 != dim {
        return Err(Error::Embedding(format!(
            "vector has dim {}, registered model has dim {dim}",
            vector.len()
        )));
    }
    // The vector lives in its row's group partition (schema v5), so the base
    // row must exist first. A silent fallback group would strand the vector
    // in the wrong partition — invisible to every group-filtered KNN.
    let group: String = tx
        .query_row(
            &format!("SELECT group_id FROM {} WHERE id = ?1", table.base()),
            params![id.to_string()],
            |r| r.get(0),
        )
        .map_err(|e| match e {
            rusqlite::Error::QueryReturnedNoRows => Error::NotFound(id),
            other => other.into(),
        })?;
    // vec0 has no upsert; delete + insert.
    tx.execute(
        &format!("DELETE FROM {} WHERE id = ?1", table.name()),
        params![id.to_string()],
    )?;
    tx.execute(
        &format!(
            "INSERT INTO {} (id, group_id, embedding) VALUES (?1, ?2, ?3)",
            table.name()
        ),
        params![id.to_string(), group, f32s_as_bytes(vector)],
    )?;
    tx.commit()?;
    Ok(())
}