1use anyhow::{Context, Result, bail};
2use serde::Serialize;
3use sha2::{Digest, Sha256};
4
5use crate::core::{
6 db::Database,
7 types::{
8 KnowledgeCard, KnowledgeCardEvent, KnowledgeEventType, KnowledgeEvidenceLink,
9 KnowledgeEvidenceRole, KnowledgeStatus, KnowledgeTier, MemoryKind,
10 },
11 utils::current_timestamp,
12};
13use crate::knowledge_gate::{GateEvidenceCounts, GateRequirements};
14
15struct CardLifecycleMutation {
16 old_status: KnowledgeStatus,
17 target_status: KnowledgeStatus,
18 event_type: KnowledgeEventType,
19 reason: String,
20 actor: Option<String>,
21 new_refs: Vec<String>,
22 link_role: KnowledgeEvidenceRole,
23 metadata: serde_json::Value,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct KnowledgeCardGateReport {
28 pub card_id: String,
29 pub tier: String,
30 pub status: String,
31 pub target_status: String,
32 pub allowed: bool,
33 pub reasons: Vec<String>,
34 pub requirements: GateRequirements,
35 pub evidence_counts: GateEvidenceCounts,
36}
37
38#[derive(Debug, Clone)]
39pub struct PromoteCardRequest {
40 pub card_id: String,
41 pub status: String,
42 pub verification_refs: Vec<String>,
43 pub reason: String,
44 pub reviewer: Option<String>,
45 pub allow_counterexamples: bool,
46 pub enforce_gate: bool,
47}
48
49#[derive(Debug, Clone)]
50pub struct DemoteCardRequest {
51 pub card_id: String,
52 pub status: String,
53 pub evidence_refs: Vec<String>,
54 pub reason: String,
55 pub reason_type: String,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct PromoteCardOutcome {
60 pub card_id: String,
61 pub old_status: String,
62 pub new_status: String,
63 pub verification_refs: Vec<String>,
64 pub gate: Option<KnowledgeCardGateReport>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct DemoteCardOutcome {
69 pub card_id: String,
70 pub old_status: String,
71 pub new_status: String,
72 pub counterexample_refs: Vec<String>,
73}
74
75pub fn evaluate_card_gate_by_id(
76 db: &Database,
77 card_id: &str,
78 target_status: Option<&str>,
79 reviewer: Option<&str>,
80 allow_counterexamples: bool,
81) -> Result<KnowledgeCardGateReport> {
82 let card = load_card(db, card_id)?;
83 let target_status = match target_status {
84 Some(value) => parse_status(value)?,
85 None => default_target_status(&card.tier),
86 };
87 validate_tier_status(&card.tier, &target_status)?;
88 let counts = evidence_counts(db, card_id)?;
89 evaluate_card_gate(
90 &card,
91 &target_status,
92 reviewer,
93 allow_counterexamples,
94 counts,
95 )
96}
97
98pub fn promote_card(db: &Database, request: PromoteCardRequest) -> Result<PromoteCardOutcome> {
99 let target_status = parse_status(&request.status)?;
100 if !matches!(
101 target_status,
102 KnowledgeStatus::Promoted | KnowledgeStatus::Canonical
103 ) {
104 bail!("promote status must be promoted or canonical");
105 }
106 validate_evidence_refs(db, &request.verification_refs)?;
107 let mut card = load_card(db, &request.card_id)?;
108 validate_tier_status(&card.tier, &target_status)?;
109 let old_status = card.status.clone();
110
111 let existing_links = db
112 .knowledge_evidence_links(&request.card_id)
113 .context("failed to list knowledge card evidence links")?;
114 let new_refs = missing_refs_for_role(
115 &existing_links,
116 &request.verification_refs,
117 &KnowledgeEvidenceRole::Verification,
118 );
119 let projected_counts = projected_counts(
120 counts_from_links(&existing_links),
121 &new_refs,
122 &KnowledgeEvidenceRole::Verification,
123 );
124 let gate = if request.enforce_gate {
125 let gate = evaluate_card_gate(
126 &card,
127 &target_status,
128 request.reviewer.as_deref(),
129 request.allow_counterexamples,
130 projected_counts,
131 )?;
132 if !gate.allowed {
133 bail!("promotion gate failed: {}", gate.reasons.join("; "));
134 }
135 Some(gate)
136 } else {
137 None
138 };
139
140 card.status = target_status.clone();
141 card.updated_at = current_timestamp();
142 apply_card_lifecycle(
143 db,
144 &card,
145 CardLifecycleMutation {
146 old_status: old_status.clone(),
147 target_status: target_status.clone(),
148 event_type: KnowledgeEventType::Promoted,
149 reason: request.reason.clone(),
150 actor: request.reviewer.clone(),
151 new_refs,
152 link_role: KnowledgeEvidenceRole::Verification,
153 metadata: serde_json::json!({
154 "verification_refs": request.verification_refs,
155 "gate_enforced": request.enforce_gate,
156 }),
157 },
158 )?;
159
160 Ok(PromoteCardOutcome {
161 card_id: request.card_id,
162 old_status: status_slug(&old_status).to_string(),
163 new_status: status_slug(&target_status).to_string(),
164 verification_refs: refs_for_role(
165 &db.knowledge_evidence_links(&card.id)
166 .context("failed to list knowledge card evidence links")?,
167 &KnowledgeEvidenceRole::Verification,
168 ),
169 gate,
170 })
171}
172
173pub fn demote_card(db: &Database, request: DemoteCardRequest) -> Result<DemoteCardOutcome> {
174 let target_status = parse_status(&request.status)?;
175 if !matches!(
176 target_status,
177 KnowledgeStatus::Demoted | KnowledgeStatus::Retired
178 ) {
179 bail!("demote status must be demoted or retired");
180 }
181 validate_demote_reason_type(&request.reason_type)?;
182 validate_evidence_refs(db, &request.evidence_refs)?;
183 let mut card = load_card(db, &request.card_id)?;
184 validate_tier_status(&card.tier, &target_status)?;
185 let old_status = card.status.clone();
186 let existing_links = db
187 .knowledge_evidence_links(&request.card_id)
188 .context("failed to list knowledge card evidence links")?;
189 let new_refs = missing_refs_for_role(
190 &existing_links,
191 &request.evidence_refs,
192 &KnowledgeEvidenceRole::Counterexample,
193 );
194
195 card.status = target_status.clone();
196 card.updated_at = current_timestamp();
197 let event_type = if matches!(target_status, KnowledgeStatus::Retired) {
198 KnowledgeEventType::Retired
199 } else {
200 KnowledgeEventType::Demoted
201 };
202 apply_card_lifecycle(
203 db,
204 &card,
205 CardLifecycleMutation {
206 old_status: old_status.clone(),
207 target_status: target_status.clone(),
208 event_type,
209 reason: request.reason.clone(),
210 actor: None,
211 new_refs,
212 link_role: KnowledgeEvidenceRole::Counterexample,
213 metadata: serde_json::json!({
214 "evidence_refs": request.evidence_refs,
215 "reason_type": request.reason_type,
216 }),
217 },
218 )?;
219
220 Ok(DemoteCardOutcome {
221 card_id: request.card_id,
222 old_status: status_slug(&old_status).to_string(),
223 new_status: status_slug(&target_status).to_string(),
224 counterexample_refs: refs_for_role(
225 &db.knowledge_evidence_links(&card.id)
226 .context("failed to list knowledge card evidence links")?,
227 &KnowledgeEvidenceRole::Counterexample,
228 ),
229 })
230}
231
232fn load_card(db: &Database, card_id: &str) -> Result<KnowledgeCard> {
233 db.get_knowledge_card(card_id)
234 .context("failed to look up knowledge card")?
235 .with_context(|| format!("knowledge card not found: {card_id}"))
236}
237
238fn evaluate_card_gate(
239 card: &KnowledgeCard,
240 target_status: &KnowledgeStatus,
241 reviewer: Option<&str>,
242 allow_counterexamples: bool,
243 evidence_counts: GateEvidenceCounts,
244) -> Result<KnowledgeCardGateReport> {
245 validate_tier_status(&card.tier, target_status)?;
246 let requirements = gate_requirements(&card.tier, target_status);
247 let mut reasons = Vec::new();
248 if evidence_counts.supporting < requirements.min_supporting_refs {
249 reasons.push(format!(
250 "supporting evidence refs below requirement: have {}, need {}",
251 evidence_counts.supporting, requirements.min_supporting_refs
252 ));
253 }
254 if evidence_counts.verification < requirements.min_verification_refs {
255 reasons.push(format!(
256 "verification evidence refs below requirement: have {}, need {}",
257 evidence_counts.verification, requirements.min_verification_refs
258 ));
259 }
260 if evidence_counts.teaching < requirements.min_teaching_refs {
261 reasons.push(format!(
262 "teaching evidence refs below requirement: have {}, need {}",
263 evidence_counts.teaching, requirements.min_teaching_refs
264 ));
265 }
266 if requirements.reviewer_required
267 && reviewer
268 .map(str::trim)
269 .filter(|value| !value.is_empty())
270 .is_none()
271 {
272 reasons.push("reviewer is required for this gate".to_string());
273 }
274 if requirements.counterexamples_block
275 && evidence_counts.counterexample > 0
276 && !allow_counterexamples
277 {
278 reasons.push(format!(
279 "counterexample refs present: {}",
280 evidence_counts.counterexample
281 ));
282 }
283
284 Ok(KnowledgeCardGateReport {
285 card_id: card.id.clone(),
286 tier: tier_slug(&card.tier).to_string(),
287 status: status_slug(&card.status).to_string(),
288 target_status: status_slug(target_status).to_string(),
289 allowed: reasons.is_empty(),
290 reasons,
291 requirements,
292 evidence_counts,
293 })
294}
295
296fn apply_card_lifecycle(
297 db: &Database,
298 card: &KnowledgeCard,
299 mutation: CardLifecycleMutation,
300) -> Result<()> {
301 db.conn().execute_batch("BEGIN IMMEDIATE TRANSACTION")?;
302 let result = (|| {
303 for evidence_drawer_id in &mutation.new_refs {
304 let link = KnowledgeEvidenceLink {
305 id: deterministic_link_id(&card.id, evidence_drawer_id, &mutation.link_role),
306 card_id: card.id.clone(),
307 evidence_drawer_id: evidence_drawer_id.clone(),
308 role: mutation.link_role.clone(),
309 note: Some(format!("linked during card lifecycle: {}", mutation.reason)),
310 created_at: current_timestamp(),
311 };
312 db.insert_knowledge_evidence_link(&link)
313 .context("failed to insert knowledge card evidence link")?;
314 }
315 db.update_knowledge_card(card)
316 .context("failed to update knowledge card")?;
317 let event = KnowledgeCardEvent {
318 id: deterministic_event_id(&card.id, &mutation.event_type, &mutation.reason),
319 card_id: card.id.clone(),
320 event_type: mutation.event_type,
321 from_status: Some(mutation.old_status),
322 to_status: Some(mutation.target_status),
323 reason: mutation.reason,
324 actor: mutation.actor,
325 metadata: Some(mutation.metadata),
326 created_at: current_timestamp(),
327 };
328 db.append_knowledge_event(&event)
329 .context("failed to append knowledge card event")?;
330 Ok(())
331 })();
332
333 match result {
334 Ok(()) => {
335 db.conn().execute_batch("COMMIT")?;
336 Ok(())
337 }
338 Err(error) => {
339 let _ = db.conn().execute_batch("ROLLBACK");
340 Err(error)
341 }
342 }
343}
344
345fn evidence_counts(db: &Database, card_id: &str) -> Result<GateEvidenceCounts> {
346 let links = db
347 .knowledge_evidence_links(card_id)
348 .context("failed to list knowledge card evidence links")?;
349 Ok(counts_from_links(&links))
350}
351
352fn counts_from_links(links: &[KnowledgeEvidenceLink]) -> GateEvidenceCounts {
353 GateEvidenceCounts {
354 supporting: links
355 .iter()
356 .filter(|link| matches!(link.role, KnowledgeEvidenceRole::Supporting))
357 .count(),
358 counterexample: links
359 .iter()
360 .filter(|link| matches!(link.role, KnowledgeEvidenceRole::Counterexample))
361 .count(),
362 teaching: links
363 .iter()
364 .filter(|link| matches!(link.role, KnowledgeEvidenceRole::Teaching))
365 .count(),
366 verification: links
367 .iter()
368 .filter(|link| matches!(link.role, KnowledgeEvidenceRole::Verification))
369 .count(),
370 }
371}
372
373fn projected_counts(
374 mut counts: GateEvidenceCounts,
375 new_refs: &[String],
376 role: &KnowledgeEvidenceRole,
377) -> GateEvidenceCounts {
378 match role {
379 KnowledgeEvidenceRole::Supporting => counts.supporting += new_refs.len(),
380 KnowledgeEvidenceRole::Verification => counts.verification += new_refs.len(),
381 KnowledgeEvidenceRole::Counterexample => counts.counterexample += new_refs.len(),
382 KnowledgeEvidenceRole::Teaching => counts.teaching += new_refs.len(),
383 }
384 counts
385}
386
387fn missing_refs_for_role(
388 existing_links: &[KnowledgeEvidenceLink],
389 refs: &[String],
390 role: &KnowledgeEvidenceRole,
391) -> Vec<String> {
392 refs.iter()
393 .filter(|drawer_id| {
394 !existing_links
395 .iter()
396 .any(|link| link.role == *role && link.evidence_drawer_id == **drawer_id)
397 })
398 .cloned()
399 .collect()
400}
401
402fn refs_for_role(links: &[KnowledgeEvidenceLink], role: &KnowledgeEvidenceRole) -> Vec<String> {
403 links
404 .iter()
405 .filter(|link| link.role == *role)
406 .map(|link| link.evidence_drawer_id.clone())
407 .collect()
408}
409
410fn validate_evidence_refs(db: &Database, refs: &[String]) -> Result<()> {
411 if refs.is_empty() {
412 bail!("at least one evidence ref is required");
413 }
414 for drawer_id in refs {
415 if !drawer_id.starts_with("drawer_") {
416 bail!("evidence refs must contain drawer ids");
417 }
418 let drawer = db
419 .get_drawer(drawer_id)
420 .with_context(|| format!("failed to load evidence drawer {drawer_id}"))?
421 .with_context(|| format!("evidence drawer not found: {drawer_id}"))?;
422 if drawer.memory_kind != MemoryKind::Evidence {
423 bail!("evidence refs must point to evidence drawers");
424 }
425 }
426 Ok(())
427}
428
429fn parse_status(value: &str) -> Result<KnowledgeStatus> {
430 match value.trim() {
431 "candidate" => Ok(KnowledgeStatus::Candidate),
432 "promoted" => Ok(KnowledgeStatus::Promoted),
433 "canonical" => Ok(KnowledgeStatus::Canonical),
434 "demoted" => Ok(KnowledgeStatus::Demoted),
435 "retired" => Ok(KnowledgeStatus::Retired),
436 other => bail!("unsupported knowledge status: {other}"),
437 }
438}
439
440fn default_target_status(tier: &KnowledgeTier) -> KnowledgeStatus {
441 match tier {
442 KnowledgeTier::DaoTian => KnowledgeStatus::Canonical,
443 KnowledgeTier::DaoRen | KnowledgeTier::Shu | KnowledgeTier::Qi => KnowledgeStatus::Promoted,
444 }
445}
446
447fn validate_tier_status(tier: &KnowledgeTier, status: &KnowledgeStatus) -> Result<()> {
448 let allowed = match tier {
449 KnowledgeTier::DaoTian => &[KnowledgeStatus::Canonical, KnowledgeStatus::Demoted][..],
450 KnowledgeTier::DaoRen => &[
451 KnowledgeStatus::Candidate,
452 KnowledgeStatus::Promoted,
453 KnowledgeStatus::Demoted,
454 KnowledgeStatus::Retired,
455 ][..],
456 KnowledgeTier::Shu => &[
457 KnowledgeStatus::Promoted,
458 KnowledgeStatus::Demoted,
459 KnowledgeStatus::Retired,
460 ][..],
461 KnowledgeTier::Qi => &[
462 KnowledgeStatus::Candidate,
463 KnowledgeStatus::Promoted,
464 KnowledgeStatus::Demoted,
465 KnowledgeStatus::Retired,
466 ][..],
467 };
468
469 if allowed.contains(status) {
470 return Ok(());
471 }
472
473 match tier {
474 KnowledgeTier::DaoTian => bail!("dao_tian only allows canonical or demoted"),
475 KnowledgeTier::DaoRen => {
476 bail!("dao_ren only allows candidate, promoted, demoted, or retired")
477 }
478 KnowledgeTier::Shu => bail!("shu only allows promoted, demoted, or retired"),
479 KnowledgeTier::Qi => bail!("qi only allows candidate, promoted, demoted, or retired"),
480 }
481}
482
483fn validate_demote_reason_type(value: &str) -> Result<()> {
484 match value.trim() {
485 "contradicted" | "obsolete" | "superseded" | "out_of_scope" | "unsafe" => Ok(()),
486 other => bail!("unsupported demote reason_type: {other}"),
487 }
488}
489
490fn gate_requirements(tier: &KnowledgeTier, target_status: &KnowledgeStatus) -> GateRequirements {
491 match (tier, target_status) {
492 (KnowledgeTier::DaoTian, KnowledgeStatus::Canonical) => GateRequirements {
493 min_supporting_refs: 3,
494 min_verification_refs: 2,
495 min_teaching_refs: 1,
496 reviewer_required: true,
497 counterexamples_block: true,
498 },
499 (KnowledgeTier::DaoRen, KnowledgeStatus::Promoted) => GateRequirements {
500 min_supporting_refs: 2,
501 min_verification_refs: 1,
502 min_teaching_refs: 0,
503 reviewer_required: false,
504 counterexamples_block: true,
505 },
506 (KnowledgeTier::Shu | KnowledgeTier::Qi, KnowledgeStatus::Promoted) => GateRequirements {
507 min_supporting_refs: 1,
508 min_verification_refs: 1,
509 min_teaching_refs: 0,
510 reviewer_required: false,
511 counterexamples_block: true,
512 },
513 _ => GateRequirements {
514 min_supporting_refs: 0,
515 min_verification_refs: 0,
516 min_teaching_refs: 0,
517 reviewer_required: false,
518 counterexamples_block: true,
519 },
520 }
521}
522
523fn deterministic_link_id(
524 card_id: &str,
525 evidence_drawer_id: &str,
526 role: &KnowledgeEvidenceRole,
527) -> String {
528 let mut hasher = Sha256::new();
529 hasher.update(b"knowledge-card-lifecycle-link-v1");
530 hasher.update([0]);
531 hasher.update(card_id.as_bytes());
532 hasher.update([0]);
533 hasher.update(evidence_drawer_id.as_bytes());
534 hasher.update([0]);
535 hasher.update(evidence_role_slug(role).as_bytes());
536 let digest = format!("{:x}", hasher.finalize());
537 format!("link_{}", &digest[..16])
538}
539
540fn deterministic_event_id(card_id: &str, event_type: &KnowledgeEventType, reason: &str) -> String {
541 let mut hasher = Sha256::new();
542 hasher.update(b"knowledge-card-lifecycle-event-v1");
543 hasher.update([0]);
544 hasher.update(card_id.as_bytes());
545 hasher.update([0]);
546 hasher.update(event_type_slug(event_type).as_bytes());
547 hasher.update([0]);
548 hasher.update(reason.as_bytes());
549 let digest = format!("{:x}", hasher.finalize());
550 format!("event_{}", &digest[..16])
551}
552
553fn evidence_role_slug(value: &KnowledgeEvidenceRole) -> &'static str {
554 match value {
555 KnowledgeEvidenceRole::Supporting => "supporting",
556 KnowledgeEvidenceRole::Verification => "verification",
557 KnowledgeEvidenceRole::Counterexample => "counterexample",
558 KnowledgeEvidenceRole::Teaching => "teaching",
559 }
560}
561
562fn event_type_slug(value: &KnowledgeEventType) -> &'static str {
563 match value {
564 KnowledgeEventType::Created => "created",
565 KnowledgeEventType::Promoted => "promoted",
566 KnowledgeEventType::Demoted => "demoted",
567 KnowledgeEventType::Retired => "retired",
568 KnowledgeEventType::Linked => "linked",
569 KnowledgeEventType::Unlinked => "unlinked",
570 KnowledgeEventType::Updated => "updated",
571 KnowledgeEventType::PublishedAnchor => "published_anchor",
572 }
573}
574
575fn tier_slug(value: &KnowledgeTier) -> &'static str {
576 match value {
577 KnowledgeTier::Qi => "qi",
578 KnowledgeTier::Shu => "shu",
579 KnowledgeTier::DaoRen => "dao_ren",
580 KnowledgeTier::DaoTian => "dao_tian",
581 }
582}
583
584fn status_slug(value: &KnowledgeStatus) -> &'static str {
585 match value {
586 KnowledgeStatus::Candidate => "candidate",
587 KnowledgeStatus::Promoted => "promoted",
588 KnowledgeStatus::Canonical => "canonical",
589 KnowledgeStatus::Demoted => "demoted",
590 KnowledgeStatus::Retired => "retired",
591 }
592}