Skip to main content

mempal_runtime/
knowledge_anchor.rs

1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use serde::Serialize;
7
8use crate::core::{
9    anchor::{self, DerivedAnchor},
10    db::Database,
11    types::{AnchorKind, Drawer, KnowledgeStatus, MemoryDomain, MemoryKind},
12    utils::current_timestamp,
13};
14
15#[derive(Debug, Clone)]
16pub struct PublishAnchorRequest {
17    pub drawer_id: String,
18    pub to: String,
19    pub target_anchor_id: Option<String>,
20    pub cwd: Option<PathBuf>,
21    pub reason: String,
22    pub reviewer: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct PublishAnchorOutcome {
27    pub drawer_id: String,
28    pub old_anchor_kind: String,
29    pub old_anchor_id: String,
30    pub old_parent_anchor_id: Option<String>,
31    pub new_anchor_kind: String,
32    pub new_anchor_id: String,
33    pub new_parent_anchor_id: Option<String>,
34}
35
36pub fn publish_anchor(
37    db: &Database,
38    request: PublishAnchorRequest,
39) -> Result<PublishAnchorOutcome> {
40    let drawer = load_publishable_knowledge(db, &request.drawer_id)?;
41    let target = resolve_target_anchor(&drawer, &request)?;
42    if drawer.anchor_kind == target.anchor_kind && drawer.anchor_id == target.anchor_id {
43        bail!("same-anchor publication is not allowed");
44    }
45    validate_publication_chain(&drawer, &target)?;
46    anchor::validate_anchor_domain(&drawer.domain, &target.anchor_kind)
47        .map_err(|message| anyhow::anyhow!(message))?;
48    anchor::validate_explicit_anchor(&target.anchor_kind, &target.anchor_id)?;
49
50    db.update_knowledge_anchor(
51        &request.drawer_id,
52        &target.anchor_kind,
53        &target.anchor_id,
54        target.parent_anchor_id.as_deref(),
55    )
56    .context("failed to update knowledge anchor")?;
57    append_audit_entry(
58        db,
59        "knowledge_publish_anchor",
60        &serde_json::json!({
61            "drawer_id": request.drawer_id,
62            "old_anchor_kind": anchor_kind_slug(&drawer.anchor_kind),
63            "old_anchor_id": drawer.anchor_id,
64            "old_parent_anchor_id": drawer.parent_anchor_id,
65            "new_anchor_kind": anchor_kind_slug(&target.anchor_kind),
66            "new_anchor_id": target.anchor_id,
67            "new_parent_anchor_id": target.parent_anchor_id,
68            "reason": request.reason,
69            "reviewer": request.reviewer,
70        }),
71    )
72    .context("failed to append audit log")?;
73
74    Ok(PublishAnchorOutcome {
75        drawer_id: drawer.id,
76        old_anchor_kind: anchor_kind_slug(&drawer.anchor_kind).to_string(),
77        old_anchor_id: drawer.anchor_id,
78        old_parent_anchor_id: drawer.parent_anchor_id,
79        new_anchor_kind: anchor_kind_slug(&target.anchor_kind).to_string(),
80        new_anchor_id: target.anchor_id,
81        new_parent_anchor_id: target.parent_anchor_id,
82    })
83}
84
85fn load_publishable_knowledge(db: &Database, drawer_id: &str) -> Result<Drawer> {
86    let drawer = db
87        .get_drawer(drawer_id)
88        .context("failed to look up drawer")?
89        .with_context(|| format!("drawer not found: {drawer_id}"))?;
90    if drawer.memory_kind != MemoryKind::Knowledge {
91        bail!("knowledge anchor publication requires a knowledge drawer");
92    }
93    match drawer.status {
94        Some(KnowledgeStatus::Promoted | KnowledgeStatus::Canonical) => Ok(drawer),
95        _ => bail!("publish-anchor requires promoted or canonical knowledge"),
96    }
97}
98
99fn resolve_target_anchor(drawer: &Drawer, request: &PublishAnchorRequest) -> Result<DerivedAnchor> {
100    match request.to.trim() {
101        "repo" => resolve_repo_target(drawer, request),
102        "global" => resolve_global_target(drawer, request),
103        other => bail!("unsupported publish target: {other}"),
104    }
105}
106
107fn resolve_repo_target(drawer: &Drawer, request: &PublishAnchorRequest) -> Result<DerivedAnchor> {
108    if let Some(anchor_id) = request.target_anchor_id.as_deref() {
109        anchor::validate_explicit_anchor(&AnchorKind::Repo, anchor_id)?;
110        return Ok(DerivedAnchor {
111            anchor_kind: AnchorKind::Repo,
112            anchor_id: anchor_id.to_string(),
113            parent_anchor_id: None,
114        });
115    }
116    if let Some(parent_anchor_id) = drawer.parent_anchor_id.as_deref() {
117        anchor::validate_explicit_anchor(&AnchorKind::Repo, parent_anchor_id)?;
118        return Ok(DerivedAnchor {
119            anchor_kind: AnchorKind::Repo,
120            anchor_id: parent_anchor_id.to_string(),
121            parent_anchor_id: None,
122        });
123    }
124    if let Some(cwd) = request.cwd.as_deref() {
125        let derived = anchor::derive_anchor_from_cwd(Some(Path::new(cwd)))?;
126        if let Some(parent_anchor_id) = derived.parent_anchor_id {
127            return Ok(DerivedAnchor {
128                anchor_kind: AnchorKind::Repo,
129                anchor_id: parent_anchor_id,
130                parent_anchor_id: None,
131            });
132        }
133    }
134    bail!("repo publication requires --target-anchor-id, parent_anchor_id, or --cwd with a repo")
135}
136
137fn resolve_global_target(drawer: &Drawer, request: &PublishAnchorRequest) -> Result<DerivedAnchor> {
138    if drawer.domain != MemoryDomain::Global {
139        bail!("global anchor requires domain=global");
140    }
141    let anchor_id = request
142        .target_anchor_id
143        .as_deref()
144        .context("--target-anchor-id is required for global publication")?;
145    anchor::validate_explicit_anchor(&AnchorKind::Global, anchor_id)?;
146    Ok(DerivedAnchor {
147        anchor_kind: AnchorKind::Global,
148        anchor_id: anchor_id.to_string(),
149        parent_anchor_id: None,
150    })
151}
152
153fn validate_publication_chain(drawer: &Drawer, target: &DerivedAnchor) -> Result<()> {
154    match (&drawer.anchor_kind, &target.anchor_kind) {
155        (AnchorKind::Worktree, AnchorKind::Repo) => Ok(()),
156        (AnchorKind::Repo, AnchorKind::Global) => Ok(()),
157        (AnchorKind::Worktree, AnchorKind::Global) => {
158            bail!("worktree -> global publication is not allowed")
159        }
160        (AnchorKind::Global, _) => bail!("inward publication from global is not allowed"),
161        (AnchorKind::Repo, AnchorKind::Worktree) | (AnchorKind::Worktree, AnchorKind::Worktree) => {
162            bail!("inward publication is not allowed")
163        }
164        (AnchorKind::Repo, AnchorKind::Repo) => bail!("same-anchor publication is not allowed"),
165    }
166}
167
168fn append_audit_entry(db: &Database, command: &str, details: &serde_json::Value) -> Result<()> {
169    let audit_path = db
170        .path()
171        .parent()
172        .map(|parent| parent.join("audit.jsonl"))
173        .unwrap_or_else(|| PathBuf::from("audit.jsonl"));
174    let mut file = OpenOptions::new()
175        .create(true)
176        .append(true)
177        .open(&audit_path)
178        .with_context(|| format!("failed to open audit log {}", audit_path.display()))?;
179    let entry = serde_json::json!({
180        "timestamp": current_timestamp(),
181        "command": command,
182        "details": details,
183    });
184    writeln!(file, "{entry}")
185        .with_context(|| format!("failed to write audit log {}", audit_path.display()))?;
186    Ok(())
187}
188
189fn anchor_kind_slug(value: &AnchorKind) -> &'static str {
190    match value {
191        AnchorKind::Global => "global",
192        AnchorKind::Repo => "repo",
193        AnchorKind::Worktree => "worktree",
194    }
195}