trusty_memory/attribution.rs
1//! Drawer creator-attribution tag helpers.
2//!
3//! Why: prior to this module, drawers carried content, room, importance,
4//! and free-form tags but no first-class metadata describing the writer.
5//! Operators who saw noise drawers in a palace had no way to trace which
6//! client wrote them — was it the trusty-memory MCP, a curl from a shell
7//! script, claude-mpm's Python hook, the dashboard form? This module
8//! defines a reserved `creator:*` tag namespace that every write path
9//! (HTTP, MCP, CLI, hook) attaches automatically. With `creator:client=…`
10//! present on every drawer, "where did this come from?" becomes
11//! grep-able. The namespace approach (vs. a `Drawer` schema change)
12//! piggy-backs on the existing `msg:` tag pattern from #99 so no
13//! migration is required.
14//!
15//! What:
16//! - `CREATOR_*_PREFIX` constants — the four reserved tag prefixes.
17//! - [`CreatorInfo`] — small value type carrying client name, version,
18//! source, and optional cwd. `into_tags()` renders the four tag
19//! strings (or three, when cwd is absent) in a stable order.
20//! - [`is_creator_tag`] — predicate used by UI render code that wants
21//! to hide the namespace from the main tag chips (mirroring how
22//! `msg:*` is filtered today).
23//!
24//! Test: see the `tests` module at the bottom — covers tag composition,
25//! prefix detection, and round-trip via `is_creator_tag`.
26
27use crate::ActivitySource;
28
29/// Tag prefix carrying the writing client's short name
30/// (e.g. `creator:client=trusty-memory-mcp`).
31///
32/// Why: the dominant question "who wrote this drawer?" reduces to a
33/// single substring search against this prefix. Stable string so curl
34/// and grep workflows keep working over time.
35/// Test: `creator_info_renders_all_fields`.
36pub const CREATOR_CLIENT_PREFIX: &str = "creator:client=";
37
38/// Tag prefix carrying the writing client's version string
39/// (e.g. `creator:version=0.5.1`).
40///
41/// Why: lets operators distinguish "old buggy client wrote this" from
42/// "current client wrote this" without rummaging through logs.
43/// Test: `creator_info_renders_all_fields`.
44pub const CREATOR_VERSION_PREFIX: &str = "creator:version=";
45
46/// Tag prefix carrying the originating subsystem (`http`/`mcp`/`hook`/`cli`).
47///
48/// Why: same labels as [`ActivitySource`] for HTTP / MCP / hook; CLI is
49/// a fourth value we accept here because drawers written from the
50/// `trusty-memory send-message` CLI never travel through the activity
51/// log emit path but still need attribution.
52/// What: lowercase string after the prefix.
53/// Test: `creator_info_renders_all_fields`.
54pub const CREATOR_SOURCE_PREFIX: &str = "creator:source=";
55
56/// Tag prefix carrying the writing process' cwd at write time
57/// (e.g. `creator:cwd=/Users/alice/projects/foo`).
58///
59/// Why: cwd is the single most useful clue when chasing noise — if a
60/// drawer carries `creator:cwd=/Users/alice/projects/claude-mpm`, the
61/// operator knows the write came from that working directory and can
62/// look at *what* was running there. Absent when the writer could not
63/// resolve a cwd (e.g. a remote HTTP client that did not send the
64/// optional header).
65/// Test: `creator_info_omits_cwd_when_absent`.
66pub const CREATOR_CWD_PREFIX: &str = "creator:cwd=";
67
68/// Tag prefix carrying the short session id of the writer (issue #202).
69///
70/// Why: when a session UUID is already attached as a bare tag, the TUI
71/// activity panel cannot easily pick it out of the tag list. Emitting a
72/// dedicated `creator:session=<first-8>` tag puts the session shorthand
73/// in the same reserved namespace as the rest of the attribution data so
74/// the dashboard / TUI can render it without bespoke parsing.
75/// What: prefix string; the suffix is the first 8 hex characters of the
76/// originating UUID.
77/// Test: `session_tag_from_tags_returns_first_uuid_short`.
78pub const CREATOR_SESSION_PREFIX: &str = "creator:session=";
79
80/// HTTP request header carrying the writing client's short name.
81///
82/// Why: lets remote HTTP callers self-identify so the recipient daemon
83/// can populate `creator:client=` without guessing. The dashboard /
84/// claude-mpm / future trusty-* clients all set this when they make
85/// writes; clients that don't get the conservative fallback below.
86/// Test: `drawer_creator_attribution_http_default`,
87/// `drawer_creator_attribution_http_header`.
88pub const X_TRUSTY_CLIENT_NAME: &str = "x-trusty-client-name";
89
90/// HTTP request header carrying the writing client's cwd.
91///
92/// Why: trusts the caller's self-reported cwd because the daemon has
93/// no other way to know it (the HTTP request originates from a remote
94/// process whose cwd is opaque). Absent header → `creator:cwd=` is
95/// omitted from the drawer tags rather than synthesised from the
96/// daemon's own cwd, which would be wrong.
97/// Test: `drawer_creator_attribution_http_default`.
98pub const X_TRUSTY_CLIENT_CWD: &str = "x-trusty-client-cwd";
99
100/// Default client name used when an HTTP caller omits the
101/// `X-Trusty-Client-Name` header.
102///
103/// Why: every drawer must carry a `creator:client=` tag so the
104/// dashboard renders a consistent "client" column; a missing header
105/// must not yield a missing tag. The fallback is verbose on purpose so
106/// operators can tell "the caller forgot to identify itself" apart from
107/// "the caller is a known trusty-* binary".
108/// Test: `drawer_creator_attribution_http_default`.
109pub const HTTP_DEFAULT_CLIENT: &str = "unknown-http-client";
110
111/// Client name attached to drawers written by the MCP tool surface.
112pub const MCP_CLIENT_NAME: &str = "trusty-memory-mcp";
113
114/// Client name attached to drawers written by the `trusty-memory` CLI.
115pub const CLI_CLIENT_NAME: &str = "trusty-memory-cli";
116
117/// Client name attached to drawers written by hook-driven code paths.
118///
119/// Why: hooks currently only read; the constant is reserved here so a
120/// future hook that *does* write a drawer (e.g. an inbox auto-archive)
121/// would tag itself consistently with the rest of the namespace.
122/// Test: `creator_info_renders_all_fields`.
123pub const HOOK_CLIENT_NAME: &str = "trusty-memory-hook";
124
125/// Originating-subsystem labels emitted into `creator:source=`.
126///
127/// Why: matches [`ActivitySource`] for HTTP/MCP/hook plus a fourth `cli`
128/// label that has no analogue on the activity-feed source enum (CLI
129/// writes go through the HTTP API, but the *origin* of the request was a
130/// CLI process; the user wants to see that distinction).
131/// What: stable lower-case strings.
132/// Test: `creator_info_renders_all_fields`.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum CreatorSource {
135 Http,
136 Mcp,
137 Hook,
138 Cli,
139}
140
141impl CreatorSource {
142 /// Stable lower-case string used in the `creator:source=` tag.
143 pub fn as_str(&self) -> &'static str {
144 match self {
145 Self::Http => "http",
146 Self::Mcp => "mcp",
147 Self::Hook => "hook",
148 Self::Cli => "cli",
149 }
150 }
151}
152
153impl From<ActivitySource> for CreatorSource {
154 fn from(s: ActivitySource) -> Self {
155 match s {
156 ActivitySource::Http => Self::Http,
157 ActivitySource::Mcp => Self::Mcp,
158 ActivitySource::Hook => Self::Hook,
159 }
160 }
161}
162
163/// Value type describing the writer of a drawer.
164///
165/// Why: each write path builds one of these and merges the rendered tags
166/// into the caller-supplied tag list before persisting. Keeping the
167/// rendering centralised guarantees every write produces tags in the
168/// same order with the same prefixes, so curl + grep workflows stay
169/// stable.
170/// What: holds an owned client name, an owned version string, the source
171/// enum, and an optional cwd. `into_tags()` consumes the value and
172/// returns the rendered tag list.
173/// Test: `creator_info_renders_all_fields`,
174/// `creator_info_omits_cwd_when_absent`.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct CreatorInfo {
177 pub client: String,
178 pub version: String,
179 pub source: CreatorSource,
180 pub cwd: Option<String>,
181}
182
183impl CreatorInfo {
184 /// Build a `CreatorInfo` with the supplied client + source, defaulting
185 /// the version to this crate's `CARGO_PKG_VERSION` and the cwd to
186 /// whatever the writing process has at construction time.
187 ///
188 /// Why: most call sites want a one-liner; explicit overrides remain
189 /// available by mutating the returned value.
190 /// What: `client.into()` + `env!("CARGO_PKG_VERSION").into()` +
191 /// `std::env::current_dir().ok().map(...)`.
192 /// Test: `creator_info_self_populates_version_and_cwd`.
193 pub fn new_self(client: impl Into<String>, source: CreatorSource) -> Self {
194 let cwd = std::env::current_dir()
195 .ok()
196 .map(|p| p.to_string_lossy().into_owned());
197 Self {
198 client: client.into(),
199 version: env!("CARGO_PKG_VERSION").to_string(),
200 source,
201 cwd,
202 }
203 }
204
205 /// Render the rendered tag strings in stable order.
206 ///
207 /// Why: stable order keeps tests deterministic and gives operators a
208 /// predictable layout when they grep through palaces with `jq`.
209 /// What: `[client, version, source, cwd?]`. `cwd` is omitted when
210 /// absent rather than rendered as an empty string so downstream
211 /// consumers can distinguish "writer didn't share a cwd" from
212 /// "writer's cwd was literally empty".
213 /// Test: `creator_info_renders_all_fields`,
214 /// `creator_info_omits_cwd_when_absent`.
215 pub fn into_tags(self) -> Vec<String> {
216 let mut out = Vec::with_capacity(4);
217 out.push(format!("{CREATOR_CLIENT_PREFIX}{}", self.client));
218 out.push(format!("{CREATOR_VERSION_PREFIX}{}", self.version));
219 out.push(format!("{CREATOR_SOURCE_PREFIX}{}", self.source.as_str()));
220 if let Some(cwd) = self.cwd.filter(|c| !c.is_empty()) {
221 out.push(format!("{CREATOR_CWD_PREFIX}{cwd}"));
222 }
223 out
224 }
225
226 /// Render the tags and append them to an existing tag list.
227 ///
228 /// Why: write-path call sites already hold a `Vec<String>` of
229 /// user-supplied tags; merging in place avoids an allocation and
230 /// preserves the caller's ordering.
231 /// What: pushes each rendered tag onto `dst`. Does not deduplicate —
232 /// caller is expected to pass a freshly-built or de-duplicated list.
233 /// Test: `merge_into_appends_creator_tags`.
234 pub fn merge_into(self, dst: &mut Vec<String>) {
235 for tag in self.into_tags() {
236 dst.push(tag);
237 }
238 }
239}
240
241/// Return `true` when a tag belongs to the `creator:*` reserved namespace.
242///
243/// Why: render paths (TUI, dashboard) want to hide attribution tags from
244/// the main tag chips so they don't clutter the UI alongside meaningful
245/// user-supplied tags (same pattern as `msg:*` hiding from #99). A single
246/// predicate keeps every renderer in lock-step.
247/// What: returns `tag.starts_with("creator:")`.
248/// Test: `is_creator_tag_detects_namespace`.
249pub fn is_creator_tag(tag: &str) -> bool {
250 tag.starts_with("creator:")
251}
252
253/// Build a `creator:session=<first-8-chars>` tag from the first bare UUID
254/// found in `tags`, if any (issue #202).
255///
256/// Why: MCP writers (claude-mpm hooks, in particular) already pass the
257/// session UUID as a free-form tag in the `tags` array. Turning that into
258/// an explicit `creator:session=...` tag puts the session id alongside
259/// the rest of the attribution data so the dashboard / TUI can surface
260/// it without inspecting every tag for UUID-shaped strings.
261/// What: scans the slice in order, parses each entry with
262/// `uuid::Uuid::parse_str`, and on the first success returns
263/// `Some("creator:session=<first-8-hex>")`. Returns `None` when no entry
264/// parses as a UUID, or when the matching tag is itself already a
265/// `creator:*` tag (so dashboard-supplied creator tags don't get
266/// re-projected).
267/// Test: `session_tag_from_tags_returns_first_uuid_short`,
268/// `session_tag_from_tags_skips_non_uuid_entries`.
269pub fn session_tag_from_tags(tags: &[String]) -> Option<String> {
270 for tag in tags {
271 // Skip the reserved-namespace tags so a stray
272 // `creator:cwd=<uuid-shaped-path>` can never be misinterpreted
273 // as a session id. We only consider free-form bare tags.
274 if is_creator_tag(tag) {
275 continue;
276 }
277 if let Ok(uuid) = uuid::Uuid::parse_str(tag) {
278 // `uuid.simple()` renders as 32 lowercase hex chars; the
279 // first 8 are the same characters that appear before the
280 // first dash in the hyphenated form. Both forms parse to the
281 // same `Uuid`, so we render canonically here for stability.
282 let simple = uuid.simple().to_string();
283 let short: String = simple.chars().take(8).collect();
284 return Some(format!("{CREATOR_SESSION_PREFIX}{short}"));
285 }
286 }
287 None
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 /// Why: every render path must emit the four tags in stable order
295 /// (`client`, `version`, `source`, `cwd`) so dashboards can rely on
296 /// the layout. A regression that swapped two would silently change
297 /// every downstream consumer's parsing.
298 /// What: constructs a `CreatorInfo` with all fields populated and
299 /// asserts the rendered list.
300 /// Test: itself.
301 #[test]
302 fn creator_info_renders_all_fields() {
303 let info = CreatorInfo {
304 client: "qa-curl".into(),
305 version: "0.1.2".into(),
306 source: CreatorSource::Http,
307 cwd: Some("/tmp/proj".into()),
308 };
309 let tags = info.into_tags();
310 assert_eq!(
311 tags,
312 vec![
313 "creator:client=qa-curl".to_string(),
314 "creator:version=0.1.2".to_string(),
315 "creator:source=http".to_string(),
316 "creator:cwd=/tmp/proj".to_string(),
317 ]
318 );
319 }
320
321 /// Why: absent cwd must produce three tags, not four with an empty
322 /// `cwd=` — that would force every parser to special-case the empty
323 /// suffix. Same for an empty-string cwd.
324 /// What: omits cwd and renders; then sets it to "" and renders.
325 /// Test: itself.
326 #[test]
327 fn creator_info_omits_cwd_when_absent() {
328 let info = CreatorInfo {
329 client: "mcp".into(),
330 version: "0.1.0".into(),
331 source: CreatorSource::Mcp,
332 cwd: None,
333 };
334 assert_eq!(info.into_tags().len(), 3);
335
336 let info_empty = CreatorInfo {
337 client: "mcp".into(),
338 version: "0.1.0".into(),
339 source: CreatorSource::Mcp,
340 cwd: Some(String::new()),
341 };
342 assert_eq!(info_empty.into_tags().len(), 3);
343 }
344
345 /// Why: `new_self` is the one-line convenience entry point most call
346 /// sites use; it must populate the version from the crate version and
347 /// the cwd from the running process so tests don't have to wire it up
348 /// by hand.
349 /// What: constructs and asserts version + cwd are non-empty.
350 /// Test: itself.
351 #[test]
352 fn creator_info_self_populates_version_and_cwd() {
353 let info = CreatorInfo::new_self("client", CreatorSource::Cli);
354 assert!(!info.version.is_empty(), "version must be populated");
355 assert!(info.cwd.is_some(), "cwd should resolve in tests");
356 }
357
358 /// Why: the merge helper exists so call sites with an existing tag
359 /// vec don't have to allocate; the contract is "appends in stable
360 /// order".
361 /// What: starts with one caller-supplied tag, merges, asserts the
362 /// trailing tags are the creator tags in order.
363 /// Test: itself.
364 #[test]
365 fn merge_into_appends_creator_tags() {
366 let mut tags = vec!["user-supplied".to_string()];
367 CreatorInfo {
368 client: "x".into(),
369 version: "1".into(),
370 source: CreatorSource::Cli,
371 cwd: None,
372 }
373 .merge_into(&mut tags);
374 assert_eq!(
375 tags,
376 vec![
377 "user-supplied".to_string(),
378 "creator:client=x".to_string(),
379 "creator:version=1".to_string(),
380 "creator:source=cli".to_string(),
381 ]
382 );
383 }
384
385 /// Why: dashboards / TUI renderers must hide `creator:*` tags from
386 /// the main tag chips so the user-supplied tags remain prominent.
387 /// What: tests true / false cases against the predicate.
388 /// Test: itself.
389 #[test]
390 fn is_creator_tag_detects_namespace() {
391 assert!(is_creator_tag("creator:client=foo"));
392 assert!(is_creator_tag("creator:cwd=/tmp"));
393 assert!(is_creator_tag(CREATOR_VERSION_PREFIX));
394 assert!(!is_creator_tag("user-tag"));
395 assert!(!is_creator_tag("msg:v1"));
396 assert!(!is_creator_tag("creatorx"));
397 }
398
399 /// Why: issue #202 — MCP writers (claude-mpm hooks) commonly pass
400 /// the session UUID as a bare tag in the `tags` array. The helper
401 /// must pick out the first parseable UUID and emit the short form
402 /// in the reserved `creator:session=` namespace so the TUI activity
403 /// panel renders it without bespoke parsing.
404 /// What: feeds a mixed tag list and asserts the first 8 hex chars
405 /// of the UUID round-trip into the returned tag.
406 /// Test: itself.
407 #[test]
408 fn session_tag_from_tags_returns_first_uuid_short() {
409 let tags = vec![
410 "user-tag".to_string(),
411 "01919e90-8a2e-7c1d-9f8b-1234567890ab".to_string(),
412 "ignored-second-uuid:11111111-2222-3333-4444-555555555555".to_string(),
413 ];
414 let session = session_tag_from_tags(&tags).expect("session tag");
415 assert_eq!(session, "creator:session=01919e90");
416 }
417
418 /// Why: non-UUID entries (free-form tags, scoped tags like `idx:0`)
419 /// must not be misinterpreted as session ids — the helper has to
420 /// return `None` when no entry parses as a UUID.
421 /// What: feeds a tag list with no UUIDs and asserts `None`.
422 /// Test: itself.
423 #[test]
424 fn session_tag_from_tags_skips_non_uuid_entries() {
425 let tags = vec![
426 "user-tag".to_string(),
427 "idx:0".to_string(),
428 "session-prefix-not-a-uuid".to_string(),
429 ];
430 assert!(session_tag_from_tags(&tags).is_none());
431
432 // Empty list returns `None`.
433 assert!(session_tag_from_tags(&[]).is_none());
434 }
435
436 /// Why: a tag in the reserved `creator:*` namespace must never be
437 /// re-projected as a session id, even if its value parses as a UUID.
438 /// `creator:cwd=` carrying a UUID-shaped temporary path is the
439 /// motivating example.
440 /// What: feeds a `creator:` tag whose value parses as a UUID and a
441 /// real bare UUID later in the list, then asserts the real one wins.
442 /// Test: itself.
443 #[test]
444 fn session_tag_from_tags_skips_reserved_namespace() {
445 let tags = vec![
446 // Reserved namespace tag with a UUID-shaped value — must be skipped.
447 "creator:cwd=11111111-1111-1111-1111-111111111111".to_string(),
448 // The real session tag — must win.
449 "22222222-2222-2222-2222-222222222222".to_string(),
450 ];
451 let session = session_tag_from_tags(&tags).expect("session tag");
452 assert_eq!(session, "creator:session=22222222");
453 }
454
455 /// Why: the `From<ActivitySource>` impl lets the HTTP path build a
456 /// `CreatorSource` from the existing `ActivitySource::Http` without
457 /// a manual match; the mapping must be identity for the three shared
458 /// variants.
459 /// What: round-trips each variant.
460 /// Test: itself.
461 #[test]
462 fn creator_source_from_activity_source() {
463 assert_eq!(
464 CreatorSource::from(ActivitySource::Http),
465 CreatorSource::Http
466 );
467 assert_eq!(CreatorSource::from(ActivitySource::Mcp), CreatorSource::Mcp);
468 assert_eq!(
469 CreatorSource::from(ActivitySource::Hook),
470 CreatorSource::Hook
471 );
472 }
473}