1use anyhow::{anyhow, bail, Context, 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 pub version: i64,
62}
63
64#[derive(Debug)]
65pub(crate) struct MemoryGovernanceVersionConflictError {
66 memory_id: i64,
67}
68
69impl std::fmt::Display for MemoryGovernanceVersionConflictError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(
72 f,
73 "memory version does not match expected_versions for id={}",
74 self.memory_id
75 )
76 }
77}
78
79impl std::error::Error for MemoryGovernanceVersionConflictError {}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct GovernMemoryResult {
83 pub dry_run: bool,
84 pub action: String,
85 pub reason: Option<String>,
86 pub affected: Vec<GovernedMemory>,
87}
88
89#[derive(Debug, Clone)]
90pub struct GovernanceSelector<'a> {
91 pub project: &'a str,
92 pub query: Option<&'a str>,
93 pub memory_type: Option<&'a str>,
94 pub status: Option<&'a str>,
95 pub limit: i64,
96 pub offset: i64,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum WebMemoryGovernanceAction {
101 Archive,
102 Restore,
103}
104
105impl WebMemoryGovernanceAction {
106 pub fn as_str(self) -> &'static str {
107 match self {
108 Self::Archive => "archive",
109 Self::Restore => "restore",
110 }
111 }
112
113 fn before_status(self) -> &'static str {
114 match self {
115 Self::Archive => "active",
116 Self::Restore => "archived",
117 }
118 }
119
120 fn after_status(self) -> &'static str {
121 match self {
122 Self::Archive => "archived",
123 Self::Restore => "active",
124 }
125 }
126}
127
128pub struct WebMemoryGovernanceRequest<'a> {
129 pub memory_id: i64,
130 pub action: WebMemoryGovernanceAction,
131 pub expected_version: i64,
132 pub operation_id: &'a str,
133 pub reason: &'a str,
134 pub actor: &'a str,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct WebMemoryGovernanceResult {
139 pub memory_id: i64,
140 pub project: String,
141 pub before_status: String,
142 pub after_status: String,
143 pub version: i64,
144 pub audit_id: i64,
145 pub occurred_at_epoch: i64,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum WebMemoryGovernanceDecision {
150 Applied(WebMemoryGovernanceResult),
151 NotFound,
152 VersionConflict,
153 NotArchivable,
154 NotRecoverable,
155}
156
157pub fn govern_memory_for_web_in_transaction(
158 conn: &Connection,
159 req: &WebMemoryGovernanceRequest<'_>,
160) -> Result<WebMemoryGovernanceDecision> {
161 let target = conn
162 .query_row(
163 "SELECT project, title, status, version, web_archive_operation_id,
164 branch, COALESCE(scope, 'project'),
165 COALESCE(owner_scope,
166 CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user' ELSE 'repo' END),
167 COALESCE(owner_key,
168 CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user:default' ELSE project END),
169 target_project, source_trust_class,
170 COALESCE(source_project, project)
171 FROM memories WHERE id = ?1",
172 params![req.memory_id],
173 |row| {
174 Ok(WebGovernanceTarget {
175 project: row.get(0)?,
176 title: row.get(1)?,
177 status: row.get(2)?,
178 version: row.get(3)?,
179 web_archive_operation_id: row.get(4)?,
180 branch: row.get(5)?,
181 scope: row.get(6)?,
182 owner_scope: row.get(7)?,
183 owner_key: row.get(8)?,
184 target_project: row.get(9)?,
185 source_trust_class: row.get(10)?,
186 source_project: row.get(11)?,
187 })
188 },
189 )
190 .optional()?;
191 let Some(target) = target else {
192 return Ok(match req.action {
193 WebMemoryGovernanceAction::Archive => WebMemoryGovernanceDecision::NotFound,
194 WebMemoryGovernanceAction::Restore => WebMemoryGovernanceDecision::NotRecoverable,
195 });
196 };
197 if target.version != req.expected_version {
198 return Ok(WebMemoryGovernanceDecision::VersionConflict);
199 }
200 match req.action {
201 WebMemoryGovernanceAction::Archive if target.status != "active" => {
202 return Ok(WebMemoryGovernanceDecision::NotArchivable)
203 }
204 WebMemoryGovernanceAction::Restore => {
205 if target.status != "archived"
206 || !current_web_archive_provenance_is_valid(conn, req.memory_id, &target)?
207 {
208 return Ok(WebMemoryGovernanceDecision::NotRecoverable);
209 }
210 }
211 WebMemoryGovernanceAction::Archive => {}
212 }
213
214 if req.action == WebMemoryGovernanceAction::Restore {
215 let expected_memory =
216 crate::memory::activation::ExpectedActiveMemory::from_existing(conn, req.memory_id)?;
217 let source_trust =
218 crate::memory::poisoning::SourceTrustClass::parse(&target.source_trust_class)
219 .ok_or_else(|| anyhow!("web restore target has unknown source trust class"))?;
220 let payload_sha256 = crate::memory::activation::payload_sha256(&[
221 &req.memory_id.to_string(),
222 &req.expected_version.to_string(),
223 req.operation_id,
224 req.reason,
225 req.actor,
226 target.web_archive_operation_id.as_deref().unwrap_or(""),
227 ]);
228 let request = crate::memory::activation::ActiveMemoryWriteRequest {
229 activation_id: crate::memory::activation::activation_id_from_key(
230 "web-restore",
231 req.operation_id,
232 ),
233 route_kind: crate::memory::activation::ActivationRouteKind::WebRestore,
234 actor_kind: crate::memory::activation::ActivationActorKind::Operator,
235 source_operation: "web_memory_restore".to_string(),
236 source_trust,
237 result_source_trust: source_trust,
238 source_project: target.source_project.clone(),
239 route: crate::memory::activation::ActiveMemoryRoute {
240 project: target.project.clone(),
241 branch: target.branch.clone(),
242 scope: target.scope.clone(),
243 owner_scope: target.owner_scope.clone(),
244 owner_key: target.owner_key.clone(),
245 target_project: target
246 .target_project
247 .clone()
248 .or_else(|| (target.owner_scope == "repo").then(|| target.project.clone())),
249 },
250 provenance_kind: crate::memory::activation::ActivationProvenanceKind::WebArchive,
251 provenance_ref: format!(
252 "archive-operation:{}",
253 target.web_archive_operation_id.as_deref().unwrap_or("")
254 ),
255 payload_sha256,
256 expected_memory,
257 poisoning_verdict: crate::memory::activation::ActivationPoisoningVerdict::ExactRecovery,
258 superseded_ids: Vec::new(),
259 };
260 let mut decision = None;
261 let result = crate::memory::activation::execute_one(conn, &request, |_permit| {
262 let applied = apply_web_governance_mutation(conn, req, &target)?;
263 decision = Some(applied);
264 Ok(req.memory_id)
265 })?;
266 if result.replayed {
267 bail!("web restore activation replay must be resolved by the API mutation ledger");
268 }
269 return decision.context("web restore activation produced no decision");
270 }
271
272 apply_web_governance_mutation(conn, req, &target)
273}
274
275fn apply_web_governance_mutation(
276 conn: &Connection,
277 req: &WebMemoryGovernanceRequest<'_>,
278 target: &WebGovernanceTarget,
279) -> Result<WebMemoryGovernanceDecision> {
280 let occurred_at_epoch = chrono::Utc::now().timestamp();
281 let updated = conn.execute(
282 "UPDATE memories
283 SET status = ?1, updated_at_epoch = ?2
284 WHERE id = ?3 AND version = ?4 AND status = ?5",
285 params![
286 req.action.after_status(),
287 occurred_at_epoch,
288 req.memory_id,
289 req.expected_version,
290 req.action.before_status()
291 ],
292 )?;
293 if updated != 1 {
294 bail!("web memory governance guarded update did not affect exactly one row");
295 }
296 if req.action == WebMemoryGovernanceAction::Archive {
297 let marker_updated = conn.execute(
298 "UPDATE memories SET web_archive_operation_id = ?1 WHERE id = ?2 AND status = 'archived'",
299 params![req.operation_id, req.memory_id],
300 )?;
301 if marker_updated != 1 {
302 bail!("web archive marker update did not affect exactly one row");
303 }
304 }
305 let (after_status, version, marker): (String, i64, Option<String>) = conn.query_row(
306 "SELECT status, version, web_archive_operation_id FROM memories WHERE id = ?1",
307 params![req.memory_id],
308 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
309 )?;
310 if after_status != req.action.after_status()
311 || (req.action == WebMemoryGovernanceAction::Archive
312 && marker.as_deref() != Some(req.operation_id))
313 || (req.action == WebMemoryGovernanceAction::Restore && marker.is_some())
314 {
315 bail!("web memory governance postcondition failed");
316 }
317 let audit_id = insert_web_audit_event(conn, req, target, &after_status, occurred_at_epoch)?;
318 Ok(WebMemoryGovernanceDecision::Applied(
319 WebMemoryGovernanceResult {
320 memory_id: req.memory_id,
321 project: target.project.clone(),
322 before_status: target.status.clone(),
323 after_status,
324 version,
325 audit_id,
326 occurred_at_epoch,
327 },
328 ))
329}
330
331struct WebGovernanceTarget {
332 project: String,
333 title: String,
334 status: String,
335 version: i64,
336 web_archive_operation_id: Option<String>,
337 branch: Option<String>,
338 scope: String,
339 owner_scope: String,
340 owner_key: String,
341 target_project: Option<String>,
342 source_trust_class: String,
343 source_project: String,
344}
345
346fn current_web_archive_provenance_is_valid(
347 conn: &Connection,
348 memory_id: i64,
349 target: &WebGovernanceTarget,
350) -> Result<bool> {
351 let Some(marker) = target.web_archive_operation_id.as_deref() else {
352 return Ok(false);
353 };
354 let evidence = conn
355 .query_row(
356 "SELECT r.audit_id, r.response_json, e.event_type, e.project, e.detail
357 FROM api_mutation_requests r
358 JOIN events e ON e.id = r.audit_id
359 WHERE r.operation_id = ?1
360 AND r.resource_kind = 'memory'
361 AND r.resource_id = ?2
362 AND r.action = 'archive'
363 AND r.response_schema_version = 1",
364 params![marker, memory_id],
365 |row| {
366 Ok((
367 row.get::<_, i64>(0)?,
368 row.get::<_, String>(1)?,
369 row.get::<_, String>(2)?,
370 row.get::<_, String>(3)?,
371 row.get::<_, Option<String>>(4)?,
372 ))
373 },
374 )
375 .optional()?;
376 let Some((audit_id, response_json, event_type, project, detail)) = evidence else {
377 return Ok(false);
378 };
379 if event_type != "memory_governance" || project != target.project {
380 return Ok(false);
381 }
382 let response: serde_json::Value = match serde_json::from_str(&response_json) {
383 Ok(response) => response,
384 Err(_) => return Ok(false),
385 };
386 let detail: serde_json::Value = match detail.as_deref().map(serde_json::from_str).transpose() {
387 Ok(Some(detail)) => detail,
388 _ => return Ok(false),
389 };
390 Ok(response["operation_id"] == marker
391 && response["response_schema_version"] == 1
392 && response["audit_id"] == audit_id
393 && response["memory_id"] == memory_id
394 && response["action"] == "archive"
395 && response["before_status"] == "active"
396 && response["after_status"] == "archived"
397 && detail["operation_id"] == marker
398 && detail["memory_id"] == memory_id
399 && detail["action"] == "archive"
400 && detail["previous_status"] == "active"
401 && detail["new_status"] == "archived")
402}
403
404fn insert_web_audit_event(
405 conn: &Connection,
406 req: &WebMemoryGovernanceRequest<'_>,
407 target: &WebGovernanceTarget,
408 new_status: &str,
409 now: i64,
410) -> Result<i64> {
411 let detail = serde_json::json!({
412 "action": req.action.as_str(),
413 "memory_id": req.memory_id,
414 "title": target.title,
415 "previous_status": target.status,
416 "new_status": new_status,
417 "reason": req.reason,
418 "actor": req.actor,
419 "operation_id": req.operation_id,
420 })
421 .to_string();
422 conn.execute(
423 "INSERT INTO events
424 (session_id, project, event_type, summary, detail, files, exit_code, created_at_epoch)
425 VALUES (?1, ?2, 'memory_governance', ?3, ?4, NULL, NULL, ?5)",
426 params![
427 format!("api:{}", req.operation_id),
428 target.project,
429 format!("Web {} memory {}", req.action.as_str(), req.memory_id),
430 detail,
431 now
432 ],
433 )?;
434 Ok(conn.last_insert_rowid())
435}
436
437pub fn select_memory_ids(conn: &Connection, selector: &GovernanceSelector<'_>) -> Result<Vec<i64>> {
438 let mut conditions = vec!["project = ?1".to_string()];
439 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
440 vec![Box::new(selector.project.to_string())];
441 let mut idx = 2;
442
443 if let Some(status) = normalized_status_filter(selector.status)? {
444 conditions.push(format!("status = ?{idx}"));
445 params.push(Box::new(status));
446 idx += 1;
447 }
448
449 if let Some(memory_type) = trimmed(selector.memory_type) {
450 conditions.push(format!("memory_type = ?{idx}"));
451 params.push(Box::new(memory_type.to_string()));
452 idx += 1;
453 }
454
455 if let Some(query) = trimmed(selector.query) {
456 let pattern = like_pattern(query);
457 conditions.push(format!(
458 "(title LIKE ?{idx} ESCAPE '\\' \
459 OR content LIKE ?{next_idx} ESCAPE '\\' \
460 OR COALESCE(search_context, '') LIKE ?{third_idx} ESCAPE '\\')",
461 idx = idx,
462 next_idx = idx + 1,
463 third_idx = idx + 2
464 ));
465 params.push(Box::new(pattern.clone()));
466 params.push(Box::new(pattern.clone()));
467 params.push(Box::new(pattern));
468 idx += 3;
469 }
470
471 params.push(Box::new(selector.limit.max(1)));
472 params.push(Box::new(selector.offset.max(0)));
473 let sql = format!(
474 "SELECT id FROM memories \
475 WHERE {} \
476 ORDER BY updated_at_epoch DESC, id DESC \
477 LIMIT ?{} OFFSET ?{}",
478 conditions.join(" AND "),
479 idx,
480 idx + 1
481 );
482 let mut stmt = conn.prepare(&sql)?;
483 let refs = crate::db::to_sql_refs(¶ms);
484 let rows = stmt.query_map(refs.as_slice(), |row| row.get::<_, i64>(0))?;
485 crate::db::query::collect_rows(rows)
486}
487
488pub fn govern_memories(
489 conn: &Connection,
490 req: &GovernMemoryRequest<'_>,
491) -> Result<GovernMemoryResult> {
492 govern_memories_inner(conn, req, None)
493}
494
495pub(crate) fn govern_memories_with_expected_versions(
496 conn: &Connection,
497 req: &GovernMemoryRequest<'_>,
498 expected_versions: &std::collections::BTreeMap<i64, i64>,
499) -> Result<GovernMemoryResult> {
500 govern_memories_inner(conn, req, Some(expected_versions))
501}
502
503fn govern_memories_inner(
504 conn: &Connection,
505 req: &GovernMemoryRequest<'_>,
506 expected_versions: Option<&std::collections::BTreeMap<i64, i64>>,
507) -> Result<GovernMemoryResult> {
508 let ids = unique_ids(req.ids);
509 if ids.is_empty() {
510 bail!("memory governance requires at least one memory id");
511 }
512 let reason = normalized_reason(req)?;
513 let acknowledged_pattern = normalized_acknowledge_pattern(req)?;
514 let target_status = req.action.target_status();
515 let tx = conn.unchecked_transaction()?;
516 let mut affected = Vec::with_capacity(ids.len());
517 let mut rule_source_ids = Vec::new();
518 for id in ids {
519 let target = load_target(&tx, req.project, id)?;
520 let expected_version = expected_versions
521 .map(|versions| {
522 versions
523 .get(&id)
524 .copied()
525 .ok_or_else(|| anyhow!("expected_versions is missing memory id={id}"))
526 })
527 .transpose()?;
528 if expected_version.is_some_and(|version| version != target.version) {
529 return Err(MemoryGovernanceVersionConflictError { memory_id: id }.into());
530 }
531 if req.action == MemoryGovernanceAction::AcknowledgePattern {
532 validate_acknowledgement(&target, acknowledged_pattern)?;
533 }
534 let new_status = if req.action == MemoryGovernanceAction::AcknowledgePattern {
535 target.status.as_str()
536 } else {
537 target_status
538 };
539 affected.push(GovernedMemory {
540 id: target.id,
541 title: target.title.clone(),
542 previous_status: target.status.clone(),
543 new_status: new_status.to_string(),
544 version: target.version,
545 });
546 if req.dry_run {
547 continue;
548 }
549 if req.action != MemoryGovernanceAction::AcknowledgePattern {
550 rule_source_ids.push(target.id);
551 }
552 let now = chrono::Utc::now().timestamp();
553 let updated = match (req.action, expected_version) {
554 (MemoryGovernanceAction::AcknowledgePattern, Some(version)) => tx.execute(
555 "UPDATE memories
556 SET acknowledged_pattern_id = ?1,
557 acknowledged_pattern_version = ?2,
558 acknowledged_at_epoch = ?3,
559 updated_at_epoch = ?3
560 WHERE id = ?4 AND project = ?5 AND version = ?6",
561 params![
562 acknowledged_pattern,
563 crate::memory::poisoning::INSTRUCTION_PATTERN_SET_VERSION,
564 now,
565 target.id,
566 req.project,
567 version
568 ],
569 )?,
570 (MemoryGovernanceAction::AcknowledgePattern, None) => tx.execute(
571 "UPDATE memories
572 SET acknowledged_pattern_id = ?1,
573 acknowledged_pattern_version = ?2,
574 acknowledged_at_epoch = ?3,
575 updated_at_epoch = ?3
576 WHERE id = ?4 AND project = ?5",
577 params![
578 acknowledged_pattern,
579 crate::memory::poisoning::INSTRUCTION_PATTERN_SET_VERSION,
580 now,
581 target.id,
582 req.project
583 ],
584 )?,
585 (_, Some(version)) => tx.execute(
586 "UPDATE memories
587 SET status = ?1, updated_at_epoch = ?2
588 WHERE id = ?3 AND project = ?4 AND version = ?5",
589 params![target_status, now, target.id, req.project, version],
590 )?,
591 (_, None) => tx.execute(
592 "UPDATE memories
593 SET status = ?1, updated_at_epoch = ?2
594 WHERE id = ?3 AND project = ?4",
595 params![target_status, now, target.id, req.project],
596 )?,
597 };
598 if updated != 1 {
599 if expected_version.is_some() {
600 return Err(MemoryGovernanceVersionConflictError { memory_id: id }.into());
601 }
602 return Err(anyhow!(
603 "failed to update memory governance target: id={} project={}",
604 target.id,
605 req.project
606 ));
607 }
608 insert_audit_event(&tx, req, &target, new_status, reason.as_deref(), now)?;
609 }
610 crate::memory::preference::compilation::enqueue_for_memory_ids(&tx, &rule_source_ids)?;
611 tx.commit()?;
612 #[cfg(test)]
613 tests::run_after_governance_commit_hook(conn);
614 Ok(GovernMemoryResult {
615 dry_run: req.dry_run,
616 action: req.action.as_str().to_string(),
617 reason,
618 affected,
619 })
620}
621
622fn unique_ids(ids: &[i64]) -> Vec<i64> {
623 let mut seen = std::collections::HashSet::with_capacity(ids.len());
624 ids.iter()
625 .copied()
626 .filter(|id| *id > 0 && seen.insert(*id))
627 .collect()
628}
629
630fn trimmed(value: Option<&str>) -> Option<&str> {
631 value.map(str::trim).filter(|value| !value.is_empty())
632}
633
634fn normalized_status_filter(status: Option<&str>) -> Result<Option<String>> {
635 let Some(status) = trimmed(status) else {
636 return Ok(Some("active".to_string()));
637 };
638 let normalized = status.to_lowercase();
639 if matches!(normalized.as_str(), "all" | "*") {
640 return Ok(None);
641 }
642 if matches!(
643 normalized.as_str(),
644 "active" | "stale" | "rejected" | "deleted" | "archived" | "superseded"
645 ) {
646 return Ok(Some(normalized));
647 }
648 bail!("unsupported memory status filter: {status}");
649}
650
651fn like_pattern(query: &str) -> String {
652 let mut pattern = String::with_capacity(query.len() + 2);
653 pattern.push('%');
654 for ch in query.chars() {
655 if matches!(ch, '%' | '_' | '\\') {
656 pattern.push('\\');
657 }
658 pattern.push(ch);
659 }
660 pattern.push('%');
661 pattern
662}
663
664fn normalized_reason(req: &GovernMemoryRequest<'_>) -> Result<Option<String>> {
665 let reason = req.reason.map(str::trim).filter(|value| !value.is_empty());
666 if req.dry_run {
667 return Ok(reason.map(str::to_string));
668 }
669 if !req.confirm_destructive {
670 bail!("memory governance mutation requires confirm_destructive=true");
671 }
672 let Some(reason) = reason else {
673 bail!("memory governance mutation requires an explicit reason");
674 };
675 Ok(Some(reason.to_string()))
676}
677
678fn normalized_acknowledge_pattern<'a>(req: &'a GovernMemoryRequest<'a>) -> Result<Option<&'a str>> {
679 let pattern = req
680 .acknowledge_pattern
681 .map(str::trim)
682 .filter(|value| !value.is_empty());
683 if req.action == MemoryGovernanceAction::AcknowledgePattern && pattern.is_none() {
684 bail!("acknowledge_pattern action requires acknowledge_pattern");
685 }
686 if req.action != MemoryGovernanceAction::AcknowledgePattern && pattern.is_some() {
687 bail!("acknowledge_pattern is only valid with acknowledge_pattern action");
688 }
689 Ok(pattern)
690}
691
692fn validate_acknowledgement(
693 target: &GovernanceTarget,
694 acknowledged_pattern: Option<&str>,
695) -> Result<()> {
696 let acknowledged_pattern = acknowledged_pattern.expect("validated acknowledge_pattern");
697 let Some(matched) = crate::memory::poisoning::scan_instruction_pattern(&format!(
698 "{}\n{}",
699 target.title, target.content
700 )) else {
701 bail!(
702 "memory id={} does not match an instruction-pattern; cannot acknowledge {}",
703 target.id,
704 acknowledged_pattern
705 );
706 };
707 if matched.pattern_id != acknowledged_pattern {
708 bail!(
709 "memory id={} acknowledged pattern {} does not match instruction-pattern {}@v{}",
710 target.id,
711 acknowledged_pattern,
712 matched.pattern_id,
713 matched.pattern_set_version
714 );
715 }
716 Ok(())
717}
718
719struct GovernanceTarget {
720 id: i64,
721 title: String,
722 content: String,
723 status: String,
724 version: i64,
725}
726
727fn load_target(conn: &Connection, project: &str, id: i64) -> Result<GovernanceTarget> {
728 conn.query_row(
729 "SELECT id, title, content, status, version
730 FROM memories
731 WHERE id = ?1 AND project = ?2",
732 params![id, project],
733 |row| {
734 Ok(GovernanceTarget {
735 id: row.get(0)?,
736 title: row.get(1)?,
737 content: row.get(2)?,
738 status: row.get(3)?,
739 version: row.get(4)?,
740 })
741 },
742 )
743 .optional()?
744 .ok_or_else(|| anyhow!("memory id={} not found in project={}", id, project))
745}
746
747fn insert_audit_event(
748 conn: &Connection,
749 req: &GovernMemoryRequest<'_>,
750 target: &GovernanceTarget,
751 new_status: &str,
752 reason: Option<&str>,
753 now: i64,
754) -> Result<()> {
755 let actor = req.actor.map(str::trim).filter(|value| !value.is_empty());
756 let detail = serde_json::json!({
757 "action": req.action.as_str(),
758 "memory_id": target.id,
759 "title": target.title,
760 "previous_status": target.status,
761 "new_status": new_status,
762 "reason": reason,
763 "actor": actor,
764 "acknowledged_pattern": req.acknowledge_pattern,
765 })
766 .to_string();
767 let summary = format!(
768 "{} memory {}: {} -> {}{}",
769 req.action.as_str(),
770 target.id,
771 target.status,
772 new_status,
773 reason
774 .map(|value| format!(" ({value})"))
775 .unwrap_or_default()
776 );
777 conn.execute(
778 "INSERT INTO events
779 (session_id, project, event_type, summary, detail, files, exit_code, created_at_epoch)
780 VALUES (?1, ?2, 'memory_governance', ?3, ?4, NULL, NULL, ?5)",
781 params![
782 actor.unwrap_or("memory-governance"),
783 req.project,
784 summary,
785 detail,
786 now
787 ],
788 )?;
789 Ok(())
790}
791
792#[cfg(test)]
793pub(crate) mod tests;
794#[cfg(test)]
795mod web_tests;