memstead_schema/archive_provenance.rs
1//! Authoring-provenance payload carried inside a sealed `.mem` archive.
2//!
3//! Memstead records a one-sentence authoring rationale (the agent's
4//! `note`) on the large majority of mutating commits — the project's
5//! headline trust signal. That signal lives author-side in git history
6//! (git-branch backend) or `.memstead/changes.jsonl` (folder backend) and
7//! is thrown away at the registry boundary: the published archive ships
8//! current entity state with no record of *why* any entity says what it
9//! says. This payload makes the per-entity rationale **portable** so a
10//! consumer who installs a third-party mem can judge "why should I
11//! believe this?" without the original repository.
12//!
13//! ## Wire shape (the archive contract)
14//!
15//! Lives at [`crate::config::ARCHIVE_PROVENANCE_PATH`]
16//! (`.memstead/provenance.json`) inside the archive. The payload is the
17//! source of truth — it travels with the `.mem` whether installed from the
18//! registry or shared out-of-band.
19//!
20//! ```json
21//! {
22//! "format": 1,
23//! "history": "summarised",
24//! "entities": {
25//! "mem:slug": { "rationale": "why this entity exists", "kind": "create", "timestamp": "2026-06-24T11:32:02Z", "actor": "agent" }
26//! }
27//! }
28//! ```
29//!
30//! ## Design commitments
31//!
32//! - **Additive & forward-compatible.** The whole member is optional; an
33//! archive that predates provenance omits it, and an engine that does
34//! not recognise the member tolerates it as an unknown meta member. New
35//! fields are added optionally so an older reader skips them.
36//! - **Per-entity, not the commit DAG.** Each entry carries the entity's
37//! *current* authoring rationale (the most recent mutation note) plus
38//! light metadata — not the full commit history. The omission is
39//! explicit: [`History::Summarised`] tells the consumer the full trail
40//! is not shipped, so absence of history is observable, never implied to
41//! be present.
42//! - **No fabricated provenance.** An entity authored without any rationale
43//! is simply absent from `entities`; a reader reports it as
44//! provenance-absent rather than substituting a default. There is no
45//! placeholder rationale.
46
47use std::collections::BTreeMap;
48
49use serde::{Deserialize, Serialize};
50
51/// Current `format` integer of the provenance payload. Bumped only on a
52/// breaking shape change; additive fields do not bump it.
53pub const ARCHIVE_PROVENANCE_FORMAT: u32 = 1;
54
55/// Whether the archive ships the full commit history or only a per-entity
56/// summary. Serialised as a lowercase string so the "history not shipped"
57/// decision is observable on the wire (the refusal-complement: a consumer
58/// can tell history is summarised rather than silently assume it is
59/// present). `#[serde(other)]` on [`Self::Unknown`] keeps an older reader
60/// forward-compatible with a future disposition it does not know.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum History {
64 /// Only per-entity current rationale travels; the full commit DAG does
65 /// not. The launch default.
66 Summarised,
67 /// The full commit history travels (reserved; not produced today).
68 Full,
69 /// A disposition a newer writer used that this reader does not know —
70 /// treat as "not the full trail" (i.e. like `Summarised`).
71 #[serde(other)]
72 Unknown,
73}
74
75/// One entity's portable authoring provenance. Every field is optional so
76/// the shape grows additively; a record present in `entities` with a
77/// `None` rationale is still meaningful (the entity was touched but carried
78/// no note), distinct from an entity absent from the map entirely.
79#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub struct EntityProvenance {
81 /// The agent-authored one-sentence rationale — the 97% trust signal.
82 /// `None` when the underlying mutation carried no note.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub rationale: Option<String>,
85 /// Mutation kind that last set this rationale (`create`, `update`, …),
86 /// as the stable kebab-case wire token.
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub kind: Option<String>,
89 /// RFC-3339 timestamp of that mutation.
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub timestamp: Option<String>,
92 /// Actor that authored the mutation (`agent`, `human`, …).
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub actor: Option<String>,
95}
96
97/// The archive-borne provenance payload. Keyed by entity id (the
98/// `mem:slug` form the changelog/commit trailers record). An entity not
99/// present in `entities` has provenance reported as absent.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct ArchiveProvenance {
102 pub format: u32,
103 pub history: History,
104 #[serde(default)]
105 pub entities: BTreeMap<String, EntityProvenance>,
106}
107
108impl ArchiveProvenance {
109 /// A payload carrying the given per-entity records, marked
110 /// [`History::Summarised`] (the launch disposition — full history is
111 /// not shipped).
112 pub fn summarised(entities: BTreeMap<String, EntityProvenance>) -> Self {
113 Self {
114 format: ARCHIVE_PROVENANCE_FORMAT,
115 history: History::Summarised,
116 entities,
117 }
118 }
119
120 /// The provenance for one entity id, or `None` when absent — the
121 /// no-fabrication read contract. A returned record may still carry a
122 /// `None` rationale (touched-but-unnoted).
123 pub fn entity(&self, id: &str) -> Option<&EntityProvenance> {
124 self.entities.get(id)
125 }
126
127 /// Serialise to the canonical archive bytes (pretty JSON, trailing
128 /// newline) for embedding at [`crate::config::ARCHIVE_PROVENANCE_PATH`].
129 pub fn to_archive_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
130 let mut s = serde_json::to_string_pretty(self)?;
131 s.push('\n');
132 Ok(s.into_bytes())
133 }
134
135 /// Parse from archive bytes. A malformed payload is an error the
136 /// caller may downgrade to "provenance absent" rather than fail the
137 /// whole install — the member is additive.
138 pub fn from_archive_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
139 serde_json::from_slice(bytes)
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn round_trips_through_archive_bytes() {
149 let mut entities = BTreeMap::new();
150 entities.insert(
151 "specs:alpha".to_string(),
152 EntityProvenance {
153 rationale: Some("first draft".to_string()),
154 kind: Some("create".to_string()),
155 timestamp: Some("2026-06-24T11:32:02Z".to_string()),
156 actor: Some("agent".to_string()),
157 },
158 );
159 let payload = ArchiveProvenance::summarised(entities);
160 let bytes = payload.to_archive_bytes().unwrap();
161 let back = ArchiveProvenance::from_archive_bytes(&bytes).unwrap();
162 assert_eq!(payload, back);
163 assert_eq!(back.history, History::Summarised);
164 assert_eq!(
165 back.entity("specs:alpha")
166 .and_then(|e| e.rationale.as_deref()),
167 Some("first draft")
168 );
169 }
170
171 #[test]
172 fn absent_entity_reports_none_not_a_default() {
173 let payload = ArchiveProvenance::summarised(BTreeMap::new());
174 assert!(payload.entity("specs:missing").is_none());
175 }
176
177 #[test]
178 fn unknown_history_disposition_is_forward_compatible() {
179 // A future writer emits a disposition this reader does not know;
180 // it must parse (as Unknown), not fail — the member is additive.
181 let json = r#"{"format":1,"history":"per_section","entities":{}}"#;
182 let back = ArchiveProvenance::from_archive_bytes(json.as_bytes()).unwrap();
183 assert_eq!(back.history, History::Unknown);
184 }
185
186 #[test]
187 fn touched_but_unnoted_entry_is_distinct_from_absent() {
188 let mut entities = BTreeMap::new();
189 entities.insert("specs:beta".to_string(), EntityProvenance::default());
190 let payload = ArchiveProvenance::summarised(entities);
191 // Present in the map (touched) but no rationale — distinct from a
192 // missing key. No fabricated value.
193 let rec = payload.entity("specs:beta").expect("present");
194 assert!(rec.rationale.is_none());
195 assert!(payload.entity("specs:gamma").is_none());
196 }
197}