Skip to main content

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}