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