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}