1use anyhow::{anyhow, bail, Result};
2use rusqlite::{params, Connection, OptionalExtension};
3use serde::Serialize;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum MemoryGovernanceAction {
7 Delete,
8 Reject,
9 MarkStale,
10 AcknowledgePattern,
11}
12
13impl MemoryGovernanceAction {
14 pub fn parse(value: &str) -> Result<Self> {
15 match value.trim().to_lowercase().as_str() {
16 "delete" | "deleted" => Ok(Self::Delete),
17 "reject" | "rejected" => Ok(Self::Reject),
18 "stale" | "mark_stale" | "mark-stale" | "invalidate" => Ok(Self::MarkStale),
19 "acknowledge_pattern" | "acknowledge-pattern" | "ack" => Ok(Self::AcknowledgePattern),
20 other => bail!("unsupported memory governance action: {other}"),
21 }
22 }
23
24 pub fn as_str(self) -> &'static str {
25 match self {
26 Self::Delete => "delete",
27 Self::Reject => "reject",
28 Self::MarkStale => "stale",
29 Self::AcknowledgePattern => "acknowledge_pattern",
30 }
31 }
32
33 pub fn target_status(self) -> &'static str {
34 match self {
35 Self::Delete => "deleted",
36 Self::Reject => "rejected",
37 Self::MarkStale => "stale",
38 Self::AcknowledgePattern => "active",
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
44pub struct GovernMemoryRequest<'a> {
45 pub project: &'a str,
46 pub ids: &'a [i64],
47 pub action: MemoryGovernanceAction,
48 pub reason: Option<&'a str>,
49 pub actor: Option<&'a str>,
50 pub dry_run: bool,
51 pub confirm_destructive: bool,
52 pub acknowledge_pattern: Option<&'a str>,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct GovernedMemory {
57 pub id: i64,
58 pub title: String,
59 pub previous_status: String,
60 pub new_status: String,
61}
62
63#[derive(Debug, Clone, Serialize)]
64pub struct GovernMemoryResult {
65 pub dry_run: bool,
66 pub action: String,
67 pub reason: Option<String>,
68 pub affected: Vec<GovernedMemory>,
69}
70
71#[derive(Debug, Clone)]
72pub struct GovernanceSelector<'a> {
73 pub project: &'a str,
74 pub query: Option<&'a str>,
75 pub memory_type: Option<&'a str>,
76 pub status: Option<&'a str>,
77 pub limit: i64,
78 pub offset: i64,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum WebMemoryGovernanceAction {
83 Archive,
84 Restore,
85}
86
87impl WebMemoryGovernanceAction {
88 pub fn as_str(self) -> &'static str {
89 match self {
90 Self::Archive => "archive",
91 Self::Restore => "restore",
92 }
93 }
94
95 fn before_status(self) -> &'static str {
96 match self {
97 Self::Archive => "active",
98 Self::Restore => "archived",
99 }
100 }
101
102 fn after_status(self) -> &'static str {
103 match self {
104 Self::Archive => "archived",
105 Self::Restore => "active",
106 }
107 }
108}
109
110pub struct WebMemoryGovernanceRequest<'a> {
111 pub memory_id: i64,
112 pub action: WebMemoryGovernanceAction,
113 pub expected_version: i64,
114 pub operation_id: &'a str,
115 pub reason: &'a str,
116 pub actor: &'a str,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct WebMemoryGovernanceResult {
121 pub memory_id: i64,
122 pub project: String,
123 pub before_status: String,
124 pub after_status: String,
125 pub version: i64,
126 pub audit_id: i64,
127 pub occurred_at_epoch: i64,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum WebMemoryGovernanceDecision {
132 Applied(WebMemoryGovernanceResult),
133 NotFound,
134 VersionConflict,
135 NotArchivable,
136 NotRecoverable,
137}
138
139pub fn govern_memory_for_web_in_transaction(
140 conn: &Connection,
141 req: &WebMemoryGovernanceRequest<'_>,
142) -> Result<WebMemoryGovernanceDecision> {
143 let target = conn
144 .query_row(
145 "SELECT project, title, status, version, web_archive_operation_id
146 FROM memories WHERE id = ?1",
147 params![req.memory_id],
148 |row| {
149 Ok(WebGovernanceTarget {
150 project: row.get(0)?,
151 title: row.get(1)?,
152 status: row.get(2)?,
153 version: row.get(3)?,
154 web_archive_operation_id: row.get(4)?,
155 })
156 },
157 )
158 .optional()?;
159 let Some(target) = target else {
160 return Ok(match req.action {
161 WebMemoryGovernanceAction::Archive => WebMemoryGovernanceDecision::NotFound,
162 WebMemoryGovernanceAction::Restore => WebMemoryGovernanceDecision::NotRecoverable,
163 });
164 };
165 if target.version != req.expected_version {
166 return Ok(WebMemoryGovernanceDecision::VersionConflict);
167 }
168 match req.action {
169 WebMemoryGovernanceAction::Archive if target.status != "active" => {
170 return Ok(WebMemoryGovernanceDecision::NotArchivable)
171 }
172 WebMemoryGovernanceAction::Restore => {
173 if target.status != "archived"
174 || !current_web_archive_provenance_is_valid(conn, req.memory_id, &target)?
175 {
176 return Ok(WebMemoryGovernanceDecision::NotRecoverable);
177 }
178 }
179 WebMemoryGovernanceAction::Archive => {}
180 }
181
182 let occurred_at_epoch = chrono::Utc::now().timestamp();
183 let updated = conn.execute(
184 "UPDATE memories
185 SET status = ?1, updated_at_epoch = ?2
186 WHERE id = ?3 AND version = ?4 AND status = ?5",
187 params![
188 req.action.after_status(),
189 occurred_at_epoch,
190 req.memory_id,
191 req.expected_version,
192 req.action.before_status()
193 ],
194 )?;
195 if updated != 1 {
196 bail!("web memory governance guarded update did not affect exactly one row");
197 }
198 if req.action == WebMemoryGovernanceAction::Archive {
199 let marker_updated = conn.execute(
200 "UPDATE memories SET web_archive_operation_id = ?1 WHERE id = ?2 AND status = 'archived'",
201 params![req.operation_id, req.memory_id],
202 )?;
203 if marker_updated != 1 {
204 bail!("web archive marker update did not affect exactly one row");
205 }
206 }
207 let (after_status, version, marker): (String, i64, Option<String>) = conn.query_row(
208 "SELECT status, version, web_archive_operation_id FROM memories WHERE id = ?1",
209 params![req.memory_id],
210 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
211 )?;
212 if after_status != req.action.after_status()
213 || (req.action == WebMemoryGovernanceAction::Archive
214 && marker.as_deref() != Some(req.operation_id))
215 || (req.action == WebMemoryGovernanceAction::Restore && marker.is_some())
216 {
217 bail!("web memory governance postcondition failed");
218 }
219 let audit_id = insert_web_audit_event(conn, req, &target, &after_status, occurred_at_epoch)?;
220 Ok(WebMemoryGovernanceDecision::Applied(
221 WebMemoryGovernanceResult {
222 memory_id: req.memory_id,
223 project: target.project,
224 before_status: target.status,
225 after_status,
226 version,
227 audit_id,
228 occurred_at_epoch,
229 },
230 ))
231}
232
233struct WebGovernanceTarget {
234 project: String,
235 title: String,
236 status: String,
237 version: i64,
238 web_archive_operation_id: Option<String>,
239}
240
241fn current_web_archive_provenance_is_valid(
242 conn: &Connection,
243 memory_id: i64,
244 target: &WebGovernanceTarget,
245) -> Result<bool> {
246 let Some(marker) = target.web_archive_operation_id.as_deref() else {
247 return Ok(false);
248 };
249 let evidence = conn
250 .query_row(
251 "SELECT r.audit_id, r.response_json, e.event_type, e.project, e.detail
252 FROM api_mutation_requests r
253 JOIN events e ON e.id = r.audit_id
254 WHERE r.operation_id = ?1
255 AND r.resource_kind = 'memory'
256 AND r.resource_id = ?2
257 AND r.action = 'archive'
258 AND r.response_schema_version = 1",
259 params![marker, memory_id],
260 |row| {
261 Ok((
262 row.get::<_, i64>(0)?,
263 row.get::<_, String>(1)?,
264 row.get::<_, String>(2)?,
265 row.get::<_, String>(3)?,
266 row.get::<_, Option<String>>(4)?,
267 ))
268 },
269 )
270 .optional()?;
271 let Some((audit_id, response_json, event_type, project, detail)) = evidence else {
272 return Ok(false);
273 };
274 if event_type != "memory_governance" || project != target.project {
275 return Ok(false);
276 }
277 let response: serde_json::Value = match serde_json::from_str(&response_json) {
278 Ok(response) => response,
279 Err(_) => return Ok(false),
280 };
281 let detail: serde_json::Value = match detail.as_deref().map(serde_json::from_str).transpose() {
282 Ok(Some(detail)) => detail,
283 _ => return Ok(false),
284 };
285 Ok(response["operation_id"] == marker
286 && response["response_schema_version"] == 1
287 && response["audit_id"] == audit_id
288 && response["memory_id"] == memory_id
289 && response["action"] == "archive"
290 && response["before_status"] == "active"
291 && response["after_status"] == "archived"
292 && detail["operation_id"] == marker
293 && detail["memory_id"] == memory_id
294 && detail["action"] == "archive"
295 && detail["previous_status"] == "active"
296 && detail["new_status"] == "archived")
297}
298
299fn insert_web_audit_event(
300 conn: &Connection,
301 req: &WebMemoryGovernanceRequest<'_>,
302 target: &WebGovernanceTarget,
303 new_status: &str,
304 now: i64,
305) -> Result<i64> {
306 let detail = serde_json::json!({
307 "action": req.action.as_str(),
308 "memory_id": req.memory_id,
309 "title": target.title,
310 "previous_status": target.status,
311 "new_status": new_status,
312 "reason": req.reason,
313 "actor": req.actor,
314 "operation_id": req.operation_id,
315 })
316 .to_string();
317 conn.execute(
318 "INSERT INTO events
319 (session_id, project, event_type, summary, detail, files, exit_code, created_at_epoch)
320 VALUES (?1, ?2, 'memory_governance', ?3, ?4, NULL, NULL, ?5)",
321 params![
322 format!("api:{}", req.operation_id),
323 target.project,
324 format!("Web {} memory {}", req.action.as_str(), req.memory_id),
325 detail,
326 now
327 ],
328 )?;
329 Ok(conn.last_insert_rowid())
330}
331
332pub fn select_memory_ids(conn: &Connection, selector: &GovernanceSelector<'_>) -> Result<Vec<i64>> {
333 let mut conditions = vec!["project = ?1".to_string()];
334 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
335 vec![Box::new(selector.project.to_string())];
336 let mut idx = 2;
337
338 if let Some(status) = normalized_status_filter(selector.status)? {
339 conditions.push(format!("status = ?{idx}"));
340 params.push(Box::new(status));
341 idx += 1;
342 }
343
344 if let Some(memory_type) = trimmed(selector.memory_type) {
345 conditions.push(format!("memory_type = ?{idx}"));
346 params.push(Box::new(memory_type.to_string()));
347 idx += 1;
348 }
349
350 if let Some(query) = trimmed(selector.query) {
351 let pattern = like_pattern(query);
352 conditions.push(format!(
353 "(title LIKE ?{idx} ESCAPE '\\' \
354 OR content LIKE ?{next_idx} ESCAPE '\\' \
355 OR COALESCE(search_context, '') LIKE ?{third_idx} ESCAPE '\\')",
356 idx = idx,
357 next_idx = idx + 1,
358 third_idx = idx + 2
359 ));
360 params.push(Box::new(pattern.clone()));
361 params.push(Box::new(pattern.clone()));
362 params.push(Box::new(pattern));
363 idx += 3;
364 }
365
366 params.push(Box::new(selector.limit.max(1)));
367 params.push(Box::new(selector.offset.max(0)));
368 let sql = format!(
369 "SELECT id FROM memories \
370 WHERE {} \
371 ORDER BY updated_at_epoch DESC, id DESC \
372 LIMIT ?{} OFFSET ?{}",
373 conditions.join(" AND "),
374 idx,
375 idx + 1
376 );
377 let mut stmt = conn.prepare(&sql)?;
378 let refs = crate::db::to_sql_refs(¶ms);
379 let rows = stmt.query_map(refs.as_slice(), |row| row.get::<_, i64>(0))?;
380 crate::db::query::collect_rows(rows)
381}
382
383pub fn govern_memories(
384 conn: &Connection,
385 req: &GovernMemoryRequest<'_>,
386) -> Result<GovernMemoryResult> {
387 let ids = unique_ids(req.ids);
388 if ids.is_empty() {
389 bail!("memory governance requires at least one memory id");
390 }
391 let reason = normalized_reason(req)?;
392 let acknowledged_pattern = normalized_acknowledge_pattern(req)?;
393 let target_status = req.action.target_status();
394 let tx = conn.unchecked_transaction()?;
395 let mut affected = Vec::with_capacity(ids.len());
396 let mut rule_source_ids = Vec::new();
397 for id in ids {
398 let target = load_target(&tx, req.project, id)?;
399 if req.action == MemoryGovernanceAction::AcknowledgePattern {
400 validate_acknowledgement(&target, acknowledged_pattern)?;
401 }
402 let new_status = if req.action == MemoryGovernanceAction::AcknowledgePattern {
403 target.status.as_str()
404 } else {
405 target_status
406 };
407 affected.push(GovernedMemory {
408 id: target.id,
409 title: target.title.clone(),
410 previous_status: target.status.clone(),
411 new_status: new_status.to_string(),
412 });
413 if req.dry_run {
414 continue;
415 }
416 if req.action != MemoryGovernanceAction::AcknowledgePattern {
417 rule_source_ids.push(target.id);
418 }
419 let now = chrono::Utc::now().timestamp();
420 let updated = if req.action == MemoryGovernanceAction::AcknowledgePattern {
421 tx.execute(
422 "UPDATE memories
423 SET acknowledged_pattern_id = ?1,
424 acknowledged_pattern_version = ?2,
425 acknowledged_at_epoch = ?3,
426 updated_at_epoch = ?3
427 WHERE id = ?4 AND project = ?5",
428 params![
429 acknowledged_pattern,
430 crate::memory::poisoning::INSTRUCTION_PATTERN_SET_VERSION,
431 now,
432 target.id,
433 req.project
434 ],
435 )?
436 } else {
437 tx.execute(
438 "UPDATE memories
439 SET status = ?1, updated_at_epoch = ?2
440 WHERE id = ?3 AND project = ?4",
441 params![target_status, now, target.id, req.project],
442 )?
443 };
444 if updated != 1 {
445 return Err(anyhow!(
446 "failed to update memory governance target: id={} project={}",
447 target.id,
448 req.project
449 ));
450 }
451 insert_audit_event(&tx, req, &target, new_status, reason.as_deref(), now)?;
452 }
453 crate::memory::preference::compilation::enqueue_for_memory_ids(&tx, &rule_source_ids)?;
454 tx.commit()?;
455 Ok(GovernMemoryResult {
456 dry_run: req.dry_run,
457 action: req.action.as_str().to_string(),
458 reason,
459 affected,
460 })
461}
462
463fn unique_ids(ids: &[i64]) -> Vec<i64> {
464 let mut seen = std::collections::HashSet::with_capacity(ids.len());
465 ids.iter()
466 .copied()
467 .filter(|id| *id > 0 && seen.insert(*id))
468 .collect()
469}
470
471fn trimmed(value: Option<&str>) -> Option<&str> {
472 value.map(str::trim).filter(|value| !value.is_empty())
473}
474
475fn normalized_status_filter(status: Option<&str>) -> Result<Option<String>> {
476 let Some(status) = trimmed(status) else {
477 return Ok(Some("active".to_string()));
478 };
479 let normalized = status.to_lowercase();
480 if matches!(normalized.as_str(), "all" | "*") {
481 return Ok(None);
482 }
483 if matches!(
484 normalized.as_str(),
485 "active" | "stale" | "rejected" | "deleted" | "archived" | "superseded"
486 ) {
487 return Ok(Some(normalized));
488 }
489 bail!("unsupported memory status filter: {status}");
490}
491
492fn like_pattern(query: &str) -> String {
493 let mut pattern = String::with_capacity(query.len() + 2);
494 pattern.push('%');
495 for ch in query.chars() {
496 if matches!(ch, '%' | '_' | '\\') {
497 pattern.push('\\');
498 }
499 pattern.push(ch);
500 }
501 pattern.push('%');
502 pattern
503}
504
505fn normalized_reason(req: &GovernMemoryRequest<'_>) -> Result<Option<String>> {
506 let reason = req.reason.map(str::trim).filter(|value| !value.is_empty());
507 if req.dry_run {
508 return Ok(reason.map(str::to_string));
509 }
510 if !req.confirm_destructive {
511 bail!("memory governance mutation requires confirm_destructive=true");
512 }
513 let Some(reason) = reason else {
514 bail!("memory governance mutation requires an explicit reason");
515 };
516 Ok(Some(reason.to_string()))
517}
518
519fn normalized_acknowledge_pattern<'a>(req: &'a GovernMemoryRequest<'a>) -> Result<Option<&'a str>> {
520 let pattern = req
521 .acknowledge_pattern
522 .map(str::trim)
523 .filter(|value| !value.is_empty());
524 if req.action == MemoryGovernanceAction::AcknowledgePattern && pattern.is_none() {
525 bail!("acknowledge_pattern action requires acknowledge_pattern");
526 }
527 if req.action != MemoryGovernanceAction::AcknowledgePattern && pattern.is_some() {
528 bail!("acknowledge_pattern is only valid with acknowledge_pattern action");
529 }
530 Ok(pattern)
531}
532
533fn validate_acknowledgement(
534 target: &GovernanceTarget,
535 acknowledged_pattern: Option<&str>,
536) -> Result<()> {
537 let acknowledged_pattern = acknowledged_pattern.expect("validated acknowledge_pattern");
538 let Some(matched) = crate::memory::poisoning::scan_instruction_pattern(&format!(
539 "{}\n{}",
540 target.title, target.content
541 )) else {
542 bail!(
543 "memory id={} does not match an instruction-pattern; cannot acknowledge {}",
544 target.id,
545 acknowledged_pattern
546 );
547 };
548 if matched.pattern_id != acknowledged_pattern {
549 bail!(
550 "memory id={} acknowledged pattern {} does not match instruction-pattern {}@v{}",
551 target.id,
552 acknowledged_pattern,
553 matched.pattern_id,
554 matched.pattern_set_version
555 );
556 }
557 Ok(())
558}
559
560struct GovernanceTarget {
561 id: i64,
562 title: String,
563 content: String,
564 status: String,
565}
566
567fn load_target(conn: &Connection, project: &str, id: i64) -> Result<GovernanceTarget> {
568 conn.query_row(
569 "SELECT id, title, content, status
570 FROM memories
571 WHERE id = ?1 AND project = ?2",
572 params![id, project],
573 |row| {
574 Ok(GovernanceTarget {
575 id: row.get(0)?,
576 title: row.get(1)?,
577 content: row.get(2)?,
578 status: row.get(3)?,
579 })
580 },
581 )
582 .optional()?
583 .ok_or_else(|| anyhow!("memory id={} not found in project={}", id, project))
584}
585
586fn insert_audit_event(
587 conn: &Connection,
588 req: &GovernMemoryRequest<'_>,
589 target: &GovernanceTarget,
590 new_status: &str,
591 reason: Option<&str>,
592 now: i64,
593) -> Result<()> {
594 let actor = req.actor.map(str::trim).filter(|value| !value.is_empty());
595 let detail = serde_json::json!({
596 "action": req.action.as_str(),
597 "memory_id": target.id,
598 "title": target.title,
599 "previous_status": target.status,
600 "new_status": new_status,
601 "reason": reason,
602 "actor": actor,
603 "acknowledged_pattern": req.acknowledge_pattern,
604 })
605 .to_string();
606 let summary = format!(
607 "{} memory {}: {} -> {}{}",
608 req.action.as_str(),
609 target.id,
610 target.status,
611 new_status,
612 reason
613 .map(|value| format!(" ({value})"))
614 .unwrap_or_default()
615 );
616 conn.execute(
617 "INSERT INTO events
618 (session_id, project, event_type, summary, detail, files, exit_code, created_at_epoch)
619 VALUES (?1, ?2, 'memory_governance', ?3, ?4, NULL, NULL, ?5)",
620 params![
621 actor.unwrap_or("memory-governance"),
622 req.project,
623 summary,
624 detail,
625 now
626 ],
627 )?;
628 Ok(())
629}
630
631#[cfg(test)]
632mod tests;
633#[cfg(test)]
634mod web_tests;