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    /// #1030: this process's hostname + kernel boot id, captured once at open —
236    /// the machine-identity half of a `live_owners` claim (paired with `pid`).
237    host: String,
238    boot_id: String,
239    /// This process's OS pid — the process-unique half of a claim. The writer
240    /// fingerprint is shared per machine (derived from `identity.pem`), so it
241    /// cannot identify a process on its own; `pid` + `host` + `boot_id` does.
242    pid: i64,
243    /// Liveness oracle used by `claim` / `is_owner_live` to decide whether a
244    /// stored claim is still a running process. Injectable for tests (default
245    /// [`system_liveness`]).
246    liveness: LivenessFn,
247}
248
249impl ConversationStore {
250    /// Open (creating if needed) the store at `<root>/conversations.db`,
251    /// scoped to `workspace`. `max_per_workspace` is the create-time prune
252    /// cap (0 = no pruning), identical to the JSON backend.
253    pub fn new(
254        root: impl AsRef<Path>,
255        workspace: impl AsRef<Path>,
256        max_per_workspace: usize,
257    ) -> anyhow::Result<Self> {
258        let root = root.as_ref().to_path_buf();
259        let workspace = std::fs::canonicalize(workspace.as_ref())?;
260        let workspace_id = crate::workspace_key::workspace_key_v2(&workspace)?;
261        std::fs::create_dir_all(&root)?;
262        let writer_fingerprint = resolve_writer_fingerprint(&root)?;
263
264        let conn = Connection::open(root.join(DB_FILE))?;
265        conn.busy_timeout(BUSY_TIMEOUT)?;
266        conn.pragma_update(None, "foreign_keys", "ON")?;
267        // First-open init under concurrency: the journal-mode transition has
268        // documented busy-handler-EXEMPT lock paths, so SQLITE_BUSY can escape
269        // despite busy_timeout when several first runs race (reproduced: 8
270        // concurrent opens under llvm-cov). Bounded retry; once the db is in
271        // WAL, re-running this phase is a no-op so steady-state never loops.
272        let wal_fallback = {
273            let mut attempt = 0u32;
274            loop {
275                match apply_journal_mode(&conn)
276                    .and_then(|fb| create_schema(&conn).map(|()| fb))
277                    .and_then(|fb| reconcile_schema(&conn).map(|()| fb))
278                    // #1086: rebuild a legacy id-only-PK roadmaps table to the
279                    // composite (id, workspace_key) key. No-op once composite.
280                    .and_then(|fb| migrate_roadmaps_pk(&conn).map(|()| fb))
281                    // After reconciliation: the FTS view reads `events`,
282                    // which on a drifted pre-17.1b db exists only once the
283                    // column reconciliation above has run.
284                    .and_then(|fb| create_fts_index(&conn).map(|()| fb))
285                {
286                    Ok(fb) => break fb,
287                    Err(e)
288                        if attempt < 20
289                            && e.to_string().to_ascii_lowercase().contains("locked") =>
290                    {
291                        attempt += 1;
292                        std::thread::sleep(std::time::Duration::from_millis(
293                            25 * u64::from(attempt.min(4)),
294                        ));
295                    }
296                    Err(e) => return Err(e),
297                }
298            }
299        };
300
301        import_legacy_json(&conn, &root, &writer_fingerprint)?;
302        // 17.2: after the import (whose records carry UUIDv5 keys), re-key
303        // THIS workspace's rows from the retired UUIDv5 derivation to v2.
304        migrate_workspace_key(&conn, &workspace, &workspace_id)?;
305
306        let (host, boot_id) = current_host_boot();
307        Ok(Self {
308            conn: Arc::new(Mutex::new(conn)),
309            workspace,
310            workspace_id,
311            writer_fingerprint,
312            max_per_workspace,
313            wal_fallback,
314            claim_clock: now_claim_nanos,
315            host,
316            boot_id,
317            pid: i64::from(std::process::id()),
318            liveness: system_liveness,
319        })
320    }
321
322    /// The RETIRED v1 workspace key: UUIDv5 of the canonical path — the
323    /// derivation the JSON backend used (its per-workspace dir names) and
324    /// 17.1a inherited for `workspace_key`. Kept for exactly two lookups:
325    /// the one-time legacy JSON import (dir names are UUIDv5) and the 17.2
326    /// open-time migration that re-keys this workspace's old rows to
327    /// [`crate::workspace_key::workspace_key_v2`]. Do not key anything new
328    /// with it.
329    #[deprecated(
330        since = "0.6.8",
331        note = "v1 keying is path-fragile; use `newt_core::workspace_key_v2` \
332                (17.2). This stays only for the UUIDv5→v2 row migration and \
333                legacy-import dir names."
334    )]
335    pub fn workspace_id_for_path(path: impl AsRef<Path>) -> anyhow::Result<String> {
336        let canonical = std::fs::canonicalize(path.as_ref())?;
337        let normalized = canonical.to_string_lossy().replace('\\', "/");
338        Ok(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, normalized.as_bytes()).to_string())
339    }
340
341    /// `Some(error text)` when the database refused WAL and the store is
342    /// running on the `journal_mode=DELETE` fallback (typical for NFS
343    /// homes). Callers should surface this once to the user.
344    pub fn wal_fallback_notice(&self) -> Option<&str> {
345        self.wal_fallback.as_deref()
346    }
347
348    /// This install's writer fingerprint — the `writer_fingerprint` half of
349    /// the §6 `(writer_fingerprint, seq)` ordering key.
350    pub fn writer_fingerprint(&self) -> &str {
351        &self.writer_fingerprint
352    }
353
354    /// Create a conversation with a freshly minted id; returns the id.
355    pub fn create(&self, title: &str, persona: Option<&str>) -> anyhow::Result<String> {
356        let id = new_conversation_id();
357        self.create_with_id(&id, title, persona)?;
358        Ok(id)
359    }
360
361    /// Create a conversation record using a caller-supplied `id`.
362    ///
363    /// The TUI pre-generates a conversation id at session start (so the
364    /// per-session plan path is stable from turn 1, see issue #220) and the
365    /// record adopts that id when the first turn is saved — same lazy-create
366    /// contract as the JSON backend.
367    pub fn create_with_id(
368        &self,
369        id: &str,
370        title: &str,
371        persona: Option<&str>,
372    ) -> anyhow::Result<()> {
373        validate_record_id(id)?;
374        let now = (self.claim_clock)();
375        {
376            let conn = self.lock_conn();
377            let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
378            // Workspace fence: `id` is a GLOBAL primary key and REPLACE fires
379            // `ON DELETE CASCADE` — without this check, re-creating an id that
380            // belongs to ANOTHER workspace would silently destroy that
381            // workspace's conversation and all its turns. Same-workspace
382            // REPLACE keeps JSON-backend parity (re-create = overwrite).
383            let foreign: Option<String> = tx
384                .query_row(
385                    "SELECT workspace_key FROM conversations WHERE id = ?1",
386                    rusqlite::params![id],
387                    |row| row.get(0),
388                )
389                .optional()?;
390            if let Some(owner) = foreign {
391                if owner != self.workspace_id {
392                    anyhow::bail!(
393                        "conversation id `{id}` already exists in another workspace \
394                         (key {owner}); refusing to overwrite across the workspace fence"
395                    );
396                }
397            }
398            let tick = next_tick(&tx, &self.writer_fingerprint)?;
399            // INSERT OR REPLACE mirrors the JSON backend, where re-creating an
400            // existing id overwrote the record (turns reset). The REPLACE
401            // deletes the old row, and `ON DELETE CASCADE` drops its turns —
402            // safe only because of the fence above.
403            tx.execute(
404                "INSERT OR REPLACE INTO conversations
405                   (id, title, workspace_path, workspace_key, persona, end_reason,
406                    writer_fingerprint, activity_tick, tip_hash,
407                    started_at_claim, updated_at_claim)
408                 VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9, ?9)",
409                rusqlite::params![
410                    id,
411                    title.trim(),
412                    self.workspace.to_string_lossy(),
413                    self.workspace_id,
414                    persona,
415                    self.writer_fingerprint,
416                    tick,
417                    genesis_hash(id, &self.writer_fingerprint),
418                    now,
419                ],
420            )?;
421            tx.commit()?;
422        }
423        self.prune_to_cap()?;
424        Ok(())
425    }
426
427    /// `true` if a record for exactly `id` exists in this workspace. Used by
428    /// the save path to decide between [`create_with_id`](Self::create_with_id)
429    /// (first turn) and [`append_turn`](Self::append_turn).
430    ///
431    /// Errors propagate rather than read as "absent": a transient failure
432    /// (e.g. a busy reader past the timeout under the NFS DELETE fallback)
433    /// mistaken for "doesn't exist" would route the caller into
434    /// `create_with_id` and overwrite a live conversation.
435    pub fn exists(&self, id: &str) -> anyhow::Result<bool> {
436        let conn = self.lock_conn();
437        Ok(conn
438            .query_row(
439                "SELECT 1 FROM conversations WHERE id = ?1 AND workspace_key = ?2",
440                rusqlite::params![id, self.workspace_id],
441                |_| Ok(()),
442            )
443            .optional()?
444            .is_some())
445    }
446
447    /// Append one `(user, assistant)` turn with no tool events and no token
448    /// usage. `id` may be a unique prefix. Thin wrapper over
449    /// [`append_turn_full`](Self::append_turn_full): an empty event slice
450    /// serializes to `'[]'` and absent tokens to NULL — byte-identical to
451    /// the pre-17.6 row shape, so existing callers are unchanged.
452    pub fn append_turn(&self, id: &str, user: &str, assistant: &str) -> anyhow::Result<()> {
453        self.append_turn_full(id, user, assistant, &[], &[], None, None)
454    }
455
456    /// Append one turn with its recorded tool events and backend-reported
457    /// token usage (Step 17.6, issue #246). `id` may be a unique prefix.
458    ///
459    /// One `BEGIN IMMEDIATE` transaction covers: tick allocation, chain
460    /// extension (`prev_hash` from the current per-writer tip), the row
461    /// insert, and the conversation's activity/tip update. Appending never
462    /// prunes — only `create` does, matching the JSON backend.
463    ///
464    /// **Chain (§6):** events and token counts are row content — the v1
465    /// canonical encoding has length-prefixed the serialized `events`
466    /// string and the token presence bytes since 17.1a, so populated
467    /// values hash under the exact rules empty ones did. No
468    /// `encoding_version` bump: pre-17.6 rows (`'[]'`, NULL) and 17.6 rows
469    /// verify under the same v1 dispatch, and tampering with a stored
470    /// event breaks [`verify_chain`](Self::verify_chain) like any other
471    /// field.
472    ///
473    /// **Tokens are measurements, not estimates:** pass the backend's
474    /// reported counts or `None`. `None` is stored as NULL — absence stays
475    /// observable (18.5 rehydrates from these columns and must be able to
476    /// trust them; gates-are-honest).
477    ///
478    /// **FTS:** the 17.3 AFTER INSERT trigger derives `tool_names` /
479    /// `tool_args_digest` from the events JSON at index time — recording
480    /// events here lights recall up with no schema work.
481    ///
482    /// **Phantom reaches (#717):** the per-turn alias-seam telemetry persists
483    /// alongside `events` in its own `phantom_reaches` column. It is deliberately
484    /// NOT part of the §6 canonical encoding (telemetry, not provenance), so an
485    /// older db gains the column on open and existing content chains verify
486    /// byte-for-byte unchanged. Folding it into the hash would require a v2
487    /// encoding bump — a deliberate follow-up, not this additive change.
488    #[allow(clippy::too_many_arguments)]
489    pub fn append_turn_full(
490        &self,
491        id: &str,
492        user: &str,
493        assistant: &str,
494        events: &[crate::ToolEvent],
495        phantom_reaches: &[crate::PhantomReach],
496        tokens_in: Option<u32>,
497        tokens_out: Option<u32>,
498    ) -> anyhow::Result<()> {
499        let id = self.resolve_id(id)?;
500        let now = (self.claim_clock)();
501        let events_json = serde_json::to_string(events)?;
502        let phantom_reaches_json = serde_json::to_string(phantom_reaches)?;
503        let conn = self.lock_conn();
504        let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
505        let tick = next_tick(&tx, &self.writer_fingerprint)?;
506
507        // The §6 content chain: hash the canonical encoding of this writer's
508        // previous turn (re-derived from the row itself, so a drifted
509        // `tip_hash` column can never poison the chain).
510        let prev_hash = match last_turn(&tx, &id, &self.writer_fingerprint)? {
511            Some(prev) => prev.content_hash()?,
512            None => genesis_hash(&id, &self.writer_fingerprint),
513        };
514
515        let row = TurnRow {
516            conversation_id: id.clone(),
517            writer_fingerprint: self.writer_fingerprint.clone(),
518            seq: tick,
519            prev_hash,
520            user: user.to_string(),
521            assistant: assistant.to_string(),
522            events: events_json,
523            tokens_in: tokens_in.map(i64::from),
524            tokens_out: tokens_out.map(i64::from),
525            ts_claim: now,
526            encoding_version: TURN_ENCODING_VERSION_CURRENT,
527        };
528        insert_turn_row(&tx, &row, &phantom_reaches_json)?;
529        // Activity tick + chain tip move together; updated_at_claim is a
530        // display claim only (§6) — nothing orders by it.
531        tx.execute(
532            "UPDATE conversations
533                SET writer_fingerprint = ?2, activity_tick = ?3, tip_hash = ?4,
534                    updated_at_claim = ?5
535              WHERE id = ?1",
536            rusqlite::params![id, self.writer_fingerprint, tick, row.content_hash()?, now],
537        )?;
538        tx.commit()?;
539        Ok(())
540    }
541
542    /// Load a full record (turns in causal `(writer, seq)` order). `id` may
543    /// be a unique prefix.
544    pub fn load(&self, id: &str) -> anyhow::Result<ConversationRecord> {
545        let id = self.resolve_id(id)?;
546        let conn = self.lock_conn();
547        let (mut record, scratchpad_json, plan_json) = conn
548            .query_row(
549                "SELECT id, title, workspace_path, workspace_key, persona,
550                        started_at_claim, updated_at_claim, scratchpad, plan,
551                        roadmap_id, node_id
552                   FROM conversations
553                  WHERE id = ?1 AND workspace_key = ?2",
554                rusqlite::params![id, self.workspace_id],
555                |row| {
556                    Ok((
557                        ConversationRecord {
558                            id: row.get(0)?,
559                            title: row.get(1)?,
560                            workspace: row.get(2)?,
561                            workspace_id: row.get(3)?,
562                            persona: row.get(4)?,
563                            turns: Vec::new(),
564                            scratchpad: std::collections::BTreeMap::new(),
565                            plan: crate::PlanSnapshot::default(),
566                            roadmap_id: row.get(9)?,
567                            node_id: row.get(10)?,
568                            created_at_unix_nanos: claim_to_u128(row.get(5)?),
569                            updated_at_unix_nanos: claim_to_u128(row.get(6)?),
570                        },
571                        row.get::<_, String>(7)?,
572                        row.get::<_, String>(8)?,
573                    ))
574                },
575            )
576            .optional()?
577            .ok_or_else(|| anyhow::anyhow!("conversation `{id}` not found"))?;
578        // #713: the scratchpad <state> snapshot. Strict decode — never hand back
579        // garbage (same discipline as the turn `events`/`phantom_reaches`
580        // columns). A pre-#713 row carries the `{}` backfill and parses empty.
581        record.scratchpad = serde_json::from_str(&scratchpad_json).map_err(|e| {
582            anyhow::anyhow!(
583                "conversation `{id}`: scratchpad column is not valid <state> JSON \
584                 ({e}); refusing to load garbage"
585            )
586        })?;
587        // #715: the plan-ledger snapshot. Same strict decode discipline. A
588        // pre-#715 row carries the `{}` backfill and parses to an empty plan.
589        record.plan = serde_json::from_str(&plan_json).map_err(|e| {
590            anyhow::anyhow!(
591                "conversation `{id}`: plan column is not valid <plan> snapshot JSON \
592                 ({e}); refusing to load garbage"
593            )
594        })?;
595
596        // §6: turn order is the causal tick, never ts_claim.
597        let mut stmt = conn.prepare(
598            "SELECT user, assistant, events, tokens_in, tokens_out, phantom_reaches FROM turns
599              WHERE conversation_id = ?1
600              ORDER BY seq ASC, writer_fingerprint ASC",
601        )?;
602        let turns = stmt.query_map([&id], |row| {
603            Ok((
604                row.get::<_, String>(0)?,
605                row.get::<_, String>(1)?,
606                row.get::<_, String>(2)?,
607                row.get::<_, Option<i64>>(3)?,
608                row.get::<_, Option<i64>>(4)?,
609                row.get::<_, String>(5)?,
610            ))
611        })?;
612        for turn in turns {
613            let (user, assistant, events_json, tokens_in, tokens_out, phantom_reaches_json) = turn?;
614            // 17.6: events deserialize strictly — a row whose blob is not
615            // ToolEvent-shaped errors clearly (the encoding_version
616            // philosophy: never quietly hand back garbage). Pre-17.6 rows
617            // carry '[]' and parse to an empty vec; unknown extra keys on
618            // future events are ignored (additive growth needs no bump).
619            let events: Vec<crate::ToolEvent> =
620                serde_json::from_str(&events_json).map_err(|e| {
621                    anyhow::anyhow!(
622                        "conversation `{id}`: turn events column is not valid tool-event \
623                         JSON ({e}); refusing to load garbage"
624                    )
625                })?;
626            // #717: same strict decode as events — never hand back garbage.
627            let phantom_reaches: Vec<crate::PhantomReach> =
628                serde_json::from_str(&phantom_reaches_json).map_err(|e| {
629                    anyhow::anyhow!(
630                        "conversation `{id}`: turn phantom_reaches column is not valid \
631                         phantom-reach JSON ({e}); refusing to load garbage"
632                    )
633                })?;
634            record.turns.push(ConversationTurn {
635                user,
636                assistant,
637                events,
638                phantom_reaches,
639                tokens_in: tokens_from_sql(tokens_in)?,
640                tokens_out: tokens_from_sql(tokens_out)?,
641            });
642        }
643        Ok(record)
644    }
645
646    /// Read ONE past turn by its `(conversation, seq)` address — the by-id
647    /// read the `memory_fetch` tool's `turn:<conv>#<seq>` resolver needs
648    /// (progressive-disclosure memory, Workstream A MVP, #319). `id` may be a
649    /// unique prefix (same `resolve_id` discipline as [`Self::load`]); `seq`
650    /// is the §6 per-writer tick the model was shown by a `recall` hit
651    /// (`SearchHit::seq`).
652    ///
653    /// Workspace-fenced: the `conversations` join carries `workspace_key`, so
654    /// a `seq` from another workspace's conversation resolves to `None`, never
655    /// a cross-workspace leak (§7 fencing). Returns `Ok(None)` when no turn at
656    /// that `(conversation, seq)` exists — labelled absence, never an error —
657    /// so the tool executor can answer "no such memory item" rather than
658    /// aborting the loop.
659    pub fn load_turn(&self, id: &str, seq: i64) -> anyhow::Result<Option<ConversationTurn>> {
660        // An unknown conversation id is absence, not an error — the tool
661        // result must be friendly text, never a loop-aborting backend failure.
662        let id = match self.resolve_id(id) {
663            Ok(id) => id,
664            Err(_) => return Ok(None),
665        };
666        let conn = self.lock_conn();
667        let row = conn
668            .query_row(
669                "SELECT t.user, t.assistant, t.events, t.tokens_in, t.tokens_out, t.phantom_reaches
670                   FROM turns t
671                   JOIN conversations c
672                     ON c.id = t.conversation_id AND c.workspace_key = ?3
673                  WHERE t.conversation_id = ?1 AND t.seq = ?2",
674                rusqlite::params![id, seq, self.workspace_id],
675                |row| {
676                    Ok((
677                        row.get::<_, String>(0)?,
678                        row.get::<_, String>(1)?,
679                        row.get::<_, String>(2)?,
680                        row.get::<_, Option<i64>>(3)?,
681                        row.get::<_, Option<i64>>(4)?,
682                        row.get::<_, String>(5)?,
683                    ))
684                },
685            )
686            .optional()?;
687        let Some((user, assistant, events_json, tokens_in, tokens_out, phantom_reaches_json)) = row
688        else {
689            return Ok(None);
690        };
691        // Same strict events decode as `load`: never hand back garbage.
692        let events: Vec<crate::ToolEvent> = serde_json::from_str(&events_json).map_err(|e| {
693            anyhow::anyhow!(
694                "conversation `{id}`: turn events column is not valid tool-event \
695                 JSON ({e}); refusing to load garbage"
696            )
697        })?;
698        // #717: same strict decode for the phantom-reach telemetry column.
699        let phantom_reaches: Vec<crate::PhantomReach> = serde_json::from_str(&phantom_reaches_json)
700            .map_err(|e| {
701                anyhow::anyhow!(
702                    "conversation `{id}`: turn phantom_reaches column is not valid \
703                     phantom-reach JSON ({e}); refusing to load garbage"
704                )
705            })?;
706        Ok(Some(ConversationTurn {
707            user,
708            assistant,
709            events,
710            phantom_reaches,
711            tokens_in: tokens_from_sql(tokens_in)?,
712            tokens_out: tokens_from_sql(tokens_out)?,
713        }))
714    }
715
716    /// All conversations in this workspace, least-recently-active first —
717    /// "active" meaning the §6 activity tick, never a timestamp. The
718    /// summaries' `updated_at_unix_nanos` is the display claim.
719    pub fn list(&self) -> anyhow::Result<Vec<ConversationSummary>> {
720        let conn = self.lock_conn();
721        let mut stmt = conn.prepare(
722            "SELECT c.id, c.title, c.persona, c.updated_at_claim,
723                    (SELECT COUNT(*) FROM turns t WHERE t.conversation_id = c.id)
724               FROM conversations c
725              WHERE c.workspace_key = ?1
726              ORDER BY c.activity_tick ASC, c.id ASC",
727        )?;
728        let rows = stmt.query_map([&self.workspace_id], |row| {
729            Ok(ConversationSummary {
730                id: row.get(0)?,
731                title: row.get(1)?,
732                persona: row.get(2)?,
733                updated_at_unix_nanos: claim_to_u128(row.get(3)?),
734                turn_count: row.get::<_, i64>(4)?.max(0) as usize,
735            })
736        })?;
737        let mut summaries = Vec::new();
738        for row in rows {
739            summaries.push(row?);
740        }
741        Ok(summaries)
742    }
743
744    /// The most-recently-active **open** conversation in this workspace —
745    /// highest `activity_tick` whose `end_reason` is still NULL — or `None`
746    /// when every conversation has been ended (or none exist). This is the
747    /// auto-resume target: an ended conversation (`/end`, `/restart`, `:wq`)
748    /// is skipped here so the next launch does not silently re-enter it, yet
749    /// it stays in [`list`](Self::list) / `/recall` because it is not deleted.
750    pub fn latest_open(&self) -> anyhow::Result<Option<ConversationSummary>> {
751        let conn = self.lock_conn();
752        conn.query_row(
753            "SELECT c.id, c.title, c.persona, c.updated_at_claim,
754                    (SELECT COUNT(*) FROM turns t WHERE t.conversation_id = c.id)
755               FROM conversations c
756              WHERE c.workspace_key = ?1 AND c.end_reason IS NULL
757              ORDER BY c.activity_tick DESC, c.id DESC
758              LIMIT 1",
759            [&self.workspace_id],
760            |row| {
761                Ok(ConversationSummary {
762                    id: row.get(0)?,
763                    title: row.get(1)?,
764                    persona: row.get(2)?,
765                    updated_at_unix_nanos: claim_to_u128(row.get(3)?),
766                    turn_count: row.get::<_, i64>(4)?.max(0) as usize,
767                })
768            },
769        )
770        .optional()
771        .map_err(Into::into)
772    }
773
774    /// Mark a conversation **ended** with a short reason (`"new"`, `"restart"`,
775    /// `"wq"`, …). Like [`rename`](Self::rename) this is metadata, not activity:
776    /// it does NOT tick the §6 clock, so it cannot perturb MRU ordering — it
777    /// only sets `end_reason` (the column reserved at 17.7), which
778    /// [`latest_open`](Self::latest_open) reads to skip the row on auto-resume.
779    /// The conversation, its turns, and its FTS rows are untouched, so
780    /// `/recall` and `/conversation` still find it. Idempotent and
781    /// workspace-fenced (an id from another workspace resolves as absent).
782    pub fn end_conversation(&self, id: &str, reason: &str) -> anyhow::Result<()> {
783        let id = self.resolve_id(id)?;
784        let now = (self.claim_clock)();
785        let conn = self.lock_conn();
786        conn.execute(
787            "UPDATE conversations SET end_reason = ?2, updated_at_claim = ?3
788              WHERE id = ?1 AND workspace_key = ?4",
789            rusqlite::params![id, reason.trim(), now, self.workspace_id],
790        )?;
791        Ok(())
792    }
793
794    /// #1030: bind (or clear) a conversation's place in a roadmap tree — the
795    /// Plan node whose context window this conversation IS. `roadmap_id` +
796    /// `node_id` together locate the [`crate::plan::Subtask`] node; passing
797    /// `None`/`None` clears the link (back to an ad-hoc chat). Workspace-fenced
798    /// and idempotent. Like [`rename`](Self::rename) this is metadata, not
799    /// activity: it does NOT tick the §6 clock, so it cannot perturb MRU
800    /// ordering — the pointers ride the row exactly like `scratchpad`/`plan`.
801    pub fn link_conversation_to_node(
802        &self,
803        id: &str,
804        roadmap_id: Option<&str>,
805        node_id: Option<&str>,
806    ) -> anyhow::Result<()> {
807        // NOT `resolve_id`: a session can bind its active conversation to a Plan
808        // node BEFORE its first turn is saved (so no row exists yet). The exact
809        // id is used; the UPDATE is a no-op until the conversation row exists,
810        // and the node→conversation forward pointer (on the roadmap side) is what
811        // /roadmap next reads either way.
812        let conn = self.lock_conn();
813        conn.execute(
814            "UPDATE conversations SET roadmap_id = ?2, node_id = ?3
815              WHERE id = ?1 AND workspace_key = ?4",
816            rusqlite::params![id, roadmap_id, node_id, self.workspace_id],
817        )?;
818        Ok(())
819    }
820
821    /// #1030 collision fix: attempt to become the SINGLE live owner of `id`.
822    /// Atomic (`BEGIN IMMEDIATE`): if the conversation is unclaimed, or its
823    /// claim is our own, or its claim is STALE (the owner is not live — a
824    /// crashed or rebooted process), this process takes ownership and returns
825    /// [`Claimed`](ClaimOutcome::Claimed). If a DIFFERENT, LIVE process owns it,
826    /// returns [`HeldBy`](ClaimOutcome::HeldBy) and writes nothing — the caller
827    /// must not attach (attaching is exactly the turn-interleaving bug #1030
828    /// fixes). Identity is `host`+`boot_id`+`pid`, never the (machine-shared)
829    /// writer fingerprint.
830    pub fn claim(&self, id: &str) -> anyhow::Result<ClaimOutcome> {
831        // NOT `resolve_id`: a session claims its freshly-minted id at startup,
832        // BEFORE the conversation row is lazily created on the first turn.
833        // `live_owners` is keyed by the (globally-unique) conversation id with
834        // no FK, so the exact id is all that is needed.
835        validate_record_id(id)?;
836        let now = (self.claim_clock)();
837        let conn = self.lock_conn();
838        let tx = rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
839        let existing = live_owner_row(&tx, id)?;
840        if let Some(owner) = &existing {
841            let is_ours =
842                owner.host == self.host && owner.boot_id == self.boot_id && owner.pid == self.pid;
843            if !is_ours && (self.liveness)(owner, now) {
844                return Ok(ClaimOutcome::HeldBy {
845                    host: owner.host.clone(),
846                    pid: owner.pid,
847                });
848            }
849            // Ours, or a stale remnant of a dead session → fall through and take it.
850        }
851        tx.execute(
852            "INSERT OR REPLACE INTO live_owners
853               (conversation_id, host, boot_id, pid, writer_fingerprint, heartbeat_tick)
854             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
855            rusqlite::params![
856                id,
857                self.host,
858                self.boot_id,
859                self.pid,
860                self.writer_fingerprint,
861                now
862            ],
863        )?;
864        tx.commit()?;
865        Ok(ClaimOutcome::Claimed)
866    }
867
868    /// Release THIS process's claim on `id` (best-effort). Only deletes a claim
869    /// this exact process holds (`host`+`boot_id`+`pid`), so it can never free
870    /// another live session's conversation. Called on clean exit / conversation
871    /// switch; a crash simply leaves a stale claim the next [`claim`](Self::claim)
872    /// reclaims. A missing / foreign id is a silent no-op.
873    pub fn release(&self, id: &str) -> anyhow::Result<()> {
874        let conn = self.lock_conn();
875        conn.execute(
876            "DELETE FROM live_owners
877              WHERE conversation_id = ?1 AND host = ?2 AND boot_id = ?3 AND pid = ?4",
878            rusqlite::params![id, self.host, self.boot_id, self.pid],
879        )?;
880        Ok(())
881    }
882
883    /// Refresh THIS process's heartbeat on `id` — the freshness signal a
884    /// cross-host / post-reboot liveness check reads. Cheap; meant to piggyback
885    /// the per-turn save. No-op if this process does not hold the claim.
886    pub fn heartbeat(&self, id: &str) -> anyhow::Result<()> {
887        let now = (self.claim_clock)();
888        let conn = self.lock_conn();
889        conn.execute(
890            "UPDATE live_owners SET heartbeat_tick = ?2
891              WHERE conversation_id = ?1 AND host = ?3 AND boot_id = ?4 AND pid = ?5",
892            rusqlite::params![id, now, self.host, self.boot_id, self.pid],
893        )?;
894        Ok(())
895    }
896
897    /// The raw `live_owners` row for `id`, WITHOUT a liveness judgement — `None`
898    /// when unclaimed. `/resume` pairs this with [`is_owner_live`](Self::is_owner_live)
899    /// to render each conversation's ● live / ○ open marker.
900    pub fn live_owner(&self, id: &str) -> anyhow::Result<Option<StoredOwner>> {
901        let conn = self.lock_conn();
902        live_owner_row(&conn, id)
903    }
904
905    /// Whether `owner` is a running process right now, per the store's (injected)
906    /// liveness oracle — the SAME judgement [`claim`](Self::claim) uses, exposed
907    /// so `/resume` renders a consistent marker.
908    #[must_use]
909    pub fn is_owner_live(&self, owner: &StoredOwner) -> bool {
910        (self.liveness)(owner, (self.claim_clock)())
911    }
912
913    // ── #1030 roadmap CRUD: the Roadmap→Phase→Plan→Task tree, persisted as a
914    //    serialized plan.rs::Plan blob in the workspace-fenced `roadmaps` table ──
915
916    /// Create (or overwrite) a roadmap with `id`, `title`, and `tree` — the
917    /// serialized [`crate::plan::Plan`] of Roadmap/Phase/Plan/Task nodes.
918    /// **Workspace-fenced on write as well as read (#1086):** the `roadmaps`
919    /// primary key is `(id, workspace_key)`, so `INSERT OR REPLACE` can only
920    /// ever replace *this* workspace's same-id row — importing an id that
921    /// exists under another workspace inserts a separate row, never steals it.
922    /// Overwrite-within-a-workspace is intentional (re-create replaces the
923    /// tree), matching the conversation store's `create_with_id` semantics.
924    pub fn create_roadmap(
925        &self,
926        id: &str,
927        title: &str,
928        tree: &crate::plan::Plan,
929    ) -> anyhow::Result<()> {
930        validate_record_id(id)?;
931        let now = (self.claim_clock)();
932        let toml = tree.to_toml_string()?;
933        let conn = self.lock_conn();
934        conn.execute(
935            "INSERT OR REPLACE INTO roadmaps
936               (id, workspace_key, title, tree, schema_version, created_at_claim, updated_at_claim)
937             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
938            rusqlite::params![
939                id,
940                self.workspace_id,
941                title.trim(),
942                toml,
943                ROADMAP_SCHEMA_VERSION,
944                now
945            ],
946        )?;
947        Ok(())
948    }
949
950    /// Load roadmap `id` (workspace-fenced), deserializing its `tree` blob back
951    /// into a [`crate::plan::Plan`]. `None` when absent. A tree column that is
952    /// not valid plan TOML is a hard error — never hand back a garbage tree.
953    pub fn load_roadmap(&self, id: &str) -> anyhow::Result<Option<Roadmap>> {
954        let conn = self.lock_conn();
955        let row = conn
956            .query_row(
957                "SELECT title, tree FROM roadmaps WHERE id = ?1 AND workspace_key = ?2",
958                rusqlite::params![id, self.workspace_id],
959                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
960            )
961            .optional()?;
962        let Some((title, toml)) = row else {
963            return Ok(None);
964        };
965        let tree = crate::plan::Plan::from_toml_str(&toml).map_err(|e| {
966            anyhow::anyhow!("roadmap `{id}`: tree column is not valid plan TOML ({e})")
967        })?;
968        Ok(Some(Roadmap {
969            id: id.to_string(),
970            title,
971            tree,
972        }))
973    }
974
975    /// Replace roadmap `id`'s tree (workspace-fenced). A no-op if absent.
976    pub fn update_roadmap(&self, id: &str, tree: &crate::plan::Plan) -> anyhow::Result<()> {
977        let now = (self.claim_clock)();
978        let toml = tree.to_toml_string()?;
979        let conn = self.lock_conn();
980        conn.execute(
981            "UPDATE roadmaps SET tree = ?2, updated_at_claim = ?3
982              WHERE id = ?1 AND workspace_key = ?4",
983            rusqlite::params![id, toml, now, self.workspace_id],
984        )?;
985        Ok(())
986    }
987
988    /// This workspace's roadmaps, most-recently-updated first.
989    pub fn list_roadmaps(&self) -> anyhow::Result<Vec<RoadmapSummary>> {
990        let conn = self.lock_conn();
991        let mut stmt = conn.prepare(
992            "SELECT id, title, tree FROM roadmaps
993              WHERE workspace_key = ?1
994              ORDER BY updated_at_claim DESC, id DESC",
995        )?;
996        let rows = stmt.query_map([&self.workspace_id], |row| {
997            Ok((
998                row.get::<_, String>(0)?,
999                row.get::<_, String>(1)?,
1000                row.get::<_, String>(2)?,
1001            ))
1002        })?;
1003        let mut out = Vec::new();
1004        for row in rows {
1005            let (id, title, toml) = row?;
1006            let node_count = crate::plan::Plan::from_toml_str(&toml)
1007                .map(|p| p.subtasks.len())
1008                .unwrap_or(0);
1009            out.push(RoadmapSummary {
1010                id,
1011                title,
1012                node_count,
1013            });
1014        }
1015        Ok(out)
1016    }
1017
1018    /// Rename a conversation. Updates the display claim but does NOT tick
1019    /// the activity clock: a rename is metadata, not activity, so it cannot
1020    /// perturb MRU ordering (§6 dissolved the old rename-bumps-`updated_at`
1021    /// defect, design doc §1).
1022    pub fn rename(&self, id: &str, title: &str) -> anyhow::Result<()> {
1023        let id = self.resolve_id(id)?;
1024        let now = (self.claim_clock)();
1025        let conn = self.lock_conn();
1026        conn.execute(
1027            "UPDATE conversations SET title = ?2, updated_at_claim = ?3 WHERE id = ?1",
1028            rusqlite::params![id, title.trim(), now],
1029        )?;
1030        Ok(())
1031    }
1032
1033    /// Persist a conversation's scratchpad `<state>` snapshot (#713). The map
1034    /// is serialized to JSON and written to the conversation row's `scratchpad`
1035    /// column so an interrupt + auto-resume can re-hydrate the live store.
1036    ///
1037    /// Like [`rename`](Self::rename) / [`end_conversation`](Self::end_conversation)
1038    /// this is metadata, not activity: it does **not** tick the §6 clock, so it
1039    /// cannot perturb MRU ordering, and the scratchpad is NOT part of the §6
1040    /// content chain (it rides the conversation row, never a turn's canonical
1041    /// encoding) — working memory, not provenance. Workspace-fenced and
1042    /// idempotent: an id from another workspace resolves as absent and the
1043    /// UPDATE matches nothing.
1044    pub fn update_scratchpad(
1045        &self,
1046        id: &str,
1047        scratchpad: &std::collections::BTreeMap<String, String>,
1048    ) -> anyhow::Result<()> {
1049        let id = self.resolve_id(id)?;
1050        let json = serde_json::to_string(scratchpad)?;
1051        let conn = self.lock_conn();
1052        conn.execute(
1053            "UPDATE conversations SET scratchpad = ?2 WHERE id = ?1 AND workspace_key = ?3",
1054            rusqlite::params![id, json, self.workspace_id],
1055        )?;
1056        Ok(())
1057    }
1058
1059    /// Persist a conversation's plan-ledger snapshot (#715). The
1060    /// [`crate::PlanSnapshot`] is serialized to JSON and written to the
1061    /// conversation row's `plan` column so an interrupt + auto-resume can
1062    /// re-hydrate the live ledger (the `<plan>` block + `plan_get` survive).
1063    ///
1064    /// Like [`update_scratchpad`](Self::update_scratchpad) this is metadata, not
1065    /// activity: it does **not** tick the §6 clock, so it cannot perturb MRU
1066    /// ordering, and the plan is NOT part of the §6 content chain (it rides the
1067    /// conversation row, never a turn's canonical encoding) — working memory, not
1068    /// provenance. Workspace-fenced and idempotent: an id from another workspace
1069    /// resolves as absent and the UPDATE matches nothing.
1070    pub fn update_plan_snapshot(&self, id: &str, plan: &crate::PlanSnapshot) -> anyhow::Result<()> {
1071        let id = self.resolve_id(id)?;
1072        let json = serde_json::to_string(plan)?;
1073        let conn = self.lock_conn();
1074        conn.execute(
1075            "UPDATE conversations SET plan = ?2 WHERE id = ?1 AND workspace_key = ?3",
1076            rusqlite::params![id, json, self.workspace_id],
1077        )?;
1078        Ok(())
1079    }
1080
1081    /// Delete a conversation (its turns cascade) and, best-effort, its
1082    /// per-session plan dir (issue #220).
1083    pub fn delete(&self, id: &str) -> anyhow::Result<()> {
1084        let id = self.resolve_id(id)?;
1085        {
1086            let conn = self.lock_conn();
1087            conn.execute(
1088                "DELETE FROM conversations WHERE id = ?1 AND workspace_key = ?2",
1089                rusqlite::params![id, self.workspace_id],
1090            )?;
1091        }
1092        // Ignore errors: the dir may not exist, and a stray plan must never
1093        // block deletion of the record.
1094        let plan_dir = self.workspace.join(session_plan_dir(&id));
1095        let _ = std::fs::remove_dir_all(plan_dir);
1096        Ok(())
1097    }
1098
1099    /// Resolve an exact id or unique prefix within this workspace.
1100    pub fn resolve_id(&self, id_or_prefix: &str) -> anyhow::Result<String> {
1101        validate_record_id(id_or_prefix)?;
1102        let conn = self.lock_conn();
1103        let exact = conn
1104            .query_row(
1105                "SELECT id FROM conversations WHERE id = ?1 AND workspace_key = ?2",
1106                rusqlite::params![id_or_prefix, self.workspace_id],
1107                |row| row.get::<_, String>(0),
1108            )
1109            .optional()?;
1110        if let Some(id) = exact {
1111            return Ok(id);
1112        }
1113        // Byte-case-exact prefix match (review NIT N5 on #261): `LIKE` is
1114        // ASCII-case-insensitive by default, which silently widened prefix
1115        // resolution when the JSON backend's `starts_with` was ported.
1116        // `substr` compares exactly; ids are validated ASCII above, so
1117        // character positions and byte positions coincide.
1118        let mut stmt = conn.prepare(
1119            "SELECT id FROM conversations
1120              WHERE workspace_key = ?1 AND substr(id, 1, length(?2)) = ?2
1121              ORDER BY id ASC",
1122        )?;
1123        let matches = stmt
1124            .query_map(rusqlite::params![self.workspace_id, id_or_prefix], |row| {
1125                row.get::<_, String>(0)
1126            })?
1127            .collect::<Result<Vec<_>, _>>()?;
1128        match matches.as_slice() {
1129            [id] => Ok(id.clone()),
1130            [] => anyhow::bail!("conversation `{id_or_prefix}` not found"),
1131            many => anyhow::bail!(
1132                "ambiguous conversation id prefix `{}`; matches: {}",
1133                id_or_prefix,
1134                many.join(", ")
1135            ),
1136        }
1137    }
1138
1139    /// Verify the §6 content chain for a conversation: every writer's turns
1140    /// must link `prev_hash` → BLAKE3(prior turn's canonical encoding) from
1141    /// the genesis hash, and the stored chain tip must match this writer's
1142    /// last turn. A tampered row (content OR claims — claims are inside the
1143    /// canonical encoding, so they are tamper-evident too) breaks the chain.
1144    pub fn verify_chain(&self, id: &str) -> anyhow::Result<()> {
1145        let id = self.resolve_id(id)?;
1146        let conn = self.lock_conn();
1147        let (tip, tip_writer): (String, String) = conn.query_row(
1148            "SELECT tip_hash, writer_fingerprint FROM conversations WHERE id = ?1",
1149            [&id],
1150            |row| Ok((row.get(0)?, row.get(1)?)),
1151        )?;
1152
1153        let mut stmt = conn.prepare(
1154            "SELECT conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
1155                    events, tokens_in, tokens_out, ts_claim, encoding_version
1156               FROM turns
1157              WHERE conversation_id = ?1
1158              ORDER BY writer_fingerprint ASC, seq ASC",
1159        )?;
1160        let rows = stmt
1161            .query_map([&id], turn_row_from_sql)?
1162            .collect::<Result<Vec<_>, _>>()?;
1163
1164        let mut prev: Option<&TurnRow> = None;
1165        for row in &rows {
1166            let same_writer = prev.is_some_and(|p| p.writer_fingerprint == row.writer_fingerprint);
1167            if same_writer {
1168                let p = prev.expect("same_writer implies prev");
1169                if row.seq <= p.seq {
1170                    anyhow::bail!(
1171                        "chain violation in `{id}`: seq {} not strictly after {}",
1172                        row.seq,
1173                        p.seq
1174                    );
1175                }
1176                if row.prev_hash != p.content_hash()? {
1177                    anyhow::bail!(
1178                        "chain violation in `{id}`: turn seq {} does not link to seq {} \
1179                         (row tampered or out of order)",
1180                        row.seq,
1181                        p.seq
1182                    );
1183                }
1184            } else {
1185                let genesis = genesis_hash(&id, &row.writer_fingerprint);
1186                if row.prev_hash != genesis {
1187                    anyhow::bail!(
1188                        "chain violation in `{id}`: first turn of writer {} (seq {}) does \
1189                         not link to the genesis hash",
1190                        row.writer_fingerprint,
1191                        row.seq
1192                    );
1193                }
1194            }
1195            prev = Some(row);
1196        }
1197
1198        // The stored tip must match the chain of the conversation row's
1199        // RECORDED last writer (set at create, updated on every append in
1200        // the same txn) — not whoever happens to be verifying. This keeps
1201        // verify_chain writer-agnostic: a store that authored no turns in a
1202        // migrated/foreign conversation still verifies it correctly
1203        // (adversarial-review finding N2 on #261).
1204        let expected_tip = match rows.iter().rfind(|r| r.writer_fingerprint == tip_writer) {
1205            Some(row) => row.content_hash()?,
1206            None => genesis_hash(&id, &tip_writer),
1207        };
1208        if tip != expected_tip {
1209            anyhow::bail!("chain violation in `{id}`: stored tip_hash does not match the chain");
1210        }
1211        Ok(())
1212    }
1213
1214    /// Full-text recall over this workspace's turns (17.3, issue #246).
1215    ///
1216    /// The raw query goes through [`sanitize_fts5_query`] (an empty result
1217    /// after sanitizing is an error, never a match-all), then a `MATCH`
1218    /// against the trigger-maintained `turns_fts` index, ranked by bm25
1219    /// (best first), **fenced to this workspace** by joining
1220    /// `conversations.workspace_key`. Each hit carries a `snippet()` of the
1221    /// matched column — the match wrapped in `>>>`/`<<<`, roughly ±10
1222    /// tokens of context, `…` at trimmed edges. Snippets are the whole
1223    /// payload by design: no full turn content, no aux-LLM recaps (the
1224    /// design doc explicitly skips those — slow and expensive on local
1225    /// models; the hermes study's own "snippet is enough, saves tokens").
1226    pub fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
1227        let fts_query = sanitize_fts5_query(query)?;
1228        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
1229        let conn = self.lock_conn();
1230        // The JOIN on `turns` is also a safety net: an index entry whose
1231        // turn row is gone (can't happen while the delete trigger holds,
1232        // but defense in depth) joins to nothing instead of surfacing a
1233        // ghost hit. Ties in rank break deterministically by (id, seq).
1234        let mut stmt = conn.prepare(
1235            "SELECT t.conversation_id, c.title, t.seq,
1236                    snippet(turns_fts, -1, '>>>', '<<<', '…', 21),
1237                    bm25(turns_fts)
1238               FROM turns_fts
1239               JOIN turns t ON t.rowid = turns_fts.rowid
1240               JOIN conversations c
1241                 ON c.id = t.conversation_id AND c.workspace_key = ?2
1242              WHERE turns_fts MATCH ?1
1243              ORDER BY bm25(turns_fts) ASC, t.conversation_id ASC, t.seq ASC
1244              LIMIT ?3",
1245        )?;
1246        let rows = stmt.query_map(
1247            rusqlite::params![fts_query, self.workspace_id, limit],
1248            |row| {
1249                Ok(SearchHit {
1250                    conversation_id: row.get(0)?,
1251                    title: row.get(1)?,
1252                    seq: row.get(2)?,
1253                    snippet: row.get(3)?,
1254                    rank: row.get(4)?,
1255                })
1256            },
1257        )?;
1258        let mut hits = Vec::new();
1259        for row in rows {
1260            hits.push(row?);
1261        }
1262        Ok(hits)
1263    }
1264
1265    /// Drive the display-claim clock from a test. Hidden, test-only: lets the
1266    /// §6 clock-skew test write *honestly skewed* claims through the normal
1267    /// API (clock runs backwards mid-conversation) and prove that ordering,
1268    /// MRU, and chain verification are all unaffected.
1269    #[doc(hidden)]
1270    pub fn set_claim_clock_for_test(&mut self, clock: fn() -> i64) {
1271        self.claim_clock = clock;
1272    }
1273
1274    /// Inject a fake liveness oracle for tests (mirrors
1275    /// [`set_claim_clock_for_test`](Self::set_claim_clock_for_test)) so #1030
1276    /// claim contention is unit-testable without touching real OS pids.
1277    #[doc(hidden)]
1278    pub fn set_liveness_for_test(&mut self, liveness: LivenessFn) {
1279        self.liveness = liveness;
1280    }
1281
1282    /// Force this store's owner identity for tests — lets one test process
1283    /// simulate a SECOND newt (a different pid/host) contending for the same
1284    /// conversation, without spawning a real process.
1285    #[doc(hidden)]
1286    pub fn set_owner_for_test(&mut self, host: &str, boot_id: &str, pid: i64) {
1287        self.host = host.to_string();
1288        self.boot_id = boot_id.to_string();
1289        self.pid = pid;
1290    }
1291
1292    fn prune_to_cap(&self) -> anyhow::Result<()> {
1293        if self.max_per_workspace == 0 {
1294            return Ok(());
1295        }
1296        let victims: Vec<String> = {
1297            let conn = self.lock_conn();
1298            let count: i64 = conn.query_row(
1299                "SELECT COUNT(*) FROM conversations WHERE workspace_key = ?1",
1300                [&self.workspace_id],
1301                |row| row.get(0),
1302            )?;
1303            let excess = count - self.max_per_workspace as i64;
1304            if excess <= 0 {
1305                return Ok(());
1306            }
1307            // Oldest = lowest activity tick (§6 — never a timestamp).
1308            let mut stmt = conn.prepare(
1309                "SELECT id FROM conversations
1310                  WHERE workspace_key = ?1
1311                  ORDER BY activity_tick ASC, id ASC
1312                  LIMIT ?2",
1313            )?;
1314            let ids = stmt
1315                .query_map(rusqlite::params![self.workspace_id, excess], |row| {
1316                    row.get::<_, String>(0)
1317                })?
1318                .collect::<Result<Vec<_>, _>>()?;
1319            ids
1320        };
1321        for id in victims {
1322            // Route through delete() so plan dirs are cleaned up too.
1323            self.delete(&id)?;
1324        }
1325        Ok(())
1326    }
1327
1328    fn lock_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
1329        // A poisoned mutex means another thread panicked mid-operation; the
1330        // connection itself is still usable (transactions roll back), so
1331        // recover rather than cascade the panic.
1332        self.conn
1333            .lock()
1334            .unwrap_or_else(std::sync::PoisonError::into_inner)
1335    }
1336}
1337
1338/// One full-text recall hit from [`ConversationStore::search`] (17.3).
1339///
1340/// `rank` is the raw FTS5 bm25 score: negative, and smaller (more negative)
1341/// = better. `search` returns hits best-first; the value is exposed so
1342/// 17.4/17.5 callers can show or threshold it. `snippet` is the matched
1343/// column's excerpt (`>>>match<<<`, `…`-trimmed) — deliberately the only
1344/// content returned.
1345#[derive(Debug, Clone, PartialEq)]
1346pub struct SearchHit {
1347    /// The conversation the matching turn belongs to.
1348    pub conversation_id: String,
1349    /// That conversation's current title.
1350    pub title: String,
1351    /// The matching turn's §6 per-writer tick (its position in the chain).
1352    pub seq: i64,
1353    /// `snippet()` of the matched column: ±~10 tokens of context around the
1354    /// match, which is wrapped in `>>>`/`<<<`; `…` marks trimmed edges.
1355    pub snippet: String,
1356    /// Raw bm25 rank (negative; more negative = better match).
1357    pub rank: f64,
1358}
1359
1360/// One turn row, exactly as stored. Internal: the canonical encoding hashes
1361/// every field, so this struct is the unit of chain verification.
1362#[derive(Debug)]
1363struct TurnRow {
1364    conversation_id: String,
1365    writer_fingerprint: String,
1366    seq: i64,
1367    prev_hash: String,
1368    user: String,
1369    assistant: String,
1370    events: String,
1371    tokens_in: Option<i64>,
1372    tokens_out: Option<i64>,
1373    ts_claim: i64,
1374    /// Which canonical encoding hashed this row (`turns.encoding_version`,
1375    /// review NIT N1 on #261). Only v1 exists today.
1376    encoding_version: i64,
1377}
1378
1379impl TurnRow {
1380    /// BLAKE3 hex of this row's canonical encoding — what the *next* turn's
1381    /// `prev_hash` must equal. Dispatches on the row's recorded
1382    /// `encoding_version`; a version this build does not understand errors
1383    /// clearly instead of hashing under the wrong rules (NIT N1 on #261).
1384    fn content_hash(&self) -> anyhow::Result<String> {
1385        match self.encoding_version {
1386            1 => Ok(blake3::hash(&self.canonical_encoding_v1())
1387                .to_hex()
1388                .to_string()),
1389            other => anyhow::bail!(
1390                "turn (conversation `{}`, writer {}, seq {}) carries encoding_version {other}, \
1391                 which this newt does not understand (known: 1) — upgrade newt to verify \
1392                 or extend this chain",
1393                self.conversation_id,
1394                self.writer_fingerprint,
1395                self.seq
1396            ),
1397        }
1398    }
1399
1400    /// Canonical v1 byte encoding of a turn: version tag, then every field
1401    /// length-prefixed (u64 LE) so adjacent fields can never be reparsed
1402    /// ambiguously (`("ab","c")` ≠ `("a","bc")`). Integers are 8-byte LE
1403    /// with a presence byte for the optional token counts.
1404    fn canonical_encoding_v1(&self) -> Vec<u8> {
1405        let mut out = Vec::with_capacity(
1406            64 + self.conversation_id.len()
1407                + self.writer_fingerprint.len()
1408                + self.prev_hash.len()
1409                + self.user.len()
1410                + self.assistant.len()
1411                + self.events.len(),
1412        );
1413        out.extend_from_slice(TURN_ENCODING_V1_PREFIX);
1414        for field in [
1415            self.conversation_id.as_bytes(),
1416            self.writer_fingerprint.as_bytes(),
1417            self.prev_hash.as_bytes(),
1418            self.user.as_bytes(),
1419            self.assistant.as_bytes(),
1420            self.events.as_bytes(),
1421        ] {
1422            out.extend_from_slice(&(field.len() as u64).to_le_bytes());
1423            out.extend_from_slice(field);
1424        }
1425        out.extend_from_slice(&self.seq.to_le_bytes());
1426        for opt in [self.tokens_in, self.tokens_out] {
1427            match opt {
1428                Some(v) => {
1429                    out.push(1);
1430                    out.extend_from_slice(&v.to_le_bytes());
1431                }
1432                None => out.push(0),
1433            }
1434        }
1435        out.extend_from_slice(&self.ts_claim.to_le_bytes());
1436        out
1437    }
1438}
1439
1440fn turn_row_from_sql(row: &rusqlite::Row<'_>) -> rusqlite::Result<TurnRow> {
1441    Ok(TurnRow {
1442        conversation_id: row.get(0)?,
1443        writer_fingerprint: row.get(1)?,
1444        seq: row.get(2)?,
1445        prev_hash: row.get(3)?,
1446        user: row.get(4)?,
1447        assistant: row.get(5)?,
1448        events: row.get(6)?,
1449        tokens_in: row.get(7)?,
1450        tokens_out: row.get(8)?,
1451        ts_claim: row.get(9)?,
1452        encoding_version: row.get(10)?,
1453    })
1454}
1455
1456/// Insert one fully-populated turn row. Must run inside the caller's
1457/// transaction (shared by the live append path and the one-time import).
1458///
1459/// `phantom_reaches_json` (#717) is a separate JSON-string argument, not a
1460/// `TurnRow` field, precisely because it is NOT part of the §6 canonical
1461/// encoding — keeping it out of `TurnRow` keeps the content hash untouched.
1462fn insert_turn_row(
1463    conn: &Connection,
1464    row: &TurnRow,
1465    phantom_reaches_json: &str,
1466) -> anyhow::Result<()> {
1467    conn.execute(
1468        "INSERT INTO turns
1469           (conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
1470            events, tokens_in, tokens_out, ts_claim, encoding_version, phantom_reaches)
1471         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1472        rusqlite::params![
1473            row.conversation_id,
1474            row.writer_fingerprint,
1475            row.seq,
1476            row.prev_hash,
1477            row.user,
1478            row.assistant,
1479            row.events,
1480            row.tokens_in,
1481            row.tokens_out,
1482            row.ts_claim,
1483            row.encoding_version,
1484            phantom_reaches_json,
1485        ],
1486    )?;
1487    Ok(())
1488}
1489
1490/// The §6 genesis hash anchoring a writer's chain within a conversation.
1491fn genesis_hash(conversation_id: &str, writer_fingerprint: &str) -> String {
1492    let mut hasher = blake3::Hasher::new();
1493    hasher.update(GENESIS_PREFIX);
1494    for field in [conversation_id.as_bytes(), writer_fingerprint.as_bytes()] {
1495        hasher.update(&(field.len() as u64).to_le_bytes());
1496        hasher.update(field);
1497    }
1498    hasher.finalize().to_hex().to_string()
1499}
1500
1501/// This writer's most recent turn in a conversation (chain tip source).
1502fn last_turn(
1503    conn: &Connection,
1504    conversation_id: &str,
1505    writer_fingerprint: &str,
1506) -> anyhow::Result<Option<TurnRow>> {
1507    Ok(conn
1508        .query_row(
1509            "SELECT conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
1510                    events, tokens_in, tokens_out, ts_claim, encoding_version
1511               FROM turns
1512              WHERE conversation_id = ?1 AND writer_fingerprint = ?2
1513              ORDER BY seq DESC
1514              LIMIT 1",
1515            rusqlite::params![conversation_id, writer_fingerprint],
1516            turn_row_from_sql,
1517        )
1518        .optional()?)
1519}
1520
1521/// Allocate the next per-writer Lamport tick (strictly monotonic — §6 floor).
1522///
1523/// Must run inside the caller's `BEGIN IMMEDIATE` transaction so the
1524/// read-modify-write is atomic across concurrent writers sharing the db.
1525///
1526/// When the writer has no clock row yet (fresh db, or `writer_clock` lost to
1527/// schema drift), the seed is the **global** max tick already present in the
1528/// database — the Lamport receive rule: a clock never starts behind any tick
1529/// it has observed, so cross-writer `activity_tick` comparisons on a shared
1530/// db stay causally meaningful and a re-seeded clock can never reuse a seq.
1531/// The seeding scan runs only on clock-row creation, never per append.
1532fn next_tick(conn: &Connection, writer_fingerprint: &str) -> anyhow::Result<i64> {
1533    let bumped = conn.execute(
1534        "UPDATE writer_clock SET last_tick = last_tick + 1 WHERE writer_fingerprint = ?1",
1535        [writer_fingerprint],
1536    )?;
1537    if bumped == 0 {
1538        conn.execute(
1539            "INSERT OR IGNORE INTO writer_clock (writer_fingerprint, last_tick)
1540             SELECT ?1, COALESCE(MAX(t), 0) FROM (
1541                 SELECT MAX(seq) AS t FROM turns
1542                 UNION ALL
1543                 SELECT MAX(activity_tick) AS t FROM conversations
1544                 UNION ALL
1545                 -- Other writers' issued ticks: keeps the seed at the true
1546                 -- issued-max even when their rows were pruned (review
1547                 -- finding N6 on #261 — Lamport receive rule over all
1548                 -- observable evidence, not just surviving rows).
1549                 SELECT MAX(last_tick) AS t FROM writer_clock
1550             )",
1551            [writer_fingerprint],
1552        )?;
1553        conn.execute(
1554            "UPDATE writer_clock SET last_tick = last_tick + 1 WHERE writer_fingerprint = ?1",
1555            [writer_fingerprint],
1556        )?;
1557    }
1558    Ok(conn.query_row(
1559        "SELECT last_tick FROM writer_clock WHERE writer_fingerprint = ?1",
1560        [writer_fingerprint],
1561        |row| row.get(0),
1562    )?)
1563}
1564
1565/// Try WAL; fall back to DELETE on the known network-filesystem failure
1566/// modes, returning the captured error text for a user-facing notice.
1567/// Any other error is real and propagates.
1568///
1569/// Under WAL, `synchronous` drops to NORMAL: SQLite documents WAL +
1570/// NORMAL as corruption-safe (fsync at checkpoints, not per commit), and
1571/// per-append cost falls from ~2 ms (one fsync per turn) to tens of µs —
1572/// a power cut can cost the last turns, never the database. The DELETE
1573/// fallback keeps the FULL default, where NORMAL is *not* corruption-safe.
1574fn apply_journal_mode(conn: &Connection) -> anyhow::Result<Option<String>> {
1575    let wal: Result<String, rusqlite::Error> =
1576        conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0));
1577    match wal {
1578        // Assert the pragma actually took (it has documented silent-no-op
1579        // cases) — NORMAL is only safe under WAL; any other mode keeps the
1580        // compiled default of FULL (review finding N4 on #261).
1581        Ok(mode) if mode.eq_ignore_ascii_case("wal") => {
1582            conn.pragma_update(None, "synchronous", "NORMAL")?;
1583            Ok(None)
1584        }
1585        Ok(mode) => {
1586            tracing::warn!(%mode, "journal_mode=WAL did not take; keeping synchronous=FULL");
1587            Ok(Some(format!("journal_mode pragma returned `{mode}`")))
1588        }
1589        Err(e) if wal_fallback_eligible(&e.to_string()) => {
1590            let captured = e.to_string();
1591            conn.pragma_update(None, "journal_mode", "DELETE")?;
1592            tracing::warn!(
1593                error = %captured,
1594                "SQLite refused WAL (network filesystem?); conversations.db is running \
1595                 on the slower journal_mode=DELETE fallback"
1596            );
1597            Ok(Some(captured))
1598        }
1599        Err(e) => Err(e.into()),
1600    }
1601}
1602
1603/// `true` for the SQLite error texts WAL is known to produce on filesystems
1604/// without shared-memory mmap / POSIX lock support (NFS homes): the store
1605/// should fall back to `journal_mode=DELETE` rather than fail to open.
1606fn wal_fallback_eligible(error_text: &str) -> bool {
1607    let lower = error_text.to_lowercase();
1608    lower.contains("locking protocol") || lower.contains("disk i/o error")
1609}
1610
1611/// Schema, v17.1a. §6-binding shape — see the module docs. Every `*_claim`
1612/// column is a DISPLAY-ONLY wall-clock claim (unix nanos): never an ordering
1613/// key, never compared. Ordering is `(writer_fingerprint, seq)` /
1614/// `activity_tick`; integrity is the `prev_hash` BLAKE3 chain + `tip_hash`.
1615/// `events`/`tokens_in`/`tokens_out` are day-one columns filled by 17.6.
1616fn create_schema(conn: &Connection) -> anyhow::Result<()> {
1617    conn.execute_batch(
1618        "CREATE TABLE IF NOT EXISTS conversations (
1619             id                 TEXT PRIMARY KEY,
1620             title              TEXT NOT NULL,
1621             workspace_path     TEXT NOT NULL,            -- display only
1622             workspace_key      TEXT NOT NULL,            -- scoping key: workspace_key_v2 (17.2 — blake3 remote+branch, path fallback)
1623             persona            TEXT,
1624             end_reason         TEXT,                     -- set by 17.7
1625             writer_fingerprint TEXT NOT NULL,            -- §6 ordering key, half 1
1626             activity_tick      INTEGER NOT NULL,         -- §6 ordering key, half 2 (per-writer Lamport tick)
1627             tip_hash           TEXT NOT NULL,            -- §6 chain tip (BLAKE3)
1628             started_at_claim   INTEGER NOT NULL,         -- DISPLAY ONLY (wall-clock claim, unix nanos)
1629             updated_at_claim   INTEGER NOT NULL,         -- DISPLAY ONLY
1630             scratchpad         TEXT NOT NULL DEFAULT '{}', -- JSON scratchpad <state> snapshot (#713); working memory, NOT hashed (§6 chain unchanged)
1631             plan               TEXT NOT NULL DEFAULT '{}', -- JSON plan-ledger snapshot (#715); working memory, NOT hashed (§6 chain unchanged)
1632             roadmap_id         TEXT,                      -- #1030: roadmap this conv's Plan node belongs to (NULL = ad-hoc chat); thin pointer, tree lives in `roadmaps`
1633             node_id            TEXT                       -- #1030: the `roadmaps` tree Subtask id this conversation realizes (NULL = ad-hoc chat)
1634         );
1635         CREATE TABLE IF NOT EXISTS turns (
1636             conversation_id    TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
1637             writer_fingerprint TEXT NOT NULL,            -- §6: whose clock ticked
1638             seq                INTEGER NOT NULL,         -- §6: strictly monotonic per writer — THE ordering key
1639             prev_hash          TEXT NOT NULL,            -- §6: BLAKE3 of prior turn's canonical encoding
1640             user               TEXT NOT NULL,
1641             assistant          TEXT NOT NULL,
1642             events             TEXT NOT NULL DEFAULT '[]', -- JSON tool events; filled by 17.6
1643             tokens_in          INTEGER,                  -- filled by 17.6, consumed by 18.x
1644             tokens_out         INTEGER,
1645             ts_claim           INTEGER NOT NULL,         -- DISPLAY ONLY (wall-clock claim, unix nanos)
1646             encoding_version   INTEGER NOT NULL DEFAULT 1, -- canonical-encoding dispatch (N1 on #261)
1647             phantom_reaches    TEXT NOT NULL DEFAULT '[]', -- JSON phantom-reach telemetry (#717); NOT hashed (§6 chain unchanged)
1648             PRIMARY KEY (conversation_id, writer_fingerprint, seq)
1649         );
1650         -- The per-writer Lamport clock (§6 'each agent is its own clock').
1651         CREATE TABLE IF NOT EXISTS writer_clock (
1652             writer_fingerprint TEXT PRIMARY KEY,
1653             last_tick          INTEGER NOT NULL
1654         );
1655         CREATE INDEX IF NOT EXISTS idx_conversations_ws_tick
1656             ON conversations (workspace_key, activity_tick);
1657         -- #1030 Plans within Plans: the roadmap tree, persisted as a serialized
1658         -- plan.rs::Plan blob (Roadmap->Phase->Plan->Task Subtask nodes). A Plan
1659         -- node binds a conversations row via conversations.node_id; the tree's
1660         -- shape lives HERE, never on the hash-chained transcript rows.
1661         CREATE TABLE IF NOT EXISTS roadmaps (
1662             id                 TEXT NOT NULL,
1663             workspace_key      TEXT NOT NULL,
1664             title              TEXT NOT NULL DEFAULT '',
1665             tree               TEXT NOT NULL DEFAULT '',   -- serialized plan.rs::Plan (TOML); empty = no nodes yet
1666             schema_version     INTEGER NOT NULL DEFAULT 1,
1667             created_at_claim   INTEGER NOT NULL DEFAULT 0,
1668             updated_at_claim   INTEGER NOT NULL DEFAULT 0,
1669             -- #1086: a roadmap's identity is (id, workspace_key), NOT id alone.
1670             -- With id-only PK an `INSERT OR REPLACE` from /roadmap import could
1671             -- REPLACE a same-id row owned by ANOTHER workspace (the read fence
1672             -- held, the write fence did not). The composite key makes the write
1673             -- path workspace-fenced too: importing an id that lives under a
1674             -- different workspace inserts a separate row, never steals.
1675             PRIMARY KEY (id, workspace_key)
1676         );
1677         CREATE INDEX IF NOT EXISTS idx_roadmaps_ws
1678             ON roadmaps (workspace_key);
1679         -- #1030 collision fix: at most ONE live process owns a conversation.
1680         -- conversation_id is the global PK, so a second live newt cannot claim a
1681         -- conversation another holds; a stale claim (dead pid / new boot_id) is
1682         -- reclaimed on the next claim. Also the source of the /resume liveness column.
1683         CREATE TABLE IF NOT EXISTS live_owners (
1684             conversation_id    TEXT PRIMARY KEY,
1685             host               TEXT NOT NULL,
1686             boot_id            TEXT NOT NULL,
1687             pid                INTEGER NOT NULL,
1688             writer_fingerprint TEXT NOT NULL,
1689             heartbeat_tick     INTEGER NOT NULL
1690         );",
1691    )?;
1692    Ok(())
1693}
1694
1695/// Expected columns per table, with ALTER-safe declarations (additive
1696/// schema-diff reconciliation: a db written by an older newt gains any
1697/// missing columns on open; unknown extra columns are left alone).
1698const EXPECTED_COLUMNS: &[(&str, &[(&str, &str)])] = &[
1699    (
1700        "conversations",
1701        &[
1702            ("id", "TEXT"),
1703            ("title", "TEXT NOT NULL DEFAULT ''"),
1704            ("workspace_path", "TEXT NOT NULL DEFAULT ''"),
1705            ("workspace_key", "TEXT NOT NULL DEFAULT ''"),
1706            ("persona", "TEXT"),
1707            ("end_reason", "TEXT"),
1708            ("writer_fingerprint", "TEXT NOT NULL DEFAULT ''"),
1709            ("activity_tick", "INTEGER NOT NULL DEFAULT 0"),
1710            ("tip_hash", "TEXT NOT NULL DEFAULT ''"),
1711            ("started_at_claim", "INTEGER NOT NULL DEFAULT 0"),
1712            ("updated_at_claim", "INTEGER NOT NULL DEFAULT 0"),
1713            // #713: scratchpad <state> snapshot. Additive — an older db gains it
1714            // on open with the historically-true empty backfill (`{}`). It rides
1715            // the conversation row, NOT a turn, so it is NEVER part of the §6
1716            // canonical encoding: working memory, not provenance.
1717            ("scratchpad", "TEXT NOT NULL DEFAULT '{}'"),
1718            // #715: plan-ledger snapshot. Additive — an older db gains it on open
1719            // with the empty backfill (`{}`, parsed via PlanSnapshot's serde
1720            // default). It rides the conversation row, NOT a turn, so it is NEVER
1721            // part of the §6 canonical encoding: working memory, not provenance.
1722            ("plan", "TEXT NOT NULL DEFAULT '{}'"),
1723            // #1030: thin pointers locating this conversation in a roadmap tree.
1724            // Additive — an older db gains them on open with the NULL backfill
1725            // (an ad-hoc chat). Metadata, NOT part of the §6 canonical encoding,
1726            // so every existing tip_hash chain still verifies byte-for-byte.
1727            ("roadmap_id", "TEXT"),
1728            ("node_id", "TEXT"),
1729        ],
1730    ),
1731    (
1732        "turns",
1733        &[
1734            ("conversation_id", "TEXT"),
1735            ("writer_fingerprint", "TEXT NOT NULL DEFAULT ''"),
1736            ("seq", "INTEGER NOT NULL DEFAULT 0"),
1737            ("prev_hash", "TEXT NOT NULL DEFAULT ''"),
1738            ("user", "TEXT NOT NULL DEFAULT ''"),
1739            ("assistant", "TEXT NOT NULL DEFAULT ''"),
1740            ("events", "TEXT NOT NULL DEFAULT '[]'"),
1741            ("tokens_in", "INTEGER"),
1742            ("tokens_out", "INTEGER"),
1743            ("ts_claim", "INTEGER NOT NULL DEFAULT 0"),
1744            // N1 on #261: rows written before this column exist only as v1,
1745            // so DEFAULT 1 is the historically-true backfill.
1746            ("encoding_version", "INTEGER NOT NULL DEFAULT 1"),
1747            // #717: phantom-reach telemetry. Additive — an older db gains it on
1748            // open with the historically-true empty backfill. NOT part of the §6
1749            // canonical encoding, so existing chains verify byte-for-byte.
1750            ("phantom_reaches", "TEXT NOT NULL DEFAULT '[]'"),
1751        ],
1752    ),
1753    (
1754        "writer_clock",
1755        &[
1756            ("writer_fingerprint", "TEXT"),
1757            ("last_tick", "INTEGER NOT NULL DEFAULT 0"),
1758        ],
1759    ),
1760];
1761
1762/// Compare `PRAGMA table_info` against [`EXPECTED_COLUMNS`] and `ALTER TABLE
1763/// ... ADD COLUMN` any additive drift. Removed/renamed columns are NOT
1764/// handled here — destructive migrations get their own explicit step.
1765fn reconcile_schema(conn: &Connection) -> anyhow::Result<()> {
1766    for (table, expected) in EXPECTED_COLUMNS {
1767        let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
1768        let present: Vec<String> = stmt
1769            .query_map([], |row| row.get::<_, String>(1))?
1770            .collect::<Result<Vec<_>, _>>()?;
1771        for (name, decl) in *expected {
1772            if !present.iter().any(|c| c == name) {
1773                conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {name} {decl}"))?;
1774                tracing::info!(
1775                    table = *table,
1776                    column = *name,
1777                    "schema migration: added missing column"
1778                );
1779            }
1780        }
1781    }
1782    Ok(())
1783}
1784
1785/// SQL expression deriving a space-joined list of `$.{key}` string values
1786/// from the `events` JSON array carried by `source` (e.g. `new.events`,
1787/// `old.events`, or the bare column in the content view).
1788///
1789/// This is THE 17.6 seam: events elements are objects, and the keys read
1790/// here — `tool` and `args_digest` — are the contract 17.6's recorder must
1791/// write. Shared verbatim by the view and both triggers so the indexed
1792/// terms and the content read back at query time can never disagree.
1793/// `json_valid` guards the whole expression: a garbage events blob yields
1794/// `''` instead of breaking the append (a trigger error would abort the
1795/// turn's transaction).
1796fn events_extract_sql(source: &str, key: &str) -> String {
1797    format!(
1798        "CASE WHEN json_valid({source}) THEN \
1799            (SELECT coalesce(group_concat(json_extract(value, '$.{key}'), ' '), '') \
1800               FROM json_each({source})) \
1801         ELSE '' END"
1802    )
1803}
1804
1805/// Create the 17.3 FTS5 recall index (module docs — FTS5 recall index):
1806/// the `turns_fts_content` view, the external-content `turns_fts` virtual
1807/// table (unicode61), and the AFTER INSERT / AFTER DELETE triggers on
1808/// `turns`. No UPDATE trigger by design: turns are append-only (§6).
1809///
1810/// Backfill-on-migration: when the virtual table does not exist yet (a
1811/// fresh db, or a 17.1/17.2 db opened by a 17.3+ newt), every existing
1812/// turn is indexed by an explicit `INSERT…SELECT` of the same derived
1813/// expressions (see the in-body comment for why not FTS5 `'rebuild'`) —
1814/// one-time, inside the same `BEGIN IMMEDIATE` transaction as the DDL,
1815/// idempotent because the presence of the table IS the done-marker
1816/// (checked under the write lock, so concurrent first opens cannot
1817/// double-backfill).
1818fn create_fts_index(conn: &Connection) -> anyhow::Result<()> {
1819    let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
1820    let have_index = tx
1821        .query_row(
1822            "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'turns_fts'",
1823            [],
1824            |_| Ok(()),
1825        )
1826        .optional()?
1827        .is_some();
1828
1829    let view_tools = events_extract_sql("events", "tool");
1830    let view_digests = events_extract_sql("events", "args_digest");
1831    let new_tools = events_extract_sql("new.events", "tool");
1832    let new_digests = events_extract_sql("new.events", "args_digest");
1833    let old_tools = events_extract_sql("old.events", "tool");
1834    let old_digests = events_extract_sql("old.events", "args_digest");
1835    tx.execute_batch(&format!(
1836        "CREATE VIEW IF NOT EXISTS turns_fts_content AS
1837            SELECT rowid,
1838                   user,
1839                   assistant,
1840                   {view_tools} AS tool_names,
1841                   {view_digests} AS tool_args_digest
1842              FROM turns;
1843         CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5(
1844             user, assistant, tool_names, tool_args_digest,
1845             content='turns_fts_content',
1846             content_rowid='rowid',
1847             tokenize='unicode61'
1848         );
1849         CREATE TRIGGER IF NOT EXISTS turns_fts_after_insert
1850         AFTER INSERT ON turns BEGIN
1851             INSERT INTO turns_fts(rowid, user, assistant, tool_names, tool_args_digest)
1852             VALUES (new.rowid, new.user, new.assistant, {new_tools}, {new_digests});
1853         END;
1854         -- Fires per cascaded row on conversation delete. The 'delete'
1855         -- command must receive the values that were indexed at insert
1856         -- time — guaranteed by the append-only invariant on turns.
1857         CREATE TRIGGER IF NOT EXISTS turns_fts_after_delete
1858         AFTER DELETE ON turns BEGIN
1859             INSERT INTO turns_fts(turns_fts, rowid, user, assistant, tool_names, tool_args_digest)
1860             VALUES ('delete', old.rowid, old.user, old.assistant, {old_tools}, {old_digests});
1861         END;"
1862    ))?;
1863
1864    if !have_index {
1865        // One-time backfill of pre-17.3 turns. NOT the FTS5 `'rebuild'`
1866        // command: rebuild scans the content table through a
1867        // schema-qualified statement, and `json_each` — an eponymous
1868        // virtual table inside the content view — cannot be resolved
1869        // schema-qualified ("no such table: main.json_each", verified
1870        // against the bundled SQLite 3.45). An explicit INSERT…SELECT of
1871        // the same derived expressions is equivalent for an empty index
1872        // and prepares unqualified, so the view's seam stays intact.
1873        tx.execute(
1874            &format!(
1875                "INSERT INTO turns_fts(rowid, user, assistant, tool_names, tool_args_digest)
1876                 SELECT rowid, user, assistant, {view_tools}, {view_digests} FROM turns"
1877            ),
1878            [],
1879        )?;
1880        tracing::info!("created the FTS5 recall index and backfilled existing turns (17.3)");
1881    }
1882    tx.commit()?;
1883    Ok(())
1884}
1885
1886/// A parsed piece of a raw recall query: a ready-to-emit term (bare word or
1887/// `"quoted phrase"`) or a boolean operator awaiting placement.
1888enum QueryPart {
1889    Term(String),
1890    Op(&'static str),
1891}
1892
1893/// Sanitize a raw user/model query into a safe FTS5 `MATCH` expression
1894/// (17.3 — the hermes `_sanitize_fts5_query` port; see
1895/// `docs/design/evidence/hermes-study/report-hermes-sessions.md` §6).
1896///
1897/// Pure function, no database required. Rules:
1898///
1899/// 1. **Balanced `"phrases"` are preserved** as phrase queries. A dangling
1900///    unbalanced quote is dropped and its text processed as plain terms.
1901/// 2. Outside phrases, the pure-syntax FTS5 metacharacters `( ) * ^ "` are
1902///    stripped wherever they appear in a token.
1903/// 3. Bare uppercase `AND` / `OR` / `NOT` survive as boolean operators
1904///    only in positions FTS5's grammar accepts (between terms): leading
1905///    and trailing operators are trimmed and operator runs collapse to
1906///    their first (`NOT foo` → `foo`, `foo AND` → `foo`,
1907///    `a AND OR b` → `a AND b`). Lowercase forms are ordinary terms.
1908///    Bare uppercase `NEAR` is quoted into a term — FTS5 reserves it.
1909/// 4. Tokens still carrying any other ASCII punctuation are **auto-quoted**
1910///    so FTS5 reads them as text, not syntax: `chat-send` → `"chat-send"`,
1911///    `P2.2` → `"P2.2"`, `src/store.rs` → `"src/store.rs"`,
1912///    `tcp:1666` → `"tcp:1666"` (this also neutralizes `col:` filters and
1913///    `-`/`.` operator injection).
1914/// 5. Tokens and phrases with nothing the unicode61 tokenizer would index
1915///    (no letter or digit in any script) are dropped.
1916///
1917/// When everything sanitizes away, this is an **error** ("query reduced to
1918/// nothing") — never an empty `MATCH` (a syntax error) and never a
1919/// match-all.
1920pub fn sanitize_fts5_query(raw: &str) -> anyhow::Result<String> {
1921    let mut parts: Vec<QueryPart> = Vec::new();
1922
1923    // Pass 1: split out balanced "phrases"; everything else is plain text.
1924    let mut rest = raw;
1925    loop {
1926        let Some(open) = rest.find('"') else {
1927            push_plain_tokens(rest, &mut parts);
1928            break;
1929        };
1930        push_plain_tokens(&rest[..open], &mut parts);
1931        let after_open = &rest[open + 1..];
1932        match after_open.find('"') {
1933            Some(close) => {
1934                let phrase = after_open[..close].trim();
1935                // An unindexable phrase ("--", "", …) would be dead weight
1936                // or an FTS5 error; drop it like an unindexable token.
1937                if phrase.chars().any(char::is_alphanumeric) {
1938                    parts.push(QueryPart::Term(format!("\"{phrase}\"")));
1939                }
1940                rest = &after_open[close + 1..];
1941            }
1942            None => {
1943                // Unbalanced: strip the dangling quote, keep its text.
1944                push_plain_tokens(after_open, &mut parts);
1945                break;
1946            }
1947        }
1948    }
1949
1950    // Pass 2: place operators. An operator is emitted only between two
1951    // terms: leading ops are dropped (no left operand), runs collapse to
1952    // the first, and a trailing pending op is never flushed.
1953    let mut out: Vec<String> = Vec::new();
1954    let mut pending_op: Option<&'static str> = None;
1955    for part in parts {
1956        match part {
1957            QueryPart::Term(term) => {
1958                if let Some(op) = pending_op.take() {
1959                    out.push(op.to_string());
1960                }
1961                out.push(term);
1962            }
1963            QueryPart::Op(op) => {
1964                if !out.is_empty() && pending_op.is_none() {
1965                    pending_op = Some(op);
1966                }
1967            }
1968        }
1969    }
1970
1971    if out.is_empty() {
1972        anyhow::bail!("search query reduced to nothing after FTS5 sanitizing: {raw:?}");
1973    }
1974    Ok(out.join(" "))
1975}
1976
1977/// Tokenize a plain (non-phrase) text run on whitespace and classify each
1978/// token: uppercase boolean keywords become [`QueryPart::Op`]; everything
1979/// else goes through [`sanitize_bare_token`].
1980fn push_plain_tokens(text: &str, parts: &mut Vec<QueryPart>) {
1981    for token in text.split_whitespace() {
1982        match token {
1983            "AND" => parts.push(QueryPart::Op("AND")),
1984            "OR" => parts.push(QueryPart::Op("OR")),
1985            "NOT" => parts.push(QueryPart::Op("NOT")),
1986            // FTS5 reserves NEAR (case-sensitively); as a quoted phrase it
1987            // is just the word again.
1988            "NEAR" => parts.push(QueryPart::Term("\"NEAR\"".to_string())),
1989            _ => {
1990                if let Some(term) = sanitize_bare_token(token) {
1991                    parts.push(QueryPart::Term(term));
1992                }
1993            }
1994        }
1995    }
1996}
1997
1998/// Sanitize one bare token: strip pure-syntax metacharacters, drop tokens
1999/// with nothing indexable, and auto-quote anything that is not a clean
2000/// FTS5 bareword (rules 2/4/5 of [`sanitize_fts5_query`]).
2001fn sanitize_bare_token(token: &str) -> Option<String> {
2002    let stripped: String = token
2003        .chars()
2004        .filter(|c| !matches!(c, '(' | ')' | '*' | '^' | '"'))
2005        .collect();
2006    // Nothing the unicode61 tokenizer would index → drop the token.
2007    if !stripped.chars().any(char::is_alphanumeric) {
2008        return None;
2009    }
2010    // FTS5's bareword alphabet: ASCII alphanumerics, `_`, and everything
2011    // non-ASCII. Any other character would parse as syntax — auto-quote
2012    // the token so `chat-send`, `P2.2`, paths, and issue refs match as
2013    // text (the hermes rule this port exists for).
2014    let is_bareword = stripped
2015        .chars()
2016        .all(|c| c.is_ascii_alphanumeric() || c == '_' || !c.is_ascii());
2017    Some(if is_bareword {
2018        stripped
2019    } else {
2020        format!("\"{stripped}\"")
2021    })
2022}
2023
2024/// One-time import of the retired JSON backend's tree (see the module docs).
2025///
2026/// Runs on every open and is a fast no-op when `<root>/conversations/` does
2027/// not exist (i.e. always, after the first successful import renames it to
2028/// the backup dir). Records are imported oldest-first by the legacy MRU
2029/// ordering (`updated_at`, then `created_at`, then id — the JSON backend's
2030/// own sort), so ticks assigned in import order reproduce the conversation
2031/// ordering users saw before the migration.
2032fn import_legacy_json(
2033    conn: &Connection,
2034    root: &Path,
2035    writer_fingerprint: &str,
2036) -> anyhow::Result<()> {
2037    let legacy_root = root.join(LEGACY_JSON_DIR);
2038    if !legacy_root.is_dir() {
2039        return Ok(());
2040    }
2041    let mut records = collect_legacy_records(&legacy_root)?;
2042    records.sort_by(|a, b| {
2043        a.updated_at_unix_nanos
2044            .cmp(&b.updated_at_unix_nanos)
2045            .then_with(|| a.created_at_unix_nanos.cmp(&b.created_at_unix_nanos))
2046            .then_with(|| a.id.cmp(&b.id))
2047    });
2048    let mut imported = 0usize;
2049    for record in &records {
2050        if import_one_record(conn, record, writer_fingerprint)? {
2051            imported += 1;
2052        }
2053    }
2054    let backup = retire_legacy_dir(root, &legacy_root)?;
2055    tracing::info!(
2056        imported,
2057        found = records.len(),
2058        backup = %backup.display(),
2059        "one-time import of legacy JSON conversations complete; \
2060         the original tree is kept as a backup"
2061    );
2062    Ok(())
2063}
2064
2065/// #1086 one-shot table migration: rebuild a legacy `roadmaps` table whose
2066/// primary key is `id` alone into one keyed by `(id, workspace_key)`.
2067///
2068/// Necessary because `create_schema` uses `CREATE TABLE IF NOT EXISTS`, so an
2069/// existing db keeps its old single-column PK — under which `create_roadmap`'s
2070/// `INSERT OR REPLACE` (conflict target = the PK) could replace a same-id
2071/// roadmap owned by a *different* workspace. `/roadmap import` makes that
2072/// trivially reachable (the id travels in the file), silently stealing another
2073/// workspace's roadmap. The composite key makes the write path workspace-fenced.
2074///
2075/// Idempotent (skips once the PK is composite) and lossless (existing ids are
2076/// globally unique under the old PK, so every row survives the rebuild). Runs
2077/// inside the open path's locked-retry, so a rebuild racing another first-open
2078/// is retried, not surfaced.
2079fn migrate_roadmaps_pk(conn: &Connection) -> anyhow::Result<()> {
2080    let sql: Option<String> = conn
2081        .query_row(
2082            "SELECT sql FROM sqlite_master WHERE type='table' AND name='roadmaps'",
2083            [],
2084            |r| r.get(0),
2085        )
2086        .optional()?;
2087    let Some(sql) = sql else {
2088        // No table yet (a brand-new db creates it composite in create_schema).
2089        return Ok(());
2090    };
2091    let normalized = sql
2092        .split_whitespace()
2093        .collect::<Vec<_>>()
2094        .join(" ")
2095        .to_ascii_lowercase();
2096    if normalized.contains("primary key (id, workspace_key)") {
2097        return Ok(()); // already composite — nothing to do
2098    }
2099    let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
2100    tx.execute_batch(
2101        "CREATE TABLE roadmaps_v2 (
2102             id                 TEXT NOT NULL,
2103             workspace_key      TEXT NOT NULL,
2104             title              TEXT NOT NULL DEFAULT '',
2105             tree               TEXT NOT NULL DEFAULT '',
2106             schema_version     INTEGER NOT NULL DEFAULT 1,
2107             created_at_claim   INTEGER NOT NULL DEFAULT 0,
2108             updated_at_claim   INTEGER NOT NULL DEFAULT 0,
2109             PRIMARY KEY (id, workspace_key)
2110         );
2111         INSERT INTO roadmaps_v2
2112             (id, workspace_key, title, tree, schema_version, created_at_claim, updated_at_claim)
2113             SELECT id, workspace_key, title, tree, schema_version, created_at_claim, updated_at_claim
2114             FROM roadmaps;
2115         DROP TABLE roadmaps;
2116         ALTER TABLE roadmaps_v2 RENAME TO roadmaps;
2117         CREATE INDEX IF NOT EXISTS idx_roadmaps_ws ON roadmaps (workspace_key);",
2118    )?;
2119    tx.commit()?;
2120    tracing::info!("migrated roadmaps to a composite (id, workspace_key) primary key (#1086)");
2121    Ok(())
2122}
2123
2124/// 17.2 one-shot row migration (see module docs — Workspace identity v2):
2125/// re-key every conversation that carries THIS workspace's retired UUIDv5
2126/// key to the v2 key, in one UPDATE inside an immediate transaction.
2127///
2128/// Idempotent by construction — once no rows carry the old key the UPDATE
2129/// matches nothing. Scoped by construction — a UUIDv5 key is derived from
2130/// one canonical path, so the WHERE clause can only ever select rows that
2131/// belonged to this workspace; every other workspace's rows are left for
2132/// their own open to migrate. Re-keying is metadata, not activity: no tick
2133/// is allocated, and the §6 chain is untouched (`workspace_key` is not part
2134/// of the turn encoding or the genesis hash).
2135fn migrate_workspace_key(conn: &Connection, workspace: &Path, v2_key: &str) -> anyhow::Result<()> {
2136    // The deprecated v1 derivation is retained exactly for this lookup.
2137    #[allow(deprecated)]
2138    let old_key = ConversationStore::workspace_id_for_path(workspace)?;
2139    let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
2140    let migrated = tx.execute(
2141        "UPDATE conversations SET workspace_key = ?2 WHERE workspace_key = ?1",
2142        rusqlite::params![old_key, v2_key],
2143    )?;
2144    tx.commit()?;
2145    if migrated > 0 {
2146        tracing::info!(
2147            migrated,
2148            workspace = %workspace.display(),
2149            "re-keyed conversations from the retired UUIDv5 workspace key to v2"
2150        );
2151    }
2152    Ok(())
2153}
2154
2155/// Walk `<legacy_root>/<workspace-uuid>/<id>.json` and parse every readable
2156/// record — all workspaces, not just the opening store's. Corrupt or
2157/// unreadable records are skipped with a warning (the legacy store's own
2158/// semantics); whatever is skipped survives untouched in the backup dir.
2159fn collect_legacy_records(legacy_root: &Path) -> anyhow::Result<Vec<ConversationRecord>> {
2160    let mut records = Vec::new();
2161    for ws_entry in std::fs::read_dir(legacy_root)? {
2162        let ws_dir = ws_entry?.path();
2163        if !ws_dir.is_dir() {
2164            // Stray file at the workspace level — not a record; the backup
2165            // rename preserves it.
2166            continue;
2167        }
2168        let dir_key = ws_dir
2169            .file_name()
2170            .map(|n| n.to_string_lossy().into_owned())
2171            .unwrap_or_default();
2172        for entry in std::fs::read_dir(&ws_dir)? {
2173            let path = entry?.path();
2174            if path.extension().and_then(|e| e.to_str()) != Some("json") {
2175                // The legacy store only ever read `.json` (crash-leftover
2176                // `.tmp` files were invisible to it).
2177                continue;
2178            }
2179            let parsed = std::fs::read_to_string(&path)
2180                .map_err(anyhow::Error::from)
2181                .and_then(|text| Ok(serde_json::from_str::<ConversationRecord>(&text)?));
2182            let record = match parsed {
2183                Ok(record) => record,
2184                Err(e) => {
2185                    tracing::warn!(
2186                        path = %path.display(),
2187                        error = %e,
2188                        "skipping unreadable legacy conversation record \
2189                         (the file is kept in the import backup)"
2190                    );
2191                    continue;
2192                }
2193            };
2194            // The legacy store only served records whose body workspace_id
2195            // matched their dir name; a mismatched record was invisible to
2196            // every workspace. Importing it would resurrect data no store
2197            // could see — skip it (it stays in the backup).
2198            if record.workspace_id != dir_key {
2199                tracing::warn!(
2200                    path = %path.display(),
2201                    body_workspace = %record.workspace_id,
2202                    dir_workspace = %dir_key,
2203                    "skipping legacy record whose workspace id does not match its dir"
2204                );
2205                continue;
2206            }
2207            if let Err(e) = validate_record_id(&record.id) {
2208                tracing::warn!(path = %path.display(), error = %e, "skipping legacy record");
2209                continue;
2210            }
2211            records.push(record);
2212        }
2213    }
2214    Ok(records)
2215}
2216
2217/// Import one legacy record inside its own `BEGIN IMMEDIATE` transaction:
2218/// conversation row first (one tick, genesis tip), then each turn through
2219/// the normal tick + chain path, then the activity/tip update — exactly the
2220/// shape the live write path produces, so `verify_chain` holds post-import.
2221/// Returns `false` (and writes nothing) when the id already exists in the
2222/// database — in any workspace: that means an earlier pass imported it (or
2223/// the id collides), and the import never overwrites.
2224fn import_one_record(
2225    conn: &Connection,
2226    record: &ConversationRecord,
2227    writer_fingerprint: &str,
2228) -> anyhow::Result<bool> {
2229    let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
2230    let already: Option<i64> = tx
2231        .query_row(
2232            "SELECT 1 FROM conversations WHERE id = ?1",
2233            [&record.id],
2234            |row| row.get(0),
2235        )
2236        .optional()?;
2237    if already.is_some() {
2238        tracing::debug!(id = %record.id, "legacy conversation already in the db; skipping");
2239        return Ok(false);
2240    }
2241
2242    // §6: the legacy unix_nanos enter ONLY as display claims.
2243    let started_claim = clamp_claim(record.created_at_unix_nanos);
2244    let updated_claim = clamp_claim(record.updated_at_unix_nanos);
2245    let create_tick = next_tick(&tx, writer_fingerprint)?;
2246    tx.execute(
2247        "INSERT INTO conversations
2248           (id, title, workspace_path, workspace_key, persona, end_reason,
2249            writer_fingerprint, activity_tick, tip_hash,
2250            started_at_claim, updated_at_claim)
2251         VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9, ?10)",
2252        rusqlite::params![
2253            record.id,
2254            record.title,
2255            record.workspace,
2256            record.workspace_id,
2257            record.persona,
2258            writer_fingerprint,
2259            create_tick,
2260            genesis_hash(&record.id, writer_fingerprint),
2261            started_claim,
2262            updated_claim,
2263        ],
2264    )?;
2265
2266    let mut prev_hash = genesis_hash(&record.id, writer_fingerprint);
2267    let mut last_tick = create_tick;
2268    for turn in &record.turns {
2269        let seq = next_tick(&tx, writer_fingerprint)?;
2270        let row = TurnRow {
2271            conversation_id: record.id.clone(),
2272            writer_fingerprint: writer_fingerprint.to_string(),
2273            seq,
2274            prev_hash,
2275            user: turn.user.clone(),
2276            assistant: turn.assistant.clone(),
2277            events: "[]".to_string(),
2278            tokens_in: None,
2279            tokens_out: None,
2280            // The legacy format recorded no per-turn time; the record-level
2281            // updated_at is the only available claim (display only, §6).
2282            ts_claim: updated_claim,
2283            encoding_version: TURN_ENCODING_VERSION_CURRENT,
2284        };
2285        // #717: the legacy JSON backend recorded no phantom reaches (it predates
2286        // the column), exactly as it recorded no tool events (`events: "[]"`).
2287        insert_turn_row(&tx, &row, "[]")?;
2288        prev_hash = row.content_hash()?;
2289        last_tick = seq;
2290    }
2291    if !record.turns.is_empty() {
2292        tx.execute(
2293            "UPDATE conversations SET activity_tick = ?2, tip_hash = ?3 WHERE id = ?1",
2294            rusqlite::params![record.id, last_tick, prev_hash],
2295        )?;
2296    }
2297    tx.commit()?;
2298    Ok(true)
2299}
2300
2301/// Move the legacy tree to the backup name (`conversations.imported/`,
2302/// suffixed if that already exists). A concurrent opener may win the rename;
2303/// finding the source already gone is success, not an error.
2304fn retire_legacy_dir(root: &Path, legacy_root: &Path) -> anyhow::Result<PathBuf> {
2305    for attempt in 0u32..100 {
2306        let candidate = if attempt == 0 {
2307            root.join(LEGACY_BACKUP_DIR)
2308        } else {
2309            root.join(format!("{LEGACY_BACKUP_DIR}.{attempt}"))
2310        };
2311        if candidate.exists() {
2312            continue;
2313        }
2314        return match std::fs::rename(legacy_root, &candidate) {
2315            Ok(()) => Ok(candidate),
2316            Err(_) if !legacy_root.exists() => Ok(candidate),
2317            Err(e) => Err(anyhow::Error::from(e).context(format!(
2318                "imported legacy conversations but could not move {} aside to {}",
2319                legacy_root.display(),
2320                candidate.display()
2321            ))),
2322        };
2323    }
2324    anyhow::bail!(
2325        "no free backup name for {} (conversations.imported* all taken)",
2326        legacy_root.display()
2327    )
2328}
2329
2330/// Clamp a legacy u128 nanosecond claim into the store's i64 claim columns.
2331/// Saturates at `i64::MAX` — claims are display-only (§6), never compared.
2332fn clamp_claim(nanos: u128) -> i64 {
2333    i64::try_from(nanos).unwrap_or(i64::MAX)
2334}
2335
2336/// The §6 writer fingerprint, in preference order (module docs — Writer
2337/// identity): the operator's mesh-key fingerprint from `<root>/identity.pem`
2338/// when it exists and parses, else the 17.1a per-install nonce.
2339///
2340/// The key type comes from `agent-mesh-protocol` (already a direct dep of
2341/// newt-core) — deliberately NOT from `newt-identity`, which depends on
2342/// newt-core and would make the coupling a cycle. The path is rooted at the
2343/// store root rather than resolved from `$HOME` so the derivation stays
2344/// hermetic (tests, alternate roots); for the production root `~/.newt`
2345/// the two spellings are the same file.
2346fn resolve_writer_fingerprint(root: &Path) -> anyhow::Result<String> {
2347    let pem = root.join(IDENTITY_PEM_FILE);
2348    if pem.is_file() {
2349        match agent_mesh_protocol::UserKey::load(&pem) {
2350            Ok(user) => return Ok(user.fingerprint().hex()),
2351            Err(e) => {
2352                // A broken key file must never block the store; the nonce
2353                // fallback keeps the Lamport clock running (and §6 chains
2354                // tolerate the writer change as a handoff).
2355                tracing::warn!(
2356                    path = %pem.display(),
2357                    error = %e,
2358                    "identity.pem exists but did not parse; \
2359                     falling back to the per-install nonce writer fingerprint"
2360                );
2361            }
2362        }
2363    }
2364    load_or_create_writer_fingerprint(root)
2365}
2366
2367/// Load (or mint, atomically) the per-install nonce and derive the writer
2368/// fingerprint as its BLAKE3 hex — the fallback half of
2369/// [`resolve_writer_fingerprint`].
2370fn load_or_create_writer_fingerprint(root: &Path) -> anyhow::Result<String> {
2371    let path = root.join(NONCE_FILE);
2372    let nonce = match std::fs::read_to_string(&path) {
2373        Ok(text) if !text.trim().is_empty() => text.trim().to_string(),
2374        _ => {
2375            // Atomic mint-with-content. Two racing first-run processes must
2376            // converge on ONE nonce, and the published file must NEVER be
2377            // observable half-written:
2378            //   * write-then-RENAME is wrong — rename replaces, so a slow
2379            //     racer can overwrite the winner's nonce after the winner
2380            //     already derived its fingerprint (orphaning its rows);
2381            //   * bare O_EXCL-then-write is wrong — the file exists EMPTY
2382            //     between create and write, so a racing reader can adopt ""
2383            //     (caught by CI: the 8-thread convergence test on Windows).
2384            // hard_link is the primitive with both properties: the name
2385            // appears only after the temp's content is fully written, and
2386            // linking FAILS (AlreadyExists) instead of replacing a winner.
2387            let minted = uuid::Uuid::new_v4().to_string();
2388            let tmp = root.join(format!(
2389                "{NONCE_FILE}.{}.{:?}.tmp",
2390                std::process::id(),
2391                std::thread::current().id()
2392            ));
2393            std::fs::write(&tmp, &minted)?;
2394            let publish = std::fs::hard_link(&tmp, &path);
2395            let _ = std::fs::remove_file(&tmp);
2396            match publish {
2397                Ok(()) => minted,
2398                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2399                    // The winner's link only exists with full content.
2400                    let adopted = std::fs::read_to_string(&path)?.trim().to_string();
2401                    if adopted.is_empty() {
2402                        anyhow::bail!(
2403                            "install nonce at {} exists but is empty — remove it and retry",
2404                            path.display()
2405                        );
2406                    }
2407                    adopted
2408                }
2409                Err(e) => return Err(e.into()),
2410            }
2411        }
2412    };
2413    Ok(blake3::hash(nonce.as_bytes()).to_hex().to_string())
2414}
2415
2416/// Wall-clock for the display-only claim columns. Saturates at `i64::MAX`
2417/// (year 2262 in unix nanos) — claims are never compared, so saturation is
2418/// harmless by construction.
2419fn now_claim_nanos() -> i64 {
2420    std::time::SystemTime::now()
2421        .duration_since(std::time::UNIX_EPOCH)
2422        .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
2423        .unwrap_or(0)
2424}
2425
2426// ── #1030 collision fix: one live owner per conversation (live_owners) ──────
2427
2428/// The current on-disk schema version of a roadmap's serialized tree (#1030).
2429/// Bumped only on a forward-incompatible change to the `plan.rs::Plan` shape;
2430/// `Subtask`'s `deny_unknown_fields` makes such a change loud, not silent.
2431const ROADMAP_SCHEMA_VERSION: i64 = 1;
2432
2433/// A #1030 roadmap loaded from the store: the Roadmap→Phase→Plan→Task tree as a
2434/// [`crate::plan::Plan`], plus its id and title.
2435#[derive(Debug, Clone, PartialEq, Eq)]
2436pub struct Roadmap {
2437    pub id: String,
2438    pub title: String,
2439    pub tree: crate::plan::Plan,
2440}
2441
2442/// A one-line roadmap summary for listings (#1030).
2443#[derive(Debug, Clone, PartialEq, Eq)]
2444pub struct RoadmapSummary {
2445    pub id: String,
2446    pub title: String,
2447    pub node_count: usize,
2448}
2449
2450/// A `live_owners` row (#1030) — a process that has a conversation open —
2451/// handed to the [`LivenessFn`] to decide whether it is still LIVE.
2452#[derive(Debug, Clone, PartialEq, Eq)]
2453pub struct StoredOwner {
2454    /// Hostname of the owning process's machine.
2455    pub host: String,
2456    /// Kernel boot id at claim time. A different boot id on the same host means
2457    /// the machine rebooted, so every prior pid is gone (the claim is stale).
2458    pub boot_id: String,
2459    /// OS process id of the owner.
2460    pub pid: i64,
2461    /// The owner's writer fingerprint. Shared per machine (from `identity.pem`),
2462    /// so it is NOT a process-unique key — stored for provenance, not identity.
2463    pub writer_fingerprint: String,
2464    /// Claim-clock tick of the owner's last heartbeat — the freshness signal a
2465    /// cross-host / post-reboot liveness check falls back to.
2466    pub heartbeat_tick: i64,
2467}
2468
2469/// The outcome of [`ConversationStore::claim`] (#1030 collision fix).
2470#[derive(Debug, Clone, PartialEq, Eq)]
2471pub enum ClaimOutcome {
2472    /// This process now owns the conversation — a fresh claim, a re-affirmation
2473    /// of its own claim, or a reclaim of a stale (crashed/rebooted) owner.
2474    Claimed,
2475    /// A DIFFERENT, LIVE process owns it. The fields drive an honest message
2476    /// ("open in another newt, pid N on host H"); the caller must NOT attach.
2477    HeldBy { host: String, pid: i64 },
2478}
2479
2480/// Liveness oracle: is `owner` still a running process, as of `now`? Injectable
2481/// (like the claim clock) so the unit tier is fully mocked — the production
2482/// [`system_liveness`] touches the OS (pid probe + boot id); a test double
2483/// decides from the row alone. A plain `fn`, so it carries no captured state.
2484pub type LivenessFn = fn(owner: &StoredOwner, now: i64) -> bool;
2485
2486/// A held conversation whose owner's last heartbeat is older than this is
2487/// treated as stale (reclaimable) — but ONLY on the fallback path where the pid
2488/// probe is not authoritative (a foreign host, or the same host after a reboot).
2489/// One hour: comfortably longer than the gap between a live session's per-turn
2490/// heartbeats, short enough that a genuinely dead cross-host session frees its
2491/// conversation the same day.
2492const LIVENESS_STALE_AFTER_NANOS: i64 = 3_600 * 1_000_000_000;
2493
2494/// The production [`LivenessFn`]. Same machine and boot: the pid probe is
2495/// authoritative. Otherwise (a foreign host, or this host after a reboot — where
2496/// the stored pid is meaningless) fall back to heartbeat freshness.
2497fn system_liveness(owner: &StoredOwner, now: i64) -> bool {
2498    let (host, boot_id) = current_host_boot();
2499    if owner.host == host && owner.boot_id == boot_id {
2500        return pid_is_alive(owner.pid);
2501    }
2502    now.saturating_sub(owner.heartbeat_tick) < LIVENESS_STALE_AFTER_NANOS
2503}
2504
2505/// Is `pid` a currently-running process? `kill(pid, 0)` delivers no signal but
2506/// performs the existence + permission check: `0` = alive; `EPERM` = alive but
2507/// owned by another user (still alive); `ESRCH` = gone.
2508#[cfg(unix)]
2509fn pid_is_alive(pid: i64) -> bool {
2510    let Ok(pid) = libc::pid_t::try_from(pid) else {
2511        return false;
2512    };
2513    if pid <= 0 {
2514        return false;
2515    }
2516    // SAFETY: `kill` with signal 0 only probes a pid; it never delivers a signal.
2517    let rc = unsafe { libc::kill(pid, 0) };
2518    rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
2519}
2520
2521/// Windows analogue of the `kill(pid, 0)` probe above: `OpenProcess` with the
2522/// lightest query right performs the same "does this pid exist" check without
2523/// acting on the process. A live pid opens; a dead/reused pid fails with
2524/// `ERROR_INVALID_PARAMETER`. `ERROR_ACCESS_DENIED` also means alive — the
2525/// process exists but this process lacks rights to query it, the Windows
2526/// analogue of the Unix `EPERM` case above.
2527#[cfg(windows)]
2528fn pid_is_alive(pid: i64) -> bool {
2529    use windows_sys::Win32::Foundation::{CloseHandle, ERROR_ACCESS_DENIED};
2530    use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
2531
2532    let Ok(pid) = u32::try_from(pid) else {
2533        return false;
2534    };
2535    if pid == 0 {
2536        return false;
2537    }
2538    // SAFETY: `OpenProcess` only queries a handle; it takes no action on the
2539    // target process.
2540    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
2541    if handle.is_null() {
2542        return std::io::Error::last_os_error().raw_os_error() == Some(ERROR_ACCESS_DENIED as i32);
2543    }
2544    // SAFETY: `handle` was just returned by the successful `OpenProcess` call
2545    // above and is not used again after this call.
2546    unsafe { CloseHandle(handle) };
2547    true
2548}
2549
2550/// This machine's `(hostname, kernel boot id)`. Both come from `/proc` (Linux —
2551/// the dev + CI + deploy target) and degrade to `("localhost", "")` off-Linux,
2552/// which simply makes the pid probe the sole liveness signal on the local host.
2553fn current_host_boot() -> (String, String) {
2554    let host = std::fs::read_to_string("/proc/sys/kernel/hostname")
2555        .ok()
2556        .map(|s| s.trim().to_string())
2557        .filter(|s| !s.is_empty())
2558        .unwrap_or_else(|| "localhost".to_string());
2559    let boot_id = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
2560        .map(|s| s.trim().to_string())
2561        .unwrap_or_default();
2562    (host, boot_id)
2563}
2564
2565/// Read a raw `live_owners` row (no liveness judgement). Shared by `claim`
2566/// (inside its `BEGIN IMMEDIATE` txn) and `live_owner`.
2567fn live_owner_row(conn: &Connection, conversation_id: &str) -> anyhow::Result<Option<StoredOwner>> {
2568    conn.query_row(
2569        "SELECT host, boot_id, pid, writer_fingerprint, heartbeat_tick
2570           FROM live_owners WHERE conversation_id = ?1",
2571        rusqlite::params![conversation_id],
2572        |row| {
2573            Ok(StoredOwner {
2574                host: row.get(0)?,
2575                boot_id: row.get(1)?,
2576                pid: row.get(2)?,
2577                writer_fingerprint: row.get(3)?,
2578                heartbeat_tick: row.get(4)?,
2579            })
2580        },
2581    )
2582    .optional()
2583    .map_err(Into::into)
2584}
2585
2586fn claim_to_u128(claim: i64) -> u128 {
2587    claim.max(0) as u128
2588}
2589
2590/// Read a token-count column back to the `u32` it was written from (17.6).
2591/// NULL stays `None` — an unreported count is absence, never zero-dressed-up.
2592/// A value outside `u32` cannot come from `append_turn_full` (which widens
2593/// from `u32`), so it errors as tampering/corruption instead of clamping —
2594/// 18.5 trusts these as measurements.
2595fn tokens_from_sql(value: Option<i64>) -> anyhow::Result<Option<u32>> {
2596    value
2597        .map(|v| {
2598            u32::try_from(v)
2599                .map_err(|_| anyhow::anyhow!("token count {v} out of range (tampered row?)"))
2600        })
2601        .transpose()
2602}
2603
2604/// The conversation-id alphabet (ASCII alphanumeric + '-'), inherited from
2605/// the JSON backend so every legacy id imports unchanged. SQL parameters
2606/// make injection moot; the validation also guarantees ids are pure ASCII,
2607/// which `resolve_id`'s byte-exact `substr` prefix match relies on.
2608fn validate_record_id(id: &str) -> anyhow::Result<()> {
2609    if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
2610        anyhow::bail!("invalid conversation id `{id}`");
2611    }
2612    Ok(())
2613}
2614
2615#[cfg(test)]
2616mod tests {
2617    use super::*;
2618
2619    #[test]
2620    fn wal_fallback_classifier_matches_known_nfs_failures() {
2621        assert!(wal_fallback_eligible("locking protocol"));
2622        assert!(wal_fallback_eligible("disk I/O error"));
2623        assert!(wal_fallback_eligible(
2624            "sqlite failure: `Error code 15: Locking Protocol`"
2625        ));
2626        assert!(!wal_fallback_eligible("no such table: turns"));
2627        assert!(!wal_fallback_eligible("database is locked"));
2628        assert!(!wal_fallback_eligible(""));
2629    }
2630
2631    #[test]
2632    fn canonical_encoding_is_unambiguous_across_field_boundaries() {
2633        let base = TurnRow {
2634            conversation_id: "c".into(),
2635            writer_fingerprint: "w".into(),
2636            seq: 1,
2637            prev_hash: "p".into(),
2638            user: "ab".into(),
2639            assistant: "c".into(),
2640            events: "[]".into(),
2641            tokens_in: None,
2642            tokens_out: None,
2643            ts_claim: 7,
2644            encoding_version: 1,
2645        };
2646        let shifted = TurnRow {
2647            user: "a".into(),
2648            assistant: "bc".into(),
2649            ..clone_row(&base)
2650        };
2651        assert_ne!(
2652            base.canonical_encoding_v1(),
2653            shifted.canonical_encoding_v1(),
2654            "length prefixes must prevent (ab,c) == (a,bc)"
2655        );
2656        // Every field participates in the hash — including the claims and
2657        // token counts, which makes even display fields tamper-evident.
2658        let skewed = TurnRow {
2659            ts_claim: 8,
2660            ..clone_row(&base)
2661        };
2662        assert_ne!(base.content_hash().unwrap(), skewed.content_hash().unwrap());
2663        let tokens = TurnRow {
2664            tokens_in: Some(5),
2665            ..clone_row(&base)
2666        };
2667        assert_ne!(base.content_hash().unwrap(), tokens.content_hash().unwrap());
2668    }
2669
2670    /// N1 (#261): `content_hash` dispatches on the row's recorded encoding
2671    /// version — v1 hashes, anything else errors clearly rather than hashing
2672    /// under the wrong rules.
2673    #[test]
2674    fn content_hash_rejects_unknown_encoding_versions() {
2675        let v1 = TurnRow {
2676            conversation_id: "c".into(),
2677            writer_fingerprint: "w".into(),
2678            seq: 1,
2679            prev_hash: "p".into(),
2680            user: "u".into(),
2681            assistant: "a".into(),
2682            events: "[]".into(),
2683            tokens_in: None,
2684            tokens_out: None,
2685            ts_claim: 7,
2686            encoding_version: 1,
2687        };
2688        v1.content_hash().expect("v1 must hash");
2689
2690        let future = TurnRow {
2691            encoding_version: 2,
2692            ..clone_row(&v1)
2693        };
2694        let err = future.content_hash().unwrap_err().to_string();
2695        assert!(
2696            err.contains("encoding_version 2") && err.contains("known: 1"),
2697            "unknown version must error clearly: {err}"
2698        );
2699    }
2700
2701    #[test]
2702    fn clamp_claim_saturates_oversized_legacy_nanos() {
2703        assert_eq!(clamp_claim(0), 0);
2704        assert_eq!(clamp_claim(42), 42);
2705        assert_eq!(clamp_claim(u128::MAX), i64::MAX);
2706    }
2707
2708    #[test]
2709    fn genesis_hash_is_deterministic_and_writer_scoped() {
2710        assert_eq!(genesis_hash("conv", "w1"), genesis_hash("conv", "w1"));
2711        assert_ne!(genesis_hash("conv", "w1"), genesis_hash("conv", "w2"));
2712        assert_ne!(genesis_hash("conv", "w1"), genesis_hash("other", "w1"));
2713        // Length-prefixing: ("ab","c") must not collide with ("a","bc").
2714        assert_ne!(genesis_hash("ab", "c"), genesis_hash("a", "bc"));
2715    }
2716
2717    #[test]
2718    fn writer_fingerprint_is_stable_per_install_and_distinct_across_installs() {
2719        let root_a = tempfile::tempdir().unwrap();
2720        let root_b = tempfile::tempdir().unwrap();
2721        let first = load_or_create_writer_fingerprint(root_a.path()).unwrap();
2722        let again = load_or_create_writer_fingerprint(root_a.path()).unwrap();
2723        let other = load_or_create_writer_fingerprint(root_b.path()).unwrap();
2724        assert_eq!(first, again, "fingerprint must be stable per install");
2725        assert_ne!(first, other, "two installs must not share a fingerprint");
2726        assert_eq!(first.len(), 64, "blake3 hex");
2727    }
2728
2729    #[test]
2730    fn wal_mode_pairs_with_synchronous_normal_on_the_stores_connection() {
2731        let root = tempfile::tempdir().unwrap();
2732        let workspace = tempfile::tempdir().unwrap();
2733        let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
2734        // `synchronous` is per-connection, so ask the store's own connection
2735        // (a fresh external connection would only show its own default).
2736        let conn = store.lock_conn();
2737        let sync_level: i64 = conn
2738            .query_row("PRAGMA synchronous", [], |row| row.get(0))
2739            .unwrap();
2740        assert_eq!(sync_level, 1, "WAL must run at synchronous=NORMAL (1)");
2741    }
2742
2743    #[test]
2744    fn claim_clock_saturates_instead_of_wrapping() {
2745        let now = now_claim_nanos();
2746        assert!(now > 0);
2747        assert_eq!(claim_to_u128(-5), 0);
2748        assert_eq!(claim_to_u128(42), 42);
2749    }
2750
2751    // --- load_turn: the by-(conv, seq) read for memory_fetch (#319) --------
2752
2753    /// `load_turn` returns one past turn verbatim, addressed by the §6 seq the
2754    /// model saw in a recall hit; an unknown seq / conversation is `Ok(None)`
2755    /// (labelled absence, never an error — the `memory_fetch` tool contract).
2756    #[test]
2757    fn load_turn_reads_one_turn_by_seq_and_misses_are_none() {
2758        let root = tempfile::tempdir().unwrap();
2759        let workspace = tempfile::tempdir().unwrap();
2760        let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
2761        let conv = store.create("t", None).unwrap();
2762        store
2763            .append_turn(&conv, "the question", "the answer")
2764            .unwrap();
2765
2766        // The seq the model would paste comes from a recall hit.
2767        let hits = store.search("question", 5).unwrap();
2768        assert_eq!(hits.len(), 1);
2769        let seq = hits[0].seq;
2770
2771        let turn = store.load_turn(&conv, seq).unwrap().expect("turn exists");
2772        assert_eq!(turn.user, "the question");
2773        assert_eq!(turn.assistant, "the answer");
2774
2775        // Unknown seq → None, not an error.
2776        assert!(store.load_turn(&conv, seq + 9_999).unwrap().is_none());
2777        // Unknown conversation id → None, not an error (no cross-ws leak path).
2778        assert!(store.load_turn("no-such-conv", seq).unwrap().is_none());
2779    }
2780
2781    // --- end_reason: /end · /restart · :wq close-out (17.7 wiring) ---------
2782
2783    /// `end_conversation` marks the row so `latest_open` skips it on
2784    /// auto-resume, while `list` (and therefore `/recall`/`/conversation`)
2785    /// still sees it — ended, not deleted.
2786    #[test]
2787    fn end_conversation_hides_row_from_latest_open_but_not_from_list() {
2788        let root = tempfile::tempdir().unwrap();
2789        let workspace = tempfile::tempdir().unwrap();
2790        let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
2791
2792        let c1 = store.create("first", None).unwrap();
2793        store.append_turn(&c1, "q1", "a1").unwrap();
2794        let c2 = store.create("second", None).unwrap();
2795        store.append_turn(&c2, "q2", "a2").unwrap();
2796
2797        // c2 was written last → highest activity tick → the resume target.
2798        assert_eq!(store.latest_open().unwrap().unwrap().id, c2);
2799
2800        // End c2: latest_open falls back to the prior OPEN conversation…
2801        store.end_conversation(&c2, "wq").unwrap();
2802        assert_eq!(
2803            store.latest_open().unwrap().unwrap().id,
2804            c1,
2805            "an ended conversation is skipped on auto-resume"
2806        );
2807        // …but both rows are still listed (ended ≠ deleted).
2808        assert_eq!(store.list().unwrap().len(), 2);
2809        // …and the ended conversation is still recall-searchable.
2810        assert!(
2811            store
2812                .search("q2", 5)
2813                .unwrap()
2814                .iter()
2815                .any(|h| h.conversation_id == c2),
2816            "ended conversation stays in the FTS index for /recall"
2817        );
2818
2819        // End the last open one too → nothing left to auto-resume → fresh.
2820        store.end_conversation(&c1, "end").unwrap();
2821        assert!(store.latest_open().unwrap().is_none());
2822        assert_eq!(store.list().unwrap().len(), 2, "still listed after ending");
2823    }
2824
2825    /// Ending is metadata, not activity: it must not tick the §6 clock (so it
2826    /// cannot perturb MRU ordering), and re-ending is harmless.
2827    #[test]
2828    fn end_conversation_does_not_tick_activity_and_is_idempotent() {
2829        let root = tempfile::tempdir().unwrap();
2830        let workspace = tempfile::tempdir().unwrap();
2831        let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
2832
2833        let older = store.create("older", None).unwrap();
2834        store.append_turn(&older, "q", "a").unwrap();
2835        let newer = store.create("newer", None).unwrap();
2836        store.append_turn(&newer, "q", "a").unwrap();
2837
2838        let tick_of = |id: &str| -> i64 {
2839            let conn = store.lock_conn();
2840            conn.query_row(
2841                "SELECT activity_tick FROM conversations WHERE id = ?1",
2842                [id],
2843                |row| row.get(0),
2844            )
2845            .unwrap()
2846        };
2847        let before = tick_of(&older);
2848        store.end_conversation(&older, "new").unwrap();
2849        assert_eq!(tick_of(&older), before, "ending must not bump the tick");
2850        // Idempotent: re-ending an already-ended conversation is fine.
2851        store.end_conversation(&older, "new").unwrap();
2852        // `newer` is still open and remains the resume target.
2853        assert_eq!(store.latest_open().unwrap().unwrap().id, newer);
2854    }
2855
2856    // --- 17.3: the query-sanitizer adversarial matrix ---------------------
2857
2858    /// Shorthand: sanitize and unwrap (the input is expected to survive).
2859    fn s(raw: &str) -> String {
2860        sanitize_fts5_query(raw).unwrap()
2861    }
2862
2863    /// The hermes examples: dotted / hyphenated / path-like / colon tokens
2864    /// are auto-quoted so FTS5 reads them as text, not syntax.
2865    #[test]
2866    fn sanitizer_auto_quotes_dotted_hyphenated_and_path_tokens() {
2867        assert_eq!(s("chat-send"), "\"chat-send\"");
2868        assert_eq!(s("P2.2"), "\"P2.2\"");
2869        assert_eq!(s("my-app.config.ts"), "\"my-app.config.ts\"");
2870        assert_eq!(s("src/store.rs"), "\"src/store.rs\"");
2871        assert_eq!(s("tcp:p4d.p4d-ascii:1666"), "\"tcp:p4d.p4d-ascii:1666\"");
2872        assert_eq!(s("issue #246"), "issue \"#246\"");
2873        // Clean barewords pass through untouched — including underscores
2874        // (in FTS5's bareword alphabet) and non-ASCII text.
2875        assert_eq!(s("hello world"), "hello world");
2876        assert_eq!(s("writer_clock"), "writer_clock");
2877        assert_eq!(s("schlüssel wörter"), "schlüssel wörter");
2878    }
2879
2880    #[test]
2881    fn sanitizer_preserves_balanced_phrases_and_drops_dangling_quotes() {
2882        assert_eq!(s("\"exact phrase\" extra"), "\"exact phrase\" extra");
2883        assert_eq!(s("say \"hello world\" now"), "say \"hello world\" now");
2884        // Unbalanced quote: the quote dies, its text survives as terms.
2885        assert_eq!(s("foo \"bar"), "foo bar");
2886        assert_eq!(s("\"unclosed"), "unclosed");
2887        assert_eq!(s("\"a b\" \"c"), "\"a b\" c");
2888        // Phrase content keeps operators/metachars as text (FTS5 allows
2889        // anything but a quote inside a phrase).
2890        assert_eq!(s("\"AND OR\""), "\"AND OR\"");
2891        assert_eq!(s("\"P2.2 chat-send\""), "\"P2.2 chat-send\"");
2892        // Empty / unindexable phrases are dropped, not emitted as "".
2893        let err = sanitize_fts5_query("\"\"").unwrap_err().to_string();
2894        assert!(err.contains("reduced to nothing"), "{err}");
2895        let err = sanitize_fts5_query("\"--\"").unwrap_err().to_string();
2896        assert!(err.contains("reduced to nothing"), "{err}");
2897    }
2898
2899    #[test]
2900    fn sanitizer_trims_dangling_operators() {
2901        assert_eq!(s("foo AND"), "foo");
2902        assert_eq!(s("OR foo"), "foo");
2903        assert_eq!(s("NOT foo"), "foo");
2904        assert_eq!(s("foo AND AND bar"), "foo AND bar");
2905        assert_eq!(s("foo AND OR bar"), "foo AND bar");
2906        assert_eq!(s("AND foo OR"), "foo");
2907        // Valid binary positions survive.
2908        assert_eq!(s("foo OR bar"), "foo OR bar");
2909        assert_eq!(s("foo NOT bar"), "foo NOT bar");
2910        assert_eq!(s("a OR b OR c"), "a OR b OR c");
2911        // Lowercase forms are ordinary terms, not operators.
2912        assert_eq!(s("foo and bar"), "foo and bar");
2913        // Bare AND reduces to nothing → error, not an FTS5 syntax error.
2914        let err = sanitize_fts5_query("AND").unwrap_err().to_string();
2915        assert!(err.contains("reduced to nothing"), "{err}");
2916        // NEAR is reserved by FTS5 — it survives only as a quoted term.
2917        assert_eq!(s("NEAR"), "\"NEAR\"");
2918        assert_eq!(s("near"), "near");
2919    }
2920
2921    #[test]
2922    fn sanitizer_strips_metacharacter_injection() {
2923        assert_eq!(s("(foo OR bar) AND baz"), "foo OR bar AND baz");
2924        assert_eq!(s("foo* ^bar"), "foo bar");
2925        assert_eq!(s("col*umn"), "column");
2926        // A lone quote / star / caret / paren reduces to nothing.
2927        for q in ["\"", "*", "^", "( )", "*^()"] {
2928            let err = sanitize_fts5_query(q).unwrap_err().to_string();
2929            assert!(err.contains("reduced to nothing"), "{q:?}: {err}");
2930        }
2931        // Mid-token quote: unbalanced → stripped; the halves survive.
2932        assert_eq!(s("fo\"o bar"), "fo o bar");
2933        // Punctuation-only tokens are dropped, indexable ones kept.
2934        assert_eq!(s("?? foo !!"), "foo");
2935        assert_eq!(s("foo \u{a0} "), "foo"); // unicode whitespace handled
2936    }
2937
2938    #[test]
2939    fn sanitizer_handles_mixed_phrases_terms_and_operators() {
2940        assert_eq!(
2941            s("\"tuning writeback\" OR coverage-floor"),
2942            "\"tuning writeback\" OR \"coverage-floor\""
2943        );
2944        assert_eq!(
2945            s("error \"chain violation\" NOT P2.2"),
2946            "error \"chain violation\" NOT \"P2.2\""
2947        );
2948        // Operator directly before a phrase works too.
2949        assert_eq!(s("AND \"lead phrase\" tail"), "\"lead phrase\" tail");
2950    }
2951
2952    #[test]
2953    fn sanitizer_errors_on_empty_and_whitespace_queries() {
2954        for q in ["", "   ", "\t\n"] {
2955            let err = sanitize_fts5_query(q).unwrap_err().to_string();
2956            assert!(err.contains("reduced to nothing"), "{q:?}: {err}");
2957        }
2958    }
2959
2960    /// The events-extraction SQL is shared between the triggers and the
2961    /// content view; pin its shape (json_valid guard + coalesce to '').
2962    #[test]
2963    fn events_extract_sql_guards_and_targets_the_seam_keys() {
2964        let sql = events_extract_sql("new.events", "tool");
2965        assert!(sql.contains("json_valid(new.events)"));
2966        assert!(sql.contains("json_each(new.events)"));
2967        assert!(sql.contains("'$.tool'"));
2968        assert!(sql.contains("ELSE '' END"));
2969    }
2970
2971    fn clone_row(row: &TurnRow) -> TurnRow {
2972        TurnRow {
2973            conversation_id: row.conversation_id.clone(),
2974            writer_fingerprint: row.writer_fingerprint.clone(),
2975            seq: row.seq,
2976            prev_hash: row.prev_hash.clone(),
2977            user: row.user.clone(),
2978            assistant: row.assistant.clone(),
2979            events: row.events.clone(),
2980            tokens_in: row.tokens_in,
2981            tokens_out: row.tokens_out,
2982            ts_claim: row.ts_claim,
2983            encoding_version: row.encoding_version,
2984        }
2985    }
2986
2987    // ── #1086: roadmap import must not steal another workspace's row ──────────
2988
2989    /// Two workspaces sharing one `conversations.db` (different workspace
2990    /// keys, same store root) must own their same-id roadmaps independently.
2991    /// Reproduces the steal: before the composite PK, `create_roadmap` in
2992    /// workspace B `INSERT OR REPLACE`d workspace A's row out from under it.
2993    #[test]
2994    fn create_roadmap_is_workspace_fenced_and_never_steals() {
2995        let root = tempfile::TempDir::new().unwrap();
2996        let ws_a = tempfile::TempDir::new().unwrap();
2997        let ws_b = tempfile::TempDir::new().unwrap();
2998        let store_a = ConversationStore::new(root.path(), ws_a.path(), 100).unwrap();
2999        let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
3000
3001        // Same roadmap id in both workspaces (exactly what /roadmap import of a
3002        // shared file into an unrelated workspace does).
3003        let id = "1783727322129749288-shared";
3004        store_a
3005            .create_roadmap(id, "A's roadmap", &crate::plan::Plan::default())
3006            .unwrap();
3007        store_b
3008            .create_roadmap(id, "B's roadmap", &crate::plan::Plan::default())
3009            .unwrap();
3010
3011        // Neither clobbered the other: each workspace still sees its own.
3012        assert_eq!(
3013            store_a.load_roadmap(id).unwrap().unwrap().title,
3014            "A's roadmap",
3015            "workspace A's roadmap must survive B's import of the same id"
3016        );
3017        assert_eq!(
3018            store_b.load_roadmap(id).unwrap().unwrap().title,
3019            "B's roadmap"
3020        );
3021        // Each workspace lists exactly one.
3022        assert_eq!(store_a.list_roadmaps().unwrap().len(), 1);
3023        assert_eq!(store_b.list_roadmaps().unwrap().len(), 1);
3024    }
3025
3026    /// Re-creating a roadmap with the SAME id in the SAME workspace still
3027    /// overwrites in place (the intended `INSERT OR REPLACE` semantics), so the
3028    /// fence does not break same-repo re-import.
3029    #[test]
3030    fn create_roadmap_overwrites_within_the_same_workspace() {
3031        let root = tempfile::TempDir::new().unwrap();
3032        let ws = tempfile::TempDir::new().unwrap();
3033        let store = ConversationStore::new(root.path(), ws.path(), 100).unwrap();
3034        let id = "rm-1";
3035        store
3036            .create_roadmap(id, "first", &crate::plan::Plan::default())
3037            .unwrap();
3038        store
3039            .create_roadmap(id, "second", &crate::plan::Plan::default())
3040            .unwrap();
3041        assert_eq!(store.load_roadmap(id).unwrap().unwrap().title, "second");
3042        assert_eq!(store.list_roadmaps().unwrap().len(), 1);
3043    }
3044
3045    /// The migration rebuilds a legacy id-only-PK `roadmaps` table into the
3046    /// composite key, preserving rows, and is idempotent.
3047    #[test]
3048    fn migrate_roadmaps_pk_rebuilds_legacy_table_losslessly() {
3049        let dir = tempfile::TempDir::new().unwrap();
3050        let conn = Connection::open(dir.path().join("t.db")).unwrap();
3051        // Stand up the OLD schema (id-only PK) and a row.
3052        conn.execute_batch(
3053            "CREATE TABLE roadmaps (
3054                 id TEXT PRIMARY KEY,
3055                 workspace_key TEXT NOT NULL,
3056                 title TEXT NOT NULL DEFAULT '',
3057                 tree TEXT NOT NULL DEFAULT '',
3058                 schema_version INTEGER NOT NULL DEFAULT 1,
3059                 created_at_claim INTEGER NOT NULL DEFAULT 0,
3060                 updated_at_claim INTEGER NOT NULL DEFAULT 0
3061             );
3062             INSERT INTO roadmaps (id, workspace_key, title) VALUES ('x', 'wsA', 'kept');",
3063        )
3064        .unwrap();
3065
3066        migrate_roadmaps_pk(&conn).unwrap();
3067
3068        // The row survived and the PK is now composite.
3069        let sql: String = conn
3070            .query_row(
3071                "SELECT sql FROM sqlite_master WHERE type='table' AND name='roadmaps'",
3072                [],
3073                |r| r.get(0),
3074            )
3075            .unwrap();
3076        assert!(
3077            sql.to_ascii_lowercase()
3078                .contains("primary key (id, workspace_key)"),
3079            "PK must be composite after migration: {sql}"
3080        );
3081        let title: String = conn
3082            .query_row("SELECT title FROM roadmaps WHERE id='x'", [], |r| r.get(0))
3083            .unwrap();
3084        assert_eq!(title, "kept");
3085
3086        // Composite key now admits the same id under a second workspace…
3087        conn.execute(
3088            "INSERT INTO roadmaps (id, workspace_key, title) VALUES ('x', 'wsB', 'other')",
3089            [],
3090        )
3091        .unwrap();
3092        let n: i64 = conn
3093            .query_row("SELECT COUNT(*) FROM roadmaps WHERE id='x'", [], |r| {
3094                r.get(0)
3095            })
3096            .unwrap();
3097        assert_eq!(n, 2, "same id can coexist across workspaces");
3098
3099        // …and a second run is a no-op (idempotent).
3100        migrate_roadmaps_pk(&conn).unwrap();
3101        let n2: i64 = conn
3102            .query_row("SELECT COUNT(*) FROM roadmaps", [], |r| r.get(0))
3103            .unwrap();
3104        assert_eq!(n2, 2);
3105    }
3106
3107    /// A store opened on a db that already went through the migration (or was
3108    /// created fresh, hence composite) leaves the table untouched.
3109    #[test]
3110    fn fresh_store_roadmaps_table_is_already_composite() {
3111        let root = tempfile::TempDir::new().unwrap();
3112        let ws = tempfile::TempDir::new().unwrap();
3113        let _store = ConversationStore::new(root.path(), ws.path(), 100).unwrap();
3114        let conn = Connection::open(root.path().join(DB_FILE)).unwrap();
3115        let sql: String = conn
3116            .query_row(
3117                "SELECT sql FROM sqlite_master WHERE type='table' AND name='roadmaps'",
3118                [],
3119                |r| r.get(0),
3120            )
3121            .unwrap();
3122        assert!(sql
3123            .to_ascii_lowercase()
3124            .contains("primary key (id, workspace_key)"));
3125    }
3126}