Skip to main content

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}