返回 CodeWhale
store.rs
根目录 / crates / memory / src / store.rs
1 use crate::{
2 Error, Result,
3 auth::{Access, Capability},
4 model::*,
5 policy,
6 };
7 use rusqlite::{Connection, OptionalExtension, Row, TransactionBehavior, named_params, params};
8 use std::{
9 collections::BTreeMap,
10 fs,
11 io::Write,
12 path::Path,
13 sync::Arc,
14 time::{Duration, SystemTime, UNIX_EPOCH},
15 };
16 use uuid::Uuid;
17
18 /// Nesting-safe transaction scope for `&self` methods. rusqlite's
19 /// `unchecked_transaction` issues a literal `BEGIN`, which fails inside an
20 /// already-open transaction; engine methods compose inside each other's
21 /// scopes (e.g. `prepare_context` → `recall`), so this issues a `SAVEPOINT`
22 /// instead — a top-level savepoint behaves as a deferred transaction, and a
23 /// nested one rolls back only its own work when dropped uncommitted.
24 pub(crate) struct Tx<'c> {
25 conn: &'c Connection,
26 name: String,
27 done: bool,
28 }
29 static TX_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
30 impl<'c> Tx<'c> {
31 pub(crate) fn begin(conn: &'c Connection) -> Result<Self> {
32 let name = format!(
33 "tx_{}",
34 TX_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
35 );
36 conn.execute_batch(&format!("SAVEPOINT \"{name}\""))?;
37 Ok(Self {
38 conn,
39 name,
40 done: false,
41 })
42 }
43 pub(crate) fn commit(mut self) -> Result<()> {
44 self.conn
45 .execute_batch(&format!("RELEASE \"{}\"", self.name))?;
46 self.done = true;
47 Ok(())
48 }
49 }
50 impl Drop for Tx<'_> {
51 fn drop(&mut self) {
52 if !self.done {
53 let _ = self
54 .conn
55 .execute_batch(&format!("ROLLBACK TO \"{0}\"; RELEASE \"{0}\"", self.name));
56 }
57 }
58 }
59
60 const APP_ID: i64 = 1129794866;
61 const SCHEMA: &str = include_str!("sql/schema.sql");
62 const ELIGIBLE: &str = include_str!("sql/eligible.sql");
63 const SELECT: &str = "m.id,m.revision,m.status,m.payload,m.created_at,m.updated_at,m.content_hash";
64
65 /// Vendor-neutral lifecycle seam. Transport adapters must preserve authorization,
66 /// revision checks, freshness, correction and forgetting, not just store/search.
67 pub trait MemoryBackend: Send {
68 fn capture(&mut self, access: &Access, request: &str, draft: Draft) -> Result<CaptureReceipt>;
69 fn get(&self, access: &Access, id: &str) -> Result<Memory>;
70 fn recall(&self, access: &Access, query: &Recall) -> Result<RecallReport>;
71 fn approve(
72 &mut self,
73 access: &Access,
74 id: &str,
75 revision: i64,
76 validation: Option<&ValidationReceipt>,
77 snapshot: &Snapshot,
78 ) -> Result<Memory>;
79 fn reject(&mut self, access: &Access, id: &str, revision: i64) -> Result<Memory>;
80 fn supersede(
81 &mut self,
82 access: &Access,
83 old: (&str, i64),
84 new: (&str, i64),
85 validation: Option<&ValidationReceipt>,
86 snapshot: &Snapshot,
87 ) -> Result<Memory>;
88 fn forget(&mut self, access: &Access, id: &str, revision: i64) -> Result<ForgetReport>;
89 fn save_checkpoint(
90 &mut self,
91 access: &Access,
92 draft: CheckpointDraft,
93 expected_revision: Option<i64>,
94 snapshot: &Snapshot,
95 ) -> Result<Checkpoint>;
96 fn resume(
97 &self,
98 access: &Access,
99 scope: &Scope,
100 key: &str,
101 snapshot: &Snapshot,
102 ) -> Result<Resume>;
103 }
104
105 pub struct Store {
106 pub(crate) conn: Connection,
107 clock: Arc<dyn Fn() -> i64 + Send + Sync>,
108 }
109 fn now() -> i64 {
110 SystemTime::now()
111 .duration_since(UNIX_EPOCH)
112 .unwrap_or_default()
113 .as_secs() as i64
114 }
115 fn row_memory(row: &Row<'_>) -> rusqlite::Result<Memory> {
116 let json: String = row.get(3)?;
117 let status: String = row.get(2)?;
118 let decode = |e: Box<dyn std::error::Error + Send + Sync>| {
119 rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, e)
120 };
121 Ok(Memory {
122 id: row.get(0)?,
123 revision: row.get(1)?,
124 status: Status::parse(&status).map_err(|e| decode(Box::new(e)))?,
125 draft: serde_json::from_str(&json).map_err(|e| decode(Box::new(e)))?,
126 created_at: row.get(4)?,
127 updated_at: row.get(5)?,
128 content_hash: row.get(6)?,
129 })
130 }
131 fn event(
132 conn: &Connection,
133 access: &Access,
134 id: &str,
135 action: &str,
136 revision: i64,
137 now: i64,
138 ) -> Result<()> {
139 conn.execute(
140 "INSERT INTO events(entity_id,action,actor_hash,revision,at) VALUES(?1,?2,?3,?4,?5)",
141 params![id, action, access.actor_hash(), revision, now],
142 )?;
143 Ok(())
144 }
145 fn get_at(conn: &Connection, access: &Access, id: &str) -> Result<Memory> {
146 access.require(Capability::Read)?;
147 let sql = format!(
148 "SELECT {SELECT} FROM memories m WHERE m.id=?1 AND m.scope IN (SELECT value FROM json_each(?2))"
149 );
150 conn.query_row(&sql, params![id, access.scope_json()?], row_memory)
151 .optional()?
152 .ok_or(Error::NotFound)
153 }
154 pub(crate) fn effective_freshness(
155 conn: &Connection,
156 access: &Access,
157 memory: &Memory,
158 snapshot: &Snapshot,
159 now: i64,
160 ) -> Result<Freshness> {
161 let own = memory.freshness(snapshot, now);
162 if own != Freshness::Current {
163 return Ok(own);
164 }
165 let mut pending = memory.draft.parent_ids.clone();
166 let mut seen = std::collections::BTreeSet::new();
167 while let Some(id) = pending.pop() {
168 if !seen.insert(id.clone()) {
169 continue;
170 }
171 if seen.len() > 4096 {
172 return Ok(Freshness::Unknown);
173 }
174 let parent = match get_at(conn, access, &id) {
175 Ok(m) => m,
176 Err(Error::NotFound) => return Ok(Freshness::Unknown),
177 Err(e) => return Err(e),
178 };
179 let freshness = parent.freshness(snapshot, now);
180 if freshness != Freshness::Current {
181 return Ok(if freshness == Freshness::Inactive {
182 Freshness::Changed
183 } else {
184 freshness
185 });
186 }
187 pending.extend(parent.draft.parent_ids);
188 }
189 Ok(Freshness::Current)
190 }
191 fn require_revision(m: &Memory, expected: i64) -> Result<()> {
192 if m.revision == expected {
193 Ok(())
194 } else {
195 Err(Error::RevisionConflict)
196 }
197 }
198 fn ensure_key_available(
199 conn: &Connection,
200 access: &Access,
201 draft: &Draft,
202 except: Option<&str>,
203 now: i64,
204 ) -> Result<()> {
205 if let Some(key) = &draft.key {
206 let old: Option<(String,i64,Option<i64>)> = conn.query_row(
207 "SELECT id,revision,expires_at FROM memories WHERE scope=?1 AND semantic_key=?2 AND status='active'",
208 params![draft.scope.key()?,key],|r| Ok((r.get(0)?,r.get(1)?,r.get(2)?))).optional()?;
209 if let Some((id, rev, expiry)) = old {
210 if Some(id.as_str()) == except {
211 return Ok(());
212 }
213 if expiry.is_some_and(|t| t <= now) {
214 conn.execute("UPDATE memories SET status='stale',revision=revision+1,updated_at=?2 WHERE id=?1",params![id,now])?;
215 event(conn, access, &id, "expired", rev + 1, now)?;
216 invalidate_descendants(conn, access, &id, now)?;
217 } else {
218 return Err(Error::KeyConflict);
219 }
220 }
221 }
222 Ok(())
223 }
224 fn descendants(conn: &Connection, id: &str) -> Result<Vec<String>> {
225 let mut q = conn.prepare("WITH RECURSIVE children(id) AS (SELECT child_id FROM lineage WHERE parent_id=?1 UNION SELECT l.child_id FROM lineage l JOIN children c ON l.parent_id=c.id) SELECT id FROM children ORDER BY id")?;
226 Ok(q.query_map([id], |r| r.get(0))?
227 .collect::<rusqlite::Result<Vec<_>>>()?)
228 }
229 fn invalidate_descendants(conn: &Connection, access: &Access, id: &str, now: i64) -> Result<()> {
230 for child in descendants(conn, id)? {
231 let m = get_at(conn, access, &child)?;
232 if m.status == Status::Active {
233 conn.execute(
234 "UPDATE memories SET status='stale',revision=revision+1,updated_at=?2 WHERE id=?1",
235 params![child, now],
236 )?;
237 event(
238 conn,
239 access,
240 &child,
241 "parent_invalidated",
242 m.revision + 1,
243 now,
244 )?;
245 }
246 }
247 Ok(())
248 }
249 fn check_activation(
250 conn: &Connection,
251 access: &Access,
252 m: &Memory,
253 receipt: Option<&ValidationReceipt>,
254 snapshot: &Snapshot,
255 now: i64,
256 ) -> Result<()> {
257 if m.status != Status::Candidate {
258 return Err(Error::InvalidState);
259 }
260 let mut projected = m.clone();
261 projected.status = Status::Active;
262 if effective_freshness(conn, access, &projected, snapshot, now)? != Freshness::Current {
263 return Err(Error::InvalidState);
264 }
265 for parent in &m.draft.parent_ids {
266 let p = get_at(conn, access, parent)?;
267 if p.status != Status::Active
268 || effective_freshness(conn, access, &p, snapshot, now)? != Freshness::Current
269 {
270 return Err(Error::InvalidParent);
271 }
272 }
273 if matches!(m.draft.kind, Kind::Procedure | Kind::Lesson) && receipt.is_none() {
274 return Err(Error::ValidationRequired);
275 }
276 if let Some(receipt) = receipt {
277 if !receipt.passed || receipt.content_hash != m.content_hash {
278 return Err(Error::ValidationRequired);
279 }
280 policy::bounded(&receipt.validator, "validator", 256, true)?;
281 policy::bounded(&receipt.evidence_uri, "validation evidence URI", 1024, true)?;
282 policy::ensure_no_secret(&serde_json::to_string(receipt)?)?;
283 }
284 Ok(())
285 }
286 fn activate(
287 conn: &Connection,
288 access: &Access,
289 m: &Memory,
290 receipt: Option<&ValidationReceipt>,
291 now: i64,
292 ) -> Result<()> {
293 conn.execute(
294 "UPDATE memories SET status='active',revision=revision+1,updated_at=?2 WHERE id=?1",
295 params![m.id, now],
296 )?;
297 if let Some(r) = receipt {
298 conn.execute(
299 "INSERT INTO validations(memory_id,receipt) VALUES(?1,?2)",
300 params![m.id, serde_json::to_string(r)?],
301 )?;
302 }
303 event(conn, access, &m.id, "approved", m.revision + 1, now)?;
304 Ok(())
305 }
306 impl Store {
307 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
308 let path = path.as_ref();
309 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty())
310 && !parent.exists()
311 {
312 #[cfg(unix)]
313 {
314 use std::os::unix::fs::DirBuilderExt;
315 fs::DirBuilder::new()
316 .recursive(true)
317 .mode(0o700)
318 .create(parent)?;
319 }
320 #[cfg(not(unix))]
321 fs::create_dir_all(parent)?;
322 }
323 // Explicit local file security. No URI filenames and no extension loading.
324 match fs::symlink_metadata(path) {
325 Ok(meta) => {
326 if meta.file_type().is_symlink() || !meta.is_file() {
327 return Err(Error::Invalid(
328 "database must be a regular, non-symlink file".into(),
329 ));
330 }
331 #[cfg(unix)]
332 {
333 use std::os::unix::fs::PermissionsExt;
334 if meta.permissions().mode() & 0o077 != 0 {
335 return Err(Error::Invalid(
336 "database permissions must exclude group and world access".into(),
337 ));
338 }
339 }
340 }
341 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
342 let mut opts = fs::OpenOptions::new();
343 opts.write(true).create_new(true);
344 #[cfg(unix)]
345 {
346 use std::os::unix::fs::OpenOptionsExt;
347 opts.mode(0o600);
348 }
349 match opts.open(path) {
350 Ok(_) => (),
351 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
352 return Self::open(path);
353 }
354 Err(e) => return Err(e.into()),
355 }
356 }
357 Err(e) => return Err(e.into()),
358 }
359 let conn = Connection::open_with_flags(
360 path,
361 rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
362 )?;
363 Self::from_connection(conn, Arc::new(now))
364 }
365 pub fn in_memory() -> Result<Self> {
366 Self::from_connection(Connection::open_in_memory()?, Arc::new(now))
367 }
368 pub fn in_memory_with_clock(clock: impl Fn() -> i64 + Send + Sync + 'static) -> Result<Self> {
369 Self::from_connection(Connection::open_in_memory()?, Arc::new(clock))
370 }
371 fn from_connection(
372 mut conn: Connection,
373 clock: Arc<dyn Fn() -> i64 + Send + Sync>,
374 ) -> Result<Self> {
375 let app: i64 = conn.query_row("PRAGMA application_id", [], |r| r.get(0))?;
376 let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
377 let count: i64 = conn.query_row(
378 "SELECT count(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'",
379 [],
380 |r| r.get(0),
381 )?;
382 if !((app == 0 && version == 0 && count == 0)
383 || (app == APP_ID && (1..=2).contains(&version)))
384 {
385 return Err(Error::DatabaseMismatch);
386 }
387 conn.busy_timeout(Duration::from_secs(5))?;
388 conn.execute_batch("PRAGMA foreign_keys=ON; PRAGMA secure_delete=ON; PRAGMA temp_store=MEMORY; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
389 if version < 2 {
390 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
391 let v: i64 = tx.query_row("PRAGMA user_version", [], |r| r.get(0))?;
392 if v == 0 {
393 tx.execute_batch(SCHEMA)?;
394 } else if !(1..=2).contains(&v) {
395 return Err(Error::DatabaseMismatch);
396 }
397 if v < 2 {
398 tx.execute_batch(include_str!("sql/migrate_002.sql"))?;
399 }
400 tx.commit()?;
401 }
402 Ok(Self { conn, clock })
403 }
404 pub fn timestamp(&self) -> i64 {
405 (self.clock)()
406 }
407 /// Validate the complete provenance chain, not just this record's own hashes.
408 pub fn freshness(
409 &self,
410 access: &Access,
411 memory: &Memory,
412 snapshot: &Snapshot,
413 ) -> Result<Freshness> {
414 access.read(&memory.draft.scope)?;
415 effective_freshness(&self.conn, access, memory, snapshot, self.timestamp())
416 }
417 pub fn list(&self, access: &Access, after: Option<&str>, limit: usize) -> Result<Vec<Memory>> {
418 access.require(Capability::Read)?;
419 let sql = format!(
420 "SELECT {SELECT} FROM memories m WHERE m.scope IN (SELECT value FROM json_each(?1)) AND m.id>?2 ORDER BY m.id LIMIT ?3"
421 );
422 let mut stmt = self.conn.prepare(&sql)?;
423 Ok(stmt
424 .query_map(
425 params![
426 access.scope_json()?,
427 after.unwrap_or(""),
428 limit.clamp(1, 500) as i64
429 ],
430 row_memory,
431 )?
432 .collect::<rusqlite::Result<Vec<_>>>()?)
433 }
434 pub fn dependency_paths(&self, access: &Access) -> Result<Vec<String>> {
435 access.require(Capability::Read)?;
436 let mut q = self.conn.prepare("SELECT DISTINCT d.path FROM dependencies d JOIN memories m ON m.id=d.memory_id WHERE m.scope IN (SELECT value FROM json_each(?1)) ORDER BY d.path LIMIT 4096")?;
437 Ok(q.query_map([access.scope_json()?], |r| r.get(0))?
438 .collect::<rusqlite::Result<Vec<_>>>()?)
439 }
440 pub fn status(&self, access: &Access) -> Result<serde_json::Value> {
441 access.require(Capability::Read)?;
442 let mut q = self.conn.prepare("SELECT status,count(*) FROM memories WHERE scope IN (SELECT value FROM json_each(?1)) GROUP BY status")?;
443 let counts = q
444 .query_map([access.scope_json()?], |r| {
445 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
446 })?
447 .collect::<rusqlite::Result<BTreeMap<_, _>>>()?;
448 Ok(
449 serde_json::json!({"schema_version":2,"backend":"sqlite-local-v2","counts":counts,"network_calls":false,"encrypted_at_rest":false}),
450 )
451 }
452 pub fn link(
453 &mut self,
454 access: &Access,
455 from: &str,
456 to: &str,
457 relation: Relation,
458 ) -> Result<()> {
459 let now = self.timestamp();
460 let tx = self
461 .conn
462 .transaction_with_behavior(TransactionBehavior::Immediate)?;
463 let a = get_at(&tx, access, from)?;
464 let b = get_at(&tx, access, to)?;
465 access.write(&a.draft.scope, Capability::Link)?;
466 access.write(&b.draft.scope, Capability::Link)?;
467 if a.draft.scope != b.draft.scope || from == to {
468 return Err(Error::Invalid(
469 "relations must join distinct memories in one exact scope".into(),
470 ));
471 }
472 if tx.execute(
473 "INSERT OR IGNORE INTO links(from_id,to_id,relation) VALUES(?1,?2,?3)",
474 params![from, to, relation.as_str()],
475 )? > 0
476 {
477 event(&tx, access, from, "linked", a.revision, now)?;
478 }
479 tx.commit()?;
480 Ok(())
481 }
482 pub fn set_embedding(
483 &mut self,
484 access: &Access,
485 id: &str,
486 expected_hash: &str,
487 embedding: &Embedding,
488 ) -> Result<()> {
489 let normalized = policy::normalize_embedding(embedding)?;
490 let bytes: Vec<u8> = normalized.iter().flat_map(|v| v.to_le_bytes()).collect();
491 let now = self.timestamp();
492 let tx = self
493 .conn
494 .transaction_with_behavior(TransactionBehavior::Immediate)?;
495 let m = get_at(&tx, access, id)?;
496 access.write(&m.draft.scope, Capability::Index)?;
497 if m.content_hash != expected_hash {
498 return Err(Error::RevisionConflict);
499 }
500 tx.execute("INSERT INTO embeddings(memory_id,model,dimensions,content_hash,vector) VALUES(?1,?2,?3,?4,?5) ON CONFLICT(memory_id) DO UPDATE SET model=excluded.model,dimensions=excluded.dimensions,content_hash=excluded.content_hash,vector=excluded.vector",params![id,embedding.model,normalized.len() as i64,expected_hash,bytes])?;
501 event(&tx, access, id, "embedded", m.revision, now)?;
502 tx.commit()?;
503 Ok(())
504 }
505 pub fn reindex(&mut self, access: &Access) -> Result<()> {
506 access.require(Capability::Maintenance)?;
507 let tx = self
508 .conn
509 .transaction_with_behavior(TransactionBehavior::Immediate)?;
510 tx.execute_batch("INSERT INTO memory_fts(memory_fts) VALUES('rebuild'); INSERT INTO memory_grams(memory_grams) VALUES('rebuild'); INSERT INTO memory_fts(memory_fts,rank) VALUES('integrity-check',1); INSERT INTO memory_grams(memory_grams,rank) VALUES('integrity-check',1);")?;
511 tx.commit()?;
512 Ok(())
513 }
514 pub fn export_jsonl(&self, access: &Access, mut output: impl Write) -> Result<usize> {
515 access.require(Capability::Export)?;
516 let _read_snapshot = Tx::begin(&self.conn)?;
517 let mut count = 0;
518 let mut after = None;
519 loop {
520 let batch = self.list(access, after.as_deref(), 500)?;
521 if batch.is_empty() {
522 break;
523 }
524 for memory in &batch {
525 serde_json::to_writer(
526 &mut output,
527 &serde_json::json!({"schema":"codewhale.memory.export.v1","memory":memory}),
528 )?;
529 output.write_all(b"\n")?;
530 count += 1;
531 }
532 after = batch.last().map(|m| m.id.clone());
533 }
534 Ok(count)
535 }
536 /// Mark active expired records and their dependents stale. Expiry is already
537 /// enforced in recall; correctness does not depend on scheduling this sweep.
538 pub fn expire(&mut self, access: &Access) -> Result<usize> {
539 access.require(Capability::Maintenance)?;
540 let now = self.timestamp();
541 let tx = self
542 .conn
543 .transaction_with_behavior(TransactionBehavior::Immediate)?;
544 let ids: Vec<String> = {
545 let mut q = tx.prepare("SELECT id FROM memories WHERE scope IN (SELECT value FROM json_each(?1)) AND status='active' AND expires_at<=?2")?;
546 q.query_map(params![access.scope_json()?, now], |r| r.get(0))?
547 .collect::<rusqlite::Result<Vec<_>>>()?
548 };
549 for id in &ids {
550 let m = get_at(&tx, access, id)?;
551 access.write(&m.draft.scope, Capability::Maintenance)?;
552 tx.execute(
553 "UPDATE memories SET status='stale',revision=revision+1,updated_at=?2 WHERE id=?1",
554 params![id, now],
555 )?;
556 invalidate_descendants(&tx, access, id, now)?;
557 event(&tx, access, id, "expired", m.revision + 1, now)?;
558 }
559 tx.commit()?;
560 Ok(ids.len())
561 }
562 }
563
564 impl MemoryBackend for Store {
565 fn capture(
566 &mut self,
567 access: &Access,
568 request: &str,
569 mut draft: Draft,
570 ) -> Result<CaptureReceipt> {
571 access.write(&draft.scope, Capability::Propose)?;
572 policy::bounded(request, "request id", 1024, true)?;
573 let now = self.timestamp();
574 draft.title = draft.title.trim().to_owned();
575 draft.body = draft.body.trim().to_owned();
576 draft.tags.sort();
577 draft.tags.dedup();
578 draft.parent_ids.sort();
579 draft.parent_ids.dedup();
580 policy::validate_draft(&draft, now)?;
581 let scope = draft.scope.key()?;
582 let request_hash = policy::sha256(request.as_bytes());
583 let request_draft_hash = policy::sha256(&serde_json::to_vec(&draft)?);
584 let tx = self
585 .conn
586 .transaction_with_behavior(TransactionBehavior::Immediate)?;
587 let existing: Option<(String, Option<String>)> = tx
588 .query_row(
589 "SELECT draft_hash,memory_id FROM requests WHERE scope=?1 AND request_hash=?2",
590 params![scope, request_hash],
591 |r| Ok((r.get(0)?, r.get(1)?)),
592 )
593 .optional()?;
594 if let Some((old_hash, id)) = existing {
595 if old_hash != request_draft_hash {
596 return Err(Error::IdempotencyConflict);
597 }
598 let id = id.ok_or(Error::Forgotten)?;
599 let memory = get_at(&tx, access, &id)?;
600 return Ok(CaptureReceipt {
601 memory,
602 created: false,
603 });
604 }
605 let content_hash = policy::content_hash(&draft)?;
606 let forgotten: bool = tx.query_row(
607 "SELECT EXISTS(SELECT 1 FROM tombstones WHERE scope=?1 AND content_hash=?2)",
608 params![scope, content_hash],
609 |r| r.get(0),
610 )?;
611 if forgotten {
612 return Err(Error::Forgotten);
613 }
614 for parent in &draft.parent_ids {
615 let p = get_at(&tx, access, parent)?;
616 if p.draft.scope != draft.scope {
617 return Err(Error::Invalid(
618 "derived memories cannot cross scope boundaries".into(),
619 ));
620 }
621 if let Some(expiry) = p.draft.expires_at {
622 draft.expires_at = Some(draft.expires_at.map_or(expiry, |e| e.min(expiry)));
623 }
624 // Inherit dependency fingerprints, not merely prose. Otherwise a
625 // summary could remain "fresh" after its supporting file changed.
626 for (path, digest) in &p.draft.dependencies {
627 if draft.dependencies.get(path).is_some_and(|h| h != digest) {
628 return Err(Error::InvalidParent);
629 }
630 draft.dependencies.insert(path.clone(), digest.clone());
631 }
632 if draft.repository_revision.is_none() {
633 draft.repository_revision = p.draft.repository_revision.clone();
634 } else if p.draft.dependencies.is_empty()
635 && p.draft.repository_revision.is_some()
636 && p.draft.repository_revision != draft.repository_revision
637 {
638 return Err(Error::InvalidParent);
639 }
640 }
641 if draft.expires_at.is_some_and(|t| t <= now) {
642 return Err(Error::InvalidParent);
643 }
644 policy::validate_draft(&draft, now)?;
645 let id = Uuid::new_v4().to_string();
646 tx.execute("INSERT INTO memories(id,scope,kind,semantic_key,status,revision,title,body,tags,payload,content_hash,repo_revision,importance,confidence,created_at,updated_at,expires_at) VALUES(?1,?2,?3,?4,'candidate',1,?5,?6,?7,?8,?9,?10,?11,?12,?13,?13,?14)",
647 params![id,scope,draft.kind.as_str(),draft.key,draft.title,draft.body,draft.tags.join(" "),serde_json::to_string(&draft)?,content_hash,draft.repository_revision,draft.importance,draft.confidence,now,draft.expires_at])?;
648 for (path, digest) in &draft.dependencies {
649 tx.execute(
650 "INSERT INTO dependencies(memory_id,path,digest) VALUES(?1,?2,?3)",
651 params![id, path, digest],
652 )?;
653 }
654 for parent in &draft.parent_ids {
655 tx.execute(
656 "INSERT INTO lineage(parent_id,child_id) VALUES(?1,?2)",
657 params![parent, id],
658 )?;
659 }
660 tx.execute(
661 "INSERT INTO requests(scope,request_hash,draft_hash,memory_id) VALUES(?1,?2,?3,?4)",
662 params![scope, request_hash, request_draft_hash, id],
663 )?;
664 event(&tx, access, &id, "proposed", 1, now)?;
665 let memory = get_at(&tx, access, &id)?;
666 tx.commit()?;
667 Ok(CaptureReceipt {
668 memory,
669 created: true,
670 })
671 }
672 fn get(&self, access: &Access, id: &str) -> Result<Memory> {
673 get_at(&self.conn, access, id)
674 }
675 fn approve(
676 &mut self,
677 access: &Access,
678 id: &str,
679 revision: i64,
680 validation: Option<&ValidationReceipt>,
681 snapshot: &Snapshot,
682 ) -> Result<Memory> {
683 let now = self.timestamp();
684 let tx = self
685 .conn
686 .transaction_with_behavior(TransactionBehavior::Immediate)?;
687 let m = get_at(&tx, access, id)?;
688 access.write(&m.draft.scope, Capability::Review)?;
689 require_revision(&m, revision)?;
690 check_activation(&tx, access, &m, validation, snapshot, now)?;
691 ensure_key_available(&tx, access, &m.draft, None, now)?;
692 activate(&tx, access, &m, validation, now)?;
693 let updated = get_at(&tx, access, id)?;
694 tx.commit()?;
695 Ok(updated)
696 }
697 fn reject(&mut self, access: &Access, id: &str, revision: i64) -> Result<Memory> {
698 let now = self.timestamp();
699 let tx = self
700 .conn
701 .transaction_with_behavior(TransactionBehavior::Immediate)?;
702 let m = get_at(&tx, access, id)?;
703 access.write(&m.draft.scope, Capability::Review)?;
704 require_revision(&m, revision)?;
705 if m.status != Status::Candidate {
706 return Err(Error::InvalidState);
707 }
708 tx.execute(
709 "UPDATE memories SET status='rejected',revision=revision+1,updated_at=?2 WHERE id=?1",
710 params![id, now],
711 )?;
712 event(&tx, access, id, "rejected", m.revision + 1, now)?;
713 let updated = get_at(&tx, access, id)?;
714 tx.commit()?;
715 Ok(updated)
716 }
717 fn supersede(
718 &mut self,
719 access: &Access,
720 old: (&str, i64),
721 new: (&str, i64),
722 validation: Option<&ValidationReceipt>,
723 snapshot: &Snapshot,
724 ) -> Result<Memory> {
725 let now = self.timestamp();
726 let tx = self
727 .conn
728 .transaction_with_behavior(TransactionBehavior::Immediate)?;
729 let a = get_at(&tx, access, old.0)?;
730 let b = get_at(&tx, access, new.0)?;
731 access.write(&a.draft.scope, Capability::Correct)?;
732 access.write(&b.draft.scope, Capability::Review)?;
733 require_revision(&a, old.1)?;
734 require_revision(&b, new.1)?;
735 if old.0 == new.0
736 || a.draft.scope != b.draft.scope
737 || a.draft.key != b.draft.key
738 || a.draft.kind != b.draft.kind
739 || !matches!(a.status, Status::Active | Status::Stale)
740 {
741 return Err(Error::InvalidState);
742 }
743 if descendants(&tx, old.0)?.iter().any(|id| id == new.0) {
744 return Err(Error::Invalid(
745 "a correction cannot depend on the memory it invalidates".into(),
746 ));
747 }
748 check_activation(&tx, access, &b, validation, snapshot, now)?;
749 ensure_key_available(&tx, access, &b.draft, Some(old.0), now)?;
750 tx.execute(
751 "UPDATE memories SET status='superseded',revision=revision+1,updated_at=?2 WHERE id=?1",
752 params![old.0, now],
753 )?;
754 invalidate_descendants(&tx, access, old.0, now)?;
755 tx.execute(
756 "INSERT INTO replacements(old_id,new_id) VALUES(?1,?2)",
757 params![old.0, new.0],
758 )?;
759 activate(&tx, access, &b, validation, now)?;
760 event(&tx, access, old.0, "superseded", a.revision + 1, now)?;
761 let updated = get_at(&tx, access, new.0)?;
762 tx.commit()?;
763 Ok(updated)
764 }
765 fn forget(&mut self, access: &Access, id: &str, revision: i64) -> Result<ForgetReport> {
766 let now = self.timestamp();
767 let tx = self
768 .conn
769 .transaction_with_behavior(TransactionBehavior::Immediate)?;
770 let root = get_at(&tx, access, id)?;
771 access.write(&root.draft.scope, Capability::Forget)?;
772 require_revision(&root, revision)?;
773 let ids: Vec<String> = {
774 let mut q = tx.prepare(include_str!("sql/forget_closure.sql"))?;
775 q.query_map(named_params! {":id":id}, |r| r.get(0))?
776 .collect::<rusqlite::Result<Vec<_>>>()?
777 };
778 for id in &ids {
779 let m = get_at(&tx, access, id)?;
780 access.write(&m.draft.scope, Capability::Forget)?;
781 tx.execute("INSERT OR IGNORE INTO tombstones(scope,content_hash,forgotten_at) VALUES(?1,?2,?3)",params![m.draft.scope.key()?,m.content_hash,now])?;
782 event(&tx, access, id, "forgotten", m.revision + 1, now)?;
783 }
784 let ids_json = serde_json::to_string(&ids)?;
785 // A cited memory retracts dependent checkpoints, including their summaries.
786 // Scope-crossing checkpoint references are same-tenant/user only (Access).
787 let checkpoints_deleted=tx.execute("DELETE FROM checkpoints WHERE id IN (SELECT checkpoint_id FROM checkpoint_refs WHERE memory_id IN (SELECT value FROM json_each(?1)))",[&ids_json])?;
788 let memories_deleted = tx.execute(
789 "DELETE FROM memories WHERE id IN (SELECT value FROM json_each(?1))",
790 [&ids_json],
791 )?;
792 tx.commit()?;
793 // A busy reader may retain the WAL. Logical deletion still succeeded;
794 // report that condition instead of claiming physical erasure or failing
795 // the already-committed operation.
796 let wal_truncated = self
797 .conn
798 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| {
799 r.get::<_, i64>(0)
800 })
801 .map(|busy| busy == 0)
802 .unwrap_or(false);
803 Ok(ForgetReport {
804 memories_deleted,
805 checkpoints_deleted,
806 wal_truncated,
807 physical_erasure_guaranteed: false,
808 })
809 }
810 fn recall(&self, access: &Access, query: &Recall) -> Result<RecallReport> {
811 self.recall_impl(access, query)
812 }
813 fn save_checkpoint(
814 &mut self,
815 access: &Access,
816 mut draft: CheckpointDraft,
817 expected_revision: Option<i64>,
818 snapshot: &Snapshot,
819 ) -> Result<Checkpoint> {
820 access.write(&draft.scope, Capability::Checkpoint)?;
821 if draft.scope.session.is_none() {
822 return Err(Error::Invalid(
823 "working state requires a session scope".into(),
824 ));
825 }
826 policy::bounded(&draft.key, "checkpoint key", 256, true)?;
827 policy::bounded(&draft.state.summary, "checkpoint summary", 8192, false)?;
828 if draft.memory_ids.len() > 128
829 || draft.state.next_steps.len() > 64
830 || draft.state.artifact_refs.len() > 64
831 || draft.state.pending_operations.len() > 64
832 {
833 return Err(Error::Invalid(
834 "checkpoint collection limit exceeded".into(),
835 ));
836 }
837 for item in draft
838 .state
839 .next_steps
840 .iter()
841 .chain(draft.state.artifact_refs.iter())
842 {
843 policy::bounded(item, "checkpoint item", 1024, true)?;
844 }
845 for op in &draft.state.pending_operations {
846 policy::bounded(&op.operation_id, "operation id", 256, true)?;
847 policy::bounded(&op.state, "operation state", 128, true)?;
848 }
849 let encoded = serde_json::to_string(&draft)?;
850 policy::bounded(&encoded, "checkpoint", 32768, false)?;
851 policy::ensure_no_secret(&encoded)?;
852 draft.memory_ids.sort();
853 draft.memory_ids.dedup();
854 let now = self.timestamp();
855 let expires = draft.expires_at.unwrap_or(now.saturating_add(7 * 86400));
856 if expires <= now {
857 return Err(Error::Invalid(
858 "checkpoint expiry must be in the future".into(),
859 ));
860 }
861 let tx = self
862 .conn
863 .transaction_with_behavior(TransactionBehavior::Immediate)?;
864 let old: Option<(String, i64)> = tx
865 .query_row(
866 "SELECT id,revision FROM checkpoints WHERE scope=?1 AND checkpoint_key=?2",
867 params![draft.scope.key()?, draft.key],
868 |r| Ok((r.get(0)?, r.get(1)?)),
869 )
870 .optional()?;
871 let (id, revision) = match old {
872 None if expected_revision.is_none() => (Uuid::new_v4().to_string(), 1),
873 Some((id, rev)) if expected_revision == Some(rev) => (id, rev + 1),
874 _ => return Err(Error::RevisionConflict),
875 };
876 let mut memories = Vec::new();
877 for mid in &draft.memory_ids {
878 let m = get_at(&tx, access, mid)?;
879 if effective_freshness(&tx, access, &m, snapshot, now)? != Freshness::Current {
880 return Err(Error::InvalidParent);
881 }
882 memories.push(MemoryRef {
883 id: m.id,
884 revision: m.revision,
885 content_hash: m.content_hash,
886 });
887 }
888 tx.execute("INSERT INTO checkpoints(id,scope,checkpoint_key,revision,payload,updated_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(scope,checkpoint_key) DO UPDATE SET revision=excluded.revision,payload=excluded.payload,updated_at=excluded.updated_at,expires_at=excluded.expires_at",params![id,draft.scope.key()?,draft.key,revision,serde_json::to_string(&draft)?,now,expires])?;
889 tx.execute("DELETE FROM checkpoint_refs WHERE checkpoint_id=?1", [&id])?;
890 for m in &memories {
891 tx.execute("INSERT INTO checkpoint_refs(checkpoint_id,memory_id,revision,content_hash) VALUES(?1,?2,?3,?4)",params![id,m.id,m.revision,m.content_hash])?;
892 }
893 event(&tx, access, &id, "checkpoint_saved", revision, now)?;
894 tx.commit()?;
895 Ok(Checkpoint {
896 id,
897 revision,
898 draft,
899 memories,
900 updated_at: now,
901 expires_at: expires,
902 })
903 }
904 fn resume(
905 &self,
906 access: &Access,
907 scope: &Scope,
908 key: &str,
909 snapshot: &Snapshot,
910 ) -> Result<Resume> {
911 access.read(scope)?;
912 let _read_snapshot = Tx::begin(&self.conn)?;
913 let row: Option<(String,i64,String,i64,i64)>=self.conn.query_row("SELECT id,revision,payload,updated_at,expires_at FROM checkpoints WHERE scope=?1 AND checkpoint_key=?2 AND expires_at>?3",params![scope.key()?,key,self.timestamp()],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?;
914 let (id, revision, payload, updated_at, expires_at) = row.ok_or(Error::NotFound)?;
915 let mut q=self.conn.prepare("SELECT memory_id,revision,content_hash FROM checkpoint_refs WHERE checkpoint_id=?1 ORDER BY memory_id")?;
916 let memories = q
917 .query_map([&id], |r| {
918 Ok(MemoryRef {
919 id: r.get(0)?,
920 revision: r.get(1)?,
921 content_hash: r.get(2)?,
922 })
923 })?
924 .collect::<rusqlite::Result<Vec<_>>>()?;
925 let mut invalidated = Vec::new();
926 for reference in &memories {
927 match self.get(access, &reference.id) {
928 Ok(m) => {
929 let current = m.revision == reference.revision
930 && m.content_hash == reference.content_hash
931 && effective_freshness(&self.conn, access, &m, snapshot, self.timestamp())?
932 == Freshness::Current;
933 if !current {
934 invalidated.push(reference.id.clone());
935 }
936 }
937 Err(Error::NotFound) => invalidated.push(reference.id.clone()),
938 Err(error) => return Err(error),
939 }
940 }
941 Ok(Resume {
942 checkpoint: Checkpoint {
943 id,
944 revision,
945 draft: serde_json::from_str(&payload)?,
946 memories,
947 updated_at,
948 expires_at,
949 },
950 invalidated_memory_ids: invalidated,
951 reconcile_pending_operations: true,
952 })
953 }
954 }
955
956 fn add_ranking(
957 target: &mut BTreeMap<String, Hit>,
958 rows: Vec<Memory>,
959 reason: &str,
960 weight: f64,
961 snapshot: &Snapshot,
962 now: i64,
963 ) {
964 for (rank, memory) in rows.into_iter().enumerate() {
965 let freshness = memory.freshness(snapshot, now);
966 let score = weight / (60.0 + rank as f64 + 1.0);
967 let hit = target.entry(memory.id.clone()).or_insert(Hit {
968 memory,
969 freshness,
970 score: 0.0,
971 reasons: Vec::new(),
972 });
973 hit.score += score;
974 hit.reasons.push(reason.to_string());
975 }
976 }
977 impl Store {
978 fn recall_impl(&self, access: &Access, query: &Recall) -> Result<RecallReport> {
979 access.require(Capability::Read)?;
980 policy::bounded(&query.query, "query", 1024, false)?;
981 let scopes = access.scope_json()?;
982 let files = serde_json::to_string(&query.snapshot.files)?;
983 let now = self.timestamp();
984 let limit = query.limit.clamp(1, 64);
985 let candidates = (limit * 8).clamp(64, 512) as i64;
986 let include_stale = if query.include_stale { 1_i64 } else { 0_i64 };
987 // All retrieval branches share a consistent read transaction. A later
988 // use still needs a revision/freshness check at the host execution boundary.
989 let _read_snapshot = Tx::begin(&self.conn)?;
990 let mut hits = BTreeMap::new();
991 let run_text = |sql: &str, text: &str| -> Result<Vec<Memory>> {
992 let mut q = self.conn.prepare(sql)?;
993 Ok(q.query_map(named_params!{":scopes":scopes,":now":now,":include_stale":include_stale,":files":files,":repo":query.snapshot.revision,":query":text,":limit":candidates},row_memory)?.collect::<rusqlite::Result<Vec<_>>>()?)
994 };
995 if let Some(fts) = policy::fts_query(&query.query)? {
996 let sql = format!(
997 "SELECT {SELECT} FROM memory_fts JOIN memories m ON m.rowid=memory_fts.rowid WHERE ({ELIGIBLE}) AND memory_fts MATCH :query ORDER BY bm25(memory_fts,3.0,1.0,2.0),m.id LIMIT :limit"
998 );
999 add_ranking(
1000 &mut hits,
1001 run_text(&sql, &fts)?,
1002 "lexical",
1003 1.0,
1004 &query.snapshot,
1005 now,
1006 );
1007 let chars = query.query.trim().chars().count();
1008 if (3..=256).contains(&chars) {
1009 let sql = format!(
1010 "SELECT {SELECT} FROM memory_grams JOIN memories m ON m.rowid=memory_grams.rowid WHERE ({ELIGIBLE}) AND memory_grams MATCH :query ORDER BY bm25(memory_grams,2.0,1.0),m.id LIMIT :limit"
1011 );
1012 let literal = format!("\"{}\"", query.query.trim().replace('"', "\"\""));
1013 add_ranking(
1014 &mut hits,
1015 run_text(&sql, &literal)?,
1016 "substring",
1017 0.6,
1018 &query.snapshot,
1019 now,
1020 );
1021 } else if chars <= 2 {
1022 let sql = format!(
1023 "SELECT {SELECT} FROM memories m WHERE ({ELIGIBLE}) AND instr(lower(m.title || ' ' || m.body),lower(:query))>0 ORDER BY m.importance DESC,m.id LIMIT :limit"
1024 );
1025 add_ranking(
1026 &mut hits,
1027 run_text(&sql, query.query.trim())?,
1028 "short_substring",
1029 0.5,
1030 &query.snapshot,
1031 now,
1032 );
1033 }
1034 } else if query.query.trim().is_empty() && query.embedding.is_none() {
1035 let sql = format!(
1036 "SELECT {SELECT} FROM memories m WHERE ({ELIGIBLE}) AND :query='' ORDER BY m.importance DESC,m.updated_at DESC,m.id LIMIT :limit"
1037 );
1038 add_ranking(
1039 &mut hits,
1040 run_text(&sql, "")?,
1041 "working_set",
1042 1.0,
1043 &query.snapshot,
1044 now,
1045 );
1046 }
1047 let mut vector_candidates = 0;
1048 let mut vector_scan_truncated = false;
1049 if let Some(embedding) = &query.embedding {
1050 let normalized = policy::normalize_embedding(embedding)?;
1051 let scan_limit = query.vector_scan_limit.clamp(1, 20_000);
1052 let sql = format!(
1053 "SELECT {SELECT},e.vector FROM embeddings e JOIN memories m ON m.id=e.memory_id WHERE ({ELIGIBLE}) AND e.model=:model AND e.dimensions=:dimensions AND e.content_hash=m.content_hash ORDER BY m.id LIMIT :limit"
1054 );
1055 let mut q = self.conn.prepare(&sql)?;
1056 let rows=q.query_map(named_params!{":scopes":scopes,":now":now,":include_stale":include_stale,":files":files,":repo":query.snapshot.revision,":model":embedding.model,":dimensions":normalized.len() as i64,":limit":(scan_limit+1) as i64},|r|Ok((row_memory(r)?,r.get::<_,Vec<u8>>(7)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
1057 vector_scan_truncated = rows.len() > scan_limit;
1058 let mut dense = Vec::new();
1059 for (m, bytes) in rows.into_iter().take(scan_limit) {
1060 vector_candidates += 1;
1061 if bytes.len() != normalized.len() * 4 {
1062 return Err(Error::Invalid("stored embedding length mismatch".into()));
1063 }
1064 let mut cosine = 0.0_f64;
1065 for (chunk, q) in bytes.as_chunks::<4>().0.iter().zip(&normalized) {
1066 let v = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
1067 if !v.is_finite() {
1068 return Err(Error::Invalid("stored embedding is non-finite".into()));
1069 }
1070 cosine += v as f64 * *q as f64;
1071 }
1072 if cosine > 0.0 {
1073 dense.push((m, cosine));
1074 }
1075 }
1076 dense.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.id.cmp(&b.0.id)));
1077 add_ranking(
1078 &mut hits,
1079 dense
1080 .into_iter()
1081 .take(candidates as usize)
1082 .map(|(m, _)| m)
1083 .collect(),
1084 "semantic",
1085 1.0,
1086 &query.snapshot,
1087 now,
1088 );
1089 }
1090 if query.expand_graph && !hits.is_empty() {
1091 let mut ordered: Vec<_> = hits.values().collect();
1092 ordered.sort_by(|a, b| {
1093 b.score
1094 .total_cmp(&a.score)
1095 .then_with(|| a.memory.id.cmp(&b.memory.id))
1096 });
1097 let seeds = serde_json::to_string(
1098 &ordered
1099 .iter()
1100 .take(8)
1101 .map(|h| &h.memory.id)
1102 .collect::<Vec<_>>(),
1103 )?;
1104 let sql = format!(
1105 "SELECT DISTINCT {SELECT} FROM memories m JOIN links l ON ((l.from_id IN (SELECT value FROM json_each(:seeds)) AND l.to_id=m.id) OR (l.to_id IN (SELECT value FROM json_each(:seeds)) AND l.from_id=m.id)) WHERE ({ELIGIBLE}) ORDER BY m.id LIMIT 32"
1106 );
1107 let mut q = self.conn.prepare(&sql)?;
1108 let rows=q.query_map(named_params!{":scopes":scopes,":now":now,":include_stale":include_stale,":files":files,":repo":query.snapshot.revision,":seeds":seeds},row_memory)?.collect::<rusqlite::Result<Vec<_>>>()?;
1109 add_ranking(
1110 &mut hits,
1111 rows,
1112 "graph_neighbor",
1113 0.25,
1114 &query.snapshot,
1115 now,
1116 );
1117 }
1118 let mut hits: Vec<_> = hits.into_values().collect();
1119 for hit in &mut hits {
1120 hit.freshness =
1121 effective_freshness(&self.conn, access, &hit.memory, &query.snapshot, now)?;
1122 // Small, explainable tie-breakers. Retrieval never writes reinforcement
1123 // or turns model confidence into a probability of correctness.
1124 hit.score += 0.002 * hit.memory.draft.importance + 0.0005 * hit.memory.draft.confidence;
1125 }
1126 hits.sort_by(|a, b| {
1127 b.score
1128 .total_cmp(&a.score)
1129 .then_with(|| a.memory.id.cmp(&b.memory.id))
1130 });
1131 hits.truncate(limit);
1132 Ok(RecallReport {
1133 hits,
1134 vector_candidates,
1135 vector_scan_truncated,
1136 })
1137 }
1138 }
1139
1139 lines RUST