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}