Skip to main content

remem/memory/
governance.rs

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