use crate::NodeId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DagActionHash(pub [u8; 32]);
impl DagActionHash {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn to_hex(&self) -> String {
self.0.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn from_hex(hex: &str) -> Option<Self> {
if hex.len() != 64 {
return None;
}
let mut bytes = [0u8; 32];
for i in 0..32 {
bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
}
Some(Self(bytes))
}
}
impl std::fmt::Display for DagActionHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_hex())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum DagPayload {
TripleInsert {
triples: Vec<TripleInsertPayload>,
},
TripleDelete {
triple_ids: Vec<[u8; 32]>,
#[serde(default)]
subjects: Vec<String>,
},
MemoryOp {
kind: MemoryOpKind,
},
Batch {
ops: Vec<DagPayload>,
},
Genesis {
triple_count: usize,
description: String,
},
Compact {
pruned_count: usize,
retained_count: usize,
policy: String,
},
Noop,
Custom {
payload_type: String,
payload_summary: String,
#[serde(default)]
payload: Option<serde_json::Value>,
#[serde(default)]
subject: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Provenance {
pub source_path: String,
pub line_start: u32,
pub line_end: u32,
pub content_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TripleInsertPayload {
pub subject: String,
pub predicate: String,
pub object: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<Provenance>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum MemoryOpKind {
Store { entry_type: String, importance: f32 },
Forget { memory_id: String },
Consolidate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DagAction {
pub parents: Vec<DagActionHash>,
pub author: NodeId,
pub seq: u64,
pub timestamp: DateTime<Utc>,
pub payload: DagPayload,
#[serde(default)]
pub signature: Option<Vec<u8>>,
}
impl DagAction {
pub fn compute_hash(&self) -> DagActionHash {
let mut hasher = blake3::Hasher::new();
hasher.update(&(self.parents.len() as u64).to_le_bytes());
for parent in &self.parents {
hasher.update(&parent.0);
}
let author_bytes =
serde_json::to_vec(&self.author).expect("NodeId serialization must not fail");
hasher.update(&(author_bytes.len() as u64).to_le_bytes());
hasher.update(&author_bytes);
hasher.update(&self.seq.to_le_bytes());
let ts = self.timestamp.to_rfc3339();
hasher.update(ts.as_bytes());
let payload_bytes =
serde_json::to_vec(&self.payload).expect("DagPayload serialization must not fail");
hasher.update(&(payload_bytes.len() as u64).to_le_bytes());
hasher.update(&payload_bytes);
DagActionHash(*hasher.finalize().as_bytes())
}
pub fn to_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("DagAction serialization must not fail")
}
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
serde_json::from_slice(bytes).ok()
}
pub fn is_genesis(&self) -> bool {
self.parents.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NodeId;
fn make_test_action(seq: u64, parents: Vec<DagActionHash>) -> DagAction {
DagAction {
parents,
author: NodeId::named("node:1"),
seq,
timestamp: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
payload: DagPayload::TripleInsert {
triples: vec![TripleInsertPayload {
subject: "alice".into(),
predicate: "knows".into(),
object: serde_json::json!("bob"),
provenance: None,
}],
},
signature: None,
}
}
#[test]
fn test_hash_deterministic() {
let action = make_test_action(1, vec![]);
let h1 = action.compute_hash();
let h2 = action.compute_hash();
assert_eq!(h1, h2);
}
#[test]
fn test_hash_differs_on_seq() {
let a1 = make_test_action(1, vec![]);
let a2 = make_test_action(2, vec![]);
assert_ne!(a1.compute_hash(), a2.compute_hash());
}
#[test]
fn test_hash_differs_on_parents() {
let a1 = make_test_action(1, vec![]);
let a2 = make_test_action(1, vec![DagActionHash([0xAB; 32])]);
assert_ne!(a1.compute_hash(), a2.compute_hash());
}
#[test]
fn test_hash_hex_roundtrip() {
let hash = DagActionHash([0xDE; 32]);
let hex = hash.to_hex();
assert_eq!(hex.len(), 64);
let restored = DagActionHash::from_hex(&hex).unwrap();
assert_eq!(hash, restored);
}
#[test]
fn test_serialization_roundtrip() {
let action = make_test_action(5, vec![DagActionHash([1; 32])]);
let bytes = action.to_bytes();
let restored = DagAction::from_bytes(&bytes).unwrap();
assert_eq!(restored.seq, 5);
assert_eq!(restored.parents.len(), 1);
}
#[test]
fn test_genesis_action() {
let genesis = DagAction {
parents: vec![],
author: NodeId::named("aingle:system"),
seq: 0,
timestamp: Utc::now(),
payload: DagPayload::Genesis {
triple_count: 42,
description: "Migration from v0.5.0".into(),
},
signature: None,
};
assert!(genesis.is_genesis());
let child = make_test_action(1, vec![genesis.compute_hash()]);
assert!(!child.is_genesis());
}
#[test]
fn test_batch_payload() {
let action = DagAction {
parents: vec![],
author: NodeId::named("node:1"),
seq: 1,
timestamp: Utc::now(),
payload: DagPayload::Batch {
ops: vec![
DagPayload::TripleInsert {
triples: vec![TripleInsertPayload {
subject: "a".into(),
predicate: "b".into(),
object: serde_json::json!("c"),
provenance: None,
}],
},
DagPayload::TripleDelete {
triple_ids: vec![[0u8; 32]],
subjects: vec![],
},
],
},
signature: None,
};
let bytes = action.to_bytes();
let restored = DagAction::from_bytes(&bytes).unwrap();
assert!(matches!(restored.payload, DagPayload::Batch { ops } if ops.len() == 2));
}
#[test]
fn test_signature_excluded_from_hash() {
let mut a1 = make_test_action(1, vec![]);
a1.signature = None;
let h1 = a1.compute_hash();
a1.signature = Some(vec![1, 2, 3, 4]);
let h2 = a1.compute_hash();
assert_eq!(h1, h2, "signature must not affect hash");
}
#[test]
fn test_forward_compat_unknown_fields_ignored() {
let json = r#"{
"parents": [],
"author": {"Named":"node:1"},
"seq": 42,
"timestamp": "2026-01-01T00:00:00Z",
"payload": "Noop",
"signature": null,
"future_field": "some_new_data",
"another_future": 123
}"#;
let action: DagAction = serde_json::from_str(json)
.expect("must deserialize actions with unknown fields (forward compat)");
assert_eq!(action.seq, 42);
assert!(matches!(action.payload, DagPayload::Noop));
}
#[test]
fn test_forward_compat_unknown_payload_variant() {
let json = r#"{
"parents": [],
"author": {"Named":"node:1"},
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"payload": {"FutureVariant": {"data": "xyz"}},
"signature": null
}"#;
let result = DagAction::from_bytes(json.as_bytes());
assert!(
result.is_none(),
"unknown payload variants must fail gracefully (None, not panic)"
);
}
#[test]
fn test_backward_compat_missing_signature() {
let json = r#"{
"parents": [],
"author": {"Named":"node:1"},
"seq": 1,
"timestamp": "2026-01-01T00:00:00Z",
"payload": "Noop"
}"#;
let action: DagAction = serde_json::from_str(json)
.expect("must deserialize actions without signature field (backward compat)");
assert!(action.signature.is_none());
}
#[test]
fn provenance_none_is_omitted_and_hash_is_stable() {
use crate::dag::{DagPayload, TripleInsertPayload};
use chrono::TimeZone;
let p = TripleInsertPayload {
subject: "alice".into(),
predicate: "knows".into(),
object: serde_json::json!("bob"),
provenance: None,
};
let json = serde_json::to_string(&p).unwrap();
assert!(
!json.contains("provenance"),
"None provenance must be skipped: {json}"
);
let old = r#"{"subject":"a","predicate":"b","object":"c"}"#;
let parsed: TripleInsertPayload = serde_json::from_str(old).unwrap();
assert!(parsed.provenance.is_none());
let prov = Provenance {
source_path: "docs/x.md".into(),
line_start: 3,
line_end: 5,
content_hash: "deadbeef".into(),
};
let p2 = TripleInsertPayload {
subject: "s".into(),
predicate: "p".into(),
object: serde_json::json!("o"),
provenance: Some(prov.clone()),
};
let round: TripleInsertPayload =
serde_json::from_str(&serde_json::to_string(&p2).unwrap()).unwrap();
assert_eq!(round.provenance, Some(prov));
let action = DagAction {
parents: vec![],
author: crate::NodeId::named("node:a"),
seq: 0,
timestamp: chrono::Utc.timestamp_opt(0, 0).unwrap(),
payload: DagPayload::TripleInsert { triples: vec![p] },
signature: None,
};
let _ = action.compute_hash(); }
}