memstead_base/vcs.rs
1//! Backend-agnostic VCS provenance types and trailer-block helpers.
2//!
3//! The engine's git-bound bits — the [`Vcs`] trait, gix-using
4//! repository helpers, [`VcsError`] and its `From<gix::*>` conversions
5//! — live in `memstead_git_branch::vcs`. What stays
6//! here is the data model that travels through every commit (caller
7//! actor, client identity, optional tool name and provenance note) and
8//! the deterministic helpers that turn that data into the author
9//! signature and trailer block. Both adapters (legacy disk + git-tree)
10//! call the helpers so two paths produce byte-identical commit
11//! messages for the same logical input.
12//!
13//! [`Vcs`]: ../../memstead_git_branch/vcs/trait.Vcs.html
14//! [`VcsError`]: ../../memstead_git_branch/vcs/enum.VcsError.html
15
16/// Generic email domain for derived author addresses. No PII: the
17/// local-part is a sanitised client name (or `external`), never a user.
18const PROVENANCE_EMAIL_DOMAIN: &str = "memstead.io";
19
20/// Maximum length (in chars) of a caller-declared identity (agent-trust
21/// plan 15). Length-bounded like the provenance note: the engine
22/// neither generates, interprets, nor enriches the value — it is an
23/// opaque caller-chosen string (an agent name, a session handle, a
24/// person's chosen tag), and the bound only keeps the append-only
25/// record from carrying unbounded input. Surfaces validate against
26/// this before the mutation touches disk.
27pub const IDENTITY_MAX_LEN: usize = 128;
28
29/// Normalise a caller-supplied identity: trim, treat empty /
30/// whitespace-only as absent. Length validation is the surface's job
31/// (typed refusal against [`IDENTITY_MAX_LEN`]); this helper only
32/// canonicalises presence, so "no identity" has exactly one recorded
33/// shape everywhere.
34pub fn normalise_identity(raw: Option<&str>) -> Option<String> {
35 raw.map(str::trim)
36 .filter(|s| !s.is_empty())
37 .map(str::to_string)
38}
39
40/// Caller categories for the `Actor:` trailer and for picking an author
41/// signature. `Agent`, `Cli`, and `App` get their author from the paired
42/// `ClientId` when one is present; `External` always uses the synthetic
43/// `external <external@memstead.io>` identity (no client is known); `Unknown`
44/// falls back to the committer identity.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Actor {
47 Agent,
48 Cli,
49 /// A human-driven application embedding or fronting the engine —
50 /// the node app's HTTP surface, any future UI
51 /// consumer. Distinct from `Agent` (an LLM speaking MCP) and `Cli`
52 /// (the memstead binary): ambient provenance needs "a human did
53 /// this through app software" as its own category, with the
54 /// paired [`ClientId`] naming which software spoke.
55 App,
56 External,
57 Unknown,
58}
59
60impl Actor {
61 /// String form used for the `Actor:` trailer. Stable; downstream LLMs
62 /// grep on these values.
63 pub fn as_trailer(&self) -> &'static str {
64 match self {
65 Actor::Agent => "agent",
66 Actor::Cli => "cli",
67 Actor::App => "app",
68 Actor::External => "external",
69 Actor::Unknown => "unknown",
70 }
71 }
72
73 /// Inverse of [`Self::as_trailer`]. Returns `None` for any string
74 /// outside the four canonical wire forms — readers that may
75 /// encounter older or malformed values choose how to handle the
76 /// absence (default to [`Actor::Unknown`], surface a warning, …).
77 pub fn from_trailer(s: &str) -> Option<Self> {
78 match s {
79 "agent" => Some(Actor::Agent),
80 "cli" => Some(Actor::Cli),
81 "app" => Some(Actor::App),
82 "external" => Some(Actor::External),
83 "unknown" => Some(Actor::Unknown),
84 _ => None,
85 }
86 }
87}
88
89/// Identity of the process speaking to the engine. For MCP, this is the
90/// `clientInfo` from the initialize handshake (e.g.
91/// `ClientId { name: "claude-code", version: "2.1.0" }`). For CLI-direct
92/// mutations, the crate populates it with its own name and version.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct ClientId {
95 pub name: String,
96 pub version: String,
97}
98
99/// The caller-declared ROLE a mutation was performed in (agent-trust
100/// plan 13) — a closed vocabulary recorded immutably alongside every
101/// mutation (commit trailer / ledger field). Caller-declared but
102/// tamper-evident: bound to specific operations in append-only
103/// history, so it cannot be edited after the fact and identities can
104/// be cross-checked across operations — which no self-written
105/// metadata field can provide. `Unspecified` is legal forever: old
106/// clients, casual sessions, and humans at the CLI are never refused
107/// for not declaring; absence is recorded as absence (no trailer),
108/// and downstream gates treat it as "cannot confirm", never as any
109/// specific role.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum Role {
113 Author,
114 Checker,
115 Verifier,
116 #[default]
117 Unspecified,
118}
119
120impl Role {
121 /// The declared-role wire vocabulary — what a `role` parameter
122 /// accepts. `unspecified` is deliberately NOT declarable: it is
123 /// the recorded absence of a declaration, not a value.
124 pub const DECLARABLE: &'static [&'static str] = &["author", "checker", "verifier"];
125
126 /// Trailer/wire form. `None` for `Unspecified` — absence is
127 /// recorded as absence (no `Role:` trailer, no ledger field).
128 pub fn as_trailer(&self) -> Option<&'static str> {
129 match self {
130 Role::Author => Some("author"),
131 Role::Checker => Some("checker"),
132 Role::Verifier => Some("verifier"),
133 Role::Unspecified => None,
134 }
135 }
136
137 /// Parse a caller-declared role. Returns `None` for anything
138 /// outside [`Self::DECLARABLE`] — the surface refuses typed with
139 /// the vocabulary named rather than defaulting.
140 pub fn from_wire(s: &str) -> Option<Self> {
141 match s {
142 "author" => Some(Role::Author),
143 "checker" => Some(Role::Checker),
144 "verifier" => Some(Role::Verifier),
145 _ => None,
146 }
147 }
148}
149
150/// Provenance bundle for a single commit. Produced at the caller boundary
151/// (`memstead-mcp` tool handler, `memstead-cli` subcommand, engine-internal drift
152/// flush) and threaded through to the VCS commit path.
153#[derive(Debug, Clone)]
154pub struct CommitContext<'a> {
155 pub actor: Actor,
156 pub client: Option<ClientId>,
157 /// Name of the MCP tool that initiated the commit (e.g.
158 /// `"memstead_update"`). Present for MCP-sourced commits; CLI-direct and
159 /// external-drift commits leave this `None`.
160 pub tool: Option<&'a str>,
161 /// Agent-authored one-sentence provenance note. When present and
162 /// non-empty it lands in the commit body between the caller's prose
163 /// and the `Tool:/Actor:/Client:` trailer block. Whitespace-only
164 /// values are treated as absent. The MCP layer validates length
165 /// (`NOTE_MAX_LEN`, 280 chars) before the mutation touches disk;
166 /// callers must not feed unbounded input to this field.
167 pub note: Option<String>,
168 /// The caller-declared role this mutation is performed in
169 /// (agent-trust plan 13). `Unspecified` (the default) emits no
170 /// trailer — absence recorded as absence; declared roles emit
171 /// `Role: <value>` in the trailer block.
172 pub role: Role,
173 /// The caller-declared identity performing this mutation
174 /// (agent-trust plan 15): an opaque caller-chosen string — an
175 /// agent name, a session handle, a person's tag. Same trust model
176 /// as the role: caller-declared, unverified, tamper-evident
177 /// (bound into append-only history). `None` emits no trailer —
178 /// absence recorded as absence; present values emit
179 /// `Identity: <value>`.
180 pub identity: Option<String>,
181 /// Correlation id linking every commit produced by a single
182 /// logical operation (notably multi-mem `memstead_rename`). When
183 /// `Some`, [`format_commit_message`] emits a `Logical-Op: <id>`
184 /// trailer alongside `Tool:` / `Actor:` / `Client:`. The git-
185 /// branch backend's `parse_commit_message` recovers the value
186 /// from the trailer block so `read_provenance` reconstructs
187 /// `Provenance::logical_operation_id` round-trip-clean. `None`
188 /// for legacy or single-call mutations that don't participate
189 /// in correlation; consumers branch on whether the id recurs to
190 /// identify a multi-commit logical operation.
191 pub logical_operation_id: Option<&'a str>,
192 /// Entity ids this commit touched, when one commit covers more than
193 /// one entity (notably `batch_update`, whose subject collapses to
194 /// `(N entities)`). When `Some` and non-empty, [`format_commit_message`]
195 /// emits an `Entities: id1, id2, …` trailer that `parse_commit_message`
196 /// recovers into `CommitNote::entity_ids`, so an `--include-notes`
197 /// consumer can name every entity a batch changed from the note record
198 /// alone. `None`/empty for single-entity commits — those name their
199 /// one id in the subject (and thus `entity_id`), so no list is needed.
200 pub entity_ids: Option<Vec<String>>,
201}
202
203impl<'a> CommitContext<'a> {
204 /// Author-neutral context: no actor, no client, no tool. The author
205 /// signature falls back to the committer identity — preserving the
206 /// pre-provenance behaviour. Used by engine tests and by call sites
207 /// that have not yet been taught to build a real context.
208 pub fn internal() -> Self {
209 Self {
210 actor: Actor::Unknown,
211 client: None,
212 tool: None,
213 note: None,
214 role: Role::Unspecified,
215 identity: None,
216 logical_operation_id: None,
217 entity_ids: None,
218 }
219 }
220}
221
222/// Inverse of the `name@version` rendering used in both the commit
223/// trailer block (`Client: <name>@<version>`) and the folder-backend
224/// JSONL changelog (`"client": "<name>@<version>"`). Splits on the
225/// **last** `@` because client names may legitimately contain `.`
226/// and `-`; versions never contain `@`. Returns `None` for malformed
227/// input (no `@`, empty name, empty version) so tolerant readers
228/// drop the field rather than constructing a half-record.
229pub fn parse_client_id(s: &str) -> Option<ClientId> {
230 let (name, version) = s.rsplit_once('@')?;
231 if name.is_empty() || version.is_empty() {
232 return None;
233 }
234 Some(ClientId {
235 name: name.to_string(),
236 version: version.to_string(),
237 })
238}
239
240/// Sanitise a raw client name to a git-safe local-part matching
241/// `[a-z0-9._-]+`. Empty/whitespace-only input falls back to `"unknown"`.
242///
243/// - Lowercase ASCII.
244/// - Anything outside `[a-z0-9._-]` becomes `-` (spaces, `/`, `@`, …).
245/// - Non-ASCII bytes also collapse to `-` rather than being dropped, so
246/// the output length still tracks the input coarsely (useful for
247/// debugging a garbled clientInfo).
248pub fn sanitise_client_name(raw: &str) -> String {
249 let mut out = String::with_capacity(raw.len());
250 for ch in raw.chars() {
251 let lower = ch.to_ascii_lowercase();
252 if lower.is_ascii_alphanumeric() || matches!(lower, '.' | '_' | '-') {
253 out.push(lower);
254 } else {
255 out.push('-');
256 }
257 }
258 if out.chars().all(|c| c == '-' || c.is_whitespace()) {
259 return "unknown".to_string();
260 }
261 out
262}
263
264/// Build the per-commit author `(name, email)` pair from the context.
265/// `None` means "fall back to the committer identity" — adapters then
266/// reuse the committer signature for the author slot.
267///
268/// Public so both the legacy disk adapter and the git-tree adapter can
269/// build byte-identical commit objects without re-implementing the
270/// trailer + author convention.
271pub fn author_identity(ctx: &CommitContext<'_>) -> Option<(String, String)> {
272 match (ctx.actor, ctx.client.as_ref()) {
273 (Actor::Agent | Actor::Cli | Actor::App, Some(c)) => {
274 let local = sanitise_client_name(&c.name);
275 let email = format!("{local}@{PROVENANCE_EMAIL_DOMAIN}");
276 Some((local, email))
277 }
278 (Actor::External, _) => Some((
279 "external".to_string(),
280 format!("external@{PROVENANCE_EMAIL_DOMAIN}"),
281 )),
282 // Agent/Cli/App without a ClientId, or Unknown: no derived
283 // identity; caller falls back to the committer signature.
284 _ => None,
285 }
286}
287
288/// Append the trailer block to the caller's prose, separated by exactly
289/// one blank line. Normalises trailing newlines so `"subject"` and
290/// `"subject\n"` both produce `"subject\n\nActor: …\n…"`.
291///
292/// When `ctx.note` carries a non-blank string, it is inserted between the
293/// prose and the trailer block — with exactly one blank line on each
294/// side. Whitespace-only notes are treated as absent (callers that want
295/// an empty note must pass `None`). The final layout is:
296///
297/// ```text
298/// <prose>
299///
300/// <note, if present>
301///
302/// <trailer block>
303/// ```
304///
305/// `Actor:` is always emitted. `Tool:` is emitted when `ctx.tool` is set.
306/// `Client:` is emitted when `ctx.client` is set. Order: `Tool`, `Actor`,
307/// `Client`.
308///
309/// Public so both adapters share the same trailer block — the two paths
310/// must produce byte-identical commit messages for the same logical
311/// input.
312pub fn format_commit_message(prose: &str, ctx: &CommitContext<'_>) -> String {
313 let trimmed = prose.trim_end_matches('\n');
314 let mut trailers: Vec<String> = Vec::with_capacity(4);
315 if let Some(tool) = ctx.tool {
316 trailers.push(format!("Tool: {tool}"));
317 }
318 trailers.push(format!("Actor: {}", ctx.actor.as_trailer()));
319 if let Some(c) = ctx.client.as_ref() {
320 trailers.push(format!("Client: {}@{}", c.name, c.version));
321 }
322 // `Role:` records the caller-declared role (plan 13); omitted for
323 // `Unspecified` — the absent trailer IS the record of absence.
324 if let Some(role) = ctx.role.as_trailer() {
325 trailers.push(format!("Role: {role}"));
326 }
327 // `Identity:` records the caller-declared identity (plan 15);
328 // omitted when absent — the absent trailer IS the record of
329 // absence, same posture as `Role:`.
330 if let Some(identity) = ctx.identity.as_deref() {
331 trailers.push(format!("Identity: {identity}"));
332 }
333 // `Logical-Op:` is the wire-stable trailer key. Recognised by
334 // `parse_commit_message` and threaded back into
335 // `Provenance::logical_operation_id` so the multi-mem rename
336 // correlation survives a commit-log round-trip through the
337 // git-branch backend.
338 if let Some(id) = ctx.logical_operation_id {
339 trailers.push(format!("Logical-Op: {id}"));
340 }
341 // `Entities:` lists every id a multi-entity commit touched (batch
342 // update), comma-separated. Recovered by `parse_commit_message` into
343 // `CommitNote::entity_ids` so a note read in isolation names the
344 // entities even though the subject only says `(N entities)`. Omitted
345 // when absent or empty — single-entity commits carry their id in the
346 // subject. Ids never contain `, ` so the join is unambiguous.
347 if let Some(ids) = ctx.entity_ids.as_ref().filter(|v| !v.is_empty()) {
348 trailers.push(format!("Entities: {}", ids.join(", ")));
349 }
350 let note_body = ctx.note.as_deref().map(str::trim).filter(|n| !n.is_empty());
351 match note_body {
352 Some(note) => format!("{trimmed}\n\n{note}\n\n{}", trailers.join("\n")),
353 None => format!("{trimmed}\n\n{}", trailers.join("\n")),
354 }
355}