1use anyhow::{anyhow, Result};
2use rusqlite::{params, Connection};
3
4use crate::memory::state_key::StateKeyDecision;
5
6pub const SHORT_CURRENT_TTL_SECONDS: i64 = 24 * 60 * 60;
7pub const BRANCH_SNAPSHOT_TTL_SECONDS: i64 = 7 * 24 * 60 * 60;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum MemoryLifecycleOp {
11 Add,
12 Update,
13 Invalidate,
14 Noop,
15 Defer,
16 Conflict,
17}
18
19impl MemoryLifecycleOp {
20 pub fn as_str(self) -> &'static str {
21 match self {
22 Self::Add => "add",
23 Self::Update => "update",
24 Self::Invalidate => "invalidate",
25 Self::Noop => "noop",
26 Self::Defer => "defer",
27 Self::Conflict => "conflict",
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct LifecycleOutcome {
34 pub op: MemoryLifecycleOp,
35 pub memory_id: Option<i64>,
36 pub superseded: usize,
37 pub noop: bool,
38 pub deferred: bool,
39 pub reason: Option<String>,
40}
41
42#[allow(clippy::too_many_arguments)]
43pub fn apply_add(
44 conn: &Connection,
45 session_id: Option<&str>,
46 project: &str,
47 topic_key: Option<&str>,
48 title: &str,
49 content: &str,
50 memory_type: &str,
51 files: Option<&str>,
52 branch: Option<&str>,
53 scope: &str,
54) -> Result<LifecycleOutcome> {
55 let memory_id = crate::memory::insert_memory_full(
56 conn,
57 session_id,
58 project,
59 topic_key,
60 title,
61 content,
62 memory_type,
63 files,
64 branch,
65 scope,
66 None,
67 )?;
68 Ok(LifecycleOutcome {
69 op: MemoryLifecycleOp::Add,
70 memory_id: Some(memory_id),
71 superseded: 0,
72 noop: false,
73 deferred: false,
74 reason: None,
75 })
76}
77
78#[allow(clippy::too_many_arguments)]
79pub fn apply_update(
80 conn: &Connection,
81 session_id: Option<&str>,
82 project: &str,
83 topic_key: &str,
84 title: &str,
85 content: &str,
86 memory_type: &str,
87 files: Option<&str>,
88 branch: Option<&str>,
89 scope: &str,
90 superseded_ids: &[i64],
91) -> Result<LifecycleOutcome> {
92 let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
93 let ownership = lifecycle_ownership(project, scope);
94 let state_key =
95 crate::memory::state_key::derive_state_key(memory_type, Some(topic_key), title, content);
96 let mut superseded_targets = superseded_ids.to_vec();
97 superseded_targets.extend(find_active_same_state_or_topic(
98 &tx,
99 project,
100 branch,
101 scope,
102 &ownership,
103 memory_type,
104 topic_key,
105 state_key.as_ref(),
106 )?);
107 superseded_targets.sort_unstable();
108 superseded_targets.dedup();
109 if let Some(matched) =
110 crate::memory::poisoning::scan_instruction_pattern(&format!("{title}\n{content}"))
111 {
112 anyhow::bail!(
113 "lifecycle update payload matched instruction-pattern {}@{}",
114 matched.pattern_id,
115 matched.pattern_set_version
116 );
117 }
118 let superseded_json = serde_json::to_string(&superseded_targets)?;
119 let payload_sha256 = crate::memory::activation::payload_sha256(&[
120 project,
121 topic_key,
122 title,
123 content,
124 memory_type,
125 files.unwrap_or(""),
126 branch.unwrap_or(""),
127 scope,
128 &superseded_json,
129 ]);
130 let request = crate::memory::activation::ActiveMemoryWriteRequest {
131 activation_id: crate::memory::activation::ephemeral_activation_id(
132 "lifecycle-update",
133 &payload_sha256,
134 ),
135 route_kind: crate::memory::activation::ActivationRouteKind::RustApi,
136 actor_kind: crate::memory::activation::ActivationActorKind::RustApi,
137 source_operation: "memory_lifecycle_update".to_string(),
138 source_trust: crate::memory::poisoning::SourceTrustClass::LocalToolOutput,
139 result_source_trust: crate::memory::poisoning::SourceTrustClass::LocalToolOutput,
140 source_project: ownership.source_project.to_string(),
141 route: crate::memory::activation::ActiveMemoryRoute {
142 project: project.to_string(),
143 branch: branch.map(str::to_string),
144 scope: scope.to_string(),
145 owner_scope: ownership.owner_scope.to_string(),
146 owner_key: ownership.owner_key.to_string(),
147 target_project: ownership.target_project.map(str::to_string),
148 },
149 provenance_kind: crate::memory::activation::ActivationProvenanceKind::RustApi,
150 provenance_ref: "rust-api:lifecycle-update:v1".to_string(),
151 payload_sha256,
152 expected_memory: crate::memory::activation::ExpectedActiveMemory::new(
153 title,
154 content,
155 memory_type,
156 )
157 .with_topic_key(Some(topic_key))
158 .with_files(files),
159 poisoning_verdict: crate::memory::activation::ActivationPoisoningVerdict::Clean,
160 superseded_ids: superseded_targets.clone(),
161 };
162 let mut superseded = None;
163 let activation_result = crate::memory::activation::execute_one(&tx, &request, |permit| {
164 let memory_id = insert_replacement_memory(
165 &tx,
166 permit,
167 session_id,
168 project,
169 topic_key,
170 title,
171 content,
172 memory_type,
173 files,
174 branch,
175 scope,
176 &ownership,
177 state_key.as_ref(),
178 )?;
179 let count = soft_supersede_lifecycle(
180 &tx,
181 project,
182 branch,
183 scope,
184 &ownership,
185 &superseded_targets,
186 Some(memory_id),
187 )?;
188 crate::memory::edge::insert_supersedes_edges(
189 &tx,
190 &superseded_targets,
191 memory_id,
192 crate::memory::edge::MemoryEdgeWriteContext {
193 reason: Some("lifecycle update supersedes old memory"),
194 ..Default::default()
195 },
196 )?;
197 superseded = Some(count);
198 Ok(memory_id)
199 })?;
200 tx.commit()?;
201 Ok(LifecycleOutcome {
202 op: MemoryLifecycleOp::Update,
203 memory_id: Some(activation_result.memory_id),
204 superseded: superseded.unwrap_or(0),
205 noop: false,
206 deferred: false,
207 reason: None,
208 })
209}
210
211pub fn apply_invalidate(
212 conn: &Connection,
213 project: &str,
214 memory_ids: &[i64],
215 reason: Option<&str>,
216) -> Result<LifecycleOutcome> {
217 let tx = conn.unchecked_transaction()?;
218 let superseded = soft_supersede(&tx, project, memory_ids, None)?;
219 tx.commit()?;
220 Ok(LifecycleOutcome {
221 op: MemoryLifecycleOp::Invalidate,
222 memory_id: None,
223 superseded,
224 noop: false,
225 deferred: false,
226 reason: reason.map(str::to_string),
227 })
228}
229
230#[allow(clippy::too_many_arguments)]
231fn insert_replacement_memory(
232 conn: &Connection,
233 _permit: &crate::memory::activation::ActiveMemoryWritePermit,
234 session_id: Option<&str>,
235 project: &str,
236 topic_key: &str,
237 title: &str,
238 content: &str,
239 memory_type: &str,
240 files: Option<&str>,
241 branch: Option<&str>,
242 scope: &str,
243 ownership: &LifecycleOwnership<'_>,
244 state_key: Option<&StateKeyDecision>,
245) -> Result<i64> {
246 let now = chrono::Utc::now().timestamp();
247 let (expires_at_epoch, valid_from_epoch) =
248 ttl_metadata(memory_type, Some(topic_key), content, now);
249 let search_context = crate::memory::search_context::build_search_context(
250 memory_type,
251 Some(topic_key),
252 content,
253 files,
254 );
255 let fallback_source_hash = crate::memory::retrieval_enrichment::enrichment_source_hash(
256 title,
257 content,
258 memory_type,
259 Some(topic_key),
260 files,
261 );
262 conn.execute(
263 "INSERT INTO memories
264 (session_id, project, topic_key, title, content, memory_type, files, search_context,
265 search_context_fallback_source_hash,
266 created_at_epoch, updated_at_epoch, status, branch, scope,
267 source_project, target_project, owner_scope, owner_key, context_class,
268 expires_at_epoch, valid_from_epoch)
269 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?18,
270 ?9, ?9, 'active', ?10, ?11,
271 ?12, ?13, ?14, ?15, 'startup_core', ?16, ?17)",
272 params![
273 session_id,
274 project,
275 topic_key,
276 title,
277 content,
278 memory_type,
279 files,
280 search_context,
281 now,
282 branch,
283 scope,
284 ownership.source_project,
285 ownership.target_project,
286 ownership.owner_scope,
287 ownership.owner_key,
288 expires_at_epoch,
289 valid_from_epoch,
290 fallback_source_hash
291 ],
292 )?;
293 let memory_id = conn.last_insert_rowid();
294 if let Some(state_key) = state_key {
295 crate::memory::state_key::attach_current_memory(
296 conn,
297 memory_id,
298 ownership.owner_scope,
299 ownership.owner_key,
300 memory_type,
301 state_key,
302 now,
303 )?;
304 }
305 crate::retrieval::vector::upsert_memory_embedding(
306 conn,
307 memory_id,
308 title,
309 content,
310 memory_type,
311 Some(topic_key),
312 "",
313 )?;
314 Ok(memory_id)
315}
316
317struct LifecycleOwnership<'a> {
318 source_project: &'a str,
319 target_project: Option<&'a str>,
320 owner_scope: &'static str,
321 owner_key: &'a str,
322}
323
324fn lifecycle_ownership<'a>(project: &'a str, scope: &str) -> LifecycleOwnership<'a> {
325 if scope == "global" {
326 LifecycleOwnership {
327 source_project: project,
328 target_project: None,
329 owner_scope: "user",
330 owner_key: "user:default",
331 }
332 } else {
333 LifecycleOwnership {
334 source_project: project,
335 target_project: Some(project),
336 owner_scope: "repo",
337 owner_key: project,
338 }
339 }
340}
341
342fn find_active_same_state_or_topic(
343 conn: &Connection,
344 project: &str,
345 branch: Option<&str>,
346 scope: &str,
347 ownership: &LifecycleOwnership<'_>,
348 memory_type: &str,
349 topic_key: &str,
350 state_key: Option<&StateKeyDecision>,
351) -> Result<Vec<i64>> {
352 let mut ids = Vec::new();
353 if let Some(state_key) = state_key {
354 let candidates = crate::memory::state_key::active_memory_ids(
355 conn,
356 ownership.owner_scope,
357 ownership.owner_key,
358 memory_type,
359 &state_key.state_key,
360 chrono::Utc::now().timestamp(),
361 false,
362 )?;
363 for memory_id in candidates {
364 if matches_lifecycle_route(conn, memory_id, project, branch, scope, ownership)? {
365 ids.push(memory_id);
366 }
367 }
368 }
369 ids.extend(find_active_same_topic_key(
370 conn,
371 project,
372 branch,
373 scope,
374 ownership,
375 memory_type,
376 topic_key,
377 )?);
378 Ok(ids)
379}
380
381fn find_active_same_topic_key(
382 conn: &Connection,
383 project: &str,
384 branch: Option<&str>,
385 scope: &str,
386 ownership: &LifecycleOwnership<'_>,
387 memory_type: &str,
388 topic_key: &str,
389) -> Result<Vec<i64>> {
390 let mut stmt = conn.prepare(
391 "SELECT id FROM memories
392 WHERE memory_type = ?1 AND topic_key = ?2
393 AND (?5 = 'global' OR project = ?3)
394 AND branch IS ?4 AND COALESCE(scope, 'project') = ?5
395 AND COALESCE(owner_scope,
396 CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user' ELSE 'repo' END) = ?6
397 AND COALESCE(owner_key,
398 CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user:default' ELSE project END) = ?7
399 AND CASE
400 WHEN COALESCE(owner_scope,
401 CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user' ELSE 'repo' END) = 'repo'
402 THEN COALESCE(target_project, project)
403 ELSE target_project
404 END IS ?8
405 AND status = 'active'",
406 )?;
407 let rows = stmt.query_map(
408 params![
409 memory_type,
410 topic_key,
411 project,
412 branch,
413 scope,
414 ownership.owner_scope,
415 ownership.owner_key,
416 ownership.target_project,
417 ],
418 |row| row.get(0),
419 )?;
420 crate::db::query::collect_rows(rows)
421}
422
423fn matches_lifecycle_route(
424 conn: &Connection,
425 memory_id: i64,
426 project: &str,
427 branch: Option<&str>,
428 scope: &str,
429 ownership: &LifecycleOwnership<'_>,
430) -> Result<bool> {
431 conn.query_row(
432 "SELECT EXISTS(
433 SELECT 1 FROM memories
434 WHERE id = ?1 AND status = 'active'
435 AND (?4 = 'global' OR project = ?2)
436 AND branch IS ?3 AND COALESCE(scope, 'project') = ?4
437 AND COALESCE(owner_scope,
438 CASE WHEN COALESCE(scope, 'project') = 'global'
439 THEN 'user' ELSE 'repo' END) = ?5
440 AND COALESCE(owner_key,
441 CASE WHEN COALESCE(scope, 'project') = 'global'
442 THEN 'user:default' ELSE project END) = ?6
443 AND CASE
444 WHEN COALESCE(owner_scope,
445 CASE WHEN COALESCE(scope, 'project') = 'global'
446 THEN 'user' ELSE 'repo' END) = 'repo'
447 THEN COALESCE(target_project, project)
448 ELSE target_project
449 END IS ?7
450 )",
451 params![
452 memory_id,
453 project,
454 branch,
455 scope,
456 ownership.owner_scope,
457 ownership.owner_key,
458 ownership.target_project,
459 ],
460 |row| row.get(0),
461 )
462 .map_err(Into::into)
463}
464
465#[allow(clippy::too_many_arguments)]
466fn soft_supersede_lifecycle(
467 conn: &Connection,
468 project: &str,
469 branch: Option<&str>,
470 scope: &str,
471 ownership: &LifecycleOwnership<'_>,
472 memory_ids: &[i64],
473 replacement_id: Option<i64>,
474) -> Result<usize> {
475 let mut seen = std::collections::HashSet::with_capacity(memory_ids.len());
476 let targets = memory_ids
477 .iter()
478 .copied()
479 .filter(|id| Some(*id) != replacement_id && seen.insert(*id))
480 .collect::<Vec<_>>();
481 for memory_id in &targets {
482 if !matches_lifecycle_route(conn, *memory_id, project, branch, scope, ownership)? {
483 return Err(anyhow!(
484 "failed to mark lifecycle memory stale outside owner route: id={memory_id}"
485 ));
486 }
487 }
488 crate::memory::preference::compilation::enqueue_for_memory_ids(conn, &targets)?;
489
490 let now = chrono::Utc::now().timestamp();
491 for memory_id in &targets {
492 let updated = conn.execute(
493 "UPDATE memories
494 SET status = 'stale',
495 valid_to_epoch = COALESCE(valid_to_epoch, ?2)
496 WHERE id = ?1 AND status = 'active'",
497 params![memory_id, now],
498 )?;
499 if updated != 1 {
500 return Err(anyhow!(
501 "failed to mark lifecycle memory stale: id={memory_id}"
502 ));
503 }
504 }
505 Ok(targets.len())
506}
507
508pub fn noop(reason: impl Into<String>) -> LifecycleOutcome {
509 LifecycleOutcome {
510 op: MemoryLifecycleOp::Noop,
511 memory_id: None,
512 superseded: 0,
513 noop: true,
514 deferred: false,
515 reason: Some(reason.into()),
516 }
517}
518
519pub fn defer(reason: impl Into<String>) -> LifecycleOutcome {
520 LifecycleOutcome {
521 op: MemoryLifecycleOp::Defer,
522 memory_id: None,
523 superseded: 0,
524 noop: false,
525 deferred: true,
526 reason: Some(reason.into()),
527 }
528}
529
530pub fn default_ttl_seconds(
531 memory_type: &str,
532 topic_key: Option<&str>,
533 content: &str,
534) -> Option<i64> {
535 let topic_key = topic_key.unwrap_or_default().to_ascii_lowercase();
536 let content = content.to_ascii_lowercase();
537
538 if has_any(&topic_key, short_current_needles()) {
539 return Some(SHORT_CURRENT_TTL_SECONDS);
540 }
541
542 if has_any(&topic_key, branch_snapshot_needles()) {
543 return Some(BRANCH_SNAPSHOT_TTL_SECONDS);
544 }
545
546 if durable_type_has_no_content_ttl(memory_type) {
547 return None;
548 }
549
550 if has_any(&content, short_current_needles()) {
551 return Some(SHORT_CURRENT_TTL_SECONDS);
552 }
553
554 if has_any(&content, branch_snapshot_needles()) {
555 return Some(BRANCH_SNAPSHOT_TTL_SECONDS);
556 }
557
558 None
559}
560
561pub fn expires_at_epoch(
562 memory_type: &str,
563 topic_key: Option<&str>,
564 content: &str,
565 now_epoch: i64,
566) -> Option<i64> {
567 default_ttl_seconds(memory_type, topic_key, content).map(|ttl| now_epoch + ttl)
568}
569
570pub fn ttl_metadata(
571 memory_type: &str,
572 topic_key: Option<&str>,
573 content: &str,
574 now_epoch: i64,
575) -> (Option<i64>, Option<i64>) {
576 let expires_at_epoch = expires_at_epoch(memory_type, topic_key, content, now_epoch);
577 let valid_from_epoch = expires_at_epoch.map(|_| now_epoch);
578 (expires_at_epoch, valid_from_epoch)
579}
580
581pub fn expire_active_memories(conn: &Connection, now_epoch: i64) -> Result<usize> {
582 if !conn.is_autocommit() {
583 return expire_active_memories_in_transaction(conn, now_epoch);
584 }
585 let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
586 let changed = expire_active_memories_in_transaction(&tx, now_epoch)?;
587 tx.commit()?;
588 Ok(changed)
589}
590
591fn expire_active_memories_in_transaction(conn: &Connection, now_epoch: i64) -> Result<usize> {
592 let mut stmt = conn.prepare(
593 "SELECT id FROM memories
594 WHERE status = 'active'
595 AND memory_type = 'preference'
596 AND expires_at_epoch IS NOT NULL
597 AND expires_at_epoch <= ?1",
598 )?;
599 let rows = stmt.query_map(params![now_epoch], |row| row.get::<_, i64>(0))?;
600 let expiring_preference_ids = crate::db::query::collect_rows(rows)?;
601 drop(stmt);
602 let changed = conn.execute(
603 "UPDATE memories
604 SET status = 'stale',
605 valid_to_epoch = COALESCE(valid_to_epoch, ?1),
606 updated_at_epoch = ?1
607 WHERE status = 'active'
608 AND expires_at_epoch IS NOT NULL
609 AND expires_at_epoch <= ?1",
610 params![now_epoch],
611 )?;
612 crate::memory::preference::compilation::enqueue_for_memory_ids(conn, &expiring_preference_ids)?;
613 Ok(changed)
614}
615
616pub fn count_expired_active_memories(conn: &Connection, now_epoch: i64) -> Result<usize> {
617 let count: i64 = conn.query_row(
618 "SELECT COUNT(*) FROM memories
619 WHERE status = 'active'
620 AND expires_at_epoch IS NOT NULL
621 AND expires_at_epoch <= ?1",
622 params![now_epoch],
623 |row| row.get(0),
624 )?;
625 Ok(count as usize)
626}
627
628pub fn soft_supersede(
629 conn: &Connection,
630 project: &str,
631 memory_ids: &[i64],
632 replacement_id: Option<i64>,
633) -> Result<usize> {
634 let mut seen = std::collections::HashSet::with_capacity(memory_ids.len());
635 let targets = memory_ids
636 .iter()
637 .copied()
638 .filter(|id| Some(*id) != replacement_id && seen.insert(*id))
639 .collect::<Vec<_>>();
640 for id in &targets {
641 let exists: bool = conn.query_row(
642 "SELECT EXISTS(SELECT 1 FROM memories WHERE id = ?1 AND project = ?2)",
643 params![id, project],
644 |row| row.get(0),
645 )?;
646 if !exists {
647 return Err(anyhow!(
648 "failed to mark superseded memory stale: id={} project={}",
649 id,
650 project
651 ));
652 }
653 }
654
655 crate::memory::preference::compilation::enqueue_for_memory_ids(conn, &targets)?;
656
657 let mut changed = 0usize;
658 let now = chrono::Utc::now().timestamp();
659 for id in targets {
660 let updated = conn.execute(
661 "UPDATE memories
662 SET status = 'stale',
663 valid_to_epoch = COALESCE(valid_to_epoch, ?3)
664 WHERE id = ?1 AND project = ?2",
665 params![id, project, now],
666 )?;
667 if updated != 1 {
668 return Err(anyhow!(
669 "failed to mark superseded memory stale: id={} project={}",
670 id,
671 project
672 ));
673 }
674 changed += updated;
675 }
676 Ok(changed)
677}
678
679fn has_any(haystack: &str, needles: &[&str]) -> bool {
680 needles.iter().any(|needle| haystack.contains(needle))
681}
682
683fn short_current_needles() -> &'static [&'static str] {
684 &[
685 "dev-server",
686 "dev server",
687 "localhost",
688 "127.0.0.1",
689 "port occupied",
690 "port is occupied",
691 "currently running",
692 "server running",
693 "local url",
694 "url healthy",
695 "healthy at",
696 "mergeability",
697 "mergeable",
698 "review status",
699 "review-status",
700 "ci state",
701 "ci status",
702 "ci-status",
703 "github actions",
704 "pull request",
705 "pull-request",
706 "pr #",
707 ]
708}
709
710fn branch_snapshot_needles() -> &'static [&'static str] {
711 &[
712 "git-divergence",
713 "branch-divergence",
714 "branch divergence",
715 "current branch",
716 "git status",
717 "ahead of",
718 "behind origin",
719 "diverged",
720 "dirty worktree",
721 ]
722}
723
724fn durable_type_has_no_content_ttl(memory_type: &str) -> bool {
725 matches!(
726 memory_type,
727 "architecture" | "bugfix" | "lesson" | "preference" | "procedure"
728 )
729}
730
731#[cfg(test)]
732mod ttl_tests;
733#[cfg(test)]
734mod vector_tests;