Skip to main content

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}