Skip to main content

majordomus_cli/session/
ledger.rs

1//! The typed event substrate: the existing append-only ledger, read and written with types.
2//!
3//! # Why this is not a new store
4//!
5//! ADR 0052 says it in as many words and this module keeps the promise: the append-only
6//! ledger at `.ai/local/state/ledger.jsonl` and the record directories beside it are the
7//! substrate, and the write path moves *under* the domain rather than being duplicated
8//! beside it. Every defect this subsystem has produced has the same shape — two accounts of
9//! one fact — and a second store would be the largest instance of it yet.
10//!
11//! # Why JSONL and not a database
12//!
13//! The requirement was stated as concurrency-safe append, corruption detection and
14//! recovery. It was *not* stated as query, transactions across records, or indexes, and
15//! measuring what the ledger is actually asked for bears that out: every reader in the tree
16//! walks the file once and filters. So the question is whether a locked append-only text
17//! file meets the three requirements, and it does:
18//!
19//! * **Concurrent append.** A write opened `O_APPEND` is positioned and written in one
20//!   operation by the kernel, so two processes cannot interleave at the offset; below
21//!   `PIPE_BUF` the write itself is atomic as well. [`Ledger::append`] additionally takes an
22//!   exclusive `flock` for the duration, which covers a long line and a filesystem with
23//!   weaker guarantees. A ledger line is one line, so there is no cross-line transaction to
24//!   want.
25//! * **Corruption detection.** The unit of corruption is a line, because the format has no
26//!   cross-line state. [`Ledger::read`] parses line by line and reports each unparseable one
27//!   with its number and what was wrong with it.
28//! * **Recovery.** A file whose last line was truncated by a machine losing power is still
29//!   the complete record of everything before it. Reading skips the bad line, counts it, and
30//!   names it; nothing fails, because a ledger with one truncated line that refuses to be
31//!   read is worse than one that reports the truncation.
32//!
33//! A database would add a binary file to a store whose value is partly that a person can
34//! `tail -f` it, a second thing to migrate at cutover, a locking model that behaves no
35//! better than `flock` over a network filesystem, and a schema that would immediately
36//! become a second account of `share/events.yaml`. It is refused, and ADR 0047 records the
37//! refusal so that it is a decision rather than an omission.
38//!
39//! # The vocabulary is not this module's
40//!
41//! `share/events.yaml` declares every event name the ledger accepts and what each one must
42//! carry, and `mj_ledger_append` already refuses an unregistered name. [`Vocabulary`] reads
43//! that file. It does not contain a list of event names, and a test below asserts that the
44//! names this domain writes are ones the shipped file declares — which is the assertion
45//! that fails if somebody adds an event here and forgets the vocabulary, rather than a
46//! durable line every reader silently ignores.
47//!
48//! ```
49//! use majordomus_cli::session::{Envelope, Ledger, LedgerEvent, Vocabulary};
50//!
51//! let dir = tempfile::tempdir().unwrap();
52//! let ledger = Ledger::at(dir.path().join("ledger.jsonl"));
53//! let vocabulary = Vocabulary::parse(
54//!     "version: 1\nevents:\n  - id: session.started\n    requires: [owner]\n    public: true\n",
55//! ).expect("a vocabulary");
56//! let envelope = Envelope {
57//!     ts: "2026-09-11T11:02:35Z".into(), head: "1a651e64".into(),
58//!     branch: "master".into(), by: "majordomus/0.5.0".into(), session: None,
59//! };
60//!
61//! ledger.append(&LedgerEvent::new("session.started").with("owner", "korczis"), &vocabulary, &envelope).unwrap();
62//! assert_eq!(ledger.read().events.len(), 1);
63//!
64//! // and a name nothing declares never becomes a durable line
65//! assert!(ledger.append(&LedgerEvent::new("session.invented"), &vocabulary, &envelope).is_err());
66//! ```
67
68use std::collections::BTreeMap;
69use std::io::{Read, Write};
70use std::path::{Path, PathBuf};
71
72use schemars::JsonSchema;
73use serde::{Deserialize, Serialize};
74use serde_json::{Map, Value};
75
76use crate::metadata::yaml;
77
78/// `share/events.yaml`, relative to the share directory.
79pub const EVENTS_FILE: &str = "events.yaml";
80
81/// The ledger, relative to the repository root.
82pub const LEDGER_PATH: &str = ".ai/local/state/ledger.jsonl";
83
84/// What can go wrong writing or reading the ledger. The first two are refusals of a
85/// malformed event and happen before anything is written; the last two are the disk.
86///
87/// ```
88/// use majordomus_cli::session::{Envelope, LedgerError, LedgerEvent, Vocabulary};
89///
90/// let v = Vocabulary::parse("version: 1\nevents:\n  - id: session.started\n    requires: [owner]\n    public: true\n").unwrap();
91/// let envelope = Envelope {
92///     ts: "2026-09-11T11:02:35Z".into(), head: "1a651e64".into(),
93///     branch: "master".into(), by: "majordomus/0.5.0".into(), session: None,
94/// };
95/// let err = LedgerEvent::new("session.started").render(&v, &envelope).expect_err("a refusal");
96/// assert!(matches!(err, LedgerError::MissingField { .. }));
97/// ```
98#[derive(Debug, thiserror::Error)]
99pub enum LedgerError {
100    /// The event name is not one `share/events.yaml` declares. An internal error and not a
101    /// finding: a command that writes an event the vocabulary does not define is a bug in
102    /// Majordomus, not a fact about the repository being supervised.
103    #[error("unregistered event '{name}'; declare it in share/{file} or use one of: {known}")]
104    UnregisteredEvent {
105        /// The name that was offered.
106        name: String,
107        /// The vocabulary file it is missing from.
108        file: String,
109        /// The names that are declared, space separated.
110        known: String,
111    },
112    /// The event is declared and the payload is missing a key its declaration requires.
113    #[error("event '{name}' is missing the required field '{field}'")]
114    MissingField {
115        /// The event.
116        name: String,
117        /// The key its `requires:` demands.
118        field: String,
119    },
120    /// The vocabulary file could not be read or does not parse.
121    #[error("the event vocabulary at {path} could not be read: {reason}")]
122    Vocabulary {
123        /// Where it was looked for.
124        path: String,
125        /// What went wrong.
126        reason: String,
127    },
128    /// The ledger file itself could not be opened, locked or written.
129    #[error("the ledger at {path} could not be written: {reason}")]
130    Io {
131        /// The ledger.
132        path: String,
133        /// What went wrong.
134        reason: String,
135    },
136}
137
138/// One declared event: what it is called, what writes it, what it must carry, and whether
139/// it is part of the documented record a user reads back.
140///
141/// ```
142/// use majordomus_cli::session::{DeclaredEvent, Vocabulary};
143///
144/// let v = Vocabulary::parse(
145///     "version: 1\nevents:\n  - id: session.closed\n    emitted_by: session\n    requires: [outcome, session_path]\n    public: true\n",
146/// ).expect("a vocabulary");
147/// let declared: &DeclaredEvent = &v.declared()[0];
148/// assert_eq!(declared.id, "session.closed");
149/// assert_eq!(declared.requires, ["outcome", "session_path"]);
150/// ```
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
152pub struct DeclaredEvent {
153    /// The name written into the `event` field of every line.
154    pub id: String,
155    /// The command whose module writes it.
156    #[serde(default, skip_serializing_if = "String::is_empty")]
157    pub emitted_by: String,
158    /// Payload keys every line of this type must carry, beyond the envelope.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub requires: Vec<String>,
161    /// Whether the event is part of the documented record a user reads back.
162    pub public: bool,
163}
164
165impl crate::order::Ordered for DeclaredEvent {
166    fn order_key(&self) -> crate::order::OrderKey<'_> {
167        crate::order::OrderKey {
168            group: Some(&self.emitted_by),
169            rank: 0,
170            label: &self.id,
171            identity: &self.id,
172        }
173    }
174}
175
176/// The event vocabulary, read from `share/events.yaml`.
177///
178/// ```
179/// use majordomus_cli::session::Vocabulary;
180///
181/// // written as the shipped file is, which is what a repository's own share directory holds
182/// let v = Vocabulary::parse(
183///     "version: 1\nevents:\n  - id: session.closed\n    emitted_by: session\n    requires: [outcome, session_path]\n    public: true\n",
184/// ).expect("a vocabulary");
185/// assert!(v.knows("session.closed"));
186/// assert!(!v.knows("session.clsoed"), "a typo is not a name");
187/// assert_eq!(v.requires("session.closed"), ["outcome", "session_path"]);
188/// assert!(v.requires("nothing.declared").is_empty());
189/// ```
190#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct Vocabulary {
192    events: BTreeMap<String, DeclaredEvent>,
193}
194
195impl Vocabulary {
196    /// Read the vocabulary from a share directory — the one the tool was invoked with,
197    /// never a copy compiled into this module.
198    ///
199    /// ```
200    /// use majordomus_cli::session::Vocabulary;
201    /// // a directory with no events.yaml is a failure with a path in it, not a default
202    /// let empty = tempfile::tempdir().unwrap();
203    /// assert!(Vocabulary::load(empty.path()).is_err());
204    /// ```
205    pub fn load(share_dir: &Path) -> Result<Self, LedgerError> {
206        let path = share_dir.join(EVENTS_FILE);
207        let text = std::fs::read_to_string(&path).map_err(|e| LedgerError::Vocabulary {
208            path: path.display().to_string(),
209            reason: e.to_string(),
210        })?;
211        Self::parse(&text).map_err(|reason| LedgerError::Vocabulary {
212            path: path.display().to_string(),
213            reason,
214        })
215    }
216
217    /// Parse the vocabulary from the file's text. A document that declares no event with
218    /// an id is an error rather than an empty vocabulary, because an empty one would refuse
219    /// every event and read as "the ledger is broken".
220    ///
221    /// ```
222    /// use majordomus_cli::session::Vocabulary;
223    /// assert!(Vocabulary::parse("version: 1\nevents: []\n").is_err());
224    /// assert!(Vocabulary::parse("version: 1\nevents:\n  - id: session.started\n").is_ok());
225    /// ```
226    pub fn parse(text: &str) -> Result<Self, String> {
227        let map = yaml::parse_mapping(text)?;
228        let list = map
229            .get("events")
230            .and_then(|v| v.as_array())
231            .ok_or_else(|| "no `events:` list".to_string())?;
232        let mut events = BTreeMap::new();
233        for entry in list {
234            let Some(obj) = entry.as_object() else {
235                continue;
236            };
237            let Some(id) = obj.get("id").and_then(yaml::scalar_string) else {
238                continue;
239            };
240            let requires = obj
241                .get("requires")
242                .and_then(|v| v.as_array())
243                .map(|a| a.iter().filter_map(yaml::scalar_string).collect())
244                .unwrap_or_default();
245            let declared = DeclaredEvent {
246                emitted_by: obj
247                    .get("emitted_by")
248                    .and_then(yaml::scalar_string)
249                    .unwrap_or_default(),
250                requires,
251                public: obj.get("public").and_then(|v| v.as_bool()).unwrap_or(false),
252                id: id.clone(),
253            };
254            events.insert(id, declared);
255        }
256        if events.is_empty() {
257            return Err("the `events:` list declares no event with an id".to_string());
258        }
259        Ok(Vocabulary { events })
260    }
261
262    /// Is `name` declared? The question `mj_ledger_append` asks before it writes, so that
263    /// a mistyped name is a refusal rather than a durable line every reader ignores.
264    ///
265    /// ```
266    /// use majordomus_cli::session::Vocabulary;
267    /// let v = Vocabulary::parse("version: 1\nevents:\n  - id: session.closed\n").unwrap();
268    /// assert!(v.knows("session.closed"));
269    /// assert!(!v.knows("session.clsoed"), "a typo is not a name");
270    /// ```
271    pub fn knows(&self, name: &str) -> bool {
272        self.events.contains_key(name)
273    }
274
275    /// The payload keys `name` must carry. Empty for a name that is not declared — the
276    /// caller has already been told by [`Vocabulary::knows`], and answering "no
277    /// requirements" for an unknown name is what keeps this from being a second refusal.
278    ///
279    /// ```
280    /// use majordomus_cli::session::Vocabulary;
281    /// let v = Vocabulary::parse(
282    ///     "version: 1\nevents:\n  - id: session.closed\n    requires: [outcome, session_path]\n",
283    /// ).unwrap();
284    /// assert_eq!(v.requires("session.closed"), ["outcome", "session_path"]);
285    /// assert!(v.requires("nothing.declared").is_empty());
286    /// ```
287    pub fn requires(&self, name: &str) -> &[String] {
288        self.events
289            .get(name)
290            .map(|e| e.requires.as_slice())
291            .unwrap_or(&[])
292    }
293
294    /// Every declared event, in the crate's one canonical order, so two surfaces listing
295    /// the vocabulary list it the same way.
296    ///
297    /// ```
298    /// use majordomus_cli::session::Vocabulary;
299    /// let v = Vocabulary::parse(
300    ///     "version: 1\nevents:\n  - id: session.started\n  - id: session.closed\n",
301    /// ).unwrap();
302    /// assert_eq!(v.declared().len(), 2);
303    /// ```
304    pub fn declared(&self) -> Vec<DeclaredEvent> {
305        let mut out: Vec<DeclaredEvent> = self.events.values().cloned().collect();
306        crate::order::canonical(&mut out);
307        out
308    }
309
310    /// The declared names, space separated, for a refusal that tells the caller what it
311    /// could have said instead.
312    fn names(&self) -> String {
313        self.events.keys().cloned().collect::<Vec<_>>().join(" ")
314    }
315}
316
317/// One event on its way into the ledger: the name, the payload, and the envelope the
318/// writer computes.
319///
320/// The envelope — `ts`, `event`, `head`, `branch`, `by`, and `session` when an episode is
321/// open — is computed and never authored, exactly as `share/events.yaml` says. This type
322/// takes the computed values from the caller rather than reaching for git itself, because
323/// a domain that shells out to git while holding a lock on the ledger is a domain that
324/// holds the lock for a hundred milliseconds on a loaded machine.
325///
326/// ```
327/// use majordomus_cli::session::{Envelope, LedgerEvent, Vocabulary};
328///
329/// let v = Vocabulary::parse(
330///     "version: 1\nevents:\n  - id: session.closed\n    requires: [outcome, session_path]\n    public: true\n",
331/// ).unwrap();
332/// let envelope = Envelope {
333///     ts: "2026-09-11T11:02:35Z".into(),
334///     head: "1a651e644".into(),
335///     branch: "feature/the-session-domain-is-typed".into(),
336///     by: "majordomus/0.5.0".into(),
337///     session: Some("s-20260910205542-e2a6".into()),
338/// };
339///
340/// // the payload its declaration requires
341/// let ok = LedgerEvent::new("session.closed")
342///     .with("outcome", "closed")
343///     .with("session_path", ".ai/repo/sessions/x.md");
344/// let line = ok.render(&v, &envelope).expect("a line");
345/// assert!(line.starts_with('{') && line.ends_with('}'));
346/// assert!(line.contains("\"event\":\"session.closed\""));
347/// assert!(line.contains("\"session\":\"s-20260910205542-e2a6\""));
348///
349/// // one key short, and it is refused before anything is written
350/// let short = LedgerEvent::new("session.closed").with("outcome", "closed");
351/// assert!(short.render(&v, &envelope).is_err());
352///
353/// // a name the vocabulary does not declare is refused too
354/// assert!(LedgerEvent::new("session.clsoed").render(&v, &envelope).is_err());
355/// ```
356#[derive(Debug, Clone, PartialEq)]
357pub struct LedgerEvent {
358    name: String,
359    payload: Map<String, Value>,
360}
361
362/// The computed half of a ledger line: what the writer knows and the event does not.
363///
364/// Every field here is computed and never authored, exactly as `share/events.yaml` says.
365/// It is passed in rather than gathered here, because a domain that shells out to git
366/// while holding the ledger's lock holds it for a hundred milliseconds on a loaded machine.
367///
368/// ```
369/// use majordomus_cli::session::Envelope;
370///
371/// let envelope = Envelope {
372///     ts: "2026-09-11T11:02:35Z".into(), head: "1a651e64".into(),
373///     branch: "master".into(), by: "majordomus/0.5.0".into(),
374///     session: Some("s-20260910205542-e2a6".into()),
375/// };
376/// // a line carrying no session belongs to no episode, which is an answer and not a gap
377/// assert!(envelope.session.is_some());
378/// ```
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct Envelope {
381    /// When, RFC 3339 in UTC.
382    pub ts: String,
383    /// The commit the writer was on.
384    pub head: String,
385    /// The branch it was on.
386    pub branch: String,
387    /// The writer, `majordomus/<version>`.
388    pub by: String,
389    /// The episode this line belongs to, when one is open. A line carrying no session id
390    /// belongs to no episode, and that is the correct answer rather than a gap: work done
391    /// outside an episode is attributed to nobody rather than to whoever had one open
392    /// nearby.
393    pub session: Option<String>,
394}
395
396impl LedgerEvent {
397    /// An event with no payload yet. The name is not checked here: it is checked against
398    /// the vocabulary at [`LedgerEvent::render`], which is the last moment before a line
399    /// could become durable.
400    ///
401    /// ```
402    /// use majordomus_cli::session::LedgerEvent;
403    /// assert_eq!(LedgerEvent::new("session.closed").name(), "session.closed");
404    /// ```
405    pub fn new(name: impl Into<String>) -> Self {
406        LedgerEvent {
407            name: name.into(),
408            payload: Map::new(),
409        }
410    }
411
412    /// Add one payload key. Strings are the common case and have their own method so that
413    /// the ordinary call site carries no `json!`.
414    ///
415    /// ```
416    /// use majordomus_cli::session::{Envelope, LedgerEvent, Vocabulary};
417    /// let v = Vocabulary::parse("version: 1\nevents:\n  - id: session.closed\n    requires: [outcome]\n").unwrap();
418    /// let envelope = Envelope {
419    ///     ts: "t".into(), head: "h".into(), branch: "b".into(), by: "m".into(), session: None,
420    /// };
421    /// let line = LedgerEvent::new("session.closed").with("outcome", "closed").render(&v, &envelope).unwrap();
422    /// assert!(line.contains("\"outcome\":\"closed\""));
423    /// ```
424    pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
425        self.payload
426            .insert(key.to_string(), Value::String(value.into()));
427        self
428    }
429
430    /// Add one payload key whose value is not a string — a count, a flag, a list.
431    ///
432    /// ```
433    /// use majordomus_cli::session::{Envelope, LedgerEvent, Vocabulary};
434    /// let v = Vocabulary::parse("version: 1\nevents:\n  - id: ledger.rotated\n    requires: [kept]\n").unwrap();
435    /// let envelope = Envelope {
436    ///     ts: "t".into(), head: "h".into(), branch: "b".into(), by: "m".into(), session: None,
437    /// };
438    /// let line = LedgerEvent::new("ledger.rotated")
439    ///     .with_value("kept", serde_json::json!(120))
440    ///     .render(&v, &envelope)
441    ///     .unwrap();
442    /// assert!(line.contains("\"kept\":120"));
443    /// ```
444    pub fn with_value(mut self, key: &str, value: Value) -> Self {
445        self.payload.insert(key.to_string(), value);
446        self
447    }
448
449    /// The event's name, as offered. It is a declared name only once the vocabulary has
450    /// been asked, which [`LedgerEvent::render`] does.
451    ///
452    /// ```
453    /// use majordomus_cli::session::LedgerEvent;
454    /// assert_eq!(LedgerEvent::new("session.started").name(), "session.started");
455    /// ```
456    pub fn name(&self) -> &str {
457        &self.name
458    }
459
460    /// Render the line, refusing an unregistered name or a missing required key. The
461    /// refusal happens here — before the lock is taken and before anything is written — so
462    /// that a malformed event never becomes a durable line.
463    ///
464    /// ```
465    /// use majordomus_cli::session::{Envelope, LedgerEvent, Vocabulary};
466    ///
467    /// let v = Vocabulary::parse(
468    ///     "version: 1\nevents:\n  - id: session.closed\n    requires: [outcome, session_path]\n",
469    /// ).unwrap();
470    /// let envelope = Envelope {
471    ///     ts: "2026-09-11T11:02:35Z".into(), head: "1a651e64".into(),
472    ///     branch: "master".into(), by: "majordomus/0.5.0".into(),
473    ///     session: Some("s-20260910205542-e2a6".into()),
474    /// };
475    ///
476    /// let line = LedgerEvent::new("session.closed")
477    ///     .with("outcome", "closed")
478    ///     .with("session_path", ".ai/repo/sessions/x.md")
479    ///     .render(&v, &envelope)
480    ///     .expect("a line");
481    /// assert!(line.contains("\"event\":\"session.closed\""));
482    /// assert!(line.contains("\"session\":\"s-20260910205542-e2a6\""));
483    ///
484    /// // one key short, and nothing is rendered at all
485    /// assert!(LedgerEvent::new("session.closed").with("outcome", "closed").render(&v, &envelope).is_err());
486    /// ```
487    pub fn render(
488        &self,
489        vocabulary: &Vocabulary,
490        envelope: &Envelope,
491    ) -> Result<String, LedgerError> {
492        if !vocabulary.knows(&self.name) {
493            return Err(LedgerError::UnregisteredEvent {
494                name: self.name.clone(),
495                file: EVENTS_FILE.to_string(),
496                known: vocabulary.names(),
497            });
498        }
499        for field in vocabulary.requires(&self.name) {
500            if !self.payload.contains_key(field) {
501                return Err(LedgerError::MissingField {
502                    name: self.name.clone(),
503                    field: field.clone(),
504                });
505            }
506        }
507        // The envelope's key order is the shell writer's, so a reader that has learned to
508        // read one ledger reads both and a diff of two lines is legible.
509        let mut line = Map::new();
510        line.insert("ts".into(), Value::String(envelope.ts.clone()));
511        line.insert("event".into(), Value::String(self.name.clone()));
512        line.insert("head".into(), Value::String(envelope.head.clone()));
513        line.insert("branch".into(), Value::String(envelope.branch.clone()));
514        line.insert("by".into(), Value::String(envelope.by.clone()));
515        if let Some(session) = &envelope.session {
516            line.insert("session".into(), Value::String(session.clone()));
517        }
518        for (k, v) in &self.payload {
519            line.insert(k.clone(), v.clone());
520        }
521        serde_json::to_string(&Value::Object(line)).map_err(|e| LedgerError::Io {
522            path: LEDGER_PATH.to_string(),
523            reason: e.to_string(),
524        })
525    }
526}
527
528/// One line that could not be read as an event: which line it was, and what was wrong with
529/// it.
530///
531/// ```
532/// use majordomus_cli::session::{Corruption, Ledger};
533///
534/// let dir = tempfile::tempdir().unwrap();
535/// let path = dir.path().join("ledger.jsonl");
536/// std::fs::write(&path, "not json at all\n").unwrap();
537/// let found: Vec<Corruption> = Ledger::at(&path).read().corrupt;
538/// assert_eq!(found[0].line, 1, "counted from one, so a person can go to it");
539/// ```
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
541pub struct Corruption {
542    /// Which line, counting from one, so a person can go to it.
543    pub line: usize,
544    /// What was wrong with it.
545    pub reason: String,
546}
547
548/// What a read of the ledger found: the lines that parsed, in ledger order, and the lines
549/// that did not, named rather than dropped.
550///
551/// ```
552/// use majordomus_cli::session::{Ledger, LedgerRead};
553///
554/// let dir = tempfile::tempdir().unwrap();
555/// let path = dir.path().join("ledger.jsonl");
556/// std::fs::write(&path, "{\"ts\":\"a\"}\nbroken\n").unwrap();
557/// let read: LedgerRead = Ledger::at(&path).read();
558/// assert_eq!(read.events.len(), 1);
559/// assert_eq!(read.corrupt.len(), 1);
560/// ```
561#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
562pub struct LedgerRead {
563    /// The lines that parsed, in ledger order — which is the order the commands ran, and
564    /// is what makes two events inside one second need no tiebreak.
565    pub events: Vec<Value>,
566    /// The lines that did not. Named rather than dropped: a reader that silently omits a
567    /// line cannot tell a ledger with a truncated tail from one that recorded less.
568    #[serde(default, skip_serializing_if = "Vec::is_empty")]
569    pub corrupt: Vec<Corruption>,
570}
571
572/// The append-only ledger of one checkout: the canonical record of what happened, written
573/// only by Majordomus and in the order the commands ran.
574///
575/// ```
576/// use majordomus_cli::session::Ledger;
577/// use std::path::Path;
578/// assert!(Ledger::of(Path::new("/r")).path().ends_with("ledger.jsonl"));
579/// ```
580#[derive(Debug, Clone)]
581pub struct Ledger {
582    path: PathBuf,
583}
584
585impl Ledger {
586    /// The ledger of the repository rooted at `root`.
587    ///
588    /// ```
589    /// use majordomus_cli::session::Ledger;
590    /// use std::path::Path;
591    /// assert!(Ledger::of(Path::new("/r")).path().ends_with("ledger.jsonl"));
592    /// ```
593    pub fn of(root: &Path) -> Self {
594        Ledger {
595            path: root.join(LEDGER_PATH),
596        }
597    }
598
599    /// The ledger at an explicit path, for a test or a store that is not a repository root.
600    ///
601    /// ```
602    /// use majordomus_cli::session::Ledger;
603    /// let dir = tempfile::tempdir().unwrap();
604    /// let ledger = Ledger::at(dir.path().join("elsewhere.jsonl"));
605    /// assert!(ledger.read().events.is_empty(), "a ledger nobody has written is empty");
606    /// ```
607    pub fn at(path: impl Into<PathBuf>) -> Self {
608        Ledger { path: path.into() }
609    }
610
611    /// Where it is. A reader that wants to name the file in a diagnostic asks here rather
612    /// than composing the path a second time.
613    ///
614    /// ```
615    /// use majordomus_cli::session::Ledger;
616    /// use std::path::Path;
617    /// assert!(Ledger::of(Path::new("/r")).path().starts_with("/r"));
618    /// ```
619    pub fn path(&self) -> &Path {
620        &self.path
621    }
622
623    /// Append one rendered line, under an exclusive lock, opened `O_APPEND`.
624    ///
625    /// The local half is never tracked, so a second worktree of one repository starts
626    /// without it and the first write creates the directory — the same thing
627    /// `mj_ledger_append` does, for the same reason.
628    ///
629    /// ```
630    /// use majordomus_cli::session::{Envelope, Ledger, LedgerEvent, Vocabulary};
631    ///
632    /// let dir = tempfile::tempdir().unwrap();
633    /// let ledger = Ledger::at(dir.path().join("ledger.jsonl"));
634    /// let v = Vocabulary::parse("version: 1\nevents:\n  - id: session.started\n    requires: [owner]\n    public: true\n").unwrap();
635    /// let envelope = Envelope {
636    ///     ts: "2026-09-11T11:02:35Z".into(), head: "1a651e64".into(),
637    ///     branch: "master".into(), by: "majordomus/0.5.0".into(), session: None,
638    /// };
639    ///
640    /// ledger.append(&LedgerEvent::new("session.started").with("owner", "korczis"), &v, &envelope).unwrap();
641    /// ledger.append(&LedgerEvent::new("session.started").with("owner", "korczis"), &v, &envelope).unwrap();
642    ///
643    /// let read = ledger.read();
644    /// assert_eq!(read.events.len(), 2);
645    /// assert!(read.corrupt.is_empty());
646    /// ```
647    pub fn append(
648        &self,
649        event: &LedgerEvent,
650        vocabulary: &Vocabulary,
651        envelope: &Envelope,
652    ) -> Result<(), LedgerError> {
653        let line = event.render(vocabulary, envelope)?;
654        let io = |reason: String| LedgerError::Io {
655            path: self.path.display().to_string(),
656            reason,
657        };
658        if let Some(parent) = self.path.parent() {
659            std::fs::create_dir_all(parent).map_err(|e| io(e.to_string()))?;
660        }
661        let mut file = std::fs::OpenOptions::new()
662            .create(true)
663            .append(true)
664            .open(&self.path)
665            .map_err(|e| io(e.to_string()))?;
666        // The descriptor number is taken before the lock so that the guard borrows
667        // nothing: `file` stays exclusively ours to write through.
668        let fd = {
669            use std::os::unix::io::AsRawFd;
670            file.as_raw_fd()
671        };
672        let _guard = FileLock::exclusive(fd).map_err(io)?;
673        file.write_all(line.as_bytes())
674            .and_then(|()| file.write_all(b"\n"))
675            .map_err(|e| io(e.to_string()))?;
676        file.flush().map_err(|e| io(e.to_string()))
677    }
678
679    /// Read the whole ledger, reporting what did not parse rather than failing on it.
680    ///
681    /// An absent ledger reads as empty: a fresh clone has never run the lifecycle, and
682    /// that is not a fault.
683    ///
684    /// ```
685    /// use majordomus_cli::session::Ledger;
686    ///
687    /// let dir = tempfile::tempdir().unwrap();
688    /// let path = dir.path().join("ledger.jsonl");
689    /// std::fs::write(
690    ///     &path,
691    ///     "{\"ts\":\"a\",\"event\":\"session.started\"}\nnot json at all\n{\"ts\":\"b\",\"event\":\"session.closed\"}\n{\"ts\":\"c\",\"event\"\n",
692    /// ).unwrap();
693    ///
694    /// let read = Ledger::at(&path).read();
695    /// assert_eq!(read.events.len(), 2, "everything that parsed");
696    /// assert_eq!(read.corrupt.len(), 2, "and both lines that did not, by number");
697    /// assert_eq!(read.corrupt[0].line, 2);
698    /// assert_eq!(read.corrupt[1].line, 4, "a truncated tail is one bad line, not a failed read");
699    ///
700    /// // absence is an answer
701    /// assert!(Ledger::at(dir.path().join("nothing.jsonl")).read().events.is_empty());
702    /// ```
703    pub fn read(&self) -> LedgerRead {
704        let Ok(mut file) = std::fs::File::open(&self.path) else {
705            return LedgerRead::default();
706        };
707        let mut text = String::new();
708        if file.read_to_string(&mut text).is_err() {
709            return LedgerRead {
710                events: Vec::new(),
711                corrupt: vec![Corruption {
712                    line: 0,
713                    reason: "the ledger is not valid UTF-8".to_string(),
714                }],
715            };
716        }
717        let mut out = LedgerRead::default();
718        for (n, line) in text.lines().enumerate() {
719            if line.trim().is_empty() {
720                continue;
721            }
722            match serde_json::from_str::<Value>(line) {
723                Ok(v) if v.is_object() => out.events.push(v),
724                Ok(_) => out.corrupt.push(Corruption {
725                    line: n + 1,
726                    reason: "a ledger line is a JSON object; this one is not".to_string(),
727                }),
728                Err(e) => out.corrupt.push(Corruption {
729                    line: n + 1,
730                    reason: e.to_string(),
731                }),
732            }
733        }
734        out
735    }
736}
737
738/// An advisory exclusive lock held for the life of the value.
739///
740/// `O_APPEND` alone positions and writes in one kernel operation, which is what stops two
741/// appends from landing at the same offset. The lock is what stops a *long* line from
742/// interleaving on a filesystem whose atomicity guarantee stops at `PIPE_BUF`, and it is
743/// cheap: an uncontended `flock` is a syscall.
744///
745/// The guard holds the descriptor *number* and no borrow of the file, so that the writes it
746/// protects can still take `&mut File`. The caller keeps the file alive for the guard's
747/// whole life; [`Ledger::append`] does, in one function, which is the only place this type
748/// is constructed.
749struct FileLock {
750    fd: std::os::unix::io::RawFd,
751}
752
753impl FileLock {
754    fn exclusive(fd: std::os::unix::io::RawFd) -> Result<Self, String> {
755        // SAFETY: the caller holds the open file whose descriptor this is for the whole
756        // life of the guard; flock has no other precondition.
757        let rc = unsafe { libc::flock(fd, libc::LOCK_EX) };
758        if rc != 0 {
759            return Err(std::io::Error::last_os_error().to_string());
760        }
761        Ok(FileLock { fd })
762    }
763}
764
765impl Drop for FileLock {
766    fn drop(&mut self) {
767        // SAFETY: as above; the descriptor is still open because the caller's file is.
768        unsafe {
769            libc::flock(self.fd, libc::LOCK_UN);
770        }
771    }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777
778    fn envelope() -> Envelope {
779        Envelope {
780            ts: "2026-09-11T11:02:35Z".into(),
781            head: "1a651e644".into(),
782            branch: "master".into(),
783            by: "majordomus/test".into(),
784            session: None,
785        }
786    }
787
788    #[test]
789    fn the_names_this_domain_writes_are_ones_the_shipped_vocabulary_declares() {
790        // The assertion that fails when somebody adds an event here and forgets
791        // share/events.yaml — instead of a durable line every reader silently ignores.
792        let shipped = Path::new(env!("CARGO_MANIFEST_DIR"))
793            .join("../../share")
794            .join(EVENTS_FILE);
795        let Ok(text) = std::fs::read_to_string(&shipped) else {
796            // a checkout without the share directory is not this test's subject
797            return;
798        };
799        let v = Vocabulary::parse(&text).expect("the shipped vocabulary parses");
800        for name in super::super::WRITTEN_EVENTS {
801            assert!(
802                v.knows(name),
803                "{name} is written by the session domain and is not declared in share/{EVENTS_FILE}"
804            );
805        }
806    }
807
808    #[test]
809    fn concurrent_appends_from_many_threads_all_land_whole() {
810        let dir = tempfile::tempdir().expect("a temp dir");
811        let ledger = Ledger::at(dir.path().join("ledger.jsonl"));
812        let v = Vocabulary::parse(
813            "version: 1\nevents:\n  - id: session.started\n    requires: [owner]\n    public: true\n",
814        )
815        .expect("a vocabulary");
816
817        // A payload long enough that the line exceeds any plausible PIPE_BUF, which is
818        // precisely the case O_APPEND alone does not cover and the lock does.
819        let long = "x".repeat(9_000);
820        std::thread::scope(|scope| {
821            for n in 0..16 {
822                let ledger = &ledger;
823                let v = &v;
824                let long = &long;
825                scope.spawn(move || {
826                    let event = LedgerEvent::new("session.started")
827                        .with("owner", format!("worker-{n}"))
828                        .with("filler", long.clone());
829                    ledger.append(&event, v, &envelope()).expect("an append");
830                });
831            }
832        });
833
834        let read = ledger.read();
835        assert_eq!(read.events.len(), 16, "every line landed");
836        assert!(read.corrupt.is_empty(), "and none of them interleaved");
837    }
838
839    #[test]
840    fn an_unregistered_name_is_refused_before_anything_is_written() {
841        let dir = tempfile::tempdir().expect("a temp dir");
842        let path = dir.path().join("ledger.jsonl");
843        let ledger = Ledger::at(&path);
844        let v = Vocabulary::parse(
845            "version: 1\nevents:\n  - id: session.started\n    requires: [owner]\n    public: true\n",
846        )
847        .expect("a vocabulary");
848        let err = ledger
849            .append(&LedgerEvent::new("session.invented"), &v, &envelope())
850            .expect_err("a refusal");
851        assert!(matches!(err, LedgerError::UnregisteredEvent { .. }));
852        assert!(!path.exists(), "and the ledger was not even created");
853    }
854
855    #[test]
856    fn a_missing_required_field_is_refused_before_anything_is_written() {
857        let dir = tempfile::tempdir().expect("a temp dir");
858        let path = dir.path().join("ledger.jsonl");
859        let v = Vocabulary::parse(
860            "version: 1\nevents:\n  - id: session.closed\n    requires: [outcome, session_path]\n    public: true\n",
861        )
862        .expect("a vocabulary");
863        let err = Ledger::at(&path)
864            .append(
865                &LedgerEvent::new("session.closed").with("outcome", "closed"),
866                &v,
867                &envelope(),
868            )
869            .expect_err("a refusal");
870        match err {
871            LedgerError::MissingField { field, .. } => assert_eq!(field, "session_path"),
872            other => panic!("the wrong refusal: {other}"),
873        }
874        assert!(!path.exists());
875    }
876}