Skip to main content

newt_core/
store.rs

1//! SQLite-backed conversation store — Phase 17.1a/17.1b (issue #246).
2//!
3//! The only conversation backend: the same public API the JSON-file store
4//! established (`create` / `create_with_id` / `exists` / `append_turn` /
5//! `load` / `list` / `rename` / `delete` / `resolve_id`, prefix resolution,
6//! workspace scoping, create-time pruning) backed by a single SQLite
7//! database at `<root>/conversations.db`. The legacy per-conversation JSON
8//! tree (`<root>/conversations/<workspace-uuid>/<id>.json`) is imported once
9//! on open and kept as a backup — see [One-time JSON import](#one-time-json-import-171b).
10//!
11//! # §6 — ordering is causal, time is a claim (BINDING)
12//!
13//! Per the mesh-readiness amendment in
14//! `docs/design/context-memory-hermes-learnings.md` §6:
15//!
16//! * **Ordering key:** `(writer_fingerprint, seq)` — a per-writer strictly
17//!   monotonic Lamport tick, allocated from the `writer_clock` table inside
18//!   the same transaction as the row it orders. "Most recent" is always
19//!   `MAX(activity_tick)` / the chain tip — **never** a wall-clock
20//!   comparison.
21//! * **Content chain:** every turn carries `prev_hash` = BLAKE3 of the prior
22//!   turn's canonical encoding (genesis-derived for the first turn), so each
23//!   conversation is a per-writer merkle log: the record carries its own
24//!   proof of order and tampering is detectable ([`ConversationStore::verify_chain`]).
25//! * **Wall-clock columns** (`started_at_claim`, `updated_at_claim`,
26//!   `ts_claim`) are **display-only claims**. No query in this module orders,
27//!   prunes, or resolves by them.
28//!
29//! **Honesty note on the envelope (17.1b, review NIT N3 on #261):** the
30//! tamper-evident envelope covers the `turns` rows and the stored chain tip
31//! — nothing else. Conversation-row metadata (`title`, `activity_tick`, the
32//! `*_claim` columns, persona) can be edited in place undetectably with any
33//! SQLite client. 17.2 derives the writer fingerprint from real key material
34//! when it exists (below), but ticks are still not *signed* — so this
35//! integrity story remains anti-naive-edit, not anti-adversary, until a
36//! future step adds signatures.
37//!
38//! # Workspace identity v2 (17.2)
39//!
40//! The `workspace_key` scoping column is the v2 derivation
41//! ([`crate::workspace_key::workspace_key_v2`]): BLAKE3 hex of
42//! `(git origin URL, branch)` when the workspace is a git checkout with
43//! both, else BLAKE3 hex of the canonical path. Two clones of the same
44//! project on the same branch therefore *share* conversations — the
45//! decision doc's "folder = conversation across clones and containers"
46//! thesis — while non-git dirs keep per-path scoping.
47//!
48//! **Row migration:** on open, any conversation whose `workspace_key`
49//! equals THIS workspace's retired UUIDv5 key
50//! ([`ConversationStore::workspace_id_for_path`], kept for exactly this
51//! lookup) is re-keyed to the v2 key in one idempotent UPDATE. Other
52//! workspaces' rows are untouched — they migrate when their own workspace
53//! next opens, because only that open knows the path the UUIDv5 key was
54//! derived from (the hash is not reversible). The key is not part of the
55//! §6 turn encoding or genesis hash, so re-keying cannot disturb chain
56//! verification.
57//!
58//! # One-time JSON import (17.1b)
59//!
60//! On open, if the retired JSON backend's tree exists at
61//! `<root>/conversations/`, every readable record in every per-workspace
62//! UUID dir is imported into SQLite — all workspaces under the root, not
63//! just the opening store's (the files carry their workspace identity in
64//! the dir name and the record body). Turns get ticks through the normal
65//! `next_tick` path in legacy MRU order (ascending `updated_at`), so
66//! post-import MRU matches what the JSON backend would have shown; the
67//! legacy `unix_nanos` fields are ingested **only** as display claims
68//! (`*_claim` / `ts_claim` — §6). The chain is built turn by turn from the
69//! genesis hash, so [`ConversationStore::verify_chain`] passes on imported
70//! history. Corrupt records are skipped with a warning (the legacy store's
71//! own semantics). The import is idempotent and non-destructive: records
72//! whose id already exists are skipped, and after a successful pass the
73//! legacy dir is renamed to `conversations.imported/` and kept as a backup,
74//! so a second open finds nothing to import.
75//!
76//! # Writer identity (17.2)
77//!
78//! `writer_fingerprint` is, in preference order:
79//!
80//! 1. **The operator's mesh-key fingerprint** — when `<root>/identity.pem`
81//!    exists and parses (the newt-identity `UserKey`; for the production
82//!    root `~/.newt` this is exactly `~/.newt/identity.pem`), the
83//!    fingerprint is [`agent_mesh_protocol::UserKey::fingerprint`] in full
84//!    hex: BLAKE3 of the ed25519 public key, stable per operator across
85//!    installs and machines. Dependency note: this comes straight from
86//!    `agent-mesh-protocol` (already a direct dep); it must NOT come from
87//!    `newt-identity`, which depends on newt-core — the inversion would be
88//!    a cycle.
89//! 2. **The 17.1a per-install nonce fallback** — BLAKE3 hex of a nonce
90//!    minted once at `<root>/install-nonce`: stable across sessions,
91//!    distinct across installs. Used when no identity exists yet, or when
92//!    `identity.pem` is unreadable/corrupt (logged; a broken key file must
93//!    never block the store).
94//!
95//! Rows written before an identity existed keep their recorded nonce-derived
96//! writer and still verify: chains are per-writer (genesis is keyed by
97//! `(conversation, writer)`), `verify_chain` follows each row's *recorded*
98//! writer, and the Lamport clock seeds from the global max tick — so a
99//! fingerprint upgrade mid-history reads as a writer handoff, which §6
100//! already supports. Ticks are still not *signed*; that needs a schema
101//! column and arrives with a later step.
102//!
103//! # FTS5 recall index (17.3)
104//!
105//! `turns_fts` is a trigger-maintained **external-content** FTS5 table
106//! (unicode61 tokenizer) over four columns per turn: `user`, `assistant`,
107//! `tool_names`, and `tool_args_digest`. The latter two are derived **at
108//! index time** from the `events` JSON column — the 17.6 seam: `events` is
109//! a JSON array, and every element carrying a `tool` / `args_digest` string
110//! field contributes to the respective column (space-joined). As of 17.6
111//! [`ConversationStore::append_turn_full`] records real tool events
112//! ([`crate::ToolEvent`] — name, privacy-preserving args digest, outcome,
113//! duration claim), so a recall search for a tool name or digest term hits;
114//! rows written through plain `append_turn` (and every pre-17.6 row) carry
115//! `'[]'` and contribute empty derived columns.
116//!
117//! External content means FTS5 stores only the inverted index; at query
118//! time, column values (for [`ConversationStore::search`]'s `snippet()`)
119//! are read back through the `turns_fts_content` view, which derives the
120//! two event columns with the **same SQL expression** the triggers use
121//! ([`events_extract_sql`]) — so the indexed terms and the content read
122//! back can never disagree.
123//!
124//! Maintenance is by trigger: AFTER INSERT on `turns` (covers live appends
125//! and the one-time legacy import alike) and AFTER DELETE on `turns`
126//! (fires per row via the conversation-delete `ON DELETE CASCADE`). There
127//! is deliberately **no UPDATE trigger**: turns are append-only — no code
128//! path updates a turn row, and the §6 content chain depends on that
129//! invariant. The external-content `'delete'` command relies on it too:
130//! the values passed at delete time must equal the values indexed at
131//! insert time, which append-only rows guarantee.
132//!
133//! **Schema-diff story:** opening an older database that predates the
134//! index creates the view + virtual table + triggers AND backfills every
135//! existing turn, all in one `BEGIN IMMEDIATE` transaction. Presence of
136//! the `turns_fts` table is the idempotence marker — the backfill runs
137//! exactly once per database.
138//!
139//! **Rowid caveat (honesty note):** the index is keyed by `turns`' implicit
140//! rowid, and `turns` has a composite TEXT primary key — so SQLite's
141//! `VACUUM` is allowed to renumber those rowids, which would silently
142//! re-point index entries at the wrong turns. Nothing in newt ever VACUUMs
143//! `conversations.db`; external tools must not either. Recovery if one
144//! did: `DROP TABLE turns_fts;` and reopen — the open-time path recreates
145//! the table and re-runs the backfill.
146//!
147//! Query strings never reach `MATCH` raw: [`sanitize_fts5_query`] (the
148//! ported hermes sanitizer) preserves balanced `"phrases"`, strips FTS5
149//! metacharacters, trims dangling `AND`/`OR`/`NOT`, and auto-quotes
150//! dotted/hyphenated/path-like tokens (`chat-send`, `P2.2`,
151//! `src/store.rs`) so they are matched as text instead of parsed as
152//! syntax.
153//!
154//! # NFS / concurrency
155//!
156//! The connection opens with `journal_mode=WAL` + `synchronous=NORMAL`
157//! (the SQLite-documented corruption-safe pairing — fsync at checkpoints,
158//! not per commit) and falls back to `journal_mode=DELETE` at the default
159//! `synchronous=FULL` when SQLite reports the WAL-on-network-filesystem
160//! failure modes ("locking protocol" / "disk I/O error" — NFS homes); the
161//! captured error is exposed via [`ConversationStore::wal_fallback_notice`]
162//! for a user-facing message. A 5 s `busy_timeout` lets two concurrent newt
163//! processes share the database; every write happens inside a single
164//! `BEGIN IMMEDIATE` transaction so tick allocation, chain extension, and the
165//! row insert are atomic.
166
167use std::path::{Path, PathBuf};
168use std::sync::{Arc, Mutex};
169use std::time::Duration;
170
171use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
172
173use crate::conversation::{
174    new_conversation_id, session_plan_dir, ConversationRecord, ConversationSummary,
175    ConversationTurn,
176};
177
178/// Database file name under the store root (`~/.newt/conversations.db`).
179const DB_FILE: &str = "conversations.db";
180
181/// The retired JSON backend's tree under the store root: one
182/// `<workspace-uuid>/<id>.json` per conversation. Imported once on open.
183const LEGACY_JSON_DIR: &str = "conversations";
184
185/// Where the legacy tree is moved after a successful import (kept as a
186/// backup, never deleted by newt).
187const LEGACY_BACKUP_DIR: &str = "conversations.imported";
188
189/// Per-install nonce file under the store root; its BLAKE3 hex is the
190/// `writer_fingerprint` *fallback* when no identity key exists (see
191/// module docs — Writer identity).
192const NONCE_FILE: &str = "install-nonce";
193
194/// The operator's root identity key under the store root (`~/.newt` in
195/// production — the same `~/.newt/identity.pem` newt-identity mints). When
196/// present, its fingerprint IS the writer fingerprint.
197const IDENTITY_PEM_FILE: &str = "identity.pem";
198
199/// How long a writer waits on a locked database before erroring. Two newts
200/// sharing `~/.newt/conversations.db` serialize their write transactions
201/// behind this.
202const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
203
204/// Domain-separation prefix for the v1 canonical turn encoding (`prev_hash`
205/// chain). Versioned so a future encoding change cannot collide with v1.
206const TURN_ENCODING_V1_PREFIX: &[u8] = b"newt-turn:v1";
207
208/// The turn encoding version this build writes, recorded per row in
209/// `turns.encoding_version` (review NIT N1 on #261). [`TurnRow::content_hash`]
210/// dispatches on the stored value; only v1 exists today, and a row carrying
211/// an unknown version errors clearly instead of hashing garbage.
212const TURN_ENCODING_VERSION_CURRENT: i64 = 1;
213
214/// Domain-separation prefix for the per-(conversation, writer) genesis hash.
215const GENESIS_PREFIX: &[u8] = b"newt-turn-chain-genesis:v1";
216
217/// SQLite-backed conversation store (see module docs).
218///
219/// Cheap to clone: clones share one connection behind a mutex. All methods
220/// take `&self`, matching the JSON-backed predecessor.
221#[derive(Debug, Clone)]
222pub struct ConversationStore {
223    conn: Arc<Mutex<Connection>>,
224    workspace: PathBuf,
225    workspace_id: String,
226    writer_fingerprint: String,
227    max_per_workspace: usize,
228    /// `Some(captured sqlite error)` when WAL was refused and the store fell
229    /// back to `journal_mode=DELETE` (NFS homes). Surface this to the user.
230    wal_fallback: Option<String>,
231    /// Wall-clock source for the display-only `*_claim` columns. Injectable
232    /// so tests can drive the clock backwards mid-conversation and prove
233    /// ordering never consults it (§6 clock-skew test).
234    claim_clock: fn() -> i64,
235}
236
237impl ConversationStore {
238    /// Open (creating if needed) the store at `<root>/conversations.db`,
239    /// scoped to `workspace`. `max_per_workspace` is the create-time prune
240    /// cap (0 = no pruning), identical to the JSON backend.
241    pub fn new(
242        root: impl AsRef<Path>,
243        workspace: impl AsRef<Path>,
244        max_per_workspace: usize,
245    ) -> anyhow::Result<Self> {
246        let root = root.as_ref().to_path_buf();
247        let workspace = std::fs::canonicalize(workspace.as_ref())?;
248        let workspace_id = crate::workspace_key::workspace_key_v2(&workspace)?;
249        std::fs::create_dir_all(&root)?;
250        let writer_fingerprint = resolve_writer_fingerprint(&root)?;
251
252        let conn = Connection::open(root.join(DB_FILE))?;
253        conn.busy_timeout(BUSY_TIMEOUT)?;
254        conn.pragma_update(None, "foreign_keys", "ON")?;
255        // First-open init under concurrency: the journal-mode transition has
256        // documented busy-handler-EXEMPT lock paths, so SQLITE_BUSY can escape
257        // despite busy_timeout when several first runs race (reproduced: 8
258        // concurrent opens under llvm-cov). Bounded retry; once the db is in
259        // WAL, re-running this phase is a no-op so steady-state never loops.
260        let wal_fallback = {
261            let mut attempt = 0u32;
262            loop {
263                match apply_journal_mode(&conn)
264                    .and_then(|fb| create_schema(&conn).map(|()| fb))
265                    .and_then(|fb| reconcile_schema(&conn).map(|()| fb))
266                    // After reconciliation: the FTS view reads `events`,
267                    // which on a drifted pre-17.1b db exists only once the
268                    // column reconciliation above has run.
269                    .and_then(|fb| create_fts_index(&conn).map(|()| fb))
270                {
271                    Ok(fb) => break fb,
272                    Err(e)
273                        if attempt < 20
274                            && e.to_string().to_ascii_lowercase().contains("locked") =>
275                    {
276                        attempt += 1;
277                        std::thread::sleep(std::time::Duration::from_millis(
278                            25 * u64::from(attempt.min(4)),
279                        ));
280                    }
281                    Err(e) => return Err(e),
282                }
283            }
284        };
285
286        import_legacy_json(&conn, &root, &writer_fingerprint)?;
287        // 17.2: after the import (whose records carry UUIDv5 keys), re-key
288        // THIS workspace's rows from the retired UUIDv5 derivation to v2.
289        migrate_workspace_key(&conn, &workspace, &workspace_id)?;
290
291        Ok(Self {
292            conn: Arc::new(Mutex::new(conn)),
293            workspace,
294            workspace_id,
295            writer_fingerprint,
296            max_per_workspace,
297            wal_fallback,
298            claim_clock: now_claim_nanos,
299        })
300    }
301
302    /// The RETIRED v1 workspace key: UUIDv5 of the canonical path — the
303    /// derivation the JSON backend used (its per-workspace dir names) and
304    /// 17.1a inherited for `workspace_key`. Kept for exactly two lookups:
305    /// the one-time legacy JSON import (dir names are UUIDv5) and the 17.2
306    /// open-time migration that re-keys this workspace's old rows to
307    /// [`crate::workspace_key::workspace_key_v2`]. Do not key anything new
308    /// with it.
309    #[deprecated(
310        since = "0.6.8",
311        note = "v1 keying is path-fragile; use `newt_core::workspace_key_v2` \
312                (17.2). This stays only for the UUIDv5→v2 row migration and \
313                legacy-import dir names."
314    )]
315    pub fn workspace_id_for_path(path: impl AsRef<Path>) -> anyhow::Result<String> {
316        let canonical = std::fs::canonicalize(path.as_ref())?;
317        let normalized = canonical.to_string_lossy().replace('\\', "/");
318        Ok(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, normalized.as_bytes()).to_string())
319    }
320
321    /// `Some(error text)` when the database refused WAL and the store is
322    /// running on the `journal_mode=DELETE` fallback (typical for NFS
323    /// homes). Callers should surface this once to the user.
324    pub fn wal_fallback_notice(&self) -> Option<&str> {
325        self.wal_fallback.as_deref()
326    }
327
328    /// This install's writer fingerprint — the `writer_fingerprint` half of
329    /// the §6 `(writer_fingerprint, seq)` ordering key.
330    pub fn writer_fingerprint(&self) -> &str {
331        &self.writer_fingerprint
332    }
333
334    /// Create a conversation with a freshly minted id; returns the id.
335    pub fn create(&self, title: &str, persona: Option<&str>) -> anyhow::Result<String> {
336        let id = new_conversation_id();
337        self.create_with_id(&id, title, persona)?;
338        Ok(id)
339    }
340
341    /// Create a conversation record using a caller-supplied `id`.
342    ///
343    /// The TUI pre-generates a conversation id at session start (so the
344    /// per-session plan path is stable from turn 1, see issue #220) and the
345    /// record adopts that id when the first turn is saved — same lazy-create
346    /// contract as the JSON backend.
347    pub fn create_with_id(
348        &self,
349        id: &str,
350        title: &str,
351        persona: Option<&str>,
352    ) -> anyhow::Result<()> {
353        validate_record_id(id)?;
354        let now = (self.claim_clock)();
355        {
356            let conn = self.lock_conn();
357            let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
358            // Workspace fence: `id` is a GLOBAL primary key and REPLACE fires
359            // `ON DELETE CASCADE` — without this check, re-creating an id that
360            // belongs to ANOTHER workspace would silently destroy that
361            // workspace's conversation and all its turns. Same-workspace
362            // REPLACE keeps JSON-backend parity (re-create = overwrite).
363            let foreign: Option<String> = tx
364                .query_row(
365                    "SELECT workspace_key FROM conversations WHERE id = ?1",
366                    rusqlite::params![id],
367                    |row| row.get(0),
368                )
369                .optional()?;
370            if let Some(owner) = foreign {
371                if owner != self.workspace_id {
372                    anyhow::bail!(
373                        "conversation id `{id}` already exists in another workspace \
374                         (key {owner}); refusing to overwrite across the workspace fence"
375                    );
376                }
377            }
378            let tick = next_tick(&tx, &self.writer_fingerprint)?;
379            // INSERT OR REPLACE mirrors the JSON backend, where re-creating an
380            // existing id overwrote the record (turns reset). The REPLACE
381            // deletes the old row, and `ON DELETE CASCADE` drops its turns —
382            // safe only because of the fence above.
383            tx.execute(
384                "INSERT OR REPLACE INTO conversations
385                   (id, title, workspace_path, workspace_key, persona, end_reason,
386                    writer_fingerprint, activity_tick, tip_hash,
387                    started_at_claim, updated_at_claim)
388                 VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9, ?9)",
389                rusqlite::params![
390                    id,
391                    title.trim(),
392                    self.workspace.to_string_lossy(),
393                    self.workspace_id,
394                    persona,
395                    self.writer_fingerprint,
396                    tick,
397                    genesis_hash(id, &self.writer_fingerprint),
398                    now,
399                ],
400            )?;
401            tx.commit()?;
402        }
403        self.prune_to_cap()?;
404        Ok(())
405    }
406
407    /// `true` if a record for exactly `id` exists in this workspace. Used by
408    /// the save path to decide between [`create_with_id`](Self::create_with_id)
409    /// (first turn) and [`append_turn`](Self::append_turn).
410    ///
411    /// Errors propagate rather than read as "absent": a transient failure
412    /// (e.g. a busy reader past the timeout under the NFS DELETE fallback)
413    /// mistaken for "doesn't exist" would route the caller into
414    /// `create_with_id` and overwrite a live conversation.
415    pub fn exists(&self, id: &str) -> anyhow::Result<bool> {
416        let conn = self.lock_conn();
417        Ok(conn
418            .query_row(
419                "SELECT 1 FROM conversations WHERE id = ?1 AND workspace_key = ?2",
420                rusqlite::params![id, self.workspace_id],
421                |_| Ok(()),
422            )
423            .optional()?
424            .is_some())
425    }
426
427    /// Append one `(user, assistant)` turn with no tool events and no token
428    /// usage. `id` may be a unique prefix. Thin wrapper over
429    /// [`append_turn_full`](Self::append_turn_full): an empty event slice
430    /// serializes to `'[]'` and absent tokens to NULL — byte-identical to
431    /// the pre-17.6 row shape, so existing callers are unchanged.
432    pub fn append_turn(&self, id: &str, user: &str, assistant: &str) -> anyhow::Result<()> {
433        self.append_turn_full(id, user, assistant, &[], &[], None, None)
434    }
435
436    /// Append one turn with its recorded tool events and backend-reported
437    /// token usage (Step 17.6, issue #246). `id` may be a unique prefix.
438    ///
439    /// One `BEGIN IMMEDIATE` transaction covers: tick allocation, chain
440    /// extension (`prev_hash` from the current per-writer tip), the row
441    /// insert, and the conversation's activity/tip update. Appending never
442    /// prunes — only `create` does, matching the JSON backend.
443    ///
444    /// **Chain (§6):** events and token counts are row content — the v1
445    /// canonical encoding has length-prefixed the serialized `events`
446    /// string and the token presence bytes since 17.1a, so populated
447    /// values hash under the exact rules empty ones did. No
448    /// `encoding_version` bump: pre-17.6 rows (`'[]'`, NULL) and 17.6 rows
449    /// verify under the same v1 dispatch, and tampering with a stored
450    /// event breaks [`verify_chain`](Self::verify_chain) like any other
451    /// field.
452    ///
453    /// **Tokens are measurements, not estimates:** pass the backend's
454    /// reported counts or `None`. `None` is stored as NULL — absence stays
455    /// observable (18.5 rehydrates from these columns and must be able to
456    /// trust them; gates-are-honest).
457    ///
458    /// **FTS:** the 17.3 AFTER INSERT trigger derives `tool_names` /
459    /// `tool_args_digest` from the events JSON at index time — recording
460    /// events here lights recall up with no schema work.
461    ///
462    /// **Phantom reaches (#717):** the per-turn alias-seam telemetry persists
463    /// alongside `events` in its own `phantom_reaches` column. It is deliberately
464    /// NOT part of the §6 canonical encoding (telemetry, not provenance), so an
465    /// older db gains the column on open and existing content chains verify
466    /// byte-for-byte unchanged. Folding it into the hash would require a v2
467    /// encoding bump — a deliberate follow-up, not this additive change.
468    #[allow(clippy::too_many_arguments)]
469    pub fn append_turn_full(
470        &self,
471        id: &str,
472        user: &str,
473        assistant: &str,
474        events: &[crate::ToolEvent],
475        phantom_reaches: &[crate::PhantomReach],
476        tokens_in: Option<u32>,
477        tokens_out: Option<u32>,
478    ) -> anyhow::Result<()> {
479        let id = self.resolve_id(id)?;
480        let now = (self.claim_clock)();
481        let events_json = serde_json::to_string(events)?;
482        let phantom_reaches_json = serde_json::to_string(phantom_reaches)?;
483        let conn = self.lock_conn();
484        let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
485        let tick = next_tick(&tx, &self.writer_fingerprint)?;
486
487        // The §6 content chain: hash the canonical encoding of this writer's
488        // previous turn (re-derived from the row itself, so a drifted
489        // `tip_hash` column can never poison the chain).
490        let prev_hash = match last_turn(&tx, &id, &self.writer_fingerprint)? {
491            Some(prev) => prev.content_hash()?,
492            None => genesis_hash(&id, &self.writer_fingerprint),
493        };
494
495        let row = TurnRow {
496            conversation_id: id.clone(),
497            writer_fingerprint: self.writer_fingerprint.clone(),
498            seq: tick,
499            prev_hash,
500            user: user.to_string(),
501            assistant: assistant.to_string(),
502            events: events_json,
503            tokens_in: tokens_in.map(i64::from),
504            tokens_out: tokens_out.map(i64::from),
505            ts_claim: now,
506            encoding_version: TURN_ENCODING_VERSION_CURRENT,
507        };
508        insert_turn_row(&tx, &row, &phantom_reaches_json)?;
509        // Activity tick + chain tip move together; updated_at_claim is a
510        // display claim only (§6) — nothing orders by it.
511        tx.execute(
512            "UPDATE conversations
513                SET writer_fingerprint = ?2, activity_tick = ?3, tip_hash = ?4,
514                    updated_at_claim = ?5
515              WHERE id = ?1",
516            rusqlite::params![id, self.writer_fingerprint, tick, row.content_hash()?, now],
517        )?;
518        tx.commit()?;
519        Ok(())
520    }
521
522    /// Load a full record (turns in causal `(writer, seq)` order). `id` may
523    /// be a unique prefix.
524    pub fn load(&self, id: &str) -> anyhow::Result<ConversationRecord> {
525        let id = self.resolve_id(id)?;
526        let conn = self.lock_conn();
527        let (mut record, scratchpad_json, plan_json) = conn
528            .query_row(
529                "SELECT id, title, workspace_path, workspace_key, persona,
530                        started_at_claim, updated_at_claim, scratchpad, plan
531                   FROM conversations
532                  WHERE id = ?1 AND workspace_key = ?2",
533                rusqlite::params![id, self.workspace_id],
534                |row| {
535                    Ok((
536                        ConversationRecord {
537                            id: row.get(0)?,
538                            title: row.get(1)?,
539                            workspace: row.get(2)?,
540                            workspace_id: row.get(3)?,
541                            persona: row.get(4)?,
542                            turns: Vec::new(),
543                            scratchpad: std::collections::BTreeMap::new(),
544                            plan: crate::PlanSnapshot::default(),
545                            created_at_unix_nanos: claim_to_u128(row.get(5)?),
546                            updated_at_unix_nanos: claim_to_u128(row.get(6)?),
547                        },
548                        row.get::<_, String>(7)?,
549                        row.get::<_, String>(8)?,
550                    ))
551                },
552            )
553            .optional()?
554            .ok_or_else(|| anyhow::anyhow!("conversation `{id}` not found"))?;
555        // #713: the scratchpad <state> snapshot. Strict decode — never hand back
556        // garbage (same discipline as the turn `events`/`phantom_reaches`
557        // columns). A pre-#713 row carries the `{}` backfill and parses empty.
558        record.scratchpad = serde_json::from_str(&scratchpad_json).map_err(|e| {
559            anyhow::anyhow!(
560                "conversation `{id}`: scratchpad column is not valid <state> JSON \
561                 ({e}); refusing to load garbage"
562            )
563        })?;
564        // #715: the plan-ledger snapshot. Same strict decode discipline. A
565        // pre-#715 row carries the `{}` backfill and parses to an empty plan.
566        record.plan = serde_json::from_str(&plan_json).map_err(|e| {
567            anyhow::anyhow!(
568                "conversation `{id}`: plan column is not valid <plan> snapshot JSON \
569                 ({e}); refusing to load garbage"
570            )
571        })?;
572
573        // §6: turn order is the causal tick, never ts_claim.
574        let mut stmt = conn.prepare(
575            "SELECT user, assistant, events, tokens_in, tokens_out, phantom_reaches FROM turns
576              WHERE conversation_id = ?1
577              ORDER BY seq ASC, writer_fingerprint ASC",
578        )?;
579        let turns = stmt.query_map([&id], |row| {
580            Ok((
581                row.get::<_, String>(0)?,
582                row.get::<_, String>(1)?,
583                row.get::<_, String>(2)?,
584                row.get::<_, Option<i64>>(3)?,
585                row.get::<_, Option<i64>>(4)?,
586                row.get::<_, String>(5)?,
587            ))
588        })?;
589        for turn in turns {
590            let (user, assistant, events_json, tokens_in, tokens_out, phantom_reaches_json) = turn?;
591            // 17.6: events deserialize strictly — a row whose blob is not
592            // ToolEvent-shaped errors clearly (the encoding_version
593            // philosophy: never quietly hand back garbage). Pre-17.6 rows
594            // carry '[]' and parse to an empty vec; unknown extra keys on
595            // future events are ignored (additive growth needs no bump).
596            let events: Vec<crate::ToolEvent> =
597                serde_json::from_str(&events_json).map_err(|e| {
598                    anyhow::anyhow!(
599                        "conversation `{id}`: turn events column is not valid tool-event \
600                         JSON ({e}); refusing to load garbage"
601                    )
602                })?;
603            // #717: same strict decode as events — never hand back garbage.
604            let phantom_reaches: Vec<crate::PhantomReach> =
605                serde_json::from_str(&phantom_reaches_json).map_err(|e| {
606                    anyhow::anyhow!(
607                        "conversation `{id}`: turn phantom_reaches column is not valid \
608                         phantom-reach JSON ({e}); refusing to load garbage"
609                    )
610                })?;
611            record.turns.push(ConversationTurn {
612                user,
613                assistant,
614                events,
615                phantom_reaches,
616                tokens_in: tokens_from_sql(tokens_in)?,
617                tokens_out: tokens_from_sql(tokens_out)?,
618            });
619        }
620        Ok(record)
621    }
622
623    /// Read ONE past turn by its `(conversation, seq)` address — the by-id
624    /// read the `memory_fetch` tool's `turn:<conv>#<seq>` resolver needs
625    /// (progressive-disclosure memory, Workstream A MVP, #319). `id` may be a
626    /// unique prefix (same `resolve_id` discipline as [`Self::load`]); `seq`
627    /// is the §6 per-writer tick the model was shown by a `recall` hit
628    /// (`SearchHit::seq`).
629    ///
630    /// Workspace-fenced: the `conversations` join carries `workspace_key`, so
631    /// a `seq` from another workspace's conversation resolves to `None`, never
632    /// a cross-workspace leak (§7 fencing). Returns `Ok(None)` when no turn at
633    /// that `(conversation, seq)` exists — labelled absence, never an error —
634    /// so the tool executor can answer "no such memory item" rather than
635    /// aborting the loop.
636    pub fn load_turn(&self, id: &str, seq: i64) -> anyhow::Result<Option<ConversationTurn>> {
637        // An unknown conversation id is absence, not an error — the tool
638        // result must be friendly text, never a loop-aborting backend failure.
639        let id = match self.resolve_id(id) {
640            Ok(id) => id,
641            Err(_) => return Ok(None),
642        };
643        let conn = self.lock_conn();
644        let row = conn
645            .query_row(
646                "SELECT t.user, t.assistant, t.events, t.tokens_in, t.tokens_out, t.phantom_reaches
647                   FROM turns t
648                   JOIN conversations c
649                     ON c.id = t.conversation_id AND c.workspace_key = ?3
650                  WHERE t.conversation_id = ?1 AND t.seq = ?2",
651                rusqlite::params![id, seq, self.workspace_id],
652                |row| {
653                    Ok((
654                        row.get::<_, String>(0)?,
655                        row.get::<_, String>(1)?,
656                        row.get::<_, String>(2)?,
657                        row.get::<_, Option<i64>>(3)?,
658                        row.get::<_, Option<i64>>(4)?,
659                        row.get::<_, String>(5)?,
660                    ))
661                },
662            )
663            .optional()?;
664        let Some((user, assistant, events_json, tokens_in, tokens_out, phantom_reaches_json)) = row
665        else {
666            return Ok(None);
667        };
668        // Same strict events decode as `load`: never hand back garbage.
669        let events: Vec<crate::ToolEvent> = serde_json::from_str(&events_json).map_err(|e| {
670            anyhow::anyhow!(
671                "conversation `{id}`: turn events column is not valid tool-event \
672                 JSON ({e}); refusing to load garbage"
673            )
674        })?;
675        // #717: same strict decode for the phantom-reach telemetry column.
676        let phantom_reaches: Vec<crate::PhantomReach> = serde_json::from_str(&phantom_reaches_json)
677            .map_err(|e| {
678                anyhow::anyhow!(
679                    "conversation `{id}`: turn phantom_reaches column is not valid \
680                     phantom-reach JSON ({e}); refusing to load garbage"
681                )
682            })?;
683        Ok(Some(ConversationTurn {
684            user,
685            assistant,
686            events,
687            phantom_reaches,
688            tokens_in: tokens_from_sql(tokens_in)?,
689            tokens_out: tokens_from_sql(tokens_out)?,
690        }))
691    }
692
693    /// All conversations in this workspace, least-recently-active first —
694    /// "active" meaning the §6 activity tick, never a timestamp. The
695    /// summaries' `updated_at_unix_nanos` is the display claim.
696    pub fn list(&self) -> anyhow::Result<Vec<ConversationSummary>> {
697        let conn = self.lock_conn();
698        let mut stmt = conn.prepare(
699            "SELECT c.id, c.title, c.persona, c.updated_at_claim,
700                    (SELECT COUNT(*) FROM turns t WHERE t.conversation_id = c.id)
701               FROM conversations c
702              WHERE c.workspace_key = ?1
703              ORDER BY c.activity_tick ASC, c.id ASC",
704        )?;
705        let rows = stmt.query_map([&self.workspace_id], |row| {
706            Ok(ConversationSummary {
707                id: row.get(0)?,
708                title: row.get(1)?,
709                persona: row.get(2)?,
710                updated_at_unix_nanos: claim_to_u128(row.get(3)?),
711                turn_count: row.get::<_, i64>(4)?.max(0) as usize,
712            })
713        })?;
714        let mut summaries = Vec::new();
715        for row in rows {
716            summaries.push(row?);
717        }
718        Ok(summaries)
719    }
720
721    /// The most-recently-active **open** conversation in this workspace —
722    /// highest `activity_tick` whose `end_reason` is still NULL — or `None`
723    /// when every conversation has been ended (or none exist). This is the
724    /// auto-resume target: an ended conversation (`/end`, `/restart`, `:wq`)
725    /// is skipped here so the next launch does not silently re-enter it, yet
726    /// it stays in [`list`](Self::list) / `/recall` because it is not deleted.
727    pub fn latest_open(&self) -> anyhow::Result<Option<ConversationSummary>> {
728        let conn = self.lock_conn();
729        conn.query_row(
730            "SELECT c.id, c.title, c.persona, c.updated_at_claim,
731                    (SELECT COUNT(*) FROM turns t WHERE t.conversation_id = c.id)
732               FROM conversations c
733              WHERE c.workspace_key = ?1 AND c.end_reason IS NULL
734              ORDER BY c.activity_tick DESC, c.id DESC
735              LIMIT 1",
736            [&self.workspace_id],
737            |row| {
738                Ok(ConversationSummary {
739                    id: row.get(0)?,
740                    title: row.get(1)?,
741                    persona: row.get(2)?,
742                    updated_at_unix_nanos: claim_to_u128(row.get(3)?),
743                    turn_count: row.get::<_, i64>(4)?.max(0) as usize,
744                })
745            },
746        )
747        .optional()
748        .map_err(Into::into)
749    }
750
751    /// Mark a conversation **ended** with a short reason (`"new"`, `"restart"`,
752    /// `"wq"`, …). Like [`rename`](Self::rename) this is metadata, not activity:
753    /// it does NOT tick the §6 clock, so it cannot perturb MRU ordering — it
754    /// only sets `end_reason` (the column reserved at 17.7), which
755    /// [`latest_open`](Self::latest_open) reads to skip the row on auto-resume.
756    /// The conversation, its turns, and its FTS rows are untouched, so
757    /// `/recall` and `/conversation` still find it. Idempotent and
758    /// workspace-fenced (an id from another workspace resolves as absent).
759    pub fn end_conversation(&self, id: &str, reason: &str) -> anyhow::Result<()> {
760        let id = self.resolve_id(id)?;
761        let now = (self.claim_clock)();
762        let conn = self.lock_conn();
763        conn.execute(
764            "UPDATE conversations SET end_reason = ?2, updated_at_claim = ?3
765              WHERE id = ?1 AND workspace_key = ?4",
766            rusqlite::params![id, reason.trim(), now, self.workspace_id],
767        )?;
768        Ok(())
769    }
770
771    /// Rename a conversation. Updates the display claim but does NOT tick
772    /// the activity clock: a rename is metadata, not activity, so it cannot
773    /// perturb MRU ordering (§6 dissolved the old rename-bumps-`updated_at`
774    /// defect, design doc §1).
775    pub fn rename(&self, id: &str, title: &str) -> anyhow::Result<()> {
776        let id = self.resolve_id(id)?;
777        let now = (self.claim_clock)();
778        let conn = self.lock_conn();
779        conn.execute(
780            "UPDATE conversations SET title = ?2, updated_at_claim = ?3 WHERE id = ?1",
781            rusqlite::params![id, title.trim(), now],
782        )?;
783        Ok(())
784    }
785
786    /// Persist a conversation's scratchpad `<state>` snapshot (#713). The map
787    /// is serialized to JSON and written to the conversation row's `scratchpad`
788    /// column so an interrupt + auto-resume can re-hydrate the live store.
789    ///
790    /// Like [`rename`](Self::rename) / [`end_conversation`](Self::end_conversation)
791    /// this is metadata, not activity: it does **not** tick the §6 clock, so it
792    /// cannot perturb MRU ordering, and the scratchpad is NOT part of the §6
793    /// content chain (it rides the conversation row, never a turn's canonical
794    /// encoding) — working memory, not provenance. Workspace-fenced and
795    /// idempotent: an id from another workspace resolves as absent and the
796    /// UPDATE matches nothing.
797    pub fn update_scratchpad(
798        &self,
799        id: &str,
800        scratchpad: &std::collections::BTreeMap<String, String>,
801    ) -> anyhow::Result<()> {
802        let id = self.resolve_id(id)?;
803        let json = serde_json::to_string(scratchpad)?;
804        let conn = self.lock_conn();
805        conn.execute(
806            "UPDATE conversations SET scratchpad = ?2 WHERE id = ?1 AND workspace_key = ?3",
807            rusqlite::params![id, json, self.workspace_id],
808        )?;
809        Ok(())
810    }
811
812    /// Persist a conversation's plan-ledger snapshot (#715). The
813    /// [`crate::PlanSnapshot`] is serialized to JSON and written to the
814    /// conversation row's `plan` column so an interrupt + auto-resume can
815    /// re-hydrate the live ledger (the `<plan>` block + `plan_get` survive).
816    ///
817    /// Like [`update_scratchpad`](Self::update_scratchpad) this is metadata, not
818    /// activity: it does **not** tick the §6 clock, so it cannot perturb MRU
819    /// ordering, and the plan is NOT part of the §6 content chain (it rides the
820    /// conversation row, never a turn's canonical encoding) — working memory, not
821    /// provenance. Workspace-fenced and idempotent: an id from another workspace
822    /// resolves as absent and the UPDATE matches nothing.
823    pub fn update_plan_snapshot(&self, id: &str, plan: &crate::PlanSnapshot) -> anyhow::Result<()> {
824        let id = self.resolve_id(id)?;
825        let json = serde_json::to_string(plan)?;
826        let conn = self.lock_conn();
827        conn.execute(
828            "UPDATE conversations SET plan = ?2 WHERE id = ?1 AND workspace_key = ?3",
829            rusqlite::params![id, json, self.workspace_id],
830        )?;
831        Ok(())
832    }
833
834    /// Delete a conversation (its turns cascade) and, best-effort, its
835    /// per-session plan dir (issue #220).
836    pub fn delete(&self, id: &str) -> anyhow::Result<()> {
837        let id = self.resolve_id(id)?;
838        {
839            let conn = self.lock_conn();
840            conn.execute(
841                "DELETE FROM conversations WHERE id = ?1 AND workspace_key = ?2",
842                rusqlite::params![id, self.workspace_id],
843            )?;
844        }
845        // Ignore errors: the dir may not exist, and a stray plan must never
846        // block deletion of the record.
847        let plan_dir = self.workspace.join(session_plan_dir(&id));
848        let _ = std::fs::remove_dir_all(plan_dir);
849        Ok(())
850    }
851
852    /// Resolve an exact id or unique prefix within this workspace.
853    pub fn resolve_id(&self, id_or_prefix: &str) -> anyhow::Result<String> {
854        validate_record_id(id_or_prefix)?;
855        let conn = self.lock_conn();
856        let exact = conn
857            .query_row(
858                "SELECT id FROM conversations WHERE id = ?1 AND workspace_key = ?2",
859                rusqlite::params![id_or_prefix, self.workspace_id],
860                |row| row.get::<_, String>(0),
861            )
862            .optional()?;
863        if let Some(id) = exact {
864            return Ok(id);
865        }
866        // Byte-case-exact prefix match (review NIT N5 on #261): `LIKE` is
867        // ASCII-case-insensitive by default, which silently widened prefix
868        // resolution when the JSON backend's `starts_with` was ported.
869        // `substr` compares exactly; ids are validated ASCII above, so
870        // character positions and byte positions coincide.
871        let mut stmt = conn.prepare(
872            "SELECT id FROM conversations
873              WHERE workspace_key = ?1 AND substr(id, 1, length(?2)) = ?2
874              ORDER BY id ASC",
875        )?;
876        let matches = stmt
877            .query_map(rusqlite::params![self.workspace_id, id_or_prefix], |row| {
878                row.get::<_, String>(0)
879            })?
880            .collect::<Result<Vec<_>, _>>()?;
881        match matches.as_slice() {
882            [id] => Ok(id.clone()),
883            [] => anyhow::bail!("conversation `{id_or_prefix}` not found"),
884            many => anyhow::bail!(
885                "ambiguous conversation id prefix `{}`; matches: {}",
886                id_or_prefix,
887                many.join(", ")
888            ),
889        }
890    }
891
892    /// Verify the §6 content chain for a conversation: every writer's turns
893    /// must link `prev_hash` → BLAKE3(prior turn's canonical encoding) from
894    /// the genesis hash, and the stored chain tip must match this writer's
895    /// last turn. A tampered row (content OR claims — claims are inside the
896    /// canonical encoding, so they are tamper-evident too) breaks the chain.
897    pub fn verify_chain(&self, id: &str) -> anyhow::Result<()> {
898        let id = self.resolve_id(id)?;
899        let conn = self.lock_conn();
900        let (tip, tip_writer): (String, String) = conn.query_row(
901            "SELECT tip_hash, writer_fingerprint FROM conversations WHERE id = ?1",
902            [&id],
903            |row| Ok((row.get(0)?, row.get(1)?)),
904        )?;
905
906        let mut stmt = conn.prepare(
907            "SELECT conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
908                    events, tokens_in, tokens_out, ts_claim, encoding_version
909               FROM turns
910              WHERE conversation_id = ?1
911              ORDER BY writer_fingerprint ASC, seq ASC",
912        )?;
913        let rows = stmt
914            .query_map([&id], turn_row_from_sql)?
915            .collect::<Result<Vec<_>, _>>()?;
916
917        let mut prev: Option<&TurnRow> = None;
918        for row in &rows {
919            let same_writer = prev.is_some_and(|p| p.writer_fingerprint == row.writer_fingerprint);
920            if same_writer {
921                let p = prev.expect("same_writer implies prev");
922                if row.seq <= p.seq {
923                    anyhow::bail!(
924                        "chain violation in `{id}`: seq {} not strictly after {}",
925                        row.seq,
926                        p.seq
927                    );
928                }
929                if row.prev_hash != p.content_hash()? {
930                    anyhow::bail!(
931                        "chain violation in `{id}`: turn seq {} does not link to seq {} \
932                         (row tampered or out of order)",
933                        row.seq,
934                        p.seq
935                    );
936                }
937            } else {
938                let genesis = genesis_hash(&id, &row.writer_fingerprint);
939                if row.prev_hash != genesis {
940                    anyhow::bail!(
941                        "chain violation in `{id}`: first turn of writer {} (seq {}) does \
942                         not link to the genesis hash",
943                        row.writer_fingerprint,
944                        row.seq
945                    );
946                }
947            }
948            prev = Some(row);
949        }
950
951        // The stored tip must match the chain of the conversation row's
952        // RECORDED last writer (set at create, updated on every append in
953        // the same txn) — not whoever happens to be verifying. This keeps
954        // verify_chain writer-agnostic: a store that authored no turns in a
955        // migrated/foreign conversation still verifies it correctly
956        // (adversarial-review finding N2 on #261).
957        let expected_tip = match rows.iter().rfind(|r| r.writer_fingerprint == tip_writer) {
958            Some(row) => row.content_hash()?,
959            None => genesis_hash(&id, &tip_writer),
960        };
961        if tip != expected_tip {
962            anyhow::bail!("chain violation in `{id}`: stored tip_hash does not match the chain");
963        }
964        Ok(())
965    }
966
967    /// Full-text recall over this workspace's turns (17.3, issue #246).
968    ///
969    /// The raw query goes through [`sanitize_fts5_query`] (an empty result
970    /// after sanitizing is an error, never a match-all), then a `MATCH`
971    /// against the trigger-maintained `turns_fts` index, ranked by bm25
972    /// (best first), **fenced to this workspace** by joining
973    /// `conversations.workspace_key`. Each hit carries a `snippet()` of the
974    /// matched column — the match wrapped in `>>>`/`<<<`, roughly ±10
975    /// tokens of context, `…` at trimmed edges. Snippets are the whole
976    /// payload by design: no full turn content, no aux-LLM recaps (the
977    /// design doc explicitly skips those — slow and expensive on local
978    /// models; the hermes study's own "snippet is enough, saves tokens").
979    pub fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
980        let fts_query = sanitize_fts5_query(query)?;
981        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
982        let conn = self.lock_conn();
983        // The JOIN on `turns` is also a safety net: an index entry whose
984        // turn row is gone (can't happen while the delete trigger holds,
985        // but defense in depth) joins to nothing instead of surfacing a
986        // ghost hit. Ties in rank break deterministically by (id, seq).
987        let mut stmt = conn.prepare(
988            "SELECT t.conversation_id, c.title, t.seq,
989                    snippet(turns_fts, -1, '>>>', '<<<', '…', 21),
990                    bm25(turns_fts)
991               FROM turns_fts
992               JOIN turns t ON t.rowid = turns_fts.rowid
993               JOIN conversations c
994                 ON c.id = t.conversation_id AND c.workspace_key = ?2
995              WHERE turns_fts MATCH ?1
996              ORDER BY bm25(turns_fts) ASC, t.conversation_id ASC, t.seq ASC
997              LIMIT ?3",
998        )?;
999        let rows = stmt.query_map(
1000            rusqlite::params![fts_query, self.workspace_id, limit],
1001            |row| {
1002                Ok(SearchHit {
1003                    conversation_id: row.get(0)?,
1004                    title: row.get(1)?,
1005                    seq: row.get(2)?,
1006                    snippet: row.get(3)?,
1007                    rank: row.get(4)?,
1008                })
1009            },
1010        )?;
1011        let mut hits = Vec::new();
1012        for row in rows {
1013            hits.push(row?);
1014        }
1015        Ok(hits)
1016    }
1017
1018    /// Drive the display-claim clock from a test. Hidden, test-only: lets the
1019    /// §6 clock-skew test write *honestly skewed* claims through the normal
1020    /// API (clock runs backwards mid-conversation) and prove that ordering,
1021    /// MRU, and chain verification are all unaffected.
1022    #[doc(hidden)]
1023    pub fn set_claim_clock_for_test(&mut self, clock: fn() -> i64) {
1024        self.claim_clock = clock;
1025    }
1026
1027    fn prune_to_cap(&self) -> anyhow::Result<()> {
1028        if self.max_per_workspace == 0 {
1029            return Ok(());
1030        }
1031        let victims: Vec<String> = {
1032            let conn = self.lock_conn();
1033            let count: i64 = conn.query_row(
1034                "SELECT COUNT(*) FROM conversations WHERE workspace_key = ?1",
1035                [&self.workspace_id],
1036                |row| row.get(0),
1037            )?;
1038            let excess = count - self.max_per_workspace as i64;
1039            if excess <= 0 {
1040                return Ok(());
1041            }
1042            // Oldest = lowest activity tick (§6 — never a timestamp).
1043            let mut stmt = conn.prepare(
1044                "SELECT id FROM conversations
1045                  WHERE workspace_key = ?1
1046                  ORDER BY activity_tick ASC, id ASC
1047                  LIMIT ?2",
1048            )?;
1049            let ids = stmt
1050                .query_map(rusqlite::params![self.workspace_id, excess], |row| {
1051                    row.get::<_, String>(0)
1052                })?
1053                .collect::<Result<Vec<_>, _>>()?;
1054            ids
1055        };
1056        for id in victims {
1057            // Route through delete() so plan dirs are cleaned up too.
1058            self.delete(&id)?;
1059        }
1060        Ok(())
1061    }
1062
1063    fn lock_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
1064        // A poisoned mutex means another thread panicked mid-operation; the
1065        // connection itself is still usable (transactions roll back), so
1066        // recover rather than cascade the panic.
1067        self.conn
1068            .lock()
1069            .unwrap_or_else(std::sync::PoisonError::into_inner)
1070    }
1071}
1072
1073/// One full-text recall hit from [`ConversationStore::search`] (17.3).
1074///
1075/// `rank` is the raw FTS5 bm25 score: negative, and smaller (more negative)
1076/// = better. `search` returns hits best-first; the value is exposed so
1077/// 17.4/17.5 callers can show or threshold it. `snippet` is the matched
1078/// column's excerpt (`>>>match<<<`, `…`-trimmed) — deliberately the only
1079/// content returned.
1080#[derive(Debug, Clone, PartialEq)]
1081pub struct SearchHit {
1082    /// The conversation the matching turn belongs to.
1083    pub conversation_id: String,
1084    /// That conversation's current title.
1085    pub title: String,
1086    /// The matching turn's §6 per-writer tick (its position in the chain).
1087    pub seq: i64,
1088    /// `snippet()` of the matched column: ±~10 tokens of context around the
1089    /// match, which is wrapped in `>>>`/`<<<`; `…` marks trimmed edges.
1090    pub snippet: String,
1091    /// Raw bm25 rank (negative; more negative = better match).
1092    pub rank: f64,
1093}
1094
1095/// One turn row, exactly as stored. Internal: the canonical encoding hashes
1096/// every field, so this struct is the unit of chain verification.
1097#[derive(Debug)]
1098struct TurnRow {
1099    conversation_id: String,
1100    writer_fingerprint: String,
1101    seq: i64,
1102    prev_hash: String,
1103    user: String,
1104    assistant: String,
1105    events: String,
1106    tokens_in: Option<i64>,
1107    tokens_out: Option<i64>,
1108    ts_claim: i64,
1109    /// Which canonical encoding hashed this row (`turns.encoding_version`,
1110    /// review NIT N1 on #261). Only v1 exists today.
1111    encoding_version: i64,
1112}
1113
1114impl TurnRow {
1115    /// BLAKE3 hex of this row's canonical encoding — what the *next* turn's
1116    /// `prev_hash` must equal. Dispatches on the row's recorded
1117    /// `encoding_version`; a version this build does not understand errors
1118    /// clearly instead of hashing under the wrong rules (NIT N1 on #261).
1119    fn content_hash(&self) -> anyhow::Result<String> {
1120        match self.encoding_version {
1121            1 => Ok(blake3::hash(&self.canonical_encoding_v1())
1122                .to_hex()
1123                .to_string()),
1124            other => anyhow::bail!(
1125                "turn (conversation `{}`, writer {}, seq {}) carries encoding_version {other}, \
1126                 which this newt does not understand (known: 1) — upgrade newt to verify \
1127                 or extend this chain",
1128                self.conversation_id,
1129                self.writer_fingerprint,
1130                self.seq
1131            ),
1132        }
1133    }
1134
1135    /// Canonical v1 byte encoding of a turn: version tag, then every field
1136    /// length-prefixed (u64 LE) so adjacent fields can never be reparsed
1137    /// ambiguously (`("ab","c")` ≠ `("a","bc")`). Integers are 8-byte LE
1138    /// with a presence byte for the optional token counts.
1139    fn canonical_encoding_v1(&self) -> Vec<u8> {
1140        let mut out = Vec::with_capacity(
1141            64 + self.conversation_id.len()
1142                + self.writer_fingerprint.len()
1143                + self.prev_hash.len()
1144                + self.user.len()
1145                + self.assistant.len()
1146                + self.events.len(),
1147        );
1148        out.extend_from_slice(TURN_ENCODING_V1_PREFIX);
1149        for field in [
1150            self.conversation_id.as_bytes(),
1151            self.writer_fingerprint.as_bytes(),
1152            self.prev_hash.as_bytes(),
1153            self.user.as_bytes(),
1154            self.assistant.as_bytes(),
1155            self.events.as_bytes(),
1156        ] {
1157            out.extend_from_slice(&(field.len() as u64).to_le_bytes());
1158            out.extend_from_slice(field);
1159        }
1160        out.extend_from_slice(&self.seq.to_le_bytes());
1161        for opt in [self.tokens_in, self.tokens_out] {
1162            match opt {
1163                Some(v) => {
1164                    out.push(1);
1165                    out.extend_from_slice(&v.to_le_bytes());
1166                }
1167                None => out.push(0),
1168            }
1169        }
1170        out.extend_from_slice(&self.ts_claim.to_le_bytes());
1171        out
1172    }
1173}
1174
1175fn turn_row_from_sql(row: &rusqlite::Row<'_>) -> rusqlite::Result<TurnRow> {
1176    Ok(TurnRow {
1177        conversation_id: row.get(0)?,
1178        writer_fingerprint: row.get(1)?,
1179        seq: row.get(2)?,
1180        prev_hash: row.get(3)?,
1181        user: row.get(4)?,
1182        assistant: row.get(5)?,
1183        events: row.get(6)?,
1184        tokens_in: row.get(7)?,
1185        tokens_out: row.get(8)?,
1186        ts_claim: row.get(9)?,
1187        encoding_version: row.get(10)?,
1188    })
1189}
1190
1191/// Insert one fully-populated turn row. Must run inside the caller's
1192/// transaction (shared by the live append path and the one-time import).
1193///
1194/// `phantom_reaches_json` (#717) is a separate JSON-string argument, not a
1195/// `TurnRow` field, precisely because it is NOT part of the §6 canonical
1196/// encoding — keeping it out of `TurnRow` keeps the content hash untouched.
1197fn insert_turn_row(
1198    conn: &Connection,
1199    row: &TurnRow,
1200    phantom_reaches_json: &str,
1201) -> anyhow::Result<()> {
1202    conn.execute(
1203        "INSERT INTO turns
1204           (conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
1205            events, tokens_in, tokens_out, ts_claim, encoding_version, phantom_reaches)
1206         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1207        rusqlite::params![
1208            row.conversation_id,
1209            row.writer_fingerprint,
1210            row.seq,
1211            row.prev_hash,
1212            row.user,
1213            row.assistant,
1214            row.events,
1215            row.tokens_in,
1216            row.tokens_out,
1217            row.ts_claim,
1218            row.encoding_version,
1219            phantom_reaches_json,
1220        ],
1221    )?;
1222    Ok(())
1223}
1224
1225/// The §6 genesis hash anchoring a writer's chain within a conversation.
1226fn genesis_hash(conversation_id: &str, writer_fingerprint: &str) -> String {
1227    let mut hasher = blake3::Hasher::new();
1228    hasher.update(GENESIS_PREFIX);
1229    for field in [conversation_id.as_bytes(), writer_fingerprint.as_bytes()] {
1230        hasher.update(&(field.len() as u64).to_le_bytes());
1231        hasher.update(field);
1232    }
1233    hasher.finalize().to_hex().to_string()
1234}
1235
1236/// This writer's most recent turn in a conversation (chain tip source).
1237fn last_turn(
1238    conn: &Connection,
1239    conversation_id: &str,
1240    writer_fingerprint: &str,
1241) -> anyhow::Result<Option<TurnRow>> {
1242    Ok(conn
1243        .query_row(
1244            "SELECT conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
1245                    events, tokens_in, tokens_out, ts_claim, encoding_version
1246               FROM turns
1247              WHERE conversation_id = ?1 AND writer_fingerprint = ?2
1248              ORDER BY seq DESC
1249              LIMIT 1",
1250            rusqlite::params![conversation_id, writer_fingerprint],
1251            turn_row_from_sql,
1252        )
1253        .optional()?)
1254}
1255
1256/// Allocate the next per-writer Lamport tick (strictly monotonic — §6 floor).
1257///
1258/// Must run inside the caller's `BEGIN IMMEDIATE` transaction so the
1259/// read-modify-write is atomic across concurrent writers sharing the db.
1260///
1261/// When the writer has no clock row yet (fresh db, or `writer_clock` lost to
1262/// schema drift), the seed is the **global** max tick already present in the
1263/// database — the Lamport receive rule: a clock never starts behind any tick
1264/// it has observed, so cross-writer `activity_tick` comparisons on a shared
1265/// db stay causally meaningful and a re-seeded clock can never reuse a seq.
1266/// The seeding scan runs only on clock-row creation, never per append.
1267fn next_tick(conn: &Connection, writer_fingerprint: &str) -> anyhow::Result<i64> {
1268    let bumped = conn.execute(
1269        "UPDATE writer_clock SET last_tick = last_tick + 1 WHERE writer_fingerprint = ?1",
1270        [writer_fingerprint],
1271    )?;
1272    if bumped == 0 {
1273        conn.execute(
1274            "INSERT OR IGNORE INTO writer_clock (writer_fingerprint, last_tick)
1275             SELECT ?1, COALESCE(MAX(t), 0) FROM (
1276                 SELECT MAX(seq) AS t FROM turns
1277                 UNION ALL
1278                 SELECT MAX(activity_tick) AS t FROM conversations
1279                 UNION ALL
1280                 -- Other writers' issued ticks: keeps the seed at the true
1281                 -- issued-max even when their rows were pruned (review
1282                 -- finding N6 on #261 — Lamport receive rule over all
1283                 -- observable evidence, not just surviving rows).
1284                 SELECT MAX(last_tick) AS t FROM writer_clock
1285             )",
1286            [writer_fingerprint],
1287        )?;
1288        conn.execute(
1289            "UPDATE writer_clock SET last_tick = last_tick + 1 WHERE writer_fingerprint = ?1",
1290            [writer_fingerprint],
1291        )?;
1292    }
1293    Ok(conn.query_row(
1294        "SELECT last_tick FROM writer_clock WHERE writer_fingerprint = ?1",
1295        [writer_fingerprint],
1296        |row| row.get(0),
1297    )?)
1298}
1299
1300/// Try WAL; fall back to DELETE on the known network-filesystem failure
1301/// modes, returning the captured error text for a user-facing notice.
1302/// Any other error is real and propagates.
1303///
1304/// Under WAL, `synchronous` drops to NORMAL: SQLite documents WAL +
1305/// NORMAL as corruption-safe (fsync at checkpoints, not per commit), and
1306/// per-append cost falls from ~2 ms (one fsync per turn) to tens of µs —
1307/// a power cut can cost the last turns, never the database. The DELETE
1308/// fallback keeps the FULL default, where NORMAL is *not* corruption-safe.
1309fn apply_journal_mode(conn: &Connection) -> anyhow::Result<Option<String>> {
1310    let wal: Result<String, rusqlite::Error> =
1311        conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0));
1312    match wal {
1313        // Assert the pragma actually took (it has documented silent-no-op
1314        // cases) — NORMAL is only safe under WAL; any other mode keeps the
1315        // compiled default of FULL (review finding N4 on #261).
1316        Ok(mode) if mode.eq_ignore_ascii_case("wal") => {
1317            conn.pragma_update(None, "synchronous", "NORMAL")?;
1318            Ok(None)
1319        }
1320        Ok(mode) => {
1321            tracing::warn!(%mode, "journal_mode=WAL did not take; keeping synchronous=FULL");
1322            Ok(Some(format!("journal_mode pragma returned `{mode}`")))
1323        }
1324        Err(e) if wal_fallback_eligible(&e.to_string()) => {
1325            let captured = e.to_string();
1326            conn.pragma_update(None, "journal_mode", "DELETE")?;
1327            tracing::warn!(
1328                error = %captured,
1329                "SQLite refused WAL (network filesystem?); conversations.db is running \
1330                 on the slower journal_mode=DELETE fallback"
1331            );
1332            Ok(Some(captured))
1333        }
1334        Err(e) => Err(e.into()),
1335    }
1336}
1337
1338/// `true` for the SQLite error texts WAL is known to produce on filesystems
1339/// without shared-memory mmap / POSIX lock support (NFS homes): the store
1340/// should fall back to `journal_mode=DELETE` rather than fail to open.
1341fn wal_fallback_eligible(error_text: &str) -> bool {
1342    let lower = error_text.to_lowercase();
1343    lower.contains("locking protocol") || lower.contains("disk i/o error")
1344}
1345
1346/// Schema, v17.1a. §6-binding shape — see the module docs. Every `*_claim`
1347/// column is a DISPLAY-ONLY wall-clock claim (unix nanos): never an ordering
1348/// key, never compared. Ordering is `(writer_fingerprint, seq)` /
1349/// `activity_tick`; integrity is the `prev_hash` BLAKE3 chain + `tip_hash`.
1350/// `events`/`tokens_in`/`tokens_out` are day-one columns filled by 17.6.
1351fn create_schema(conn: &Connection) -> anyhow::Result<()> {
1352    conn.execute_batch(
1353        "CREATE TABLE IF NOT EXISTS conversations (
1354             id                 TEXT PRIMARY KEY,
1355             title              TEXT NOT NULL,
1356             workspace_path     TEXT NOT NULL,            -- display only
1357             workspace_key      TEXT NOT NULL,            -- scoping key: workspace_key_v2 (17.2 — blake3 remote+branch, path fallback)
1358             persona            TEXT,
1359             end_reason         TEXT,                     -- set by 17.7
1360             writer_fingerprint TEXT NOT NULL,            -- §6 ordering key, half 1
1361             activity_tick      INTEGER NOT NULL,         -- §6 ordering key, half 2 (per-writer Lamport tick)
1362             tip_hash           TEXT NOT NULL,            -- §6 chain tip (BLAKE3)
1363             started_at_claim   INTEGER NOT NULL,         -- DISPLAY ONLY (wall-clock claim, unix nanos)
1364             updated_at_claim   INTEGER NOT NULL,         -- DISPLAY ONLY
1365             scratchpad         TEXT NOT NULL DEFAULT '{}', -- JSON scratchpad <state> snapshot (#713); working memory, NOT hashed (§6 chain unchanged)
1366             plan               TEXT NOT NULL DEFAULT '{}' -- JSON plan-ledger snapshot (#715); working memory, NOT hashed (§6 chain unchanged)
1367         );
1368         CREATE TABLE IF NOT EXISTS turns (
1369             conversation_id    TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
1370             writer_fingerprint TEXT NOT NULL,            -- §6: whose clock ticked
1371             seq                INTEGER NOT NULL,         -- §6: strictly monotonic per writer — THE ordering key
1372             prev_hash          TEXT NOT NULL,            -- §6: BLAKE3 of prior turn's canonical encoding
1373             user               TEXT NOT NULL,
1374             assistant          TEXT NOT NULL,
1375             events             TEXT NOT NULL DEFAULT '[]', -- JSON tool events; filled by 17.6
1376             tokens_in          INTEGER,                  -- filled by 17.6, consumed by 18.x
1377             tokens_out         INTEGER,
1378             ts_claim           INTEGER NOT NULL,         -- DISPLAY ONLY (wall-clock claim, unix nanos)
1379             encoding_version   INTEGER NOT NULL DEFAULT 1, -- canonical-encoding dispatch (N1 on #261)
1380             phantom_reaches    TEXT NOT NULL DEFAULT '[]', -- JSON phantom-reach telemetry (#717); NOT hashed (§6 chain unchanged)
1381             PRIMARY KEY (conversation_id, writer_fingerprint, seq)
1382         );
1383         -- The per-writer Lamport clock (§6 'each agent is its own clock').
1384         CREATE TABLE IF NOT EXISTS writer_clock (
1385             writer_fingerprint TEXT PRIMARY KEY,
1386             last_tick          INTEGER NOT NULL
1387         );
1388         CREATE INDEX IF NOT EXISTS idx_conversations_ws_tick
1389             ON conversations (workspace_key, activity_tick);",
1390    )?;
1391    Ok(())
1392}
1393
1394/// Expected columns per table, with ALTER-safe declarations (additive
1395/// schema-diff reconciliation: a db written by an older newt gains any
1396/// missing columns on open; unknown extra columns are left alone).
1397const EXPECTED_COLUMNS: &[(&str, &[(&str, &str)])] = &[
1398    (
1399        "conversations",
1400        &[
1401            ("id", "TEXT"),
1402            ("title", "TEXT NOT NULL DEFAULT ''"),
1403            ("workspace_path", "TEXT NOT NULL DEFAULT ''"),
1404            ("workspace_key", "TEXT NOT NULL DEFAULT ''"),
1405            ("persona", "TEXT"),
1406            ("end_reason", "TEXT"),
1407            ("writer_fingerprint", "TEXT NOT NULL DEFAULT ''"),
1408            ("activity_tick", "INTEGER NOT NULL DEFAULT 0"),
1409            ("tip_hash", "TEXT NOT NULL DEFAULT ''"),
1410            ("started_at_claim", "INTEGER NOT NULL DEFAULT 0"),
1411            ("updated_at_claim", "INTEGER NOT NULL DEFAULT 0"),
1412            // #713: scratchpad <state> snapshot. Additive — an older db gains it
1413            // on open with the historically-true empty backfill (`{}`). It rides
1414            // the conversation row, NOT a turn, so it is NEVER part of the §6
1415            // canonical encoding: working memory, not provenance.
1416            ("scratchpad", "TEXT NOT NULL DEFAULT '{}'"),
1417            // #715: plan-ledger snapshot. Additive — an older db gains it on open
1418            // with the empty backfill (`{}`, parsed via PlanSnapshot's serde
1419            // default). It rides the conversation row, NOT a turn, so it is NEVER
1420            // part of the §6 canonical encoding: working memory, not provenance.
1421            ("plan", "TEXT NOT NULL DEFAULT '{}'"),
1422        ],
1423    ),
1424    (
1425        "turns",
1426        &[
1427            ("conversation_id", "TEXT"),
1428            ("writer_fingerprint", "TEXT NOT NULL DEFAULT ''"),
1429            ("seq", "INTEGER NOT NULL DEFAULT 0"),
1430            ("prev_hash", "TEXT NOT NULL DEFAULT ''"),
1431            ("user", "TEXT NOT NULL DEFAULT ''"),
1432            ("assistant", "TEXT NOT NULL DEFAULT ''"),
1433            ("events", "TEXT NOT NULL DEFAULT '[]'"),
1434            ("tokens_in", "INTEGER"),
1435            ("tokens_out", "INTEGER"),
1436            ("ts_claim", "INTEGER NOT NULL DEFAULT 0"),
1437            // N1 on #261: rows written before this column exist only as v1,
1438            // so DEFAULT 1 is the historically-true backfill.
1439            ("encoding_version", "INTEGER NOT NULL DEFAULT 1"),
1440            // #717: phantom-reach telemetry. Additive — an older db gains it on
1441            // open with the historically-true empty backfill. NOT part of the §6
1442            // canonical encoding, so existing chains verify byte-for-byte.
1443            ("phantom_reaches", "TEXT NOT NULL DEFAULT '[]'"),
1444        ],
1445    ),
1446    (
1447        "writer_clock",
1448        &[
1449            ("writer_fingerprint", "TEXT"),
1450            ("last_tick", "INTEGER NOT NULL DEFAULT 0"),
1451        ],
1452    ),
1453];
1454
1455/// Compare `PRAGMA table_info` against [`EXPECTED_COLUMNS`] and `ALTER TABLE
1456/// ... ADD COLUMN` any additive drift. Removed/renamed columns are NOT
1457/// handled here — destructive migrations get their own explicit step.
1458fn reconcile_schema(conn: &Connection) -> anyhow::Result<()> {
1459    for (table, expected) in EXPECTED_COLUMNS {
1460        let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
1461        let present: Vec<String> = stmt
1462            .query_map([], |row| row.get::<_, String>(1))?
1463            .collect::<Result<Vec<_>, _>>()?;
1464        for (name, decl) in *expected {
1465            if !present.iter().any(|c| c == name) {
1466                conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {name} {decl}"))?;
1467                tracing::info!(
1468                    table = *table,
1469                    column = *name,
1470                    "schema migration: added missing column"
1471                );
1472            }
1473        }
1474    }
1475    Ok(())
1476}
1477
1478/// SQL expression deriving a space-joined list of `$.{key}` string values
1479/// from the `events` JSON array carried by `source` (e.g. `new.events`,
1480/// `old.events`, or the bare column in the content view).
1481///
1482/// This is THE 17.6 seam: events elements are objects, and the keys read
1483/// here — `tool` and `args_digest` — are the contract 17.6's recorder must
1484/// write. Shared verbatim by the view and both triggers so the indexed
1485/// terms and the content read back at query time can never disagree.
1486/// `json_valid` guards the whole expression: a garbage events blob yields
1487/// `''` instead of breaking the append (a trigger error would abort the
1488/// turn's transaction).
1489fn events_extract_sql(source: &str, key: &str) -> String {
1490    format!(
1491        "CASE WHEN json_valid({source}) THEN \
1492            (SELECT coalesce(group_concat(json_extract(value, '$.{key}'), ' '), '') \
1493               FROM json_each({source})) \
1494         ELSE '' END"
1495    )
1496}
1497
1498/// Create the 17.3 FTS5 recall index (module docs — FTS5 recall index):
1499/// the `turns_fts_content` view, the external-content `turns_fts` virtual
1500/// table (unicode61), and the AFTER INSERT / AFTER DELETE triggers on
1501/// `turns`. No UPDATE trigger by design: turns are append-only (§6).
1502///
1503/// Backfill-on-migration: when the virtual table does not exist yet (a
1504/// fresh db, or a 17.1/17.2 db opened by a 17.3+ newt), every existing
1505/// turn is indexed by an explicit `INSERT…SELECT` of the same derived
1506/// expressions (see the in-body comment for why not FTS5 `'rebuild'`) —
1507/// one-time, inside the same `BEGIN IMMEDIATE` transaction as the DDL,
1508/// idempotent because the presence of the table IS the done-marker
1509/// (checked under the write lock, so concurrent first opens cannot
1510/// double-backfill).
1511fn create_fts_index(conn: &Connection) -> anyhow::Result<()> {
1512    let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
1513    let have_index = tx
1514        .query_row(
1515            "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'turns_fts'",
1516            [],
1517            |_| Ok(()),
1518        )
1519        .optional()?
1520        .is_some();
1521
1522    let view_tools = events_extract_sql("events", "tool");
1523    let view_digests = events_extract_sql("events", "args_digest");
1524    let new_tools = events_extract_sql("new.events", "tool");
1525    let new_digests = events_extract_sql("new.events", "args_digest");
1526    let old_tools = events_extract_sql("old.events", "tool");
1527    let old_digests = events_extract_sql("old.events", "args_digest");
1528    tx.execute_batch(&format!(
1529        "CREATE VIEW IF NOT EXISTS turns_fts_content AS
1530            SELECT rowid,
1531                   user,
1532                   assistant,
1533                   {view_tools} AS tool_names,
1534                   {view_digests} AS tool_args_digest
1535              FROM turns;
1536         CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5(
1537             user, assistant, tool_names, tool_args_digest,
1538             content='turns_fts_content',
1539             content_rowid='rowid',
1540             tokenize='unicode61'
1541         );
1542         CREATE TRIGGER IF NOT EXISTS turns_fts_after_insert
1543         AFTER INSERT ON turns BEGIN
1544             INSERT INTO turns_fts(rowid, user, assistant, tool_names, tool_args_digest)
1545             VALUES (new.rowid, new.user, new.assistant, {new_tools}, {new_digests});
1546         END;
1547         -- Fires per cascaded row on conversation delete. The 'delete'
1548         -- command must receive the values that were indexed at insert
1549         -- time — guaranteed by the append-only invariant on turns.
1550         CREATE TRIGGER IF NOT EXISTS turns_fts_after_delete
1551         AFTER DELETE ON turns BEGIN
1552             INSERT INTO turns_fts(turns_fts, rowid, user, assistant, tool_names, tool_args_digest)
1553             VALUES ('delete', old.rowid, old.user, old.assistant, {old_tools}, {old_digests});
1554         END;"
1555    ))?;
1556
1557    if !have_index {
1558        // One-time backfill of pre-17.3 turns. NOT the FTS5 `'rebuild'`
1559        // command: rebuild scans the content table through a
1560        // schema-qualified statement, and `json_each` — an eponymous
1561        // virtual table inside the content view — cannot be resolved
1562        // schema-qualified ("no such table: main.json_each", verified
1563        // against the bundled SQLite 3.45). An explicit INSERT…SELECT of
1564        // the same derived expressions is equivalent for an empty index
1565        // and prepares unqualified, so the view's seam stays intact.
1566        tx.execute(
1567            &format!(
1568                "INSERT INTO turns_fts(rowid, user, assistant, tool_names, tool_args_digest)
1569                 SELECT rowid, user, assistant, {view_tools}, {view_digests} FROM turns"
1570            ),
1571            [],
1572        )?;
1573        tracing::info!("created the FTS5 recall index and backfilled existing turns (17.3)");
1574    }
1575    tx.commit()?;
1576    Ok(())
1577}
1578
1579/// A parsed piece of a raw recall query: a ready-to-emit term (bare word or
1580/// `"quoted phrase"`) or a boolean operator awaiting placement.
1581enum QueryPart {
1582    Term(String),
1583    Op(&'static str),
1584}
1585
1586/// Sanitize a raw user/model query into a safe FTS5 `MATCH` expression
1587/// (17.3 — the hermes `_sanitize_fts5_query` port; see
1588/// `docs/design/evidence/hermes-study/report-hermes-sessions.md` §6).
1589///
1590/// Pure function, no database required. Rules:
1591///
1592/// 1. **Balanced `"phrases"` are preserved** as phrase queries. A dangling
1593///    unbalanced quote is dropped and its text processed as plain terms.
1594/// 2. Outside phrases, the pure-syntax FTS5 metacharacters `( ) * ^ "` are
1595///    stripped wherever they appear in a token.
1596/// 3. Bare uppercase `AND` / `OR` / `NOT` survive as boolean operators
1597///    only in positions FTS5's grammar accepts (between terms): leading
1598///    and trailing operators are trimmed and operator runs collapse to
1599///    their first (`NOT foo` → `foo`, `foo AND` → `foo`,
1600///    `a AND OR b` → `a AND b`). Lowercase forms are ordinary terms.
1601///    Bare uppercase `NEAR` is quoted into a term — FTS5 reserves it.
1602/// 4. Tokens still carrying any other ASCII punctuation are **auto-quoted**
1603///    so FTS5 reads them as text, not syntax: `chat-send` → `"chat-send"`,
1604///    `P2.2` → `"P2.2"`, `src/store.rs` → `"src/store.rs"`,
1605///    `tcp:1666` → `"tcp:1666"` (this also neutralizes `col:` filters and
1606///    `-`/`.` operator injection).
1607/// 5. Tokens and phrases with nothing the unicode61 tokenizer would index
1608///    (no letter or digit in any script) are dropped.
1609///
1610/// When everything sanitizes away, this is an **error** ("query reduced to
1611/// nothing") — never an empty `MATCH` (a syntax error) and never a
1612/// match-all.
1613pub fn sanitize_fts5_query(raw: &str) -> anyhow::Result<String> {
1614    let mut parts: Vec<QueryPart> = Vec::new();
1615
1616    // Pass 1: split out balanced "phrases"; everything else is plain text.
1617    let mut rest = raw;
1618    loop {
1619        let Some(open) = rest.find('"') else {
1620            push_plain_tokens(rest, &mut parts);
1621            break;
1622        };
1623        push_plain_tokens(&rest[..open], &mut parts);
1624        let after_open = &rest[open + 1..];
1625        match after_open.find('"') {
1626            Some(close) => {
1627                let phrase = after_open[..close].trim();
1628                // An unindexable phrase ("--", "", …) would be dead weight
1629                // or an FTS5 error; drop it like an unindexable token.
1630                if phrase.chars().any(char::is_alphanumeric) {
1631                    parts.push(QueryPart::Term(format!("\"{phrase}\"")));
1632                }
1633                rest = &after_open[close + 1..];
1634            }
1635            None => {
1636                // Unbalanced: strip the dangling quote, keep its text.
1637                push_plain_tokens(after_open, &mut parts);
1638                break;
1639            }
1640        }
1641    }
1642
1643    // Pass 2: place operators. An operator is emitted only between two
1644    // terms: leading ops are dropped (no left operand), runs collapse to
1645    // the first, and a trailing pending op is never flushed.
1646    let mut out: Vec<String> = Vec::new();
1647    let mut pending_op: Option<&'static str> = None;
1648    for part in parts {
1649        match part {
1650            QueryPart::Term(term) => {
1651                if let Some(op) = pending_op.take() {
1652                    out.push(op.to_string());
1653                }
1654                out.push(term);
1655            }
1656            QueryPart::Op(op) => {
1657                if !out.is_empty() && pending_op.is_none() {
1658                    pending_op = Some(op);
1659                }
1660            }
1661        }
1662    }
1663
1664    if out.is_empty() {
1665        anyhow::bail!("search query reduced to nothing after FTS5 sanitizing: {raw:?}");
1666    }
1667    Ok(out.join(" "))
1668}
1669
1670/// Tokenize a plain (non-phrase) text run on whitespace and classify each
1671/// token: uppercase boolean keywords become [`QueryPart::Op`]; everything
1672/// else goes through [`sanitize_bare_token`].
1673fn push_plain_tokens(text: &str, parts: &mut Vec<QueryPart>) {
1674    for token in text.split_whitespace() {
1675        match token {
1676            "AND" => parts.push(QueryPart::Op("AND")),
1677            "OR" => parts.push(QueryPart::Op("OR")),
1678            "NOT" => parts.push(QueryPart::Op("NOT")),
1679            // FTS5 reserves NEAR (case-sensitively); as a quoted phrase it
1680            // is just the word again.
1681            "NEAR" => parts.push(QueryPart::Term("\"NEAR\"".to_string())),
1682            _ => {
1683                if let Some(term) = sanitize_bare_token(token) {
1684                    parts.push(QueryPart::Term(term));
1685                }
1686            }
1687        }
1688    }
1689}
1690
1691/// Sanitize one bare token: strip pure-syntax metacharacters, drop tokens
1692/// with nothing indexable, and auto-quote anything that is not a clean
1693/// FTS5 bareword (rules 2/4/5 of [`sanitize_fts5_query`]).
1694fn sanitize_bare_token(token: &str) -> Option<String> {
1695    let stripped: String = token
1696        .chars()
1697        .filter(|c| !matches!(c, '(' | ')' | '*' | '^' | '"'))
1698        .collect();
1699    // Nothing the unicode61 tokenizer would index → drop the token.
1700    if !stripped.chars().any(char::is_alphanumeric) {
1701        return None;
1702    }
1703    // FTS5's bareword alphabet: ASCII alphanumerics, `_`, and everything
1704    // non-ASCII. Any other character would parse as syntax — auto-quote
1705    // the token so `chat-send`, `P2.2`, paths, and issue refs match as
1706    // text (the hermes rule this port exists for).
1707    let is_bareword = stripped
1708        .chars()
1709        .all(|c| c.is_ascii_alphanumeric() || c == '_' || !c.is_ascii());
1710    Some(if is_bareword {
1711        stripped
1712    } else {
1713        format!("\"{stripped}\"")
1714    })
1715}
1716
1717/// One-time import of the retired JSON backend's tree (see the module docs).
1718///
1719/// Runs on every open and is a fast no-op when `<root>/conversations/` does
1720/// not exist (i.e. always, after the first successful import renames it to
1721/// the backup dir). Records are imported oldest-first by the legacy MRU
1722/// ordering (`updated_at`, then `created_at`, then id — the JSON backend's
1723/// own sort), so ticks assigned in import order reproduce the conversation
1724/// ordering users saw before the migration.
1725fn import_legacy_json(
1726    conn: &Connection,
1727    root: &Path,
1728    writer_fingerprint: &str,
1729) -> anyhow::Result<()> {
1730    let legacy_root = root.join(LEGACY_JSON_DIR);
1731    if !legacy_root.is_dir() {
1732        return Ok(());
1733    }
1734    let mut records = collect_legacy_records(&legacy_root)?;
1735    records.sort_by(|a, b| {
1736        a.updated_at_unix_nanos
1737            .cmp(&b.updated_at_unix_nanos)
1738            .then_with(|| a.created_at_unix_nanos.cmp(&b.created_at_unix_nanos))
1739            .then_with(|| a.id.cmp(&b.id))
1740    });
1741    let mut imported = 0usize;
1742    for record in &records {
1743        if import_one_record(conn, record, writer_fingerprint)? {
1744            imported += 1;
1745        }
1746    }
1747    let backup = retire_legacy_dir(root, &legacy_root)?;
1748    tracing::info!(
1749        imported,
1750        found = records.len(),
1751        backup = %backup.display(),
1752        "one-time import of legacy JSON conversations complete; \
1753         the original tree is kept as a backup"
1754    );
1755    Ok(())
1756}
1757
1758/// 17.2 one-shot row migration (see module docs — Workspace identity v2):
1759/// re-key every conversation that carries THIS workspace's retired UUIDv5
1760/// key to the v2 key, in one UPDATE inside an immediate transaction.
1761///
1762/// Idempotent by construction — once no rows carry the old key the UPDATE
1763/// matches nothing. Scoped by construction — a UUIDv5 key is derived from
1764/// one canonical path, so the WHERE clause can only ever select rows that
1765/// belonged to this workspace; every other workspace's rows are left for
1766/// their own open to migrate. Re-keying is metadata, not activity: no tick
1767/// is allocated, and the §6 chain is untouched (`workspace_key` is not part
1768/// of the turn encoding or the genesis hash).
1769fn migrate_workspace_key(conn: &Connection, workspace: &Path, v2_key: &str) -> anyhow::Result<()> {
1770    // The deprecated v1 derivation is retained exactly for this lookup.
1771    #[allow(deprecated)]
1772    let old_key = ConversationStore::workspace_id_for_path(workspace)?;
1773    let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
1774    let migrated = tx.execute(
1775        "UPDATE conversations SET workspace_key = ?2 WHERE workspace_key = ?1",
1776        rusqlite::params![old_key, v2_key],
1777    )?;
1778    tx.commit()?;
1779    if migrated > 0 {
1780        tracing::info!(
1781            migrated,
1782            workspace = %workspace.display(),
1783            "re-keyed conversations from the retired UUIDv5 workspace key to v2"
1784        );
1785    }
1786    Ok(())
1787}
1788
1789/// Walk `<legacy_root>/<workspace-uuid>/<id>.json` and parse every readable
1790/// record — all workspaces, not just the opening store's. Corrupt or
1791/// unreadable records are skipped with a warning (the legacy store's own
1792/// semantics); whatever is skipped survives untouched in the backup dir.
1793fn collect_legacy_records(legacy_root: &Path) -> anyhow::Result<Vec<ConversationRecord>> {
1794    let mut records = Vec::new();
1795    for ws_entry in std::fs::read_dir(legacy_root)? {
1796        let ws_dir = ws_entry?.path();
1797        if !ws_dir.is_dir() {
1798            // Stray file at the workspace level — not a record; the backup
1799            // rename preserves it.
1800            continue;
1801        }
1802        let dir_key = ws_dir
1803            .file_name()
1804            .map(|n| n.to_string_lossy().into_owned())
1805            .unwrap_or_default();
1806        for entry in std::fs::read_dir(&ws_dir)? {
1807            let path = entry?.path();
1808            if path.extension().and_then(|e| e.to_str()) != Some("json") {
1809                // The legacy store only ever read `.json` (crash-leftover
1810                // `.tmp` files were invisible to it).
1811                continue;
1812            }
1813            let parsed = std::fs::read_to_string(&path)
1814                .map_err(anyhow::Error::from)
1815                .and_then(|text| Ok(serde_json::from_str::<ConversationRecord>(&text)?));
1816            let record = match parsed {
1817                Ok(record) => record,
1818                Err(e) => {
1819                    tracing::warn!(
1820                        path = %path.display(),
1821                        error = %e,
1822                        "skipping unreadable legacy conversation record \
1823                         (the file is kept in the import backup)"
1824                    );
1825                    continue;
1826                }
1827            };
1828            // The legacy store only served records whose body workspace_id
1829            // matched their dir name; a mismatched record was invisible to
1830            // every workspace. Importing it would resurrect data no store
1831            // could see — skip it (it stays in the backup).
1832            if record.workspace_id != dir_key {
1833                tracing::warn!(
1834                    path = %path.display(),
1835                    body_workspace = %record.workspace_id,
1836                    dir_workspace = %dir_key,
1837                    "skipping legacy record whose workspace id does not match its dir"
1838                );
1839                continue;
1840            }
1841            if let Err(e) = validate_record_id(&record.id) {
1842                tracing::warn!(path = %path.display(), error = %e, "skipping legacy record");
1843                continue;
1844            }
1845            records.push(record);
1846        }
1847    }
1848    Ok(records)
1849}
1850
1851/// Import one legacy record inside its own `BEGIN IMMEDIATE` transaction:
1852/// conversation row first (one tick, genesis tip), then each turn through
1853/// the normal tick + chain path, then the activity/tip update — exactly the
1854/// shape the live write path produces, so `verify_chain` holds post-import.
1855/// Returns `false` (and writes nothing) when the id already exists in the
1856/// database — in any workspace: that means an earlier pass imported it (or
1857/// the id collides), and the import never overwrites.
1858fn import_one_record(
1859    conn: &Connection,
1860    record: &ConversationRecord,
1861    writer_fingerprint: &str,
1862) -> anyhow::Result<bool> {
1863    let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
1864    let already: Option<i64> = tx
1865        .query_row(
1866            "SELECT 1 FROM conversations WHERE id = ?1",
1867            [&record.id],
1868            |row| row.get(0),
1869        )
1870        .optional()?;
1871    if already.is_some() {
1872        tracing::debug!(id = %record.id, "legacy conversation already in the db; skipping");
1873        return Ok(false);
1874    }
1875
1876    // §6: the legacy unix_nanos enter ONLY as display claims.
1877    let started_claim = clamp_claim(record.created_at_unix_nanos);
1878    let updated_claim = clamp_claim(record.updated_at_unix_nanos);
1879    let create_tick = next_tick(&tx, writer_fingerprint)?;
1880    tx.execute(
1881        "INSERT INTO conversations
1882           (id, title, workspace_path, workspace_key, persona, end_reason,
1883            writer_fingerprint, activity_tick, tip_hash,
1884            started_at_claim, updated_at_claim)
1885         VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9, ?10)",
1886        rusqlite::params![
1887            record.id,
1888            record.title,
1889            record.workspace,
1890            record.workspace_id,
1891            record.persona,
1892            writer_fingerprint,
1893            create_tick,
1894            genesis_hash(&record.id, writer_fingerprint),
1895            started_claim,
1896            updated_claim,
1897        ],
1898    )?;
1899
1900    let mut prev_hash = genesis_hash(&record.id, writer_fingerprint);
1901    let mut last_tick = create_tick;
1902    for turn in &record.turns {
1903        let seq = next_tick(&tx, writer_fingerprint)?;
1904        let row = TurnRow {
1905            conversation_id: record.id.clone(),
1906            writer_fingerprint: writer_fingerprint.to_string(),
1907            seq,
1908            prev_hash,
1909            user: turn.user.clone(),
1910            assistant: turn.assistant.clone(),
1911            events: "[]".to_string(),
1912            tokens_in: None,
1913            tokens_out: None,
1914            // The legacy format recorded no per-turn time; the record-level
1915            // updated_at is the only available claim (display only, §6).
1916            ts_claim: updated_claim,
1917            encoding_version: TURN_ENCODING_VERSION_CURRENT,
1918        };
1919        // #717: the legacy JSON backend recorded no phantom reaches (it predates
1920        // the column), exactly as it recorded no tool events (`events: "[]"`).
1921        insert_turn_row(&tx, &row, "[]")?;
1922        prev_hash = row.content_hash()?;
1923        last_tick = seq;
1924    }
1925    if !record.turns.is_empty() {
1926        tx.execute(
1927            "UPDATE conversations SET activity_tick = ?2, tip_hash = ?3 WHERE id = ?1",
1928            rusqlite::params![record.id, last_tick, prev_hash],
1929        )?;
1930    }
1931    tx.commit()?;
1932    Ok(true)
1933}
1934
1935/// Move the legacy tree to the backup name (`conversations.imported/`,
1936/// suffixed if that already exists). A concurrent opener may win the rename;
1937/// finding the source already gone is success, not an error.
1938fn retire_legacy_dir(root: &Path, legacy_root: &Path) -> anyhow::Result<PathBuf> {
1939    for attempt in 0u32..100 {
1940        let candidate = if attempt == 0 {
1941            root.join(LEGACY_BACKUP_DIR)
1942        } else {
1943            root.join(format!("{LEGACY_BACKUP_DIR}.{attempt}"))
1944        };
1945        if candidate.exists() {
1946            continue;
1947        }
1948        return match std::fs::rename(legacy_root, &candidate) {
1949            Ok(()) => Ok(candidate),
1950            Err(_) if !legacy_root.exists() => Ok(candidate),
1951            Err(e) => Err(anyhow::Error::from(e).context(format!(
1952                "imported legacy conversations but could not move {} aside to {}",
1953                legacy_root.display(),
1954                candidate.display()
1955            ))),
1956        };
1957    }
1958    anyhow::bail!(
1959        "no free backup name for {} (conversations.imported* all taken)",
1960        legacy_root.display()
1961    )
1962}
1963
1964/// Clamp a legacy u128 nanosecond claim into the store's i64 claim columns.
1965/// Saturates at `i64::MAX` — claims are display-only (§6), never compared.
1966fn clamp_claim(nanos: u128) -> i64 {
1967    i64::try_from(nanos).unwrap_or(i64::MAX)
1968}
1969
1970/// The §6 writer fingerprint, in preference order (module docs — Writer
1971/// identity): the operator's mesh-key fingerprint from `<root>/identity.pem`
1972/// when it exists and parses, else the 17.1a per-install nonce.
1973///
1974/// The key type comes from `agent-mesh-protocol` (already a direct dep of
1975/// newt-core) — deliberately NOT from `newt-identity`, which depends on
1976/// newt-core and would make the coupling a cycle. The path is rooted at the
1977/// store root rather than resolved from `$HOME` so the derivation stays
1978/// hermetic (tests, alternate roots); for the production root `~/.newt`
1979/// the two spellings are the same file.
1980fn resolve_writer_fingerprint(root: &Path) -> anyhow::Result<String> {
1981    let pem = root.join(IDENTITY_PEM_FILE);
1982    if pem.is_file() {
1983        match agent_mesh_protocol::UserKey::load(&pem) {
1984            Ok(user) => return Ok(user.fingerprint().hex()),
1985            Err(e) => {
1986                // A broken key file must never block the store; the nonce
1987                // fallback keeps the Lamport clock running (and §6 chains
1988                // tolerate the writer change as a handoff).
1989                tracing::warn!(
1990                    path = %pem.display(),
1991                    error = %e,
1992                    "identity.pem exists but did not parse; \
1993                     falling back to the per-install nonce writer fingerprint"
1994                );
1995            }
1996        }
1997    }
1998    load_or_create_writer_fingerprint(root)
1999}
2000
2001/// Load (or mint, atomically) the per-install nonce and derive the writer
2002/// fingerprint as its BLAKE3 hex — the fallback half of
2003/// [`resolve_writer_fingerprint`].
2004fn load_or_create_writer_fingerprint(root: &Path) -> anyhow::Result<String> {
2005    let path = root.join(NONCE_FILE);
2006    let nonce = match std::fs::read_to_string(&path) {
2007        Ok(text) if !text.trim().is_empty() => text.trim().to_string(),
2008        _ => {
2009            // Atomic mint-with-content. Two racing first-run processes must
2010            // converge on ONE nonce, and the published file must NEVER be
2011            // observable half-written:
2012            //   * write-then-RENAME is wrong — rename replaces, so a slow
2013            //     racer can overwrite the winner's nonce after the winner
2014            //     already derived its fingerprint (orphaning its rows);
2015            //   * bare O_EXCL-then-write is wrong — the file exists EMPTY
2016            //     between create and write, so a racing reader can adopt ""
2017            //     (caught by CI: the 8-thread convergence test on Windows).
2018            // hard_link is the primitive with both properties: the name
2019            // appears only after the temp's content is fully written, and
2020            // linking FAILS (AlreadyExists) instead of replacing a winner.
2021            let minted = uuid::Uuid::new_v4().to_string();
2022            let tmp = root.join(format!(
2023                "{NONCE_FILE}.{}.{:?}.tmp",
2024                std::process::id(),
2025                std::thread::current().id()
2026            ));
2027            std::fs::write(&tmp, &minted)?;
2028            let publish = std::fs::hard_link(&tmp, &path);
2029            let _ = std::fs::remove_file(&tmp);
2030            match publish {
2031                Ok(()) => minted,
2032                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2033                    // The winner's link only exists with full content.
2034                    let adopted = std::fs::read_to_string(&path)?.trim().to_string();
2035                    if adopted.is_empty() {
2036                        anyhow::bail!(
2037                            "install nonce at {} exists but is empty — remove it and retry",
2038                            path.display()
2039                        );
2040                    }
2041                    adopted
2042                }
2043                Err(e) => return Err(e.into()),
2044            }
2045        }
2046    };
2047    Ok(blake3::hash(nonce.as_bytes()).to_hex().to_string())
2048}
2049
2050/// Wall-clock for the display-only claim columns. Saturates at `i64::MAX`
2051/// (year 2262 in unix nanos) — claims are never compared, so saturation is
2052/// harmless by construction.
2053fn now_claim_nanos() -> i64 {
2054    std::time::SystemTime::now()
2055        .duration_since(std::time::UNIX_EPOCH)
2056        .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
2057        .unwrap_or(0)
2058}
2059
2060fn claim_to_u128(claim: i64) -> u128 {
2061    claim.max(0) as u128
2062}
2063
2064/// Read a token-count column back to the `u32` it was written from (17.6).
2065/// NULL stays `None` — an unreported count is absence, never zero-dressed-up.
2066/// A value outside `u32` cannot come from `append_turn_full` (which widens
2067/// from `u32`), so it errors as tampering/corruption instead of clamping —
2068/// 18.5 trusts these as measurements.
2069fn tokens_from_sql(value: Option<i64>) -> anyhow::Result<Option<u32>> {
2070    value
2071        .map(|v| {
2072            u32::try_from(v)
2073                .map_err(|_| anyhow::anyhow!("token count {v} out of range (tampered row?)"))
2074        })
2075        .transpose()
2076}
2077
2078/// The conversation-id alphabet (ASCII alphanumeric + '-'), inherited from
2079/// the JSON backend so every legacy id imports unchanged. SQL parameters
2080/// make injection moot; the validation also guarantees ids are pure ASCII,
2081/// which `resolve_id`'s byte-exact `substr` prefix match relies on.
2082fn validate_record_id(id: &str) -> anyhow::Result<()> {
2083    if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
2084        anyhow::bail!("invalid conversation id `{id}`");
2085    }
2086    Ok(())
2087}
2088
2089#[cfg(test)]
2090mod tests {
2091    use super::*;
2092
2093    #[test]
2094    fn wal_fallback_classifier_matches_known_nfs_failures() {
2095        assert!(wal_fallback_eligible("locking protocol"));
2096        assert!(wal_fallback_eligible("disk I/O error"));
2097        assert!(wal_fallback_eligible(
2098            "sqlite failure: `Error code 15: Locking Protocol`"
2099        ));
2100        assert!(!wal_fallback_eligible("no such table: turns"));
2101        assert!(!wal_fallback_eligible("database is locked"));
2102        assert!(!wal_fallback_eligible(""));
2103    }
2104
2105    #[test]
2106    fn canonical_encoding_is_unambiguous_across_field_boundaries() {
2107        let base = TurnRow {
2108            conversation_id: "c".into(),
2109            writer_fingerprint: "w".into(),
2110            seq: 1,
2111            prev_hash: "p".into(),
2112            user: "ab".into(),
2113            assistant: "c".into(),
2114            events: "[]".into(),
2115            tokens_in: None,
2116            tokens_out: None,
2117            ts_claim: 7,
2118            encoding_version: 1,
2119        };
2120        let shifted = TurnRow {
2121            user: "a".into(),
2122            assistant: "bc".into(),
2123            ..clone_row(&base)
2124        };
2125        assert_ne!(
2126            base.canonical_encoding_v1(),
2127            shifted.canonical_encoding_v1(),
2128            "length prefixes must prevent (ab,c) == (a,bc)"
2129        );
2130        // Every field participates in the hash — including the claims and
2131        // token counts, which makes even display fields tamper-evident.
2132        let skewed = TurnRow {
2133            ts_claim: 8,
2134            ..clone_row(&base)
2135        };
2136        assert_ne!(base.content_hash().unwrap(), skewed.content_hash().unwrap());
2137        let tokens = TurnRow {
2138            tokens_in: Some(5),
2139            ..clone_row(&base)
2140        };
2141        assert_ne!(base.content_hash().unwrap(), tokens.content_hash().unwrap());
2142    }
2143
2144    /// N1 (#261): `content_hash` dispatches on the row's recorded encoding
2145    /// version — v1 hashes, anything else errors clearly rather than hashing
2146    /// under the wrong rules.
2147    #[test]
2148    fn content_hash_rejects_unknown_encoding_versions() {
2149        let v1 = TurnRow {
2150            conversation_id: "c".into(),
2151            writer_fingerprint: "w".into(),
2152            seq: 1,
2153            prev_hash: "p".into(),
2154            user: "u".into(),
2155            assistant: "a".into(),
2156            events: "[]".into(),
2157            tokens_in: None,
2158            tokens_out: None,
2159            ts_claim: 7,
2160            encoding_version: 1,
2161        };
2162        v1.content_hash().expect("v1 must hash");
2163
2164        let future = TurnRow {
2165            encoding_version: 2,
2166            ..clone_row(&v1)
2167        };
2168        let err = future.content_hash().unwrap_err().to_string();
2169        assert!(
2170            err.contains("encoding_version 2") && err.contains("known: 1"),
2171            "unknown version must error clearly: {err}"
2172        );
2173    }
2174
2175    #[test]
2176    fn clamp_claim_saturates_oversized_legacy_nanos() {
2177        assert_eq!(clamp_claim(0), 0);
2178        assert_eq!(clamp_claim(42), 42);
2179        assert_eq!(clamp_claim(u128::MAX), i64::MAX);
2180    }
2181
2182    #[test]
2183    fn genesis_hash_is_deterministic_and_writer_scoped() {
2184        assert_eq!(genesis_hash("conv", "w1"), genesis_hash("conv", "w1"));
2185        assert_ne!(genesis_hash("conv", "w1"), genesis_hash("conv", "w2"));
2186        assert_ne!(genesis_hash("conv", "w1"), genesis_hash("other", "w1"));
2187        // Length-prefixing: ("ab","c") must not collide with ("a","bc").
2188        assert_ne!(genesis_hash("ab", "c"), genesis_hash("a", "bc"));
2189    }
2190
2191    #[test]
2192    fn writer_fingerprint_is_stable_per_install_and_distinct_across_installs() {
2193        let root_a = tempfile::tempdir().unwrap();
2194        let root_b = tempfile::tempdir().unwrap();
2195        let first = load_or_create_writer_fingerprint(root_a.path()).unwrap();
2196        let again = load_or_create_writer_fingerprint(root_a.path()).unwrap();
2197        let other = load_or_create_writer_fingerprint(root_b.path()).unwrap();
2198        assert_eq!(first, again, "fingerprint must be stable per install");
2199        assert_ne!(first, other, "two installs must not share a fingerprint");
2200        assert_eq!(first.len(), 64, "blake3 hex");
2201    }
2202
2203    #[test]
2204    fn wal_mode_pairs_with_synchronous_normal_on_the_stores_connection() {
2205        let root = tempfile::tempdir().unwrap();
2206        let workspace = tempfile::tempdir().unwrap();
2207        let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
2208        // `synchronous` is per-connection, so ask the store's own connection
2209        // (a fresh external connection would only show its own default).
2210        let conn = store.lock_conn();
2211        let sync_level: i64 = conn
2212            .query_row("PRAGMA synchronous", [], |row| row.get(0))
2213            .unwrap();
2214        assert_eq!(sync_level, 1, "WAL must run at synchronous=NORMAL (1)");
2215    }
2216
2217    #[test]
2218    fn claim_clock_saturates_instead_of_wrapping() {
2219        let now = now_claim_nanos();
2220        assert!(now > 0);
2221        assert_eq!(claim_to_u128(-5), 0);
2222        assert_eq!(claim_to_u128(42), 42);
2223    }
2224
2225    // --- load_turn: the by-(conv, seq) read for memory_fetch (#319) --------
2226
2227    /// `load_turn` returns one past turn verbatim, addressed by the §6 seq the
2228    /// model saw in a recall hit; an unknown seq / conversation is `Ok(None)`
2229    /// (labelled absence, never an error — the `memory_fetch` tool contract).
2230    #[test]
2231    fn load_turn_reads_one_turn_by_seq_and_misses_are_none() {
2232        let root = tempfile::tempdir().unwrap();
2233        let workspace = tempfile::tempdir().unwrap();
2234        let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
2235        let conv = store.create("t", None).unwrap();
2236        store
2237            .append_turn(&conv, "the question", "the answer")
2238            .unwrap();
2239
2240        // The seq the model would paste comes from a recall hit.
2241        let hits = store.search("question", 5).unwrap();
2242        assert_eq!(hits.len(), 1);
2243        let seq = hits[0].seq;
2244
2245        let turn = store.load_turn(&conv, seq).unwrap().expect("turn exists");
2246        assert_eq!(turn.user, "the question");
2247        assert_eq!(turn.assistant, "the answer");
2248
2249        // Unknown seq → None, not an error.
2250        assert!(store.load_turn(&conv, seq + 9_999).unwrap().is_none());
2251        // Unknown conversation id → None, not an error (no cross-ws leak path).
2252        assert!(store.load_turn("no-such-conv", seq).unwrap().is_none());
2253    }
2254
2255    // --- end_reason: /end · /restart · :wq close-out (17.7 wiring) ---------
2256
2257    /// `end_conversation` marks the row so `latest_open` skips it on
2258    /// auto-resume, while `list` (and therefore `/recall`/`/conversation`)
2259    /// still sees it — ended, not deleted.
2260    #[test]
2261    fn end_conversation_hides_row_from_latest_open_but_not_from_list() {
2262        let root = tempfile::tempdir().unwrap();
2263        let workspace = tempfile::tempdir().unwrap();
2264        let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
2265
2266        let c1 = store.create("first", None).unwrap();
2267        store.append_turn(&c1, "q1", "a1").unwrap();
2268        let c2 = store.create("second", None).unwrap();
2269        store.append_turn(&c2, "q2", "a2").unwrap();
2270
2271        // c2 was written last → highest activity tick → the resume target.
2272        assert_eq!(store.latest_open().unwrap().unwrap().id, c2);
2273
2274        // End c2: latest_open falls back to the prior OPEN conversation…
2275        store.end_conversation(&c2, "wq").unwrap();
2276        assert_eq!(
2277            store.latest_open().unwrap().unwrap().id,
2278            c1,
2279            "an ended conversation is skipped on auto-resume"
2280        );
2281        // …but both rows are still listed (ended ≠ deleted).
2282        assert_eq!(store.list().unwrap().len(), 2);
2283        // …and the ended conversation is still recall-searchable.
2284        assert!(
2285            store
2286                .search("q2", 5)
2287                .unwrap()
2288                .iter()
2289                .any(|h| h.conversation_id == c2),
2290            "ended conversation stays in the FTS index for /recall"
2291        );
2292
2293        // End the last open one too → nothing left to auto-resume → fresh.
2294        store.end_conversation(&c1, "end").unwrap();
2295        assert!(store.latest_open().unwrap().is_none());
2296        assert_eq!(store.list().unwrap().len(), 2, "still listed after ending");
2297    }
2298
2299    /// Ending is metadata, not activity: it must not tick the §6 clock (so it
2300    /// cannot perturb MRU ordering), and re-ending is harmless.
2301    #[test]
2302    fn end_conversation_does_not_tick_activity_and_is_idempotent() {
2303        let root = tempfile::tempdir().unwrap();
2304        let workspace = tempfile::tempdir().unwrap();
2305        let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
2306
2307        let older = store.create("older", None).unwrap();
2308        store.append_turn(&older, "q", "a").unwrap();
2309        let newer = store.create("newer", None).unwrap();
2310        store.append_turn(&newer, "q", "a").unwrap();
2311
2312        let tick_of = |id: &str| -> i64 {
2313            let conn = store.lock_conn();
2314            conn.query_row(
2315                "SELECT activity_tick FROM conversations WHERE id = ?1",
2316                [id],
2317                |row| row.get(0),
2318            )
2319            .unwrap()
2320        };
2321        let before = tick_of(&older);
2322        store.end_conversation(&older, "new").unwrap();
2323        assert_eq!(tick_of(&older), before, "ending must not bump the tick");
2324        // Idempotent: re-ending an already-ended conversation is fine.
2325        store.end_conversation(&older, "new").unwrap();
2326        // `newer` is still open and remains the resume target.
2327        assert_eq!(store.latest_open().unwrap().unwrap().id, newer);
2328    }
2329
2330    // --- 17.3: the query-sanitizer adversarial matrix ---------------------
2331
2332    /// Shorthand: sanitize and unwrap (the input is expected to survive).
2333    fn s(raw: &str) -> String {
2334        sanitize_fts5_query(raw).unwrap()
2335    }
2336
2337    /// The hermes examples: dotted / hyphenated / path-like / colon tokens
2338    /// are auto-quoted so FTS5 reads them as text, not syntax.
2339    #[test]
2340    fn sanitizer_auto_quotes_dotted_hyphenated_and_path_tokens() {
2341        assert_eq!(s("chat-send"), "\"chat-send\"");
2342        assert_eq!(s("P2.2"), "\"P2.2\"");
2343        assert_eq!(s("my-app.config.ts"), "\"my-app.config.ts\"");
2344        assert_eq!(s("src/store.rs"), "\"src/store.rs\"");
2345        assert_eq!(s("tcp:p4d.p4d-ascii:1666"), "\"tcp:p4d.p4d-ascii:1666\"");
2346        assert_eq!(s("issue #246"), "issue \"#246\"");
2347        // Clean barewords pass through untouched — including underscores
2348        // (in FTS5's bareword alphabet) and non-ASCII text.
2349        assert_eq!(s("hello world"), "hello world");
2350        assert_eq!(s("writer_clock"), "writer_clock");
2351        assert_eq!(s("schlüssel wörter"), "schlüssel wörter");
2352    }
2353
2354    #[test]
2355    fn sanitizer_preserves_balanced_phrases_and_drops_dangling_quotes() {
2356        assert_eq!(s("\"exact phrase\" extra"), "\"exact phrase\" extra");
2357        assert_eq!(s("say \"hello world\" now"), "say \"hello world\" now");
2358        // Unbalanced quote: the quote dies, its text survives as terms.
2359        assert_eq!(s("foo \"bar"), "foo bar");
2360        assert_eq!(s("\"unclosed"), "unclosed");
2361        assert_eq!(s("\"a b\" \"c"), "\"a b\" c");
2362        // Phrase content keeps operators/metachars as text (FTS5 allows
2363        // anything but a quote inside a phrase).
2364        assert_eq!(s("\"AND OR\""), "\"AND OR\"");
2365        assert_eq!(s("\"P2.2 chat-send\""), "\"P2.2 chat-send\"");
2366        // Empty / unindexable phrases are dropped, not emitted as "".
2367        let err = sanitize_fts5_query("\"\"").unwrap_err().to_string();
2368        assert!(err.contains("reduced to nothing"), "{err}");
2369        let err = sanitize_fts5_query("\"--\"").unwrap_err().to_string();
2370        assert!(err.contains("reduced to nothing"), "{err}");
2371    }
2372
2373    #[test]
2374    fn sanitizer_trims_dangling_operators() {
2375        assert_eq!(s("foo AND"), "foo");
2376        assert_eq!(s("OR foo"), "foo");
2377        assert_eq!(s("NOT foo"), "foo");
2378        assert_eq!(s("foo AND AND bar"), "foo AND bar");
2379        assert_eq!(s("foo AND OR bar"), "foo AND bar");
2380        assert_eq!(s("AND foo OR"), "foo");
2381        // Valid binary positions survive.
2382        assert_eq!(s("foo OR bar"), "foo OR bar");
2383        assert_eq!(s("foo NOT bar"), "foo NOT bar");
2384        assert_eq!(s("a OR b OR c"), "a OR b OR c");
2385        // Lowercase forms are ordinary terms, not operators.
2386        assert_eq!(s("foo and bar"), "foo and bar");
2387        // Bare AND reduces to nothing → error, not an FTS5 syntax error.
2388        let err = sanitize_fts5_query("AND").unwrap_err().to_string();
2389        assert!(err.contains("reduced to nothing"), "{err}");
2390        // NEAR is reserved by FTS5 — it survives only as a quoted term.
2391        assert_eq!(s("NEAR"), "\"NEAR\"");
2392        assert_eq!(s("near"), "near");
2393    }
2394
2395    #[test]
2396    fn sanitizer_strips_metacharacter_injection() {
2397        assert_eq!(s("(foo OR bar) AND baz"), "foo OR bar AND baz");
2398        assert_eq!(s("foo* ^bar"), "foo bar");
2399        assert_eq!(s("col*umn"), "column");
2400        // A lone quote / star / caret / paren reduces to nothing.
2401        for q in ["\"", "*", "^", "( )", "*^()"] {
2402            let err = sanitize_fts5_query(q).unwrap_err().to_string();
2403            assert!(err.contains("reduced to nothing"), "{q:?}: {err}");
2404        }
2405        // Mid-token quote: unbalanced → stripped; the halves survive.
2406        assert_eq!(s("fo\"o bar"), "fo o bar");
2407        // Punctuation-only tokens are dropped, indexable ones kept.
2408        assert_eq!(s("?? foo !!"), "foo");
2409        assert_eq!(s("foo \u{a0} "), "foo"); // unicode whitespace handled
2410    }
2411
2412    #[test]
2413    fn sanitizer_handles_mixed_phrases_terms_and_operators() {
2414        assert_eq!(
2415            s("\"tuning writeback\" OR coverage-floor"),
2416            "\"tuning writeback\" OR \"coverage-floor\""
2417        );
2418        assert_eq!(
2419            s("error \"chain violation\" NOT P2.2"),
2420            "error \"chain violation\" NOT \"P2.2\""
2421        );
2422        // Operator directly before a phrase works too.
2423        assert_eq!(s("AND \"lead phrase\" tail"), "\"lead phrase\" tail");
2424    }
2425
2426    #[test]
2427    fn sanitizer_errors_on_empty_and_whitespace_queries() {
2428        for q in ["", "   ", "\t\n"] {
2429            let err = sanitize_fts5_query(q).unwrap_err().to_string();
2430            assert!(err.contains("reduced to nothing"), "{q:?}: {err}");
2431        }
2432    }
2433
2434    /// The events-extraction SQL is shared between the triggers and the
2435    /// content view; pin its shape (json_valid guard + coalesce to '').
2436    #[test]
2437    fn events_extract_sql_guards_and_targets_the_seam_keys() {
2438        let sql = events_extract_sql("new.events", "tool");
2439        assert!(sql.contains("json_valid(new.events)"));
2440        assert!(sql.contains("json_each(new.events)"));
2441        assert!(sql.contains("'$.tool'"));
2442        assert!(sql.contains("ELSE '' END"));
2443    }
2444
2445    fn clone_row(row: &TurnRow) -> TurnRow {
2446        TurnRow {
2447            conversation_id: row.conversation_id.clone(),
2448            writer_fingerprint: row.writer_fingerprint.clone(),
2449            seq: row.seq,
2450            prev_hash: row.prev_hash.clone(),
2451            user: row.user.clone(),
2452            assistant: row.assistant.clone(),
2453            events: row.events.clone(),
2454            tokens_in: row.tokens_in,
2455            tokens_out: row.tokens_out,
2456            ts_claim: row.ts_claim,
2457            encoding_version: row.encoding_version,
2458        }
2459    }
2460}