Skip to main content

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