memstead_base/derivation.rs
1//! Derivation baselines — "my source changed" computed, never stamped
2//! (agent-trust plan 12).
3//!
4//! When a schema declares a rel-type `derivation: true`, explicitly
5//! writing such an edge records the TARGET's content hash at write
6//! time as the edge's baseline. The include-gated `stale_derivations`
7//! health axis compares each such edge's baseline against the
8//! target's CURRENT hash: differ → the source is stale against the
9//! target; no baseline recorded → `unbaselined`, distinctly — never
10//! fabricated as fresh or stale.
11//!
12//! Baselines are engine-owned sidecar state at
13//! [`DERIVATION_SIDECAR_PATH`] (`.memstead/derivations.json`) — the
14//! anchors-sidecar precedent: staged through the backend's normal
15//! entity-path write so it rides the SAME commit as the mutation that
16//! produced it, filtered from entity listings by the `.memstead/`
17//! rule, invisible in the mem's markdown, and excluded from `_hash`
18//! by construction. Export/import behaviour follows the anchors
19//! sidecar's decisions (the archive path carries `.memstead/` members
20//! as-is).
21//!
22//! Baseline hashes are the engine's per-entity `content_hash` —
23//! SHA-256 over the raw markdown truncated to 16 hex characters, the
24//! same 64-bit form optimistic locking uses.
25//!
26//! Only EXPLICITLY written edges record baselines (create
27//! `relations`, update `declare_relations`, `memstead_relate`) —
28//! alias-synthesized body-link edges and hierarchy edges are
29//! load-derived, not written, and never carry one. Rows whose edge
30//! has since been removed are inert (the axis walks live edges, so an
31//! orphaned row can never surface); relate-remove prunes its row
32//! eagerly.
33
34use std::collections::BTreeMap;
35
36use serde::{Deserialize, Serialize};
37
38/// Where the sidecar lives inside a mem — sibling of the anchors
39/// sidecar, inside the `.memstead/` prefix every backend filters from
40/// entity listings.
41pub const DERIVATION_SIDECAR_PATH: &str = ".memstead/derivations.json";
42
43/// One recorded baseline: the target's content hash when the edge was
44/// (last) asserted.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct DerivationBaseline {
47 pub rel_type: String,
48 pub target: String,
49 /// The target's `content_hash` (16-hex truncated SHA-256) at
50 /// assert time. Empty when the target was a stub with no body —
51 /// the real content landing later then reads as a change, which
52 /// is honest: the derivation was asserted against nothing.
53 pub target_hash: String,
54}
55
56/// The sidecar document: baselines keyed by source entity id.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct DerivationSidecar {
59 #[serde(default = "default_version")]
60 pub version: u32,
61 /// source id → recorded baselines for its derivation edges.
62 #[serde(default)]
63 pub baselines: BTreeMap<String, Vec<DerivationBaseline>>,
64}
65
66fn default_version() -> u32 {
67 1
68}
69
70impl Default for DerivationSidecar {
71 /// Fresh sidecars serialize `version: 1` — aligned with the
72 /// serde default for files missing the field, so a future
73 /// version-gated migration never reads a fresh file as pre-1.
74 fn default() -> Self {
75 Self {
76 version: default_version(),
77 baselines: BTreeMap::new(),
78 }
79 }
80}
81
82impl DerivationSidecar {
83 pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
84 serde_json::from_slice(bytes)
85 }
86
87 pub fn to_bytes(&self) -> Vec<u8> {
88 serde_json::to_vec_pretty(self).expect("derivation sidecar serialises")
89 }
90
91 /// Record (or refresh) the baseline for one edge.
92 pub fn set(&mut self, source: &str, rel_type: &str, target: &str, target_hash: &str) {
93 let list = self.baselines.entry(source.to_string()).or_default();
94 if let Some(existing) = list
95 .iter_mut()
96 .find(|b| b.rel_type == rel_type && b.target == target)
97 {
98 existing.target_hash = target_hash.to_string();
99 } else {
100 list.push(DerivationBaseline {
101 rel_type: rel_type.to_string(),
102 target: target.to_string(),
103 target_hash: target_hash.to_string(),
104 });
105 }
106 }
107
108 /// The recorded baseline hash for one edge, if any.
109 pub fn get(&self, source: &str, rel_type: &str, target: &str) -> Option<&str> {
110 self.baselines.get(source)?.iter().find_map(|b| {
111 (b.rel_type == rel_type && b.target == target).then_some(b.target_hash.as_str())
112 })
113 }
114
115 /// Drop the baseline for one edge (relate-remove's eager prune).
116 pub fn remove(&mut self, source: &str, rel_type: &str, target: &str) {
117 if let Some(list) = self.baselines.get_mut(source) {
118 list.retain(|b| !(b.rel_type == rel_type && b.target == target));
119 if list.is_empty() {
120 self.baselines.remove(source);
121 }
122 }
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn set_get_refresh_remove_round_trip() {
132 let mut s = DerivationSidecar::default();
133 assert_eq!(s.get("m--a", "DERIVED_FROM", "m--b"), None);
134 s.set("m--a", "DERIVED_FROM", "m--b", "hash1");
135 assert_eq!(s.get("m--a", "DERIVED_FROM", "m--b"), Some("hash1"));
136 // Refresh replaces in place, no duplicate row.
137 s.set("m--a", "DERIVED_FROM", "m--b", "hash2");
138 assert_eq!(s.get("m--a", "DERIVED_FROM", "m--b"), Some("hash2"));
139 assert_eq!(s.baselines["m--a"].len(), 1);
140 // Distinct edges coexist.
141 s.set("m--a", "SUMMARIZES", "m--b", "hash3");
142 assert_eq!(s.baselines["m--a"].len(), 2);
143 let bytes = s.to_bytes();
144 let back = DerivationSidecar::from_bytes(&bytes).unwrap();
145 assert_eq!(back.get("m--a", "SUMMARIZES", "m--b"), Some("hash3"));
146 let mut back = back;
147 back.remove("m--a", "DERIVED_FROM", "m--b");
148 assert_eq!(back.get("m--a", "DERIVED_FROM", "m--b"), None);
149 assert_eq!(back.get("m--a", "SUMMARIZES", "m--b"), Some("hash3"));
150 }
151}