Skip to main content

mempal_runtime/
knowledge_lifecycle.rs

1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result, bail};
6use serde::Serialize;
7
8use crate::core::{
9    db::Database,
10    types::{Drawer, KnowledgeStatus, KnowledgeTier, MemoryKind},
11    utils::current_timestamp,
12};
13use crate::knowledge_gate::{GateReport, evaluate_gate_for_drawer};
14
15#[derive(Debug, Clone)]
16pub struct PromoteRequest {
17    pub drawer_id: String,
18    pub status: String,
19    pub verification_refs: Vec<String>,
20    pub reason: String,
21    pub reviewer: Option<String>,
22    pub allow_counterexamples: bool,
23    pub enforce_gate: bool,
24}
25
26#[derive(Debug, Clone)]
27pub struct DemoteRequest {
28    pub drawer_id: String,
29    pub status: String,
30    pub evidence_refs: Vec<String>,
31    pub reason: String,
32    pub reason_type: String,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct PromoteOutcome {
37    pub drawer_id: String,
38    pub old_status: String,
39    pub new_status: String,
40    pub verification_refs: Vec<String>,
41    pub gate: Option<GateReport>,
42}
43
44#[derive(Debug, Clone, Serialize)]
45pub struct DemoteOutcome {
46    pub drawer_id: String,
47    pub old_status: String,
48    pub new_status: String,
49    pub counterexample_refs: Vec<String>,
50}
51
52pub fn promote_knowledge(db: &Database, request: PromoteRequest) -> Result<PromoteOutcome> {
53    let target_status = parse_lifecycle_status(&request.status)?;
54    if !matches!(
55        target_status,
56        KnowledgeStatus::Promoted | KnowledgeStatus::Canonical
57    ) {
58        bail!("promote status must be promoted or canonical");
59    }
60    let mut drawer = load_lifecycle_knowledge_drawer(db, &request.drawer_id)?;
61    validate_tier_status(
62        drawer.tier.as_ref().expect("knowledge drawer has tier"),
63        &target_status,
64    )?;
65    validate_lifecycle_refs(db, &request.verification_refs)?;
66
67    let old_status = drawer.status.clone().expect("knowledge drawer has status");
68    append_unique_refs(&mut drawer.verification_refs, &request.verification_refs);
69    let gate = if request.enforce_gate {
70        let gate = evaluate_gate_for_drawer(
71            db,
72            &drawer,
73            &target_status,
74            request.reviewer.as_deref(),
75            request.allow_counterexamples,
76        )?;
77        if !gate.allowed {
78            bail!("promotion gate failed: {}", gate.reasons.join("; "));
79        }
80        Some(gate)
81    } else {
82        None
83    };
84
85    db.update_knowledge_lifecycle(
86        &request.drawer_id,
87        &target_status,
88        &drawer.verification_refs,
89        &drawer.counterexample_refs,
90    )
91    .context("failed to update knowledge lifecycle")?;
92    append_audit_entry(
93        db,
94        "knowledge_promote",
95        &serde_json::json!({
96            "drawer_id": request.drawer_id,
97            "old_status": knowledge_status_slug(&old_status),
98            "new_status": knowledge_status_slug(&target_status),
99            "verification_refs": request.verification_refs,
100            "reason": request.reason,
101            "reviewer": request.reviewer,
102        }),
103    )
104    .context("failed to append audit log")?;
105
106    Ok(PromoteOutcome {
107        drawer_id: drawer.id,
108        old_status: knowledge_status_slug(&old_status).to_string(),
109        new_status: knowledge_status_slug(&target_status).to_string(),
110        verification_refs: drawer.verification_refs,
111        gate,
112    })
113}
114
115pub fn demote_knowledge(db: &Database, request: DemoteRequest) -> Result<DemoteOutcome> {
116    let target_status = parse_lifecycle_status(&request.status)?;
117    if !matches!(
118        target_status,
119        KnowledgeStatus::Demoted | KnowledgeStatus::Retired
120    ) {
121        bail!("demote status must be demoted or retired");
122    }
123    validate_demote_reason_type(&request.reason_type)?;
124    let mut drawer = load_lifecycle_knowledge_drawer(db, &request.drawer_id)?;
125    validate_tier_status(
126        drawer.tier.as_ref().expect("knowledge drawer has tier"),
127        &target_status,
128    )?;
129    validate_lifecycle_refs(db, &request.evidence_refs)?;
130
131    let old_status = drawer.status.clone().expect("knowledge drawer has status");
132    append_unique_refs(&mut drawer.counterexample_refs, &request.evidence_refs);
133    db.update_knowledge_lifecycle(
134        &request.drawer_id,
135        &target_status,
136        &drawer.verification_refs,
137        &drawer.counterexample_refs,
138    )
139    .context("failed to update knowledge lifecycle")?;
140    append_audit_entry(
141        db,
142        "knowledge_demote",
143        &serde_json::json!({
144            "drawer_id": request.drawer_id,
145            "old_status": knowledge_status_slug(&old_status),
146            "new_status": knowledge_status_slug(&target_status),
147            "evidence_refs": request.evidence_refs,
148            "reason": request.reason,
149            "reason_type": request.reason_type,
150        }),
151    )
152    .context("failed to append audit log")?;
153
154    Ok(DemoteOutcome {
155        drawer_id: drawer.id,
156        old_status: knowledge_status_slug(&old_status).to_string(),
157        new_status: knowledge_status_slug(&target_status).to_string(),
158        counterexample_refs: drawer.counterexample_refs,
159    })
160}
161
162fn load_lifecycle_knowledge_drawer(db: &Database, drawer_id: &str) -> Result<Drawer> {
163    let drawer = db
164        .get_drawer(drawer_id)
165        .context("failed to look up drawer")?
166        .with_context(|| format!("drawer not found: {drawer_id}"))?;
167    if drawer.memory_kind != MemoryKind::Knowledge {
168        bail!("knowledge lifecycle requires a knowledge drawer");
169    }
170    if drawer.tier.is_none() || drawer.status.is_none() {
171        bail!("knowledge lifecycle requires tier and status metadata");
172    }
173    Ok(drawer)
174}
175
176fn parse_lifecycle_status(value: &str) -> Result<KnowledgeStatus> {
177    match value.trim() {
178        "candidate" => Ok(KnowledgeStatus::Candidate),
179        "promoted" => Ok(KnowledgeStatus::Promoted),
180        "canonical" => Ok(KnowledgeStatus::Canonical),
181        "demoted" => Ok(KnowledgeStatus::Demoted),
182        "retired" => Ok(KnowledgeStatus::Retired),
183        other => bail!("unsupported knowledge status: {other}"),
184    }
185}
186
187fn validate_tier_status(tier: &KnowledgeTier, status: &KnowledgeStatus) -> Result<()> {
188    let allowed = match tier {
189        KnowledgeTier::DaoTian => &[KnowledgeStatus::Canonical, KnowledgeStatus::Demoted][..],
190        KnowledgeTier::DaoRen => &[
191            KnowledgeStatus::Candidate,
192            KnowledgeStatus::Promoted,
193            KnowledgeStatus::Demoted,
194            KnowledgeStatus::Retired,
195        ][..],
196        KnowledgeTier::Shu => &[
197            KnowledgeStatus::Promoted,
198            KnowledgeStatus::Demoted,
199            KnowledgeStatus::Retired,
200        ][..],
201        KnowledgeTier::Qi => &[
202            KnowledgeStatus::Candidate,
203            KnowledgeStatus::Promoted,
204            KnowledgeStatus::Demoted,
205            KnowledgeStatus::Retired,
206        ][..],
207    };
208
209    if allowed.contains(status) {
210        return Ok(());
211    }
212
213    match tier {
214        KnowledgeTier::DaoTian => bail!("dao_tian only allows canonical or demoted"),
215        KnowledgeTier::DaoRen => {
216            bail!("dao_ren only allows candidate, promoted, demoted, or retired")
217        }
218        KnowledgeTier::Shu => bail!("shu only allows promoted, demoted, or retired"),
219        KnowledgeTier::Qi => bail!("qi only allows candidate, promoted, demoted, or retired"),
220    }
221}
222
223fn validate_demote_reason_type(value: &str) -> Result<()> {
224    match value.trim() {
225        "contradicted" | "obsolete" | "superseded" | "out_of_scope" | "unsafe" => Ok(()),
226        other => bail!("unsupported demote reason_type: {other}"),
227    }
228}
229
230fn validate_lifecycle_refs(db: &Database, refs: &[String]) -> Result<()> {
231    if refs.is_empty() {
232        bail!("at least one lifecycle evidence ref is required");
233    }
234    for drawer_id in refs {
235        if !drawer_id.starts_with("drawer_") {
236            bail!("lifecycle refs must contain drawer ids");
237        }
238        let drawer = db
239            .get_drawer(drawer_id)
240            .with_context(|| format!("failed to load ref drawer {drawer_id}"))?
241            .with_context(|| format!("ref drawer not found: {drawer_id}"))?;
242        if drawer.memory_kind != MemoryKind::Evidence {
243            bail!("lifecycle refs must point to evidence drawers");
244        }
245    }
246    Ok(())
247}
248
249fn append_unique_refs(target: &mut Vec<String>, refs: &[String]) {
250    for item in refs {
251        if !target.iter().any(|existing| existing == item) {
252            target.push(item.clone());
253        }
254    }
255}
256
257fn append_audit_entry(db: &Database, command: &str, details: &serde_json::Value) -> Result<()> {
258    let audit_path = db
259        .path()
260        .parent()
261        .map(|parent| parent.join("audit.jsonl"))
262        .unwrap_or_else(|| PathBuf::from("audit.jsonl"));
263    let mut file = OpenOptions::new()
264        .create(true)
265        .append(true)
266        .open(&audit_path)
267        .with_context(|| format!("failed to open audit log {}", audit_path.display()))?;
268    let entry = serde_json::json!({
269        "timestamp": current_timestamp(),
270        "command": command,
271        "details": details,
272    });
273    writeln!(file, "{entry}")
274        .with_context(|| format!("failed to write audit log {}", audit_path.display()))?;
275    Ok(())
276}
277
278fn knowledge_status_slug(value: &KnowledgeStatus) -> &'static str {
279    match value {
280        KnowledgeStatus::Candidate => "candidate",
281        KnowledgeStatus::Promoted => "promoted",
282        KnowledgeStatus::Canonical => "canonical",
283        KnowledgeStatus::Demoted => "demoted",
284        KnowledgeStatus::Retired => "retired",
285    }
286}