Skip to main content

majordomus_cli/session/
identity.rs

1//! The identities of the session domain: six things that the stores spell as three words.
2//!
3//! # The defect this module exists for
4//!
5//! A forensic read of the writers on 2026-09-11 found one word covering two values in three
6//! separate places, and in every case both spellings are durable, both are written by
7//! Majordomus, and neither is wrong on its own terms:
8//!
9//! | The word | One writer says | Another writer says |
10//! |---|---|---|
11//! | the repository | `mj_git_repo_id` — the absolute path of the shared git directory | `mj_repository_id` — the remote URL, or `local:<hash of the worktree path>` |
12//! | the worktree | `mj_record_front_matter` — `worktree:`, an absolute path | `mj_session_close` — `worktree_id:`, sixteen hex digits of that path's digest |
13//! | the session | the episode's own `session_id: s-20260910205542-e2a6` | `provider_session:`, the provider's own opaque identity, which is also the key the open record is *stored under* |
14//!
15//! A reader that compares a local record's repository against a shared record's compares a
16//! path with a URL and concludes the two records are about different repositories. A reader
17//! that takes `provider_session` for the session identity attributes an episode to whichever
18//! window of the provider reconnected last. Both are the same mistake — treating a value as
19//! interchangeable with one that means something else — and prose has already failed to
20//! prevent it twice.
21//!
22//! So the six are six types here. There is deliberately **no** `From` between any two of
23//! them, no shared trait that would accept one where another is wanted, and no
24//! `as_str()`-to-`new()` round trip that would launder one into another: every constructor
25//! takes the value's own spelling and says which store it came from. The compiler refuses
26//! the comparison the audit found.
27//!
28//! # What is reused rather than reinvented
29//!
30//! [`RepositoryId`] and [`CheckoutId`] wrap the digests this executable already computes —
31//! [`crate::repository::git_identity`] and [`crate::repository::identity`] — which are the
32//! same values its server lease, its index cache and its worktree topology are keyed by.
33//! A seventh notion of "which repository" would be exactly the defect above with one more
34//! row. What this module adds is the *typing* and the record of each store's own spelling
35//! beside the canonical value, so that a reader can recognise a store's string without
36//! having to choose which of the two spellings is the real one.
37//!
38//! ```
39//! use majordomus_cli::session::{CheckoutId, EpisodeId, ProviderSessionId, TaskId};
40//! use std::path::Path;
41//!
42//! let checkout = CheckoutId::of(Path::new("/src/repo-wt/feature/y"));
43//! let episode = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
44//! let provider = ProviderSessionId::new("01Bv2gJsfAoYrXnpfC9uJ4uv");
45//!
46//! assert_ne!(checkout.as_str(), episode.as_str());
47//! assert_ne!(episode.as_str(), provider.as_str());
48//! // and an id of one kind never parses as another, whatever it looks like
49//! assert!(TaskId::parse(episode.as_str()).is_none());
50//! assert!(EpisodeId::parse("t-20260905034523-a9f1").is_none());
51//! ```
52
53use std::path::Path;
54
55use schemars::JsonSchema;
56use serde::{Deserialize, Serialize};
57
58/// A git repository, named without naming a disk.
59///
60/// The value is [`crate::repository::git_identity`]'s digest of the git directory every
61/// work tree shares: one value for the primary checkout and every linked worktree of it,
62/// a different value for a different repository, and never a path — a shared record that
63/// carried the path would disclose where somebody keeps their work, which is of no use to
64/// a reader and of some use to somebody else (ADR 0014).
65///
66/// ```
67/// use majordomus_cli::session::RepositoryId;
68/// use std::process::Command;
69///
70/// let dir = tempfile::tempdir().unwrap();
71/// let root = dir.path().join("repo");
72/// std::fs::create_dir_all(&root).unwrap();
73/// let git = |args: &[&str]| assert!(
74///     Command::new("git").arg("-C").arg(&root).args(args).status().unwrap().success()
75/// );
76/// git(&["init", "-q", "."]);
77/// git(&["-c", "user.email=t@e.x", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "i"]);
78/// let wt = dir.path().join("repo-wt");
79/// git(&["worktree", "add", "-q", "-b", "feature/x", wt.to_str().unwrap()]);
80///
81/// let here = RepositoryId::of(&root).expect("a work tree");
82/// let there = RepositoryId::of(&wt).expect("a linked work tree");
83/// assert_eq!(here, there, "one repository, two checkouts");
84/// assert!(!here.as_str().contains('/'), "a digest, never a path");
85/// ```
86#[derive(
87    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
88)]
89#[serde(transparent)]
90pub struct RepositoryId(String);
91
92impl RepositoryId {
93    /// The repository the checkout at `root` belongs to. `None` where git cannot be asked:
94    /// a repository of the layer does not have to be version controlled, and saying so is
95    /// better than inventing an identity for one that is not.
96    ///
97    /// ```
98    /// use majordomus_cli::session::RepositoryId;
99    /// // absence is an answer: a plain directory belongs to no repository, and saying so is
100    /// // better than minting an identity nothing could ever match
101    /// let plain = tempfile::tempdir().unwrap();
102    /// assert!(RepositoryId::of(plain.path()).is_none());
103    /// ```
104    pub fn of(root: &Path) -> Option<Self> {
105        crate::repository::git_identity(root).map(|g| RepositoryId(g.id))
106    }
107
108    /// A value read back from a record that already carries one.
109    ///
110    /// It takes whatever the store wrote, because a store written by an older version of
111    /// the tool carries an older spelling and refusing to read it would lose the record.
112    /// What this type guarantees is not the shape of the string; it is that the string is
113    /// never passed where a checkout, an episode or a provider session is wanted.
114    ///
115    /// ```
116    /// use majordomus_cli::session::RepositoryId;
117    /// let recorded = RepositoryId::recorded("git@github.com:korczis/prismatic-majordomus.git");
118    /// assert_eq!(recorded.as_str(), "git@github.com:korczis/prismatic-majordomus.git");
119    /// ```
120    pub fn recorded(value: impl Into<String>) -> Self {
121        RepositoryId(value.into())
122    }
123
124    /// The value as text: what a record writes down. It is never parsed back into a
125    /// checkout, an episode or a provider identity.
126    ///
127    /// ```
128    /// use majordomus_cli::session::RepositoryId;
129    /// assert_eq!(RepositoryId::recorded("origin-url").as_str(), "origin-url");
130    /// ```
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134}
135
136/// One checkout of a repository: the primary one, or one linked worktree of it.
137///
138/// [`crate::repository::identity`]'s digest of the checkout root — the value this
139/// executable's lease, index and server already key a checkout by. Two worktrees of one
140/// repository share a [`RepositoryId`] and differ here, which is the distinction the
141/// continuity resolver's first tier is made of: a record from another worktree is never
142/// silently offered as your context.
143///
144/// ```
145/// use majordomus_cli::session::{CheckoutId, RepositoryId};
146/// use std::path::Path;
147///
148/// let a = CheckoutId::of(Path::new("/a/b"));
149/// let b = CheckoutId::of(Path::new("/a/c"));
150/// assert_ne!(a, b, "two checkouts");
151/// assert_eq!(a, CheckoutId::of(Path::new("/a/b")), "the same checkout across runs");
152/// assert!(!a.as_str().contains('/'), "a digest, never a path");
153///
154/// // and the two identities are not interchangeable: this does not compile.
155/// // let _: RepositoryId = a;
156/// let _ = RepositoryId::recorded("unused");
157/// ```
158#[derive(
159    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
160)]
161#[serde(transparent)]
162pub struct CheckoutId(String);
163
164impl CheckoutId {
165    /// The checkout rooted at `root`. Total: a path that does not exist still has an
166    /// identity, because the question "which checkout is this record about" has to be
167    /// answerable for a record written on a disk that has since been unmounted.
168    ///
169    /// ```
170    /// use majordomus_cli::session::CheckoutId;
171    /// use std::path::Path;
172    /// let gone = CheckoutId::of(Path::new("/Volumes/unmounted/repo"));
173    /// assert_eq!(gone.as_str().len(), 32, "a path that does not exist still has one");
174    /// ```
175    pub fn of(root: &Path) -> Self {
176        CheckoutId(crate::repository::identity(root))
177    }
178
179    /// A value read back from a record that carries one.
180    /// Whatever the store wrote is accepted: a record written by an older version of the
181    /// tool carries an older spelling, and refusing it would lose the record.
182    ///
183    /// ```
184    /// use majordomus_cli::session::CheckoutId;
185    /// use std::path::Path;
186    /// // a record's own string is taken as it stands, never made to agree with a digest
187    /// assert_ne!(CheckoutId::recorded("2b0f1c8e9a774d31"), CheckoutId::of(Path::new("/a/b")));
188    /// ```
189    pub fn recorded(value: impl Into<String>) -> Self {
190        CheckoutId(value.into())
191    }
192
193    /// The value as text: what a shared record writes as `worktree_id:`. It is never
194    /// compared with a repository identity, which is a different question with a different
195    /// answer.
196    ///
197    /// ```
198    /// use majordomus_cli::session::CheckoutId;
199    /// assert_eq!(CheckoutId::recorded("2b0f1c8e").as_str(), "2b0f1c8e");
200    /// ```
201    pub fn as_str(&self) -> &str {
202        &self.0
203    }
204}
205
206/// An episode's own identity: `s-<YYYYMMDDHHMMSS>-<4 hex>`.
207///
208/// This is the canonical session identity and the only one. It is minted once, when the
209/// episode opens, by whoever opens it; it is what every ledger line of the episode is
210/// stamped with; and it is what `session_id:` names in the closed record. A provider's own
211/// session string is [`ProviderSessionId`] and is never this.
212///
213/// The shape is the shell tool's, parsed as written rather than as would be tidier: there
214/// are months of these ids in `.ai/repo/sessions/` and a parser that refused them would be
215/// a parser that cannot read this repository. It is *not* [`crate::execution::ExecutionId`]'s
216/// shape — that one is `x-20260908T010203Z-0a1b2c3d`, with a `T`, a `Z` and eight hex
217/// digits — and the difference is asserted below so that a refactor cannot quietly align
218/// them and orphan every record already written.
219///
220/// ```
221/// use majordomus_cli::session::EpisodeId;
222///
223/// let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
224/// assert_eq!(id.as_str(), "s-20260910205542-e2a6");
225/// assert_eq!(id.minted_at(), "20260910205542");
226///
227/// // what it refuses, and why: a path segment is what this value becomes on disk
228/// assert!(EpisodeId::parse("../../etc/passwd").is_none());
229/// assert!(EpisodeId::parse("s-20260910205542-e2a").is_none(), "four hex digits");
230/// assert!(EpisodeId::parse("x-20260908T010203Z-0a1b2c3d").is_none(), "that is an execution");
231/// assert!(EpisodeId::parse("").is_none());
232/// ```
233#[derive(
234    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
235)]
236#[serde(transparent)]
237pub struct EpisodeId(String);
238
239impl EpisodeId {
240    /// Parse an id as the stores write it. `None` for anything else — which is what keeps
241    /// a path, a traversal or a wildcard out of a store lookup, since this value names a
242    /// file under `state/sessions-closing/` and is matched against record front matter.
243    ///
244    /// ```
245    /// use majordomus_cli::session::EpisodeId;
246    /// assert!(EpisodeId::parse("s-20260910205542-e2a6").is_some());
247    /// assert!(EpisodeId::parse("s-20260910205542-e2a6/../x").is_none(), "one path segment");
248    /// ```
249    pub fn parse(text: &str) -> Option<Self> {
250        let rest = text.strip_prefix("s-")?;
251        let (stamp, suffix) = rest.split_once('-')?;
252        let stamp_ok = stamp.len() == 14 && stamp.bytes().all(|b| b.is_ascii_digit());
253        let suffix_ok = suffix.len() == 4 && suffix.bytes().all(|b| b.is_ascii_hexdigit());
254        (stamp_ok && suffix_ok).then(|| EpisodeId(text.to_string()))
255    }
256
257    /// The `YYYYMMDDHHMMSS` the id was minted at. A reader's convenience and never a
258    /// substitute for `started_at`: the stamp says when the id was made, the record says
259    /// when the episode opened, and an episode recovered from a crash can have the two
260    /// differ.
261    ///
262    /// ```
263    /// use majordomus_cli::session::EpisodeId;
264    /// let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
265    /// assert_eq!(id.minted_at(), "20260910205542");
266    /// ```
267    pub fn minted_at(&self) -> &str {
268        &self.0[2..16]
269    }
270
271    /// The id as text. This is the string every ledger line of the episode is stamped with and the one
272    /// `session_id:` names in the closed record.
273    ///
274    /// ```
275    /// use majordomus_cli::session::EpisodeId;
276    /// let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
277    /// assert_eq!(id.as_str(), "s-20260910205542-e2a6");
278    /// ```
279    pub fn as_str(&self) -> &str {
280        &self.0
281    }
282}
283
284impl std::fmt::Display for EpisodeId {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.write_str(&self.0)
287    }
288}
289
290/// A provider's own name for its conversation — **an external correlation id, never the
291/// episode's identity.**
292///
293/// It is how a provider's hook finds the episode it opened: the hook passes
294/// `--provider-session <x>`, the open record lives at `state/sessions-open/<key of x>.yaml`,
295/// and an end event for `x` closes that episode and no other. That is the whole of its job.
296///
297/// It is not the session identity, for three reasons that are each a measured incident
298/// rather than a worry. An episode opened by hand has no provider session at all and is
299/// keyed `hand`, so the identity would be missing for the one episode a person opens.
300/// Two providers may name their sessions in the same shape, so the value is unique only
301/// within a provider. And the value changes under the worker: on 2026-09-09 a session in
302/// this repository was re-established, came back under a different peer identity, and was
303/// invisible to eight others for three hours because a durable thing had been keyed by a
304/// value that is not.
305///
306/// The stored key is reduced to one path segment so that nothing a provider sends can name
307/// a file outside the store.
308///
309/// ```
310/// use majordomus_cli::session::ProviderSessionId;
311///
312/// let p = ProviderSessionId::new("01Bv2gJsfAoYrXnpfC9uJ4uv");
313/// assert_eq!(p.store_key(), "01Bv2gJsfAoYrXnpfC9uJ4uv");
314///
315/// // anything that could leave the store becomes an inert segment
316/// assert_eq!(ProviderSessionId::new("../../etc/passwd").store_key(), ".._.._etc_passwd");
317/// assert_eq!(ProviderSessionId::new("a/b").store_key(), "a_b");
318///
319/// // and a segment made only of dots is not a directory: `.` and `..` survive an
320/// // allow-list that permits a dot, so the segment is what is checked, not the bytes
321/// assert_eq!(ProviderSessionId::new("..").store_key(), "_..");
322/// assert_eq!(ProviderSessionId::new(".").store_key(), "_.");
323///
324/// // an episode nobody named is the hand-opened one, and there is at most one
325/// assert_eq!(ProviderSessionId::hand().store_key(), "hand");
326/// assert!(ProviderSessionId::hand().is_hand());
327/// assert!(ProviderSessionId::new("   ").is_hand(), "a blank name names nobody");
328/// ```
329#[derive(
330    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
331)]
332#[serde(transparent)]
333pub struct ProviderSessionId(String);
334
335impl ProviderSessionId {
336    /// The provider's own string, as sent.
337    /// Nothing about its shape is assumed: it is a provider's private identifier, and this
338    /// type's job is to stop it being used as an episode identity rather than to validate it.
339    ///
340    /// ```
341    /// use majordomus_cli::session::ProviderSessionId;
342    /// assert_eq!(ProviderSessionId::new("01Bv2gJsf").as_str(), "01Bv2gJsf");
343    /// ```
344    pub fn new(value: impl Into<String>) -> Self {
345        ProviderSessionId(value.into())
346    }
347
348    /// The episode nobody named: a provider that sends no session identity is
349    /// indistinguishable from a person at a terminal, and inventing a distinction there
350    /// would multiply episodes nobody can close.
351    ///
352    /// ```
353    /// use majordomus_cli::session::ProviderSessionId;
354    /// assert!(ProviderSessionId::hand().is_hand());
355    /// assert_eq!(ProviderSessionId::hand().store_key(), "hand");
356    /// ```
357    pub fn hand() -> Self {
358        ProviderSessionId(String::new())
359    }
360
361    /// Is this the hand-opened episode's key? True for an empty or blank value.
362    /// A provider that sends whitespace has named nobody just as surely as one that sends
363    /// nothing, so both are the hand-opened episode.
364    ///
365    /// ```
366    /// use majordomus_cli::session::ProviderSessionId;
367    /// assert!(ProviderSessionId::new(" \t ").is_hand());
368    /// assert!(!ProviderSessionId::new("01Bv2gJsf").is_hand());
369    /// ```
370    pub fn is_hand(&self) -> bool {
371        self.0.trim().is_empty()
372    }
373
374    /// The value as sent, which is what a record carries and what a hook passes back. Not
375    /// the file name: [`ProviderSessionId::store_key`] is that, and the two differ
376    /// exactly when the provider sent something a path segment may not contain.
377    ///
378    /// ```
379    /// use majordomus_cli::session::ProviderSessionId;
380    /// let p = ProviderSessionId::new("a/b");
381    /// assert_eq!(p.as_str(), "a/b");
382    /// assert_ne!(p.as_str(), p.store_key());
383    /// ```
384    pub fn as_str(&self) -> &str {
385        &self.0
386    }
387
388    /// The value reduced to exactly one path segment, for the file the open record lives
389    /// in. Every byte outside `A-Za-z0-9._-` becomes `_`; the blank value becomes `hand`.
390    ///
391    /// A value made only of dots gets a `_` in front of it. `.` and `..` survive an
392    /// allow-list that permits a dot — they are made of nothing else — and both name a
393    /// directory rather than a file in it. The test that found this is in this module: an
394    /// allow-list is a claim about bytes, and the claim that has to hold is about the
395    /// *segment*.
396    ///
397    /// ```
398    /// use majordomus_cli::session::ProviderSessionId;
399    /// assert_eq!(ProviderSessionId::new("01Bv2gJsf").store_key(), "01Bv2gJsf");
400    /// assert!(!ProviderSessionId::new("../etc/passwd").store_key().contains('/'));
401    /// ```
402    pub fn store_key(&self) -> String {
403        if self.is_hand() {
404            return "hand".to_string();
405        }
406        let mapped: String = self
407            .0
408            .chars()
409            .map(|c| {
410                if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
411                    c
412                } else {
413                    '_'
414                }
415            })
416            .collect();
417        if mapped.chars().all(|c| c == '.') {
418            return format!("_{mapped}");
419        }
420        mapped
421    }
422}
423
424/// A unit of intended work that a person opens and closes: `t-<YYYYMMDDHHMMSS>-<4 hex>`.
425///
426/// An [`super::Episode`] carries `Option<TaskId>` and nothing else of a task. The
427/// separation is ADR 0052's whole finding: a task is optional, is opened and closed by a
428/// person, and outlives or predeceases any number of episodes. What made this repository
429/// write no checkpoint and no handover for six days was code that asked a task whether an
430/// episode's artefact should be written.
431///
432/// ```
433/// use majordomus_cli::session::{EpisodeId, TaskId};
434///
435/// let t = TaskId::parse("t-20260905034523-a9f1").expect("the task from the audit");
436/// assert_eq!(t.as_str(), "t-20260905034523-a9f1");
437///
438/// // the shapes rhyme and the types do not: an episode id is not a task id
439/// assert!(TaskId::parse("s-20260910205542-e2a6").is_none());
440/// assert!(EpisodeId::parse("t-20260905034523-a9f1").is_none());
441/// ```
442#[derive(
443    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
444)]
445#[serde(transparent)]
446pub struct TaskId(String);
447
448impl TaskId {
449    /// Parse an id as `lib/start.sh` writes it. `None` for anything else, including the
450    /// literal `none` the records use for "no task", which is an absence and is
451    /// represented as `Option::None` rather than as a task whose name is a word.
452    ///
453    /// ```
454    /// use majordomus_cli::session::TaskId;
455    /// assert!(TaskId::parse("t-20260905034523-a9f1").is_some());
456    /// assert!(TaskId::parse("none").is_none(), "the records' word for no task");
457    /// ```
458    pub fn parse(text: &str) -> Option<Self> {
459        let rest = text.strip_prefix("t-")?;
460        let (stamp, suffix) = rest.split_once('-')?;
461        let stamp_ok = stamp.len() == 14 && stamp.bytes().all(|b| b.is_ascii_digit());
462        let suffix_ok = suffix.len() == 4 && suffix.bytes().all(|b| b.is_ascii_hexdigit());
463        (stamp_ok && suffix_ok).then(|| TaskId(text.to_string()))
464    }
465
466    /// The id as text: what a record writes as `task_id:`. An episode relates to a task by
467    /// this value and holds nothing else of one.
468    ///
469    /// ```
470    /// use majordomus_cli::session::TaskId;
471    /// let t = TaskId::parse("t-20260905034523-a9f1").expect("a task id");
472    /// assert_eq!(t.as_str(), "t-20260905034523-a9f1");
473    /// ```
474    pub fn as_str(&self) -> &str {
475        &self.0
476    }
477}
478
479/// Who is working: a worker identity somebody supplied, never one inferred.
480///
481/// `lib/session.sh` is explicit about this and the type keeps the promise: *absent stays
482/// absent*. An inferred worker is indistinguishable from a recorded one the moment it is
483/// written down, and a record that attributes six days of somebody else's work to the user
484/// account that happened to run the hook is worse than a record that attributes it to
485/// nobody.
486///
487/// ```
488/// use majordomus_cli::session::WorkerId;
489/// assert_eq!(WorkerId::supplied("claude-opus-5").map(|w| w.as_str().to_string()),
490///            Some("claude-opus-5".to_string()));
491/// assert!(WorkerId::supplied("").is_none(), "absent stays absent");
492/// assert!(WorkerId::supplied("  ").is_none());
493/// ```
494#[derive(
495    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
496)]
497#[serde(transparent)]
498pub struct WorkerId(String);
499
500impl WorkerId {
501    /// A worker identity that was supplied. `None` for a blank one, which is an absence.
502    ///
503    /// ```
504    /// use majordomus_cli::session::WorkerId;
505    /// assert!(WorkerId::supplied("codex").is_some());
506    /// assert!(WorkerId::supplied("\n").is_none(), "absent stays absent");
507    /// ```
508    pub fn supplied(value: &str) -> Option<Self> {
509        let trimmed = value.trim();
510        (!trimmed.is_empty()).then(|| WorkerId(trimmed.to_string()))
511    }
512
513    /// The identity as text, trimmed as it was supplied, which is what a record writes as
514    /// `worker:` when one was supplied at all.
515    ///
516    /// ```
517    /// use majordomus_cli::session::WorkerId;
518    /// assert_eq!(WorkerId::supplied("  codex  ").expect("a worker").as_str(), "codex");
519    /// ```
520    pub fn as_str(&self) -> &str {
521        &self.0
522    }
523}
524
525/// Which store a recorded identity was read from, kept beside the value.
526///
527/// The point is not to choose between `mj_git_repo_id` and `mj_repository_id`, or between
528/// `worktree:` and `worktree_id:`. It is to stop pretending they are one value. A reader
529/// that knows a string is the *local* spelling can compare it with other local spellings
530/// and can refuse to compare it with a shared one, which is precisely the comparison that
531/// produced "these two records are about different repositories" for records of the same
532/// repository written four minutes apart.
533///
534/// ```
535/// use majordomus_cli::session::Spelling;
536/// // the two halves are different answers to one question, and are never compared
537/// assert_ne!(Spelling::Local, Spelling::Shared);
538/// assert_eq!(Spelling::Canonical.as_str(), "canonical");
539/// ```
540#[derive(
541    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
542)]
543#[serde(rename_all = "snake_case")]
544pub enum Spelling {
545    /// The local half's spelling: a path, in `.ai/local/state/`. It names this disk and is
546    /// never published.
547    Local,
548    /// The shared half's spelling: a remote URL or a digest, in `.ai/repo/sessions/`. It
549    /// names the repository and travels with a clone (ADR 0014).
550    Shared,
551    /// This executable's own canonical digest, computed rather than read.
552    Canonical,
553}
554
555impl Spelling {
556    /// The word as serialised.
557    ///
558    /// ```
559    /// use majordomus_cli::session::Spelling;
560    /// assert_eq!(Spelling::Shared.as_str(), "shared");
561    /// ```
562    /// It is what a projection of this value carries, and what a reader groups spellings by.
563    pub fn as_str(self) -> &'static str {
564        match self {
565            Spelling::Local => "local",
566            Spelling::Shared => "shared",
567            Spelling::Canonical => "canonical",
568        }
569    }
570}
571
572/// One identity of this checkout, as this executable computes it and as each store spells
573/// it. What `session.identity` answers, and the direct projection of the audit's table.
574///
575/// ```
576/// use majordomus_cli::session::{identities, IdentityFacet};
577///
578/// let dir = tempfile::tempdir().unwrap();
579/// let facets: Vec<IdentityFacet> = identities(dir.path());
580/// let repository = facets.iter().find(|f| f.subject == "repository").expect("a facet");
581/// assert!(repository.spellings.len() >= 2, "one word, two writers, two values");
582/// ```
583#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
584pub struct IdentityFacet {
585    /// What is being identified: `repository`, `checkout`, `episode`, `provider_session`.
586    pub subject: String,
587    /// The canonical value this executable computes, when it can compute one.
588    #[serde(default, skip_serializing_if = "String::is_empty")]
589    pub canonical: String,
590    /// How each store spells the same subject, in the order [`crate::order`] gives.
591    #[serde(default, skip_serializing_if = "Vec::is_empty")]
592    pub spellings: Vec<RecordedSpelling>,
593    /// Why the subject needs its own type, in one line, for a reader meeting it in a
594    /// projection rather than in this module.
595    pub note: String,
596}
597
598/// One store's spelling of one subject.
599/// Which half of the layer wrote it, what wrote it, and the value it would have here.
600///
601/// ```
602/// use majordomus_cli::session::{RecordedSpelling, Spelling};
603///
604/// let local = RecordedSpelling {
605///     spelling: Spelling::Local,
606///     writer: "mj_git_repo_id (lib/common.sh)".into(),
607///     value: "/src/repo/.git".into(),
608/// };
609/// // knowing which half a string came from is what stops a path being compared with a URL
610/// assert_eq!(local.spelling.as_str(), "local");
611/// assert!(local.value.starts_with('/'));
612/// ```
613#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
614pub struct RecordedSpelling {
615    /// Which half of the layer wrote it.
616    pub spelling: Spelling,
617    /// The writer, named so a reader can go and look at it.
618    pub writer: String,
619    /// The value, as this checkout would have it written. Empty when nothing has written
620    /// one here yet, which is a fresh clone and not a fault.
621    #[serde(default, skip_serializing_if = "String::is_empty")]
622    pub value: String,
623}
624
625impl crate::order::Ordered for RecordedSpelling {
626    fn order_key(&self) -> crate::order::OrderKey<'_> {
627        crate::order::OrderKey {
628            group: Some(self.spelling.as_str()),
629            rank: 0,
630            label: &self.writer,
631            identity: &self.writer,
632        }
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn an_episode_id_and_an_execution_id_are_not_the_same_shape() {
642        // This is the assertion that stops a tidying refactor from aligning the two id
643        // shapes: months of `s-` ids exist on disk, and a parser that accepted `x-` ids
644        // here would let an execution be looked up as an episode.
645        assert!(EpisodeId::parse("x-20260908T010203Z-0a1b2c3d").is_none());
646        assert!(crate::execution::ExecutionId::parse("s-20260910205542-e2a6").is_none());
647    }
648
649    #[test]
650    fn a_provider_session_key_is_always_one_inert_path_segment() {
651        for hostile in [
652            "../../etc/passwd",
653            "a/b/c",
654            "..",
655            ".",
656            "a\0b",
657            "*",
658            "~/x",
659            "a b",
660        ] {
661            let key = ProviderSessionId::new(hostile).store_key();
662            assert!(!key.contains('/'), "{hostile} kept a separator: {key}");
663            assert!(key != ".." && key != ".", "{hostile} stayed a traversal");
664        }
665    }
666
667    #[test]
668    fn the_hand_opened_episode_has_one_key_however_it_is_spelled() {
669        assert_eq!(ProviderSessionId::hand().store_key(), "hand");
670        assert_eq!(ProviderSessionId::new("").store_key(), "hand");
671        assert_eq!(ProviderSessionId::new("\t \n").store_key(), "hand");
672    }
673
674    #[test]
675    fn a_task_id_that_is_the_word_none_is_an_absence() {
676        // `task_id: none` is how the records spell "no task". Parsing it as a task would
677        // give every episode without one a task called `none` — and the whole point of
678        // ADR 0052 is that an episode without a task is ordinary.
679        assert!(TaskId::parse("none").is_none());
680    }
681
682    #[test]
683    fn two_checkouts_of_one_repository_share_a_repository_id_and_differ_in_checkout_id() {
684        let a = CheckoutId::of(Path::new("/tmp/repo"));
685        let b = CheckoutId::of(Path::new("/tmp/repo-wt/feature/x"));
686        assert_ne!(a, b);
687        assert_eq!(a.as_str().len(), 32, "a fixed-width digest");
688    }
689}