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}
102
103impl Provenance {
104 /// Build a record, normalising a whitespace-only `note` to `None`.
105 /// Callers that already have a normalised `Option<String>` may set
106 /// the field directly. `logical_operation_id` defaults to `None`;
107 /// callers that need to tag a multi-commit logical operation use
108 /// [`Self::with_logical_operation_id`].
109 pub fn new(
110 timestamp: SystemTime,
111 kind: ProvenanceKind,
112 entity: Option<String>,
113 actor: Actor,
114 client: Option<ClientId>,
115 note: Option<String>,
116 ) -> Self {
117 let note = note
118 .as_deref()
119 .map(str::trim)
120 .filter(|n| !n.is_empty())
121 .map(|s| s.to_string());
122 Self {
123 timestamp,
124 kind,
125 entity,
126 actor,
127 client,
128 note,
129 logical_operation_id: None,
130 }
131 }
132
133 /// Builder: attach a correlation id so multiple commits produced
134 /// by a single logical operation can be linked at read time.
135 pub fn with_logical_operation_id(mut self, id: String) -> Self {
136 self.logical_operation_id = Some(id);
137 self
138 }
139}
140
141/// Mint a fresh `logical_operation_id`. Combines a nanosecond-
142/// precision timestamp with a process-monotonic counter so two ids
143/// produced in the same nanosecond are still distinct, and the
144/// timestamp prefix gives consumers a rough ordering hint without
145/// a dedicated comparator. Mirrors the shape of
146/// `make_commit_id` in the filesystem backend.
147pub fn mint_logical_operation_id() -> String {
148 use std::sync::atomic::{AtomicU64, Ordering};
149 static LOGICAL_OP_COUNTER: AtomicU64 = AtomicU64::new(0);
150 let nanos = SystemTime::now()
151 .duration_since(std::time::UNIX_EPOCH)
152 .map(|d| d.as_nanos())
153 .unwrap_or(0);
154 let counter = LOGICAL_OP_COUNTER.fetch_add(1, Ordering::Relaxed);
155 format!("logop-{nanos:032x}{counter:016x}")
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn kind_wire_strings_are_stable() {
164 // Locks the wire shape — readers (jq, external tools) key on
165 // these exact strings.
166 assert_eq!(ProvenanceKind::Create.as_str(), "create");
167 assert_eq!(ProvenanceKind::Update.as_str(), "update");
168 assert_eq!(ProvenanceKind::Delete.as_str(), "delete");
169 assert_eq!(ProvenanceKind::Relate.as_str(), "relate");
170 assert_eq!(ProvenanceKind::Rename.as_str(), "rename");
171 assert_eq!(ProvenanceKind::Batch.as_str(), "batch");
172 }
173
174 #[test]
175 fn new_normalises_whitespace_only_note_to_none() {
176 let r = Provenance::new(
177 SystemTime::UNIX_EPOCH,
178 ProvenanceKind::Create,
179 Some("v:e".into()),
180 Actor::Cli,
181 None,
182 Some(" \t ".into()),
183 );
184 assert!(r.note.is_none());
185 }
186
187 #[test]
188 fn new_preserves_non_blank_note() {
189 let r = Provenance::new(
190 SystemTime::UNIX_EPOCH,
191 ProvenanceKind::Create,
192 Some("v:e".into()),
193 Actor::Cli,
194 None,
195 Some(" first draft ".into()),
196 );
197 assert_eq!(r.note.as_deref(), Some("first draft"));
198 }
199}