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