Skip to main content

memstead_base/
provenance.rs

1//! Backend-neutral provenance record.
2//!
3//! Two persistence shapes exist today — commit-message trailer
4//! (git-branch backend) and JSONL line (folder backend's
5//! `.memstead/changes.jsonl`) — and historically each backend modelled
6//! its mutation log with its own type. After the workspace-store
7//! rebuild both adapters construct (and are read into) this single
8//! [`Provenance`] record so `memstead_changes_since` returns
9//! identically-shaped values regardless of which backend serves the
10//! queried mem.
11//!
12//! This module ships the **shape**; the read/write wiring on each
13//! backend lands as that backend gains a [`crate::backend::MemBackend`]
14//! implementation. The existing `crate::filesystem::changelog`
15//! `ChangeEntry` / `MutationKind` pair stays as the folder backend's
16//! on-disk encoder until that wiring lands; the two are kept in
17//! lockstep by deliberate field correspondence (timestamp, kind,
18//! entity, actor, client, note).
19
20use std::time::SystemTime;
21
22use crate::vcs::{Actor, ClientId};
23
24/// Mutation kind written to provenance. The string forms produced by
25/// [`Self::as_str`] are the wire shape — readers and external tools
26/// (jq, grep) branch on them.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ProvenanceKind {
29    Create,
30    Update,
31    Delete,
32    Relate,
33    Rename,
34    /// A type change in place (`Engine::retype_entity`).
35    Retype,
36    Batch,
37}
38
39impl ProvenanceKind {
40    /// Stable kebab-case wire form.
41    pub fn as_str(&self) -> &'static str {
42        match self {
43            ProvenanceKind::Create => "create",
44            ProvenanceKind::Update => "update",
45            ProvenanceKind::Delete => "delete",
46            ProvenanceKind::Relate => "relate",
47            ProvenanceKind::Rename => "rename",
48            ProvenanceKind::Retype => "retype",
49            ProvenanceKind::Batch => "batch",
50        }
51    }
52
53    /// Inverse of [`Self::as_str`]. Returns `None` for any unknown
54    /// string so backend readers can treat unrecognised kinds as a
55    /// forward-compat extension rather than misclassify.
56    pub fn parse(s: &str) -> Option<Self> {
57        match s {
58            "create" => Some(ProvenanceKind::Create),
59            "update" => Some(ProvenanceKind::Update),
60            "delete" => Some(ProvenanceKind::Delete),
61            "relate" => Some(ProvenanceKind::Relate),
62            "rename" => Some(ProvenanceKind::Rename),
63            "retype" => Some(ProvenanceKind::Retype),
64            "batch" => Some(ProvenanceKind::Batch),
65            _ => None,
66        }
67    }
68}
69
70/// One mutation event in a mem's provenance log.
71///
72/// Constructed at the engine boundary (one per MCP mutating tool, one
73/// per CLI mutation, one per drift-flush) and handed to the backend
74/// via [`crate::backend::MemBackend::append_provenance`]. Read back
75/// out via [`crate::backend::MemBackend::read_provenance`] for
76/// `memstead_changes_since`.
77///
78/// The folder backend persists this as a JSONL line under
79/// `.memstead/changes.jsonl`; the git-branch backend persists it as part
80/// of the commit-message trailer block (timestamp / kind / entity ride
81/// the commit metadata). The persistence form differs per backend, the
82/// in-memory record does not.
83#[derive(Debug, Clone)]
84pub struct Provenance {
85    pub timestamp: SystemTime,
86    pub kind: ProvenanceKind,
87    /// Mem-relative entity id (`mem:slug`), or `None` for batch
88    /// mutations that touch multiple entities.
89    pub entity: Option<String>,
90    pub actor: Actor,
91    pub client: Option<ClientId>,
92    /// Agent-authored one-sentence provenance note. Whitespace-only
93    /// values are normalised to `None` at construction; callers that
94    /// want an empty note pass `None`.
95    pub note: Option<String>,
96    /// Correlation id that ties every commit produced by a single
97    /// logical operation (notably a multi-mem `memstead_rename`) to one
98    /// another. `Some(id)` on every commit a single logical call
99    /// produced; `None` on legacy or single-call mutations that don't
100    /// participate in correlation. Consumers that don't know the
101    /// field continue working — it's purely additive. Single-mem
102    /// mutations may carry an id too (a logical-op with one commit),
103    /// or `None` — both are valid wire shapes.
104    pub logical_operation_id: Option<String>,
105    /// The caller-declared role (agent-trust plan 13).
106    /// `Unspecified` records as absence on both backends (no trailer,
107    /// no ledger field) — old records read back as `Unspecified`.
108    pub role: crate::vcs::Role,
109    /// The caller-declared identity (agent-trust plan 15): an opaque
110    /// caller-chosen string, same trust model as the role
111    /// (caller-declared, unverified, tamper-evident). `None` records
112    /// as absence on both backends (no trailer, no ledger field) —
113    /// old records read back as `None`, never backfilled or inferred.
114    pub identity: Option<String>,
115}
116
117impl Provenance {
118    /// Build a record, normalising a whitespace-only `note` to `None`.
119    /// Callers that already have a normalised `Option<String>` may set
120    /// the field directly. `logical_operation_id` defaults to `None`;
121    /// callers that need to tag a multi-commit logical operation use
122    /// [`Self::with_logical_operation_id`].
123    pub fn new(
124        timestamp: SystemTime,
125        kind: ProvenanceKind,
126        entity: Option<String>,
127        actor: Actor,
128        client: Option<ClientId>,
129        note: Option<String>,
130    ) -> Self {
131        let note = note
132            .as_deref()
133            .map(str::trim)
134            .filter(|n| !n.is_empty())
135            .map(|s| s.to_string());
136        Self {
137            timestamp,
138            kind,
139            entity,
140            actor,
141            client,
142            note,
143            logical_operation_id: None,
144            role: crate::vcs::Role::Unspecified,
145            identity: None,
146        }
147    }
148
149    /// Builder: attach the caller-declared role (agent-trust plan 13).
150    pub fn with_role(mut self, role: crate::vcs::Role) -> Self {
151        self.role = role;
152        self
153    }
154
155    /// Builder: attach the caller-declared identity (agent-trust plan
156    /// 15). Callers pass an already-normalised value
157    /// ([`crate::vcs::normalise_identity`]); `None` stays absence.
158    pub fn with_identity(mut self, identity: Option<String>) -> Self {
159        self.identity = identity;
160        self
161    }
162
163    /// Builder: attach a correlation id so multiple commits produced
164    /// by a single logical operation can be linked at read time.
165    pub fn with_logical_operation_id(mut self, id: String) -> Self {
166        self.logical_operation_id = Some(id);
167        self
168    }
169}
170
171/// Mint a fresh `logical_operation_id`. Combines a nanosecond-
172/// precision timestamp with a process-monotonic counter so two ids
173/// produced in the same nanosecond are still distinct, and the
174/// timestamp prefix gives consumers a rough ordering hint without
175/// a dedicated comparator. Mirrors the shape of
176/// `make_commit_id` in the filesystem backend.
177pub fn mint_logical_operation_id() -> String {
178    use std::sync::atomic::{AtomicU64, Ordering};
179    static LOGICAL_OP_COUNTER: AtomicU64 = AtomicU64::new(0);
180    let nanos = SystemTime::now()
181        .duration_since(std::time::UNIX_EPOCH)
182        .map(|d| d.as_nanos())
183        .unwrap_or(0);
184    let counter = LOGICAL_OP_COUNTER.fetch_add(1, Ordering::Relaxed);
185    format!("logop-{nanos:032x}{counter:016x}")
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn kind_wire_strings_are_stable() {
194        // Locks the wire shape — readers (jq, external tools) key on
195        // these exact strings.
196        assert_eq!(ProvenanceKind::Create.as_str(), "create");
197        assert_eq!(ProvenanceKind::Update.as_str(), "update");
198        assert_eq!(ProvenanceKind::Delete.as_str(), "delete");
199        assert_eq!(ProvenanceKind::Relate.as_str(), "relate");
200        assert_eq!(ProvenanceKind::Rename.as_str(), "rename");
201        assert_eq!(ProvenanceKind::Batch.as_str(), "batch");
202    }
203
204    #[test]
205    fn new_normalises_whitespace_only_note_to_none() {
206        let r = Provenance::new(
207            SystemTime::UNIX_EPOCH,
208            ProvenanceKind::Create,
209            Some("v:e".into()),
210            Actor::Cli,
211            None,
212            Some("   \t  ".into()),
213        );
214        assert!(r.note.is_none());
215    }
216
217    #[test]
218    fn new_preserves_non_blank_note() {
219        let r = Provenance::new(
220            SystemTime::UNIX_EPOCH,
221            ProvenanceKind::Create,
222            Some("v:e".into()),
223            Actor::Cli,
224            None,
225            Some("  first draft  ".into()),
226        );
227        assert_eq!(r.note.as_deref(), Some("first draft"));
228    }
229}