use crate::error::{Error, Result};
use crate::state::AppState;
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct ApprovalRecord {
pub note_path: String,
pub content_hash: String,
pub source: String,
pub approved_at: String,
pub anchor: Option<String>,
}
pub const APPROVAL_PAYLOAD_TYPE: &str = "review_approval";
#[cfg(feature = "dag")]
pub async fn record_approval(
state: &AppState,
note_path: &str,
content: &str,
source: &str,
) -> Result<Option<String>> {
let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
let graph = state.graph.read().await;
let Some(dag_store) = graph.dag_store() else {
return Ok(None);
};
let author = state
.dag_author
.clone()
.unwrap_or_else(|| aingle_graph::NodeId::named("node:local"));
let seq = state
.dag_seq_counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let parents = dag_store.tips().unwrap_or_default();
let approved_at = chrono::Utc::now();
let mut action = aingle_graph::dag::DagAction {
parents,
author,
seq,
timestamp: approved_at,
payload: aingle_graph::dag::DagPayload::Custom {
payload_type: APPROVAL_PAYLOAD_TYPE.to_string(),
payload_summary: format!("Approved note: {note_path}"),
payload: Some(serde_json::json!({
"note_path": note_path,
"content_hash": content_hash,
"approved_at": approved_at.to_rfc3339(),
"source": source,
})),
subject: Some(note_path.to_string()),
},
signature: None,
};
let sign = |a: &mut aingle_graph::dag::DagAction| {
if let Some(ref key) = state.dag_signing_key {
key.sign(a);
}
};
sign(&mut action);
if let Err(e) = dag_store.put(&action) {
tracing::warn!("review approval put failed ({e}); retrying without parents");
action.parents = Vec::new();
action.signature = None;
sign(&mut action);
dag_store
.put(&action)
.map_err(|e2| Error::Internal(format!("review approval DAG put failed: {e2}")))?;
}
let anchor = action.compute_hash().to_hex();
let signed = action.signature.is_some();
Ok(signed.then_some(anchor))
}
#[cfg(feature = "dag")]
pub async fn list_approvals(state: &AppState, limit: usize) -> Vec<ApprovalRecord> {
let graph = state.graph.read().await;
let Some(dag_store) = graph.dag_store() else {
return vec![];
};
let author = state
.dag_author
.clone()
.unwrap_or_else(|| aingle_graph::NodeId::named("node:local"));
let chain = dag_store
.chain(&author, limit.saturating_mul(8).max(64))
.unwrap_or_default();
let mut out: Vec<ApprovalRecord> = Vec::new();
for action in chain {
let aingle_graph::dag::DagPayload::Custom {
payload_type,
payload,
subject,
..
} = &action.payload
else {
continue;
};
if payload_type != APPROVAL_PAYLOAD_TYPE {
continue;
}
let data = payload.as_ref();
let get = |k: &str| {
data.and_then(|v| v.get(k))
.and_then(|v| v.as_str())
.map(str::to_string)
};
let anchor = action
.signature
.as_ref()
.map(|_| action.compute_hash().to_hex());
out.push(ApprovalRecord {
note_path: subject
.clone()
.or_else(|| get("note_path"))
.unwrap_or_default(),
content_hash: get("content_hash").unwrap_or_default(),
source: get("source").unwrap_or_else(|| "unknown".to_string()),
approved_at: get("approved_at").unwrap_or_else(|| action.timestamp.to_rfc3339()),
anchor,
});
}
out.sort_by(|a, b| b.approved_at.cmp(&a.approved_at));
out.truncate(limit);
out
}
#[cfg(all(test, feature = "dag"))]
mod tests {
use super::*;
async fn dag_state() -> AppState {
let state = AppState::with_db_path(":memory:", None).unwrap();
{
let mut graph = state.graph.write().await;
graph.enable_dag();
}
state
}
#[tokio::test]
async fn approval_is_signed_and_anchored_when_key_present() {
let mut state = dag_state().await;
state.dag_signing_key = Some(std::sync::Arc::new(
aingle_graph::dag::DagSigningKey::generate(),
));
let content = "# Approved\n\nCurated knowledge.\n";
let anchor = record_approval(&state, "notes/foo.md", content, "mcp")
.await
.unwrap()
.expect("a signing key is present, so the approval must be anchored");
assert_eq!(anchor.len(), 64, "anchor is a blake3 hex digest: {anchor}");
let records = list_approvals(&state, 10).await;
assert_eq!(records.len(), 1, "the approval must be listed: {records:?}");
let r = &records[0];
assert_eq!(r.note_path, "notes/foo.md");
assert_eq!(r.source, "mcp");
assert_eq!(
r.content_hash,
blake3::hash(content.as_bytes()).to_hex().to_string(),
"content_hash must attest to the exact approved content"
);
assert_eq!(
r.anchor.as_deref(),
Some(anchor.as_str()),
"the listed anchor must match the one returned at record time"
);
}
#[tokio::test]
async fn approval_persists_unsigned_when_no_key() {
let state = dag_state().await; let out = record_approval(&state, "notes/bar.md", "body", "claude")
.await
.unwrap();
assert!(out.is_none(), "no key ⇒ no verifiable anchor returned");
let records = list_approvals(&state, 10).await;
assert_eq!(records.len(), 1);
assert_eq!(records[0].note_path, "notes/bar.md");
assert!(
records[0].anchor.is_none(),
"an unsigned approval must surface anchor = None"
);
}
#[tokio::test]
async fn approvals_are_newest_first_and_capped() {
let mut state = dag_state().await;
state.dag_signing_key = Some(std::sync::Arc::new(
aingle_graph::dag::DagSigningKey::generate(),
));
for i in 0..3 {
record_approval(&state, &format!("notes/n{i}.md"), &format!("c{i}"), "mcp")
.await
.unwrap();
}
let all = list_approvals(&state, 10).await;
assert_eq!(all.len(), 3);
assert!(
all[0].approved_at >= all[1].approved_at && all[1].approved_at >= all[2].approved_at,
"must be sorted newest-first: {all:?}"
);
let capped = list_approvals(&state, 2).await;
assert_eq!(capped.len(), 2, "limit must cap the result set");
}
}