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