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}