Skip to main content

remem/memory/
governance.rs

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(&params);
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;