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}