majordomus_cli/capability/builtin/obligations.rs
1//! The `obligations` module: what a task owes before it may be called completed, and
2//! whether the evidence that discharged each one still describes this tree.
3//!
4//! Two halves, and the difference between them decides the design.
5//!
6//! **The vocabulary is shipped.** `share/obligations.yaml` is data the distribution
7//! carries beside `share/events.yaml` and `share/kinds.yaml`: a token, what discharges
8//! it, and the pathspecs its evidence is taken over. It is identical in every clone and
9//! in every installation, so it is read here the way `commands.list` reads the command
10//! registry — through [`crate::share::Share`] — and it answers in a checkout that has
11//! never run the lifecycle.
12//!
13//! **The closure is local.** The task record and the ledger live under
14//! `.ai/local/state/`, the half [`super::continuity`] documents: never tracked, never
15//! published, one file per checkout. A served instance answers about the checkout the
16//! process was started in and about no other, and a fresh clone has nothing to report —
17//! which is reported as nothing rather than as zero obligations discharged.
18//!
19//! **It is read, never written.** `majordomus evidence` records; this reads. A second
20//! writer for the same ledger would be a second account of events, which is the thing
21//! ADR 0030 refuses.
22//!
23//! **The judgement is the validator's, not a second opinion.** `mj_validate_obligations`
24//! in `lib/evidence.sh` decides whether a task may be completed; everything below
25//! reproduces its rule — a remote fact is labelled against the commit it was taken at, a
26//! tree-bound fact is re-hashed over the pathspecs its token declares, and the label is
27//! [`Divergence`], whose four words are `mj_git_label`'s. No fifth vocabulary is invented
28//! here, and where the shell is generous — a token that names neither inputs nor a remote
29//! fact can never go stale — this is generous in the same place rather than stricter in
30//! private.
31//!
32//! **Where the two can differ, and in which direction.** `mj_obligation_inputs_hash`
33//! interpolates the token's pathspecs unquoted, so the shell expands them as filename
34//! globs before `git ls-files` ever sees them; without `globstar`, `**` matches one level.
35//! `docs`, whose pathspecs include `.ai/repo/**/README.md`, therefore selects 255 files
36//! there and 261 here, and `implementation`, whose pathspec is `*`, selects 1915 there and
37//! 2480 here — a bare `*` expands to the top-level entries that are not dotfiles, so the
38//! whole of `.ai/` and `.github/` is outside the hash the shell takes. This reader passes
39//! the pathspecs to git verbatim, which is what the vocabulary's `inputs` plainly mean, so
40//! its selection is a superset and the disagreement can only run one way: it may call
41//! evidence stale that `finish` would still accept, and never the reverse. [`inputs_files`]
42//! is on every entry so the difference is measurable from the surface rather than argued
43//! about. The repair belongs to `lib/evidence.sh` — `set -f` around the `git ls-files`
44//! call, so that word splitting still happens and pathname expansion does not.
45//!
46//! [`inputs_files`]: ObligationClosure::inputs_files
47//!
48//! ```
49//! use majordomus_cli::capability::builtin::obligations;
50//!
51//! // two capabilities: what a token means, and where this checkout's task stands
52//! let m = obligations::module();
53//! let ids: Vec<&str> = m.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
54//! assert_eq!(ids, ["obligations.vocabulary", "obligations.closure"]);
55//!
56//! // and both report; neither records. `majordomus evidence` is the writer.
57//! assert!(m.capabilities.iter().all(|e| e.capability.kind.is_read_only()));
58//! ```
59
60use std::collections::BTreeMap;
61use std::path::{Path, PathBuf};
62
63use schemars::JsonSchema;
64use serde::{Deserialize, Serialize};
65
66use crate::capability::handler::{CapabilityError, Context};
67use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
68use crate::capability::module::ModuleDescriptor;
69use crate::capability::CachePolicy;
70use crate::git::{self, GitState};
71use crate::metadata::yaml;
72use crate::{capability, module};
73
74use super::continuity::{self, ActiveTask, Divergence};
75use super::{get, Empty};
76
77/// The URI under which the shipped vocabulary is read as an MCP resource.
78///
79/// ```
80/// use majordomus_cli::capability::builtin::obligations::OBLIGATIONS_URI;
81/// assert_eq!(OBLIGATIONS_URI, "majordomus://obligations");
82/// ```
83pub const OBLIGATIONS_URI: &str = "majordomus://obligations";
84
85/// The URI under which this checkout's closure is read as an MCP resource.
86///
87/// Under the vocabulary's path rather than beside it: the closure is the vocabulary
88/// applied to one task, and a reader that found the first has found the second.
89///
90/// ```
91/// use majordomus_cli::capability::builtin::obligations::{CLOSURE_URI, OBLIGATIONS_URI};
92/// assert!(CLOSURE_URI.starts_with(OBLIGATIONS_URI));
93/// ```
94pub const CLOSURE_URI: &str = "majordomus://obligations/closure";
95
96/// The vocabulary file inside the distribution's share directory.
97const VOCABULARY_FILE: &str = "obligations.yaml";
98
99/// The local half of the layer, relative to the repository root. The same constant
100/// [`super::continuity`] states, for the same reason: the shell tool decides where its
101/// state lives and a second opinion about the path would be a second source of truth.
102const STATE_DIR: &str = ".ai/local/state";
103
104/// The ledger event that carries evidence, as `share/events.yaml` registers it.
105const EVIDENCE_EVENT: &str = "task.evidence";
106
107// ---------------------------------------------------------------- the vocabulary
108
109/// One token a task may declare in `requires`, as the distribution ships it.
110///
111/// `remote` and `inputs` are what decide how its evidence is judged, and they are the
112/// file's decision rather than this reader's: a fact the working tree cannot establish is
113/// bound to the commit it was taken at, and everything else is bound to the bytes of the
114/// files its pathspecs select.
115///
116/// ```
117/// use majordomus_cli::capability::builtin::obligations::Obligation;
118/// let push: Obligation = serde_json::from_str(
119/// r#"{"id":"push","title":"The commit reached the remote",
120/// "summary":"The branch's head exists on the remote it tracks.",
121/// "discharged_by":"git","remote":true}"#,
122/// )
123/// .unwrap();
124/// assert!(push.remote);
125/// assert!(push.inputs.is_empty(), "a remote fact is bound to a commit, not to a tree");
126/// ```
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128pub struct Obligation {
129 /// The token, as a task's `requires` names it.
130 pub id: String,
131 /// The obligation as a heading.
132 pub title: String,
133 /// What a worker is being asked to have done.
134 pub summary: String,
135 /// The command that produces the evidence. `none` for a token held by another line of
136 /// the contract and listed so that a report can say it rather than leave a hole.
137 pub discharged_by: String,
138 /// The pathspecs the evidence is hashed over. Empty for a token whose fact is remote,
139 /// and empty for one bound to neither: see [`ObligationClosure::staleness`].
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub inputs: Vec<String>,
142 /// True when the fact cannot be established from the working tree alone — a push, a
143 /// publication, a deployment. Such evidence is bound to a commit, not to a tree.
144 pub remote: bool,
145 /// What the vocabulary says about the token beyond its summary.
146 #[serde(default, skip_serializing_if = "String::is_empty")]
147 pub note: String,
148}
149
150/// The vocabulary the distribution ships: every token there is.
151///
152/// `count` is measured from `obligations` rather than written down, so a token added to
153/// the file is counted by the same act that declares it.
154///
155/// ```
156/// use majordomus_cli::capability::builtin::obligations::Vocabulary;
157/// let v: Vocabulary = serde_json::from_str(
158/// r#"{"version":1,"source":"/opt/majordomus/share/obligations.yaml","count":1,
159/// "obligations":[{"id":"commit","title":"The work is committed",
160/// "summary":"In the branch's history, not the working tree.",
161/// "discharged_by":"git","remote":false}]}"#,
162/// )
163/// .unwrap();
164/// assert_eq!(v.count, v.obligations.len());
165/// assert_eq!(v.version, 1, "the only shape this reader accepts");
166/// ```
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
168pub struct Vocabulary {
169 /// The file's own format version. `1` is the only one this reader accepts.
170 pub version: u64,
171 /// Where it was read from, absolute: the distribution's share directory, which is not
172 /// necessarily inside the repository.
173 pub source: String,
174 /// How many tokens there are. No number anywhere is written down.
175 pub count: usize,
176 /// Every token, in the order the file declares them.
177 pub obligations: Vec<Obligation>,
178}
179
180/// `share/obligations.yaml` as written. Unknown fields are not refused: the vocabulary
181/// belongs to the shell tool, this is one of its readers, and `doctor` is what proves the
182/// file well-formed. A reader that refused a field it had not been taught would turn
183/// somebody else's addition into this process's failure.
184#[derive(Debug, Default, Deserialize)]
185struct VocabularyFile {
186 #[serde(default)]
187 version: u64,
188 #[serde(default)]
189 obligations: Vec<Obligation>,
190}
191
192/// Read the shipped vocabulary from `share`, the distribution this process located, or —
193/// when the caller has none, which is an index built without an application around it —
194/// the way every other reader locates one, from `root`. A distribution that ships no
195/// vocabulary declares no tokens, which is not an error but is reported as such by the
196/// closure's findings when a task names one.
197///
198/// The distribution is passed in rather than resolved here because the two answers differ:
199/// a process told which distribution to read (`--share`, or a released binary run inside a
200/// repository that is not its own) has already resolved one, and a second resolution from
201/// the repository root would find another distribution or, when the executable lives
202/// outside the repository, none.
203fn vocabulary_at(share: Option<&Path>, root: &Path) -> Result<Vocabulary, String> {
204 let share = crate::share::Share::locate(share, root)
205 .map_err(|e| format!("the distribution's share directory was not found: {e}"))?;
206 let path = share.dir().join(VOCABULARY_FILE);
207 let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
208 let file: VocabularyFile =
209 yaml::parse_into(&text).map_err(|e| format!("{}: {e}", path.display()))?;
210 if file.version != 1 {
211 return Err(format!(
212 "{}: the obligation vocabulary must be version 1, and this one is {}",
213 path.display(),
214 file.version
215 ));
216 }
217 Ok(Vocabulary {
218 version: file.version,
219 source: path.to_string_lossy().to_string(),
220 count: file.obligations.len(),
221 obligations: file.obligations,
222 })
223}
224
225// ---------------------------------------------------------------- the closure
226
227/// Where one obligation stands.
228///
229/// The words are the validator's verdicts, not a severity scale: `stale` is not a worse
230/// `owed`, it is evidence that was true and no longer describes what it proved.
231///
232/// ```
233/// use majordomus_cli::capability::builtin::obligations::ObligationState;
234/// // only one of the four lets a task be called completed
235/// let completable = |s: ObligationState| s == ObligationState::Discharged;
236/// assert!(completable(ObligationState::Discharged));
237/// assert!(!completable(ObligationState::Stale), "evidence that no longer describes the tree");
238/// assert!(!completable(ObligationState::Owed));
239/// assert!(!completable(ObligationState::Undeclared));
240/// ```
241#[derive(
242 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
243)]
244#[serde(rename_all = "snake_case")]
245pub enum ObligationState {
246 /// Declared, and no evidence for it was ever recorded.
247 Owed,
248 /// Evidence exists and still describes this tree, or this commit.
249 Discharged,
250 /// Evidence exists and no longer describes what it proved: the inputs changed, or the
251 /// commit it named is not this one.
252 Stale,
253 /// The task requires a token the shipped vocabulary does not declare. Nothing can
254 /// discharge it, because nothing knows what would.
255 Undeclared,
256}
257
258impl ObligationState {
259 /// The word as serialised, for a caller that tallies or renders states without
260 /// carrying a copy of the vocabulary: the tallies of a [`Closure`] are keyed by
261 /// exactly these strings, and so is anything that groups the entries by standing.
262 ///
263 /// ```
264 /// use majordomus_cli::capability::builtin::obligations::ObligationState;
265 /// assert_eq!(ObligationState::Stale.as_str(), "stale");
266 /// assert_eq!(ObligationState::Undeclared.as_str(), "undeclared");
267 /// ```
268 pub fn as_str(self) -> &'static str {
269 match self {
270 ObligationState::Owed => "owed",
271 ObligationState::Discharged => "discharged",
272 ObligationState::Stale => "stale",
273 ObligationState::Undeclared => "undeclared",
274 }
275 }
276}
277
278/// The `task.evidence` line that discharged an obligation, as the ledger holds it.
279///
280/// Nothing older is consulted: evidence is superseded by evidence, and the ledger keeps
281/// the history for a reader that wants it.
282/// ```
283/// use majordomus_cli::capability::builtin::obligations::Evidence;
284/// let e: Evidence = serde_json::from_str(
285/// r#"{"recorded_at":"2026-09-09T21:01:00Z","head":"f00ba7","branch":"master",
286/// "kind":"ci","command":"scripts/ci/reference-check","inputs_hash":"7f68b9"}"#,
287/// )
288/// .unwrap();
289/// assert_eq!(e.head, "f00ba7", "the envelope's commit is what a remote fact is judged against");
290/// assert!(e.artifact.is_empty(), "one of command and artifact; narrative is not evidence");
291/// ```
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
293pub struct Evidence {
294 /// When the line was appended.
295 pub recorded_at: String,
296 /// The commit the ledger's envelope stamped on it.
297 pub head: String,
298 /// The branch it was recorded on.
299 #[serde(default, skip_serializing_if = "String::is_empty")]
300 pub branch: String,
301 /// How it was taken: `test`, `build`, `ci`, `artifact` or `manual`.
302 #[serde(default, skip_serializing_if = "String::is_empty")]
303 pub kind: String,
304 /// The command that produced it. Narrative is not evidence, so one of this and
305 /// `artifact` is always present.
306 #[serde(default, skip_serializing_if = "String::is_empty")]
307 pub command: String,
308 /// The reference it points at, such as a published URL.
309 #[serde(default, skip_serializing_if = "String::is_empty")]
310 pub artifact: String,
311 /// What the command said, when its output was the point.
312 #[serde(default, skip_serializing_if = "String::is_empty")]
313 pub result: String,
314 /// The episode that recorded it, when one was open.
315 #[serde(default, skip_serializing_if = "String::is_empty")]
316 pub session: String,
317 /// The hash of the token's declared inputs at the moment it was taken. Empty for a
318 /// token that declares none.
319 #[serde(default, skip_serializing_if = "String::is_empty")]
320 pub inputs_hash: String,
321}
322
323/// One obligation the task declared, joined with what the vocabulary says about it and
324/// with the evidence that does or does not discharge it.
325///
326/// The vocabulary's fields are repeated here on purpose: a client asking what this task
327/// owes gets the token's title, its summary and the command that would discharge it in the
328/// same answer, and needs no second call to render a report.
329///
330/// ```
331/// use majordomus_cli::capability::builtin::obligations::{ObligationClosure, ObligationState};
332/// let owed: ObligationClosure = serde_json::from_str(
333/// r#"{"id":"pages","title":"The published site serves this commit","remote":true,
334/// "state":"owed","detail":"owed, and no evidence was recorded",
335/// "reproduce":"majordomus evidence --covers pages --command 'scripts/pages verify'"}"#,
336/// )
337/// .unwrap();
338/// assert_eq!(owed.state, ObligationState::Owed);
339/// assert!(owed.evidence.is_none() && owed.staleness.is_none(), "nothing to label");
340/// assert!(owed.reproduce.starts_with("majordomus evidence"), "a finding carries its repair");
341/// ```
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
343pub struct ObligationClosure {
344 /// The token, as the task's `requires` names it.
345 pub id: String,
346 /// From the vocabulary; the token itself when it declares none.
347 pub title: String,
348 /// From the vocabulary.
349 #[serde(default, skip_serializing_if = "String::is_empty")]
350 pub summary: String,
351 /// From the vocabulary: the command that produces the evidence.
352 #[serde(default, skip_serializing_if = "String::is_empty")]
353 pub discharged_by: String,
354 /// From the vocabulary: whether the fact is remote.
355 pub remote: bool,
356 /// From the vocabulary: the pathspecs the evidence is hashed over.
357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub inputs: Vec<String>,
359 /// Where it stands.
360 pub state: ObligationState,
361 /// How far the evidence is from this checkout, in the repository's one staleness
362 /// vocabulary. `None` when there is no evidence to label.
363 ///
364 /// A remote fact is labelled against the commit it was taken at, because the site that
365 /// serves a commit goes on serving it while the tree moves underneath. A tree-bound
366 /// fact is labelled by re-hashing: equal hashes are `exact`, and a difference takes the
367 /// commit's label, which is `advanced` when the working tree has merely moved on and
368 /// `diverged` when the history it named is gone. A token that declares neither inputs
369 /// nor a remote fact is bound to nothing and stays `exact` once recorded — that is the
370 /// validator's behaviour, and it is reproduced rather than tightened here.
371 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub staleness: Option<Divergence>,
373 /// The line that discharged it, or `None`.
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub evidence: Option<Evidence>,
376 /// What the token's inputs hash to in this tree now — the other half of "stale against
377 /// what". `None` for a token that declares no inputs, and `None` when git could not be
378 /// asked which files they select.
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub inputs_hash_now: Option<String>,
381 /// How many tracked files that hash was taken over. Reported because the shell's
382 /// selection and this one are not always the same set — see the module header — and a
383 /// count is the cheapest way for a reader to see it rather than be told it.
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub inputs_files: Option<usize>,
386 /// One line: what this obligation's standing actually is, in the words the validator
387 /// uses when it refuses.
388 pub detail: String,
389 /// The command that would discharge it. Every finding here carries the way to act on
390 /// it, as `project.finding-carries-reproduce` asks.
391 pub reproduce: String,
392}
393
394/// What this checkout's active task owes, and how much of it is still true.
395///
396/// A clone that has never run the lifecycle answers this too, and answers it as absence:
397/// `present` false, no task, and a finding saying so. "Nothing owed" and "nothing to owe
398/// it" are different facts, and a served instance must not report the second as the first.
399///
400/// ```
401/// use majordomus_cli::capability::builtin::obligations::Closure;
402/// let fresh: Closure = serde_json::from_str(
403/// r#"{"present":false,"worktree":"/srv/clone","branch":"master","closed":false,
404/// "tallies":{},
405/// "findings":["no active task in this checkout (.ai/local/state/current.yaml); nothing owes anything here"]}"#,
406/// )
407/// .unwrap();
408/// assert!(!fresh.present && !fresh.closed, "nothing that does not exist is closed");
409/// assert!(fresh.task.is_none() && fresh.obligations.is_empty());
410/// assert_eq!(fresh.findings.len(), 1, "absence is reported, not implied");
411/// ```
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
413pub struct Closure {
414 /// Whether this checkout has a task to report about at all. False in a clone that has
415 /// never run the lifecycle, which is not a fault and is not "nothing owed".
416 pub present: bool,
417 /// The worktree this answer is about. Every reading below is scoped to it, and to no
418 /// other checkout of the same repository.
419 pub worktree: String,
420 /// The branch, or `DETACHED`.
421 pub branch: String,
422 /// The commit this checkout is on.
423 #[serde(default, skip_serializing_if = "String::is_empty")]
424 pub head: String,
425 /// `clean` or `dirty`.
426 #[serde(default, skip_serializing_if = "String::is_empty")]
427 pub working_tree: String,
428 /// The active task, or `None`. Its `requires` is the list the entries below expand.
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub task: Option<ActiveTask>,
431 /// True when the task declares obligations and every one of them is discharged and
432 /// current — that is, when `majordomus.obligation-closure` would not refuse
433 /// `finish --outcome completed`. False when anything is owed, stale or undeclared, and
434 /// false when there is no task: nothing that does not exist is closed.
435 pub closed: bool,
436 /// How many obligations stand where, by state word. Absent states are absent rather
437 /// than zero, so a reader never has to know the vocabulary to read the tallies.
438 pub tallies: BTreeMap<String, usize>,
439 /// Every obligation the task declared, in the order it declared them.
440 #[serde(default, skip_serializing_if = "Vec::is_empty")]
441 pub obligations: Vec<ObligationClosure>,
442 /// What a reader should know before trusting any of the above: an unreadable
443 /// vocabulary, a ledger that could not be read, a task that declares nothing. Empty is
444 /// the ordinary case.
445 #[serde(default, skip_serializing_if = "Vec::is_empty")]
446 pub findings: Vec<String>,
447}
448
449// ---------------------------------------------------------------- the input hash
450
451/// The hash `mj_inputs_hash` computes, reproduced.
452///
453/// The shell hashes each selected file, renders one `<path> <sha256>` line per file in
454/// git's own index order, and hashes that listing. Both halves matter: the order is git's,
455/// so two runs over one tree agree, and the listing carries the path, so moving a file
456/// changes the hash even when its bytes do not.
457///
458/// A file git tracks and the working tree does not hold is skipped rather than failed, for
459/// the same reason the shell skips it — `shasum` writes to stderr and `xargs` carries on —
460/// so a deleted-but-tracked file makes the hash differ rather than making the answer an
461/// error.
462///
463/// ```
464/// use majordomus_cli::capability::builtin::obligations::listing_hash;
465/// // the listing is what is hashed, and it is one line per file, path first
466/// assert_eq!(
467/// listing_hash("a.txt e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n"),
468/// listing_hash("a.txt e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n")
469/// );
470/// assert_ne!(listing_hash("a.txt 00\n"), listing_hash("b.txt 00\n"));
471/// ```
472pub fn listing_hash(listing: &str) -> String {
473 crate::policy::sha256_hex(listing)
474}
475
476/// The hash of one obligation's declared inputs in this tree and how many files it was
477/// taken over, or `None` when the token declares none — which is the empty string in the
478/// ledger and is compared as such.
479pub(crate) fn inputs_hash(root: &Path, specs: &[String]) -> Option<(String, usize)> {
480 inputs_hash_with(root, specs, &mut FileHashes::default())
481}
482
483/// The digest of each file one reading of the tree has already hashed, by its path.
484///
485/// One completion report takes a hash per gate, and the gates of a real model overlap: this
486/// repository's 74 gates select about 60,000 files between them out of 5,000 tracked, so
487/// hashing per gate opened and digested every file about twelve times — 925 MB of SHA-256
488/// per call, most of what `gates.completion` cost. A file's bytes are not expected to change
489/// between two gates of one report, so each is read once and its digest reused; a file that
490/// could not be read is remembered as such and skipped by every gate, as a fresh read would.
491#[derive(Debug, Default)]
492pub(crate) struct FileHashes(std::collections::HashMap<String, Option<String>>);
493
494impl FileHashes {
495 fn digest(&mut self, root: &Path, file: &str) -> Option<&str> {
496 self.0
497 .entry(file.to_string())
498 .or_insert_with(|| {
499 std::fs::read(root.join(file))
500 .ok()
501 .map(|bytes| crate::policy::sha256_bytes_hex(&bytes))
502 })
503 .as_deref()
504 }
505}
506
507/// [`inputs_hash`], reusing the digests `cache` already holds: the same answer, with each
508/// file read once however many callers select it within one reading of the tree.
509pub(crate) fn inputs_hash_with(
510 root: &Path,
511 specs: &[String],
512 cache: &mut FileHashes,
513) -> Option<(String, usize)> {
514 if specs.is_empty() {
515 return None;
516 }
517 let refs: Vec<&str> = specs.iter().map(String::as_str).collect();
518 let files = git::ls_files_any(root, &refs).ok()?;
519 if files.is_empty() {
520 // the shell's empty answer: no file selected hashes to nothing at all, and the
521 // recorded value for such a token is the empty string
522 return Some((String::new(), 0));
523 }
524 let mut listing = String::new();
525 for file in &files {
526 let Some(digest) = cache.digest(root, file) else {
527 continue;
528 };
529 listing.push_str(file);
530 listing.push(' ');
531 listing.push_str(digest);
532 listing.push('\n');
533 }
534 Some((listing_hash(&listing), files.len()))
535}
536
537// ---------------------------------------------------------------- the ledger
538
539/// The newest `task.evidence` line per token for one task, read from this checkout's
540/// ledger. A line that is not JSON is skipped and counted, because a ledger that has grown
541/// one bad line still holds the rest.
542fn evidence_for(path: &Path, task: &str) -> (BTreeMap<String, Evidence>, usize) {
543 let Ok(text) = std::fs::read_to_string(path) else {
544 return (BTreeMap::new(), 0);
545 };
546 let mut out: BTreeMap<String, Evidence> = BTreeMap::new();
547 let mut skipped = 0usize;
548 for line in text.lines().filter(|l| !l.trim().is_empty()) {
549 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
550 skipped += 1;
551 continue;
552 };
553 let s = |k: &str| {
554 v.get(k)
555 .and_then(serde_json::Value::as_str)
556 .unwrap_or_default()
557 .to_string()
558 };
559 if s("event") != EVIDENCE_EVENT || s("task") != task {
560 continue;
561 }
562 let covers = s("covers");
563 if covers.is_empty() {
564 skipped += 1;
565 continue;
566 }
567 // last line wins: the file is append-only and ordered, so the last is the newest
568 out.insert(
569 covers,
570 Evidence {
571 recorded_at: s("ts"),
572 head: s("head"),
573 branch: s("branch"),
574 kind: s("kind"),
575 command: s("command"),
576 artifact: s("artifact"),
577 result: s("result"),
578 session: s("session"),
579 inputs_hash: s("inputs_hash"),
580 },
581 );
582 }
583 (out, skipped)
584}
585
586// ---------------------------------------------------------------- the judgement
587
588/// `mj_git_label`, whole: the branch decides first, then equality, then ancestry. A
589/// recorded branch this reader was not given is not a `different_context` — an older
590/// ledger line carries no branch, and calling somebody else's work on it would be worse
591/// than declining to.
592fn label(
593 root: &Path,
594 recorded_head: &str,
595 recorded_branch: &str,
596 head: Option<&str>,
597 branch: &str,
598) -> Divergence {
599 if !recorded_branch.is_empty() && recorded_branch != branch {
600 return Divergence::DifferentContext;
601 }
602 continuity::divergence(root, recorded_head, head)
603}
604
605/// The standing of one declared token, by the rule `mj_validate_obligations` applies.
606fn judge(
607 root: &Path,
608 token: &str,
609 declared: Option<&Obligation>,
610 evidence: Option<&Evidence>,
611 head: Option<&str>,
612 branch: &str,
613) -> ObligationClosure {
614 let Some(o) = declared else {
615 return ObligationClosure {
616 id: token.to_string(),
617 title: token.to_string(),
618 summary: String::new(),
619 discharged_by: String::new(),
620 remote: false,
621 inputs: Vec::new(),
622 state: ObligationState::Undeclared,
623 staleness: None,
624 evidence: evidence.cloned(),
625 inputs_hash_now: None,
626 inputs_files: None,
627 detail: format!(
628 "the task requires '{token}', which the shipped obligation vocabulary does not declare"
629 ),
630 reproduce: "majordomus evidence --help".into(),
631 };
632 };
633
634 let reproduce = format!(
635 "majordomus evidence --covers {} --command '{}'",
636 o.id, o.discharged_by
637 );
638 let measured = if o.remote {
639 // a remote fact is bound to a commit; re-hashing a tree it never described would
640 // be a number nobody could act on
641 None
642 } else {
643 inputs_hash(root, &o.inputs)
644 };
645 let hash_now = measured.as_ref().map(|(h, _)| h.clone());
646 let files_now = measured.as_ref().map(|(_, n)| *n);
647
648 let Some(ev) = evidence else {
649 return ObligationClosure {
650 id: o.id.clone(),
651 title: o.title.clone(),
652 summary: o.summary.clone(),
653 discharged_by: o.discharged_by.clone(),
654 remote: o.remote,
655 inputs: o.inputs.clone(),
656 state: ObligationState::Owed,
657 staleness: None,
658 evidence: None,
659 inputs_hash_now: hash_now,
660 inputs_files: files_now,
661 detail: "owed, and no evidence was recorded".into(),
662 reproduce,
663 };
664 };
665
666 let commit = label(root, &ev.head, &ev.branch, head, branch);
667 let short = |h: &str| h.chars().take(12).collect::<String>();
668
669 let (staleness, detail) = if o.remote {
670 let detail = match commit {
671 Divergence::Exact => "discharged at this commit".to_string(),
672 Divergence::Advanced => format!(
673 "the evidence names {}, and the branch has moved since; the fact it proved is about the older commit",
674 short(&ev.head)
675 ),
676 other => format!(
677 "the evidence was taken in a {} context ({}); it does not describe this branch",
678 other.as_str(),
679 short(&ev.head)
680 ),
681 };
682 (commit, detail)
683 } else if hash_now.as_deref().unwrap_or("") == ev.inputs_hash {
684 (
685 Divergence::Exact,
686 format!("discharged over inputs {}", short(&ev.inputs_hash)),
687 )
688 } else {
689 // the tree no longer hashes to what the evidence proved. Which of the four words
690 // that is, is the commit's answer — with `exact` ruled out, because a tree that
691 // hashes differently at the same commit has moved on inside it.
692 let moved = match commit {
693 Divergence::Exact => Divergence::Advanced,
694 other => other,
695 };
696 (
697 moved,
698 format!(
699 "the evidence was taken over inputs {} and this tree hashes to {} over {} file(s); it no longer describes what it proved",
700 short(&ev.inputs_hash),
701 short(hash_now.as_deref().unwrap_or("")),
702 files_now.unwrap_or(0)
703 ),
704 )
705 };
706
707 ObligationClosure {
708 id: o.id.clone(),
709 title: o.title.clone(),
710 summary: o.summary.clone(),
711 discharged_by: o.discharged_by.clone(),
712 remote: o.remote,
713 inputs: o.inputs.clone(),
714 state: if staleness == Divergence::Exact {
715 ObligationState::Discharged
716 } else {
717 ObligationState::Stale
718 },
719 staleness: Some(staleness),
720 evidence: Some(ev.clone()),
721 inputs_hash_now: hash_now,
722 inputs_files: files_now,
723 detail,
724 reproduce,
725 }
726}
727
728// ---------------------------------------------------------------- handlers
729
730fn obligations_vocabulary(ctx: &Context, _: Empty) -> Result<Vocabulary, CapabilityError> {
731 let root = PathBuf::from(&ctx.index.repository.root);
732 vocabulary_at(ctx.index.share.as_deref(), &root).map_err(CapabilityError::Internal)
733}
734
735fn obligations_closure(ctx: &Context, _: Empty) -> Result<Closure, CapabilityError> {
736 let root = PathBuf::from(&ctx.index.repository.root);
737 let dir = root.join(STATE_DIR);
738
739 let (branch, head, working_tree) = match &ctx.index.repository.git {
740 GitState::Available(info) => (
741 info.branch.clone().unwrap_or_else(|| "DETACHED".into()),
742 info.head.clone(),
743 info.working_tree.clone(),
744 ),
745 GitState::Unavailable { .. } => ("DETACHED".into(), None, String::new()),
746 };
747
748 let mut findings = Vec::new();
749 let task = continuity::read_task(&dir.join("current.yaml"));
750
751 let vocabulary = match vocabulary_at(ctx.index.share.as_deref(), &root) {
752 Ok(v) => v.obligations,
753 Err(reason) => {
754 findings.push(format!(
755 "the obligation vocabulary could not be read, so no token can be resolved: {reason}"
756 ));
757 Vec::new()
758 }
759 };
760
761 let mut obligations = Vec::new();
762 if let Some(t) = &task {
763 if t.requires.is_empty() {
764 findings.push(format!(
765 "task {} declares no obligations, so this rule holds it to nothing beyond the rest of the contract",
766 t.id
767 ));
768 } else {
769 let (evidence, skipped) = evidence_for(&dir.join("ledger.jsonl"), &t.id);
770 if skipped > 0 {
771 findings.push(format!(
772 "{skipped} ledger line(s) could not be read and were skipped; run `majordomus doctor`"
773 ));
774 }
775 for token in &t.requires {
776 obligations.push(judge(
777 &root,
778 token,
779 vocabulary.iter().find(|o| &o.id == token),
780 evidence.get(token),
781 head.as_deref(),
782 &branch,
783 ));
784 }
785 }
786 } else {
787 findings.push(format!(
788 "no active task in this checkout ({STATE_DIR}/current.yaml); nothing owes anything here"
789 ));
790 }
791
792 let mut tallies: BTreeMap<String, usize> = BTreeMap::new();
793 for o in &obligations {
794 *tallies.entry(o.state.as_str().to_string()).or_default() += 1;
795 }
796
797 Ok(Closure {
798 present: task.is_some(),
799 worktree: root.to_string_lossy().to_string(),
800 branch,
801 head: head.unwrap_or_default(),
802 working_tree,
803 closed: !obligations.is_empty()
804 && obligations
805 .iter()
806 .all(|o| o.state == ObligationState::Discharged),
807 task,
808 tallies,
809 obligations,
810 findings,
811 })
812}
813
814/// The module: the vocabulary every clone shares, and the closure only this checkout can
815/// answer.
816///
817/// Both are read-only and neither has a command-line projection, for the reason
818/// [`super::continuity`] states about the local half: `majordomus check` and
819/// `majordomus finish` are already the command line's answer to this question, and a
820/// second verb would be a second account of the same judgement.
821///
822/// ```
823/// use majordomus_cli::capability::builtin::obligations::module;
824/// let m = module();
825/// assert_eq!(m.id.as_str(), "obligations");
826/// // every projection is derived from the declaration; none is written anywhere else
827/// let routes: Vec<&str> = m
828/// .capabilities
829/// .iter()
830/// .filter_map(|e| e.capability.exposure.http.as_ref().map(|h| h.path.as_str()))
831/// .collect();
832/// assert_eq!(routes, ["/api/v1/obligations", "/api/v1/obligations/closure"]);
833/// ```
834pub fn module() -> ModuleDescriptor {
835 module! {
836 id: "obligations",
837 title: "Obligations",
838 description: "What a task owes before it may be called completed, and whether the evidence that discharged each obligation still describes this tree. The vocabulary is data the distribution ships and answers in any clone; the closure is read from the local half of the layer, which this process serves to the worker in front of it and never publishes. Read, never written: `majordomus evidence` records, and a second writer for one ledger would be a second account of the same events.",
839 stability: Stability::BehaviorallyVerified,
840 capabilities: [
841 capability! {
842 id: "obligations.vocabulary",
843 title: "Every obligation there is",
844 description: "The tokens a task may declare in `requires`: what each one asks of a worker, the command that discharges it, the pathspecs its evidence is hashed over, and whether its fact is remote and therefore bound to a commit rather than to a tree. Shipped data, identical in every clone, so this answers in a checkout that has never run the lifecycle.",
845 input: Empty,
846 output: Vocabulary,
847 stability: Stability::BehaviorallyVerified,
848 exposure: Exposure {
849 mcp: Some(McpExposure {
850 tool: Some("majordomus_obligations".into()),
851 resource: Some(McpResource { uri: OBLIGATIONS_URI.into(), name: "obligations".into() }),
852 }),
853 http: get("/api/v1/obligations"),
854 cli: None,
855 },
856 tags: ["obligations", "completion"],
857 // shipped data, immutable for the life of the process
858 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: None },
859 handler: obligations_vocabulary,
860 },
861 capability! {
862 id: "obligations.closure",
863 title: "What this task still owes",
864 description: "Every obligation the active task declared, joined with what the vocabulary says about it and with the evidence that does or does not discharge it: what is owed, what is discharged, and what has gone stale — with the recorded input hash and the tree's current one, or the recorded commit and its label, so a reader can see against what. The judgement is the one `finish` applies, reproduced rather than re-decided, and the staleness words are the repository's only four. A checkout with no task reports that, rather than reporting nothing owed.",
865 input: Empty,
866 output: Closure,
867 stability: Stability::BehaviorallyVerified,
868 exposure: Exposure {
869 mcp: Some(McpExposure {
870 tool: Some("majordomus_obligation_closure".into()),
871 resource: Some(McpResource { uri: CLOSURE_URI.into(), name: "obligation-closure".into() }),
872 }),
873 http: get("/api/v1/obligations/closure"),
874 cli: None,
875 },
876 tags: ["obligations", "completion", "continuity"],
877 // Short-lived, for continuity's reason: the ledger and the task record are
878 // written by another process, and an answer cached for a minute would
879 // report an obligation that had since been discharged.
880 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(2) },
881 handler: obligations_closure,
882 },
883 ],
884 }
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890
891 /// The declaration is the only place these ids, tool names, resource URIs and routes
892 /// exist, and every projection — the MCP tool, the MCP resource, the HTTP route, the
893 /// OpenAPI operation, the benchmark target — is derived from it. A refactor that
894 /// dropped an exposure or renamed a route would still compile, and every suite that
895 /// exercised the behaviour behind it would still pass. This is the assertion that
896 /// would not, which is what `project.rust-command-tested-in-file` asks of a command.
897 #[test]
898 fn the_declaration_yields_the_projections_it_claims() {
899 let m = module();
900 assert_eq!(m.id.as_str(), "obligations");
901 let expected: &[(&str, &str, &str, &str)] = &[
902 (
903 "obligations.vocabulary",
904 "majordomus_obligations",
905 OBLIGATIONS_URI,
906 "/api/v1/obligations",
907 ),
908 (
909 "obligations.closure",
910 "majordomus_obligation_closure",
911 CLOSURE_URI,
912 "/api/v1/obligations/closure",
913 ),
914 ];
915 let ids: Vec<&str> = m
916 .capabilities
917 .iter()
918 .map(|e| e.capability.id.as_str())
919 .collect();
920 let want: Vec<&str> = expected.iter().map(|(id, _, _, _)| *id).collect();
921 assert_eq!(
922 ids, want,
923 "the module declares a different set of capabilities"
924 );
925 for (executable, (id, tool, uri, path)) in m.capabilities.iter().zip(expected) {
926 let c = &executable.capability;
927 let mcp = c.exposure.mcp.as_ref().expect("an MCP projection");
928 assert_eq!(mcp.tool.as_deref(), Some(*tool), "{id} lost its MCP tool");
929 assert_eq!(
930 mcp.resource.as_ref().map(|r| r.uri.as_str()),
931 Some(*uri),
932 "{id} lost its MCP resource"
933 );
934 assert_eq!(
935 c.exposure.http.as_ref().map(|h| h.path.as_str()),
936 Some(*path),
937 "{id} lost or renamed its HTTP route"
938 );
939 // this layer reports and never records, and the registry refuses an executable
940 // capability that claims to be read-only and is not
941 assert!(
942 c.kind.is_read_only() && c.kind.is_executable(),
943 "{id} is not read-only"
944 );
945 assert!(id.starts_with("obligations."));
946 }
947 }
948
949 /// The vocabulary is read from the file rather than held here, which is the whole
950 /// point: a token added to `share/obligations.yaml` is answered by this module without
951 /// a line changing in it.
952 #[test]
953 fn the_vocabulary_is_read_from_the_file_and_not_held_here() {
954 let dir = tempfile::tempdir().unwrap();
955 std::fs::write(dir.path().join("kinds.yaml"), "version: 1\nkinds: []\n").unwrap();
956 std::fs::write(
957 dir.path().join(VOCABULARY_FILE),
958 "# a comment the reader skips\nversion: 1\nobligations:\n - id: tests\n title: The cases were run\n summary: They passed.\n discharged_by: usecase impact\n inputs: [\"lib/**\", \"share/**\"]\n remote: false\n - id: push\n title: The commit reached the remote\n summary: The branch head exists on the remote.\n discharged_by: git\n remote: true\n",
959 )
960 .unwrap();
961 // the distribution is named, not searched for: the reader is handed one the way an
962 // application hands it the one the process located, and the environment of whoever
963 // runs the tests does not reach it
964 let v = vocabulary_at(Some(dir.path()), dir.path()).expect("the vocabulary parses");
965
966 assert_eq!(v.version, 1);
967 assert_eq!(v.count, 2, "the count is measured, never written down");
968 assert_eq!(v.obligations[0].id, "tests");
969 assert_eq!(
970 v.obligations[0].inputs,
971 vec!["lib/**".to_string(), "share/**".to_string()],
972 "an inline list of quoted pathspecs is the shape the file uses"
973 );
974 assert!(!v.obligations[0].remote);
975 assert!(v.obligations[1].remote);
976 assert!(
977 v.obligations[1].inputs.is_empty(),
978 "a remote fact hashes no tree"
979 );
980 }
981
982 /// The four verdicts, decided the way `mj_validate_obligations` decides them. The
983 /// point of the test is that the two surfaces cannot disagree: a token with no
984 /// evidence is owed, a remote fact whose branch has moved on is stale, and a
985 /// tree-bound fact is judged by its hash and not by its commit.
986 #[test]
987 fn the_verdict_is_the_validators_verdict() {
988 let root = Path::new("/nonexistent-so-git-cannot-answer");
989 let remote = Obligation {
990 id: "push".into(),
991 title: "The commit reached the remote".into(),
992 summary: String::new(),
993 discharged_by: "git".into(),
994 inputs: Vec::new(),
995 remote: true,
996 note: String::new(),
997 };
998 let tree = Obligation {
999 id: "tests".into(),
1000 title: "The cases were run".into(),
1001 summary: String::new(),
1002 discharged_by: "usecase impact".into(),
1003 // a pathspec git is never asked about, because the root does not exist: the
1004 // hash is `None`, which compares equal to the empty recorded hash
1005 inputs: vec!["lib/**".into()],
1006 remote: false,
1007 note: String::new(),
1008 };
1009 let ev = |head: &str, branch: &str, hash: &str| Evidence {
1010 recorded_at: "2026-09-09T00:00:00Z".into(),
1011 head: head.into(),
1012 branch: branch.into(),
1013 kind: "manual".into(),
1014 command: "git".into(),
1015 artifact: String::new(),
1016 result: String::new(),
1017 session: String::new(),
1018 inputs_hash: hash.into(),
1019 };
1020
1021 // nothing recorded: owed, and the way to discharge it is in the answer
1022 let owed = judge(root, "push", Some(&remote), None, Some("aaa"), "master");
1023 assert_eq!(owed.state, ObligationState::Owed);
1024 assert_eq!(owed.staleness, None);
1025 assert_eq!(
1026 owed.reproduce,
1027 "majordomus evidence --covers push --command 'git'"
1028 );
1029
1030 // a remote fact taken at this commit on this branch: discharged, exactly
1031 let ok = judge(
1032 root,
1033 "push",
1034 Some(&remote),
1035 Some(&ev("aaa", "master", "")),
1036 Some("aaa"),
1037 "master",
1038 );
1039 assert_eq!(ok.state, ObligationState::Discharged);
1040 assert_eq!(ok.staleness, Some(Divergence::Exact));
1041
1042 // the same fact, recorded on another branch: not about this work at all
1043 let elsewhere = judge(
1044 root,
1045 "push",
1046 Some(&remote),
1047 Some(&ev("aaa", "other", "")),
1048 Some("aaa"),
1049 "master",
1050 );
1051 assert_eq!(elsewhere.state, ObligationState::Stale);
1052 assert_eq!(elsewhere.staleness, Some(Divergence::DifferentContext));
1053
1054 // a tree-bound fact whose recorded hash is not this tree's: stale, and the answer
1055 // carries both hashes so a reader can see against what
1056 let moved = judge(
1057 root,
1058 "tests",
1059 Some(&tree),
1060 Some(&ev("aaa", "master", "deadbeefdeadbeef")),
1061 Some("aaa"),
1062 "master",
1063 );
1064 assert_eq!(moved.state, ObligationState::Stale);
1065 assert!(moved.detail.contains("deadbeefdead"));
1066
1067 // a token nobody declares can be discharged by nothing, and says so
1068 let undeclared = judge(root, "invented", None, None, Some("aaa"), "master");
1069 assert_eq!(undeclared.state, ObligationState::Undeclared);
1070 assert!(undeclared.detail.contains("does not declare"));
1071 }
1072
1073 /// The evidence a token carries is the newest line for it and nothing older: evidence
1074 /// is superseded by evidence, and the ledger keeps the history for a reader that wants
1075 /// it.
1076 #[test]
1077 fn the_newest_line_per_token_wins_and_other_tasks_are_not_read() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let path = dir.path().join("ledger.jsonl");
1080 std::fs::write(
1081 &path,
1082 concat!(
1083 r#"{"ts":"1","event":"task.start","head":"a","branch":"m","task":"t-1"}"#,
1084 "\n",
1085 r#"{"ts":"2","event":"task.evidence","head":"a","branch":"m","task":"t-1","covers":"tests","kind":"test","inputs_hash":"old"}"#,
1086 "\n",
1087 r#"{"ts":"3","event":"task.evidence","head":"b","branch":"m","task":"t-1","covers":"tests","kind":"test","inputs_hash":"new"}"#,
1088 "\n",
1089 r#"{"ts":"4","event":"task.evidence","head":"c","branch":"m","task":"t-2","covers":"tests","kind":"test","inputs_hash":"other"}"#,
1090 "\n",
1091 "not json at all\n",
1092 ),
1093 )
1094 .unwrap();
1095
1096 let (found, skipped) = evidence_for(&path, "t-1");
1097 assert_eq!(skipped, 1, "a line that is not JSON is counted, not fatal");
1098 assert_eq!(found.len(), 1, "another task's evidence is another task's");
1099 assert_eq!(found["tests"].inputs_hash, "new");
1100 assert_eq!(found["tests"].head, "b");
1101 }
1102
1103 /// One reading of the tree shares its file digests between callers, and the sharing is
1104 /// invisible in the answer: every overlapping selection hashes exactly as it would alone,
1105 /// a tracked file the tree no longer holds is skipped by each of them alike, and a file
1106 /// is digested once however many selections name it.
1107 #[test]
1108 fn a_shared_digest_cache_answers_what_a_fresh_read_answers() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let root = dir.path();
1111 let git = |args: &[&str]| {
1112 let ok = std::process::Command::new("git")
1113 .arg("-C")
1114 .arg(root)
1115 .args(args)
1116 .output()
1117 .expect("git runs")
1118 .status
1119 .success();
1120 assert!(ok, "git {args:?}");
1121 };
1122 git(&["init", "-q", "."]);
1123 for (path, body) in [
1124 ("lib/a.sh", "a"),
1125 ("lib/b.sh", "b"),
1126 ("docs/x.md", "x"),
1127 ("gone.txt", "g"),
1128 ] {
1129 std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
1130 std::fs::write(root.join(path), body).unwrap();
1131 }
1132 git(&["add", "-A"]);
1133 // tracked, then removed from the working tree: skipped, never an error
1134 std::fs::remove_file(root.join("gone.txt")).unwrap();
1135
1136 let selections: Vec<Vec<String>> = vec![
1137 vec!["lib/**".into()],
1138 vec!["lib/**".into(), "docs/**".into()],
1139 vec!["docs/**".into(), "gone.txt".into()],
1140 vec!["nowhere/**".into()],
1141 Vec::new(),
1142 ];
1143 let mut cache = FileHashes::default();
1144 for specs in &selections {
1145 assert_eq!(
1146 inputs_hash_with(root, specs, &mut cache),
1147 inputs_hash(root, specs),
1148 "a shared cache changed the hash of {specs:?}"
1149 );
1150 }
1151 // lib/a.sh, lib/b.sh, docs/x.md read, gone.txt remembered as unreadable: four
1152 // entries, however many of the selections above named each of them
1153 assert_eq!(
1154 cache.0.len(),
1155 4,
1156 "each file is digested once: {:?}",
1157 cache.0
1158 );
1159 assert_eq!(cache.0["gone.txt"], None);
1160 }
1161}