Skip to main content

majordomus_cli/capability/builtin/
knowledge_base.rs

1//! The `knowledge_base` module: what the knowledge deriver left for review and whether it is
2//! still writing — the candidate records awaiting promotion, one record by id with every
3//! reference it names resolved, and the derivation status of this checkout judged against
4//! `session.freshness`.
5//!
6//! **It is read, never written.** The writer is the shell tool's `lib/knowledge.sh`, which
7//! derives a candidate from the ledger and git at every episode boundary (ADR 0091), and a
8//! person promotes or rejects one. This process reads three things and restates none of
9//! them: the candidates from the index, where they are tracked files discovered by the
10//! `candidates` source class like every other object of the layer; the ledger through
11//! [`crate::session::Ledger`], which is the one reader of that file; and the thresholds and
12//! switches from the policy, through the reading [`super::continuity`] already does.
13//!
14//! **The module id is not `knowledge`.** The registry refuses a builtin module named like a
15//! declarative kind, and `knowledge` is the kind these records are. The command line still
16//! reads `majordomus knowledge <verb>`, because the words a person types are a projection
17//! and the module id is not.
18//!
19//! **Absence is an answer.** A fresh clone has no ledger and no candidates, a repository
20//! whose policy predates the keys declares no thresholds, and a record can name an episode
21//! this checkout never saw. Each is reported as itself, never as a failure, and the
22//! stopped-writer judgement is withheld — and says so — until a derivation has run here at
23//! least once, so that the day the deriver arrives no checkout turns red for what it could
24//! not yet have done.
25//!
26//! ```
27//! use majordomus_cli::capability::builtin::knowledge_base::{module, KNOWLEDGE_STATUS_URI};
28//! let m = module();
29//! assert_eq!(m.id.as_str(), "knowledge_base");
30//! assert!(m.capabilities.iter().any(|e| {
31//!     e.capability.exposure.mcp.as_ref().and_then(|x| x.resource.as_ref())
32//!         .is_some_and(|r| r.uri == KNOWLEDGE_STATUS_URI)
33//! }));
34//! ```
35
36use std::collections::BTreeMap;
37use std::path::{Path, PathBuf};
38
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41use serde_json::Value;
42
43use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
44use crate::capability::handler::{CapabilityError, Context};
45use crate::capability::model::{CliExposure, Exposure, McpExposure, McpResource, Stability};
46use crate::capability::module::ModuleDescriptor;
47use crate::capability::CachePolicy;
48use crate::git::GitState;
49use crate::model::Object;
50use crate::session::Ledger;
51use crate::{capability, module};
52
53use super::continuity::{thresholds_of, Freshness, Thresholds};
54use super::{get, mcp, Empty};
55
56/// The URI under which `knowledge_base.candidates` is read as an MCP resource.
57pub const KNOWLEDGE_CANDIDATES_URI: &str = "majordomus://knowledge-candidates";
58/// The URI under which `knowledge_base.status` is read as an MCP resource.
59pub const KNOWLEDGE_STATUS_URI: &str = "majordomus://knowledge-status";
60
61/// The kind a record is, and the source class the deriver writes into. The class id is the
62/// one both `sources.yaml` files declare; a record of the kind under any other class is
63/// curated, or is somebody's, and is not awaiting review.
64const KIND: &str = "knowledge";
65const CANDIDATES_CLASS: &str = "candidates";
66/// The local half of the layer, relative to the repository root; the same path the
67/// continuity module reads, stated once more only because the task store under it is
68/// where a `task:` reference resolves when the ledger no longer names it.
69const STATE_DIR: &str = ".ai/local/state";
70
71// --------------------------------------------------------------------- candidates
72
73/// Where the age of a candidate in the review queue was read from. A candidate's `date` is
74/// the day of the evidence it came from, deliberately, so that the same evidence yields the
75/// same bytes; the day it entered the queue is a different fact and is read from the ledger
76/// first, then from git, and only then from the record.
77///
78/// ```
79/// use majordomus_cli::capability::builtin::knowledge_base::AgeSource;
80/// let from_ledger: AgeSource = serde_json::from_str(r#""derivation""#).unwrap();
81/// assert_eq!(from_ledger, AgeSource::Derivation);
82/// assert!(AgeSource::Derivation < AgeSource::Date, "the ledger is asked before the record");
83/// ```
84#[derive(
85    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
86)]
87#[serde(rename_all = "snake_case")]
88pub enum AgeSource {
89    /// The oldest `knowledge.derived` line in this checkout's ledger whose paths name it.
90    Derivation,
91    /// The commit that added the file to this history.
92    Commit,
93    /// The record's own `date`, at midnight UTC: the evidence day, not the queue day.
94    Date,
95}
96
97impl AgeSource {
98    /// The word as serialised, for a text rendering that names its source.
99    ///
100    /// ```
101    /// use majordomus_cli::capability::builtin::knowledge_base::AgeSource;
102    /// assert_eq!(AgeSource::Derivation.as_str(), "derivation");
103    /// assert_eq!(AgeSource::Date.as_str(), "date");
104    /// ```
105    pub fn as_str(self) -> &'static str {
106        match self {
107            AgeSource::Derivation => "derivation",
108            AgeSource::Commit => "commit",
109            AgeSource::Date => "date",
110        }
111    }
112}
113
114/// One candidate record awaiting review: what a reader needs to decide whether to open it.
115///
116/// ```
117/// use majordomus_cli::capability::builtin::knowledge_base::KnowledgeCandidate;
118/// let c: KnowledgeCandidate = serde_json::from_str(r#"{
119///   "id": "e1-0123456789ab", "uri": "majordomus://knowledge/e1-0123456789ab",
120///   "path": ".ai/repo/knowledge/candidates/e1-0123456789ab.md", "class": "convention",
121///   "status": "candidate", "epistemics": "decided", "title": "Tests run in disposable repositories",
122///   "date": "2026-09-12", "derived_from": ["session:e1"], "episode": "e1",
123///   "freshness": "unknown", "freshness_reason": "no timestamp", "age_source": "date"
124/// }"#).unwrap();
125/// assert_eq!(c.episode, "e1");
126/// assert_eq!(c.branch, None, "an episode this checkout never saw has no branch, and says so");
127/// ```
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
129pub struct KnowledgeCandidate {
130    /// The record id, which is also its file name.
131    pub id: String,
132    /// `majordomus://knowledge/<id>`.
133    pub uri: String,
134    /// Repository-relative path.
135    pub path: String,
136    /// `fact`, `convention`, `constraint`, `memory` or `lesson`.
137    pub class: String,
138    /// Always `candidate` here; carried so that a reader sees the same record shape as `record`.
139    pub status: String,
140    /// `observed`, `inferred` or `decided`.
141    pub epistemics: String,
142    /// The assertion, in one line.
143    pub title: String,
144    /// The day of the evidence, `YYYY-MM-DD`.
145    pub date: String,
146    /// The typed references the record names as its evidence, as declared.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub derived_from: Vec<String>,
149    /// The episode the record came from: the `session:` reference, or empty when it names none.
150    #[serde(default, skip_serializing_if = "String::is_empty")]
151    pub episode: String,
152    /// The branch that episode ran on, joined to the tracked session record or, while that
153    /// is not yet written, to the ledger's `session.started` line. `None` when this checkout
154    /// knows nothing about the episode — named rather than hidden, so that a reader can tell
155    /// "not this branch" from "not known here". Serialised as `null` in that case, never
156    /// omitted.
157    #[serde(default)]
158    pub branch: Option<String>,
159    /// How long it has waited, judged against `session.freshness`.
160    pub freshness: Freshness,
161    /// The wait in whole minutes, when it could be measured.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub age_minutes: Option<i64>,
164    /// Why it was judged that way, in one phrase. Never empty.
165    pub freshness_reason: String,
166    /// Where the wait was read from.
167    pub age_source: AgeSource,
168    /// What the observation it was derived from is about (ADR 0118); empty for a record
169    /// derived from anything else.
170    #[serde(default, skip_serializing_if = "String::is_empty")]
171    pub about: String,
172    /// Which owner should look first, derived from `about`'s structure; empty without one.
173    #[serde(default, skip_serializing_if = "String::is_empty")]
174    pub route: String,
175}
176
177/// The records under one subject: every candidate awaiting review and every rejected one,
178/// with the episodes behind them (ADR 0118). Recurrence is read here, never written: each
179/// record stays one episode's own file, and a group is how several of them read as one thing.
180///
181/// ```
182/// use majordomus_cli::capability::builtin::knowledge_base::KnowledgeGroup;
183/// let g: KnowledgeGroup = serde_json::from_str(
184///     r#"{"about":"lib/a.sh","route":"project","candidates":["e1-a","e2-b"],"episodes":["e1","e2"]}"#,
185/// ).unwrap();
186/// assert_eq!(g.episodes.len(), 2, "two episodes observed it");
187/// assert!(g.rejected.is_empty(), "and nobody rejected it");
188/// ```
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190pub struct KnowledgeGroup {
191    /// The subject, as written.
192    pub about: String,
193    /// The route of its records.
194    pub route: String,
195    /// The candidates awaiting review, by id.
196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197    pub candidates: Vec<String>,
198    /// The records rejected and kept, by id.
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub rejected: Vec<String>,
201    /// The episodes behind all of them, each once, in order.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub episodes: Vec<String>,
204}
205
206/// The queue orders by id, which the deriver makes total: the episode, then a digest of the
207/// evidence.
208impl crate::order::Ordered for KnowledgeCandidate {
209    fn order_key(&self) -> crate::order::OrderKey<'_> {
210        crate::order::OrderKey::plain(&self.id, &self.id)
211    }
212}
213
214/// The candidates awaiting review, against the policy's cap.
215///
216/// ```
217/// use majordomus_cli::capability::builtin::knowledge_base::KnowledgeCandidates;
218/// let empty: KnowledgeCandidates = serde_json::from_str(
219///     r#"{"total":0,"on_this_branch":0,"unattributed":0,"branch":"master","candidates":[],"over_cap":false}"#,
220/// ).unwrap();
221/// assert_eq!(empty.total, 0, "an empty queue is an answer");
222/// assert_eq!(empty.cap, None, "and a policy without the key declares no cap");
223/// ```
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
225pub struct KnowledgeCandidates {
226    /// How many records carry `status: candidate` under the candidates class.
227    pub total: usize,
228    /// How many of them came from an episode on this checkout's branch.
229    pub on_this_branch: usize,
230    /// How many name an episode this checkout knows nothing about.
231    pub unattributed: usize,
232    /// The branch this answer is about, or `DETACHED`.
233    pub branch: String,
234    /// Every candidate, in canonical order of id.
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub candidates: Vec<KnowledgeCandidate>,
237    /// The candidates and rejected records derived from observations, grouped by what they
238    /// are about, in order of subject.
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub groups: Vec<KnowledgeGroup>,
241    /// `knowledge.candidates_max_files`, when the policy declares it.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub cap: Option<usize>,
244    /// True when `total` exceeds `cap`. False when no cap is declared: nobody wrote one down.
245    pub over_cap: bool,
246    /// What a reader should know before trusting the above: a malformed record that was
247    /// skipped, a cap that was exceeded, a policy that declares none. Empty is the good case.
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub findings: Vec<String>,
250}
251
252// --------------------------------------------------------------------- record
253
254/// Which knowledge record: the input of `knowledge_base.record`. The id is the file name
255/// and the identity the index keys the record by, so one string names it on every surface.
256///
257/// ```
258/// use majordomus_cli::capability::builtin::knowledge_base::KnowledgeRecordInput;
259/// let input: KnowledgeRecordInput = serde_json::from_str(r#"{"id":"e1-0123456789ab"}"#).unwrap();
260/// assert_eq!(input.id, "e1-0123456789ab");
261/// // a key the input does not declare is refused, never ignored
262/// assert!(serde_json::from_str::<KnowledgeRecordInput>(r#"{"id":"x","uri":"y"}"#).is_err());
263/// ```
264#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
265#[serde(deny_unknown_fields)]
266pub struct KnowledgeRecordInput {
267    /// The record id, as the file name and `majordomus://knowledge/<id>` carry it.
268    pub id: String,
269}
270
271impl BenchmarkCases for KnowledgeRecordInput {
272    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
273        ctx.index
274            .objects
275            .iter()
276            .find(|o| o.kind == KIND)
277            .map(|o| {
278                vec![NamedCase::new(
279                    "first-record",
280                    KnowledgeRecordInput {
281                        id: o.identity.clone(),
282                    },
283                )]
284            })
285            .unwrap_or_else(|| {
286                vec![NamedCase::new(
287                    "absent",
288                    KnowledgeRecordInput {
289                        id: "absent".into(),
290                    },
291                )]
292            })
293    }
294}
295
296/// How a typed reference resolved.
297///
298/// Four words and no fifth: a reference names an object of the index, or something only
299/// the ledger and git can vouch for, or a file the tree no longer holds and git history
300/// does, or nothing. `external` is not a weaker `object` — a commit or a task is exactly as
301/// real as a rule — it says which reader answered; `historical` resolves as well, and is
302/// named apart so a reader sees which provenance outlived its file (I1987).
303///
304/// ```
305/// use majordomus_cli::capability::builtin::knowledge_base::ReferenceResolution;
306/// let r: ReferenceResolution = serde_json::from_str(r#""external""#).unwrap();
307/// assert_eq!(r, ReferenceResolution::External);
308/// assert!(r.resolves(), "vouched for by the ledger or git is resolved");
309/// assert!(!ReferenceResolution::Missing.resolves());
310/// ```
311#[derive(
312    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
313)]
314#[serde(rename_all = "snake_case")]
315pub enum ReferenceResolution {
316    /// An object of the index: the `target` is its URI, or its path for a file.
317    Object,
318    /// Vouched for by the ledger, the task store or git: a session, a task, a decision, a commit.
319    External,
320    /// A `file:` or `test:` the tree no longer holds and git history does: cited before the
321    /// file was deleted, or pinned as `file:<path>@<commit>`. The `target` is the blob that
322    /// answers, `<commit>:<path>`, which `git show` reads.
323    Historical,
324    /// Nothing answers to it. A dangling reference is the integrity validator's finding.
325    Missing,
326}
327
328impl ReferenceResolution {
329    /// The word as serialised, for a text rendering that prints it beside the reference.
330    ///
331    /// ```
332    /// use majordomus_cli::capability::builtin::knowledge_base::ReferenceResolution;
333    /// assert_eq!(ReferenceResolution::External.as_str(), "external");
334    /// assert!(!ReferenceResolution::Missing.resolves());
335    /// ```
336    pub fn as_str(self) -> &'static str {
337        match self {
338            ReferenceResolution::Object => "object",
339            ReferenceResolution::External => "external",
340            ReferenceResolution::Historical => "historical",
341            ReferenceResolution::Missing => "missing",
342        }
343    }
344
345    /// Whether something answered to the reference.
346    ///
347    /// ```
348    /// use majordomus_cli::capability::builtin::knowledge_base::ReferenceResolution;
349    /// assert!(ReferenceResolution::Object.resolves());
350    /// assert!(!ReferenceResolution::Missing.resolves());
351    /// ```
352    pub fn resolves(self) -> bool {
353        !matches!(self, ReferenceResolution::Missing)
354    }
355}
356
357/// One `derived_from` reference of a record, with how it resolved and against what.
358///
359/// ```
360/// use majordomus_cli::capability::builtin::knowledge_base::{ReferenceResolution, ResolvedReference};
361/// let r: ResolvedReference = serde_json::from_str(
362///     r#"{"reference":"task:none","resolution":"missing","reason":"`none` is not a task"}"#,
363/// ).unwrap();
364/// assert_eq!(r.resolution, ReferenceResolution::Missing);
365/// assert_eq!(r.target, None);
366/// ```
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
368pub struct ResolvedReference {
369    /// The reference as written: `<prefix>:<value>`.
370    pub reference: String,
371    /// How it resolved.
372    pub resolution: ReferenceResolution,
373    /// What it resolved to: a URI for an object, a path for a file, the reader that vouched
374    /// for an external reference. Absent when nothing did.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub target: Option<String>,
377    /// Why it did not resolve, when it did not.
378    #[serde(default, skip_serializing_if = "String::is_empty")]
379    pub reason: String,
380}
381
382/// One `relations` entry, resolved the same way.
383///
384/// ```
385/// use majordomus_cli::capability::builtin::knowledge_base::{ReferenceResolution, ResolvedRelation};
386/// let r: ResolvedRelation = serde_json::from_str(
387///     r#"{"relation_type":"relates_to","reference":"file:docs/CLI.md","resolution":"object","target":"docs/CLI.md"}"#,
388/// ).unwrap();
389/// assert_eq!(r.relation_type, "relates_to");
390/// assert!(r.resolution.resolves());
391/// ```
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
393pub struct ResolvedRelation {
394    /// `relates_to`, `depends_on`, `documents`, `supports`, `contradicts`, `supersedes` or `derived_from`.
395    pub relation_type: String,
396    /// The target as written.
397    pub reference: String,
398    /// How it resolved.
399    pub resolution: ReferenceResolution,
400    /// What it resolved to, when something did.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub target: Option<String>,
403    /// Why it did not resolve, when it did not.
404    #[serde(default, skip_serializing_if = "String::is_empty")]
405    pub reason: String,
406}
407
408/// One knowledge record, with every reference it names resolved.
409///
410/// `objects.get` already serves the same file as metadata and content. What this adds is
411/// the resolution: which of the record's references answer, through which reader, and which
412/// dangle — the question a reviewer asks before promoting a candidate, and the question the
413/// integrity validator asks of every record.
414///
415/// ```
416/// use majordomus_cli::capability::builtin::knowledge_base::KnowledgeRecord;
417/// let r: KnowledgeRecord = serde_json::from_str(r#"{
418///   "id": "e1-0123456789ab", "uri": "majordomus://knowledge/e1-0123456789ab",
419///   "path": ".ai/repo/knowledge/candidates/e1-0123456789ab.md", "source_class": "candidates",
420///   "class": "convention", "status": "candidate", "epistemics": "decided", "origin": "extracted",
421///   "date": "2026-09-12", "title": "An assertion", "resolved": true, "content": "---\n---\n"
422/// }"#).unwrap();
423/// assert!(r.derived_from.is_empty(), "a record that names nothing has nothing to resolve");
424/// assert_eq!(r.superseded_by, None, "and is not superseded");
425/// ```
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
427pub struct KnowledgeRecord {
428    /// The record id.
429    pub id: String,
430    /// `majordomus://knowledge/<id>`.
431    pub uri: String,
432    /// Repository-relative path.
433    pub path: String,
434    /// The source class that discovered it: `candidates` or `curated`.
435    pub source_class: String,
436    /// `fact`, `convention`, `constraint`, `memory` or `lesson`.
437    pub class: String,
438    /// `candidate`, `verified` or `superseded`.
439    pub status: String,
440    /// `observed`, `inferred` or `decided`.
441    pub epistemics: String,
442    /// `authored` or `extracted`; empty when the record carries no provenance.
443    #[serde(default, skip_serializing_if = "String::is_empty")]
444    pub origin: String,
445    /// The day the record reached its status.
446    pub date: String,
447    /// The assertion.
448    pub title: String,
449    /// Every `provenance.derived_from` reference, resolved, in the record's order.
450    #[serde(default, skip_serializing_if = "Vec::is_empty")]
451    pub derived_from: Vec<ResolvedReference>,
452    /// Every `relations` entry, resolved, in the record's order.
453    #[serde(default, skip_serializing_if = "Vec::is_empty")]
454    pub relations: Vec<ResolvedRelation>,
455    /// The record that replaced this one, when the status is `superseded`.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub superseded_by: Option<String>,
458    /// True when every reference above resolved. The one bit a reviewer reads first.
459    #[serde(default)]
460    pub resolved: bool,
461    /// The whole file, front matter and body.
462    pub content: String,
463}
464
465// --------------------------------------------------------------------- status
466
467/// One ledger line that matters to the judgement: when, and which episode.
468///
469/// ```
470/// use majordomus_cli::capability::builtin::knowledge_base::LedgerMark;
471/// let m: LedgerMark = serde_json::from_str(r#"{"ts":"2026-09-12T10:00:00Z","episode":"e1"}"#).unwrap();
472/// assert_eq!(m.episode, "e1");
473/// ```
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
475pub struct LedgerMark {
476    /// The line's timestamp, RFC 3339 in UTC.
477    pub ts: String,
478    /// The episode the line is about; empty when the line names none.
479    #[serde(default, skip_serializing_if = "String::is_empty")]
480    pub episode: String,
481}
482
483/// Whether the deriver is still writing, and what it last wrote.
484///
485/// The judgement is ADR 0052's, applied to this writer: while episodes keep closing, a
486/// derivation follows every close. It compares episode ids, never line order or timestamps
487/// between the two events, because the close derives before it appends `session.closed`.
488/// It is the freshness half of the judgement only; whether the close path and the
489/// compaction adapter still call the deriver is read from the source by the shell
490/// validator, which is where the source is.
491///
492/// ```
493/// use majordomus_cli::capability::builtin::knowledge_base::KnowledgeStatus;
494/// let fresh: KnowledgeStatus = serde_json::from_str(r#"{
495///   "present": false, "branch": "master", "closed_without_derivation": 0, "candidates": 0,
496///   "on_this_branch": 0, "freshness": "unknown", "freshness_reason": "no episode has closed here",
497///   "judged": false, "stopped_writer": false, "knowledge_on_end": true, "knowledge_on_compact": true
498/// }"#).unwrap();
499/// assert!(!fresh.present, "a fresh clone has no ledger, and that is not a fault");
500/// assert!(!fresh.stopped_writer);
501/// ```
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
503pub struct KnowledgeStatus {
504    /// Whether anything is here to judge: a ledger line or a candidate. False in a fresh clone.
505    pub present: bool,
506    /// The branch this checkout is on, or `DETACHED`.
507    pub branch: String,
508    /// The newest `knowledge.derived` line, or `None` when the deriver has never run here.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub last_derived: Option<LedgerMark>,
511    /// The newest `session.closed` line, or `None` when no episode has closed here.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub last_closed: Option<LedgerMark>,
514    /// How many closed episodes in this ledger no `knowledge.derived` line names.
515    pub closed_without_derivation: usize,
516    /// How many records carry `status: candidate` under the candidates class.
517    pub candidates: usize,
518    /// How many of those came from an episode on this branch.
519    pub on_this_branch: usize,
520    /// How old the newest closed episode is, judged against `session.freshness`.
521    pub freshness: Freshness,
522    /// That age in whole minutes, when it could be measured.
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub age_minutes: Option<i64>,
525    /// Why it was judged that way. Never empty.
526    pub freshness_reason: String,
527    /// Whether the stopped-writer judgement was made at all. False, with the reason among
528    /// `findings`, while no episode has closed here, while the switch is off, or while no
529    /// derivation has yet run in this checkout.
530    pub judged: bool,
531    /// The finding: episodes close here and no derivation followed the newest one within
532    /// the stale threshold. True only when `judged`.
533    pub stopped_writer: bool,
534    /// `session.knowledge_on_end`, as the policy declares it; absent means on.
535    pub knowledge_on_end: bool,
536    /// `session.knowledge_on_compact`, likewise.
537    pub knowledge_on_compact: bool,
538    /// What a reader should know: why the judgement was withheld, or what it found.
539    #[serde(default, skip_serializing_if = "Vec::is_empty")]
540    pub findings: Vec<String>,
541}
542
543// --------------------------------------------------------------------- reading
544
545/// A scalar of an object's metadata, or empty.
546fn field(o: &Object, key: &str) -> String {
547    o.metadata
548        .get(key)
549        .and_then(Value::as_str)
550        .unwrap_or_default()
551        .to_string()
552}
553
554/// The `provenance.derived_from` list of a record, as written.
555fn derived_from_of(o: &Object) -> Vec<String> {
556    o.metadata
557        .get("provenance")
558        .and_then(|p| p.get("derived_from"))
559        .and_then(Value::as_array)
560        .map(|a| {
561            a.iter()
562                .filter_map(Value::as_str)
563                .map(str::to_string)
564                .collect()
565        })
566        .unwrap_or_default()
567}
568
569/// The `relations` list of a record, as `(type, target)` pairs.
570fn relations_of(o: &Object) -> Vec<(String, String)> {
571    o.metadata
572        .get("relations")
573        .and_then(Value::as_array)
574        .map(|a| {
575            a.iter()
576                .map(|r| {
577                    let s = |k: &str| {
578                        r.get(k)
579                            .and_then(Value::as_str)
580                            .unwrap_or_default()
581                            .to_string()
582                    };
583                    (s("type"), s("target"))
584                })
585                .collect()
586        })
587        .unwrap_or_default()
588}
589
590/// The episode a record names: its first `session:` reference.
591fn episode_of(refs: &[String]) -> String {
592    refs.iter()
593        .find_map(|r| r.strip_prefix("session:"))
594        .unwrap_or_default()
595        .to_string()
596}
597
598/// The branch this checkout is on, as git reports it, or `DETACHED`.
599fn branch_of(ctx: &Context) -> String {
600    match &ctx.index.repository.git {
601        GitState::Available(info) => info.branch.clone().unwrap_or_else(|| "DETACHED".into()),
602        GitState::Unavailable { .. } => "DETACHED".into(),
603    }
604}
605
606/// One reading of the clock per call, in Unix seconds.
607fn now() -> i64 {
608    std::time::SystemTime::now()
609        .duration_since(std::time::UNIX_EPOCH)
610        .map(|d| d.as_secs() as i64)
611        .unwrap_or(0)
612}
613
614/// The policy, typed, or `None` when the repository or its policy cannot be read. Absence is
615/// carried as absence: the switches then take their declared defaults and the cap is none.
616fn policy_of(ctx: &Context) -> Option<crate::policy::Policy> {
617    let root = PathBuf::from(&ctx.index.repository.root);
618    let repo = crate::repository::Repository::open(&root).ok()?;
619    Some(crate::policy::LoadedPolicy::load(&repo).ok()?.policy)
620}
621
622/// Every episode's branch, as far as this checkout knows it: the tracked session record
623/// first, because it is what every clone shares, then the ledger's `session.started` line,
624/// because the record is written only when the episode closes. One map for the whole call,
625/// so that the ledger is walked once however many candidates name episodes.
626fn episode_branches(ctx: &Context, events: &[Value]) -> BTreeMap<String, String> {
627    let mut out = BTreeMap::new();
628    for e in events {
629        if e["event"].as_str() == Some("session.started") {
630            if let (Some(sid), Some(branch)) = (e["session"].as_str(), e["branch"].as_str()) {
631                out.entry(sid.to_string())
632                    .or_insert_with(|| branch.to_string());
633            }
634        }
635    }
636    for o in ctx.index.objects.iter().filter(|o| o.kind == "session") {
637        let sid = field(o, "session_id");
638        let branch = field(o, "branch");
639        if !sid.is_empty() && !branch.is_empty() {
640            out.insert(sid, branch);
641        }
642    }
643    out
644}
645
646/// When each candidate entered the review queue, by path: the oldest `knowledge.derived`
647/// line whose `paths` names it. The ledger is the first source because it is the one that
648/// knows the derivation happened; a candidate somebody hand-copied into the directory has
649/// no such line and falls through to git.
650fn derivation_times(events: &[Value]) -> BTreeMap<String, String> {
651    let mut out = BTreeMap::new();
652    for e in events {
653        if e["event"].as_str() != Some("knowledge.derived") {
654            continue;
655        }
656        let Some(ts) = e["ts"].as_str() else {
657            continue;
658        };
659        for path in e["paths"].as_str().unwrap_or_default().split_whitespace() {
660            out.entry(path.to_string())
661                .or_insert_with(|| ts.to_string());
662        }
663    }
664    out
665}
666
667/// When git first saw each of `paths`: the commit that added it, as an RFC 3339 instant.
668/// One `git log` for every path at once, never one per candidate, and nothing when git
669/// cannot answer — a candidate the hook just wrote is not yet in any commit, and that is an
670/// answer rather than an error.
671fn added_times(root: &Path, paths: &[&str]) -> BTreeMap<String, String> {
672    if paths.is_empty() {
673        return BTreeMap::new();
674    }
675    match crate::git::read_only(root)
676        .args([
677            "log",
678            "--diff-filter=A",
679            "--format=commit:%ct",
680            "--name-only",
681            "--",
682        ])
683        .args(paths)
684        .output()
685    {
686        Ok(run) if run.status.success() => added_from_log(&String::from_utf8_lossy(&run.stdout)),
687        _ => BTreeMap::new(),
688    }
689}
690
691/// The paths of a `git log --format=commit:%ct --name-only` and when each was added: each
692/// `commit:` line is the time of the names under it, and a name before any commit line, or
693/// under one whose time does not parse, has no time to give.
694fn added_from_log(text: &str) -> BTreeMap<String, String> {
695    let mut out = BTreeMap::new();
696    let mut current: Option<u64> = None;
697    for line in text.lines() {
698        if let Some(secs) = line.strip_prefix("commit:") {
699            current = secs.trim().parse().ok();
700            continue;
701        }
702        let path = line.trim();
703        if path.is_empty() {
704            continue;
705        }
706        if let Some(secs) = current {
707            let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs);
708            // the log walks newest first, so the last write for a path is its oldest
709            // addition — which is when it entered the queue
710            out.insert(path.to_string(), crate::peers::rfc3339(at));
711        }
712    }
713    out
714}
715
716/// The candidate objects of the index: the kind, under the class the deriver writes into,
717/// carrying the status. A record under the class that claims another status is the
718/// integrity validator's finding and is not awaiting review.
719fn candidate_objects(ctx: &Context) -> Vec<&Object> {
720    ctx.index
721        .objects
722        .iter()
723        .filter(|o| {
724            o.kind == KIND
725                && o.provenance.source_class == CANDIDATES_CLASS
726                && field(o, "status") == "candidate"
727        })
728        .collect()
729}
730
731/// Every record under the candidates class that says what it is about, awaiting review or
732/// rejected, grouped by that subject: one pass over the index, ordered by subject and by id.
733fn groups_of(ctx: &Context) -> Vec<KnowledgeGroup> {
734    let mut by: BTreeMap<String, KnowledgeGroup> = BTreeMap::new();
735    // Keyed by identity rather than collected and sorted: the map is ordered by construction,
736    // and `order-check` counts every sort site in the crate against a baseline.
737    let records: BTreeMap<&str, &Object> = ctx
738        .index
739        .objects
740        .iter()
741        .filter(|o| o.kind == KIND && o.provenance.source_class == CANDIDATES_CLASS)
742        .filter(|o| !field(o, "about").is_empty())
743        .map(|o| (o.identity.as_str(), o))
744        .collect();
745    for o in records.into_values() {
746        let about = field(o, "about");
747        let group = by.entry(about.clone()).or_insert_with(|| KnowledgeGroup {
748            about,
749            route: field(o, "route"),
750            candidates: Vec::new(),
751            rejected: Vec::new(),
752            episodes: Vec::new(),
753        });
754        match field(o, "status").as_str() {
755            "candidate" => group.candidates.push(o.identity.clone()),
756            "superseded" => group.rejected.push(o.identity.clone()),
757            _ => continue,
758        }
759        let episode = episode_of(&derived_from_of(o));
760        if !episode.is_empty() && !group.episodes.contains(&episode) {
761            group.episodes.push(episode);
762        }
763    }
764    by.into_values()
765        .filter(|g| !(g.candidates.is_empty() && g.rejected.is_empty()))
766        .collect()
767}
768
769/// The candidates of this checkout, judged. Shared by `candidates` and `status`, which
770/// count the same queue; one derivation so the two cannot disagree about its size.
771fn candidates_of(ctx: &Context, events: &[Value], now: i64) -> KnowledgeCandidates {
772    let root = PathBuf::from(&ctx.index.repository.root);
773    let branch = branch_of(ctx);
774    let thresholds = thresholds_of(ctx);
775    let policy = policy_of(ctx);
776    let cap = policy
777        .as_ref()
778        .and_then(|p| p.knowledge.candidates_max_files);
779
780    let objects = candidate_objects(ctx);
781    let branches = episode_branches(ctx, events);
782    let derived = derivation_times(events);
783    let paths: Vec<&str> = objects
784        .iter()
785        .filter(|o| !derived.contains_key(&o.provenance.path))
786        .map(|o| o.provenance.path.as_str())
787        .collect();
788    let added = added_times(&root, &paths);
789
790    let mut findings = Vec::new();
791    let mut candidates: Vec<KnowledgeCandidate> = objects
792        .iter()
793        .map(|o| {
794            let refs = derived_from_of(o);
795            let episode = episode_of(&refs);
796            let path = o.provenance.path.clone();
797            let date = field(o, "date");
798            let (entered, age_source) = match derived.get(&path) {
799                Some(ts) => (ts.clone(), AgeSource::Derivation),
800                None => match added.get(&path) {
801                    Some(ts) => (ts.clone(), AgeSource::Commit),
802                    None if date.is_empty() => (String::new(), AgeSource::Date),
803                    None => (format!("{date}T00:00:00Z"), AgeSource::Date),
804                },
805            };
806            let (freshness, age_minutes, freshness_reason) = thresholds.judge(&entered, now);
807            KnowledgeCandidate {
808                id: o.identity.clone(),
809                uri: o.uri.clone(),
810                path,
811                class: field(o, "class"),
812                status: field(o, "status"),
813                epistemics: field(o, "epistemics"),
814                title: o.title.clone().unwrap_or_default(),
815                date,
816                branch: if episode.is_empty() {
817                    None
818                } else {
819                    branches.get(&episode).cloned()
820                },
821                episode,
822                derived_from: refs,
823                freshness,
824                age_minutes,
825                freshness_reason,
826                age_source,
827                about: field(o, "about"),
828                route: field(o, "route"),
829            }
830        })
831        .collect();
832    crate::order::canonical(&mut candidates);
833    let groups = groups_of(ctx);
834
835    let total = candidates.len();
836    let on_this_branch = candidates
837        .iter()
838        .filter(|c| c.branch.as_deref() == Some(branch.as_str()))
839        .count();
840    let unattributed = candidates.iter().filter(|c| c.branch.is_none()).count();
841    if unattributed > 0 {
842        findings.push(format!(
843            "{unattributed} candidate(s) name an episode this checkout does not know; their branch is reported as absent, not guessed"
844        ));
845    }
846    let over_cap = cap.is_some_and(|cap| total > cap);
847    if over_cap {
848        findings.push(format!(
849            "{total} candidates await review, over the {} the policy calls the cap (knowledge.candidates_max_files); the queue is measured, never emptied by a machine",
850            cap.unwrap_or_default()
851        ));
852    }
853    if let Some(age) = policy
854        .as_ref()
855        .and_then(|p| p.knowledge.candidate_max_age_minutes)
856    {
857        let old = candidates
858            .iter()
859            .filter(|c| c.age_minutes.is_some_and(|m| m >= age))
860            .count();
861        if old > 0 {
862            findings.push(format!(
863                "{old} candidate(s) have waited longer than the {} the policy calls the age (knowledge.candidate_max_age_minutes); review them or reject them",
864                super::continuity::span(age)
865            ));
866        }
867    }
868
869    KnowledgeCandidates {
870        total,
871        on_this_branch,
872        unattributed,
873        branch,
874        candidates,
875        groups,
876        cap,
877        over_cap,
878        findings,
879    }
880}
881
882fn candidates(ctx: &Context, _: Empty) -> Result<KnowledgeCandidates, CapabilityError> {
883    let root = PathBuf::from(&ctx.index.repository.root);
884    let read = Ledger::of(&root).read();
885    Ok(candidates_of(ctx, &read.events, now()))
886}
887
888// --------------------------------------------------------------------- resolution
889
890/// Resolve one typed reference against the index, the ledger, the task store and git.
891///
892/// The prefixes are the schema's; a prefix the schema does not admit resolves to nothing,
893/// with the reason, rather than to a guess. `task:none` and `decision:none` are refused by
894/// name: `none` is the literal the ledger stamps on a line recorded outside a task, and a
895/// record that names it as its evidence names nothing.
896fn resolve(
897    ctx: &Context,
898    root: &Path,
899    events: &[Value],
900    reference: &str,
901) -> (ReferenceResolution, Option<String>, String) {
902    use ReferenceResolution::{External, Historical, Missing, Object};
903    let Some((prefix, value)) = reference.split_once(':') else {
904        return (
905            Missing,
906            None,
907            "a reference is `<prefix>:<value>`; this one has no prefix".into(),
908        );
909    };
910    if value.is_empty() {
911        return (Missing, None, format!("`{prefix}:` names nothing"));
912    }
913    let by_kind = |kind: &str| -> Option<String> {
914        ctx.index
915            .objects
916            .iter()
917            .find(|o| {
918                o.kind == kind
919                    && (o.identity == value || o.identity.starts_with(&format!("{value}@")))
920            })
921            .map(|o| o.uri.clone())
922    };
923    match prefix {
924        "session" => {
925            let object = ctx
926                .index
927                .objects
928                .iter()
929                .find(|o| o.kind == "session" && field(o, "session_id") == value)
930                .map(|o| o.uri.clone());
931            if let Some(uri) = object {
932                return (Object, Some(uri), String::new());
933            }
934            if events.iter().any(|e| e["session"].as_str() == Some(value)) {
935                return (External, Some("ledger".into()), String::new());
936            }
937            (
938                Missing,
939                None,
940                format!("no session record and no ledger line names the episode {value}"),
941            )
942        }
943        "task" | "decision" => {
944            if value == "none" {
945                return (
946                    Missing,
947                    None,
948                    format!("`{prefix}:none` is not a reference: `none` is the ledger's word for a line recorded outside a task"),
949                );
950            }
951            if events.iter().any(|e| e["task_id"].as_str() == Some(value)) {
952                return (External, Some("ledger".into()), String::new());
953            }
954            let state = root.join(STATE_DIR);
955            let current = super::continuity::read_task(&state.join("current.yaml"))
956                .is_some_and(|t| t.id == value);
957            if current {
958                return (External, Some("task store".into()), String::new());
959            }
960            if state
961                .join("completed")
962                .join(format!("{value}.md"))
963                .is_file()
964            {
965                return (External, Some("task store".into()), String::new());
966            }
967            (
968                Missing,
969                None,
970                format!("no ledger line and no task record names the task {value}"),
971            )
972        }
973        "commit" => {
974            let ok = crate::git::read_only(root)
975                .args([
976                    "rev-parse",
977                    "--verify",
978                    "--quiet",
979                    &format!("{value}^{{commit}}"),
980                ])
981                .output()
982                .is_ok_and(|o| o.status.success());
983            if ok {
984                (External, Some("git".into()), String::new())
985            } else {
986                (Missing, None, format!("this history has no commit {value}"))
987            }
988        }
989        "file" | "test" => {
990            if let Some(o) = ctx
991                .index
992                .objects
993                .iter()
994                .find(|o| o.provenance.path == value)
995            {
996                return (Object, Some(o.uri.clone()), String::new());
997            }
998            if crate::policy::is_safe_relative(value) && root.join(value).exists() {
999                return (External, Some(value.to_string()), String::new());
1000            }
1001            if let Some(blob) = file_in_history(root, value) {
1002                return (Historical, Some(blob), String::new());
1003            }
1004            (
1005                Missing,
1006                None,
1007                format!("no such path in the tree or in its history: {value}"),
1008            )
1009        }
1010        "knowledge" | "rule" | "adr" | "issue" => match by_kind(prefix) {
1011            Some(uri) => (Object, Some(uri), String::new()),
1012            None => (
1013                Missing,
1014                None,
1015                format!("the index holds no {prefix} with the identity {value}"),
1016            ),
1017        },
1018        other => (
1019            Missing,
1020            None,
1021            format!("`{other}:` is not a reference prefix the knowledge schema admits"),
1022        ),
1023    }
1024}
1025
1026/// A `file:` or `test:` reference the tree no longer holds, found in git history: the blob
1027/// that answers, as `<commit>:<path>`, or `None` when no commit of this history held the
1028/// path.
1029///
1030/// The shell check asks the same question in `mj_knowledge_file_in_history`
1031/// (lib/knowledge.sh), and the two must agree (I1987). A record's provenance says what it was
1032/// derived from when it was written; deleting the file later does not make that false, and
1033/// OSCILLA's 5467193 failed `knowledge check` on exactly that. Two forms:
1034///
1035/// - `<path>@<commit>`, with 7 to 40 hex digits: the path at that commit, and only there;
1036/// - `<path>`: the newest commit reachable from HEAD that touched it, which either still
1037///   holds it or deleted it — then the parent does.
1038///
1039/// Literal pathspecs, so `file:*.md` names one path rather than a glob. A shallow clone that
1040/// lacks the commit answers `None`, as it does for a `commit:` reference.
1041fn file_in_history(root: &Path, value: &str) -> Option<String> {
1042    let git = |args: &[&str]| {
1043        crate::git::read_only(root)
1044            .args(args)
1045            .output()
1046            .ok()
1047            .filter(|o| o.status.success())
1048    };
1049    if let Some((path, commit)) = value.rsplit_once('@') {
1050        let hex = (7..=40).contains(&commit.len())
1051            && commit
1052                .bytes()
1053                .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
1054        if !path.is_empty() && hex {
1055            let blob = format!("{commit}:{path}");
1056            return git(&["cat-file", "-e", &blob]).map(|_| blob);
1057        }
1058    }
1059    if !crate::policy::is_safe_relative(value) {
1060        return None;
1061    }
1062    let out = git(&["--literal-pathspecs", "rev-list", "-1", "HEAD", "--", value])?;
1063    let last = String::from_utf8_lossy(&out.stdout).trim().to_string();
1064    if last.is_empty() {
1065        return None;
1066    }
1067    let held = format!("{last}:{value}");
1068    if git(&["cat-file", "-e", &held]).is_some() {
1069        Some(held)
1070    } else {
1071        Some(format!("{last}^:{value}"))
1072    }
1073}
1074
1075fn record(ctx: &Context, input: KnowledgeRecordInput) -> Result<KnowledgeRecord, CapabilityError> {
1076    let uri = crate::model::uri_for(KIND, &input.id);
1077    let o = ctx.index.get(&uri).ok_or_else(|| {
1078        CapabilityError::NotFound(format!(
1079            "no knowledge record '{}'; `knowledge candidates` lists the ones awaiting review",
1080            input.id
1081        ))
1082    })?;
1083    let root = PathBuf::from(&ctx.index.repository.root);
1084    let events = Ledger::of(&root).read().events;
1085
1086    let derived_from: Vec<ResolvedReference> = derived_from_of(o)
1087        .into_iter()
1088        .map(|reference| {
1089            let (resolution, target, reason) = resolve(ctx, &root, &events, &reference);
1090            ResolvedReference {
1091                reference,
1092                resolution,
1093                target,
1094                reason,
1095            }
1096        })
1097        .collect();
1098    let relations: Vec<ResolvedRelation> = relations_of(o)
1099        .into_iter()
1100        .map(|(relation_type, reference)| {
1101            let (resolution, target, reason) = resolve(ctx, &root, &events, &reference);
1102            ResolvedRelation {
1103                relation_type,
1104                reference,
1105                resolution,
1106                target,
1107                reason,
1108            }
1109        })
1110        .collect();
1111    let resolved = derived_from.iter().all(|r| r.resolution.resolves())
1112        && relations.iter().all(|r| r.resolution.resolves());
1113    let superseded_by = Some(field(o, "superseded_by")).filter(|s| !s.is_empty());
1114
1115    Ok(KnowledgeRecord {
1116        id: o.identity.clone(),
1117        uri: o.uri.clone(),
1118        path: o.provenance.path.clone(),
1119        source_class: o.provenance.source_class.clone(),
1120        class: field(o, "class"),
1121        status: field(o, "status"),
1122        epistemics: field(o, "epistemics"),
1123        origin: o
1124            .metadata
1125            .get("provenance")
1126            .and_then(|p| p.get("origin"))
1127            .and_then(Value::as_str)
1128            .unwrap_or_default()
1129            .to_string(),
1130        date: field(o, "date"),
1131        title: o.title.clone().unwrap_or_default(),
1132        derived_from,
1133        relations,
1134        superseded_by,
1135        resolved,
1136        content: o.content.clone(),
1137    })
1138}
1139
1140// --------------------------------------------------------------------- status
1141
1142/// The `ts` and the episode of a ledger line, the episode read from the field the event
1143/// carries it in: the envelope's `session` stamp for a close, the explicit `episode` for a
1144/// derivation, which is explicit precisely because the stamp is absent once the episode is
1145/// closed.
1146fn mark(e: &Value, episode_field: &str) -> LedgerMark {
1147    LedgerMark {
1148        ts: e["ts"].as_str().unwrap_or_default().to_string(),
1149        episode: e[episode_field].as_str().unwrap_or_default().to_string(),
1150    }
1151}
1152
1153/// The stopped-writer judgement over a ledger already read: what the handler assembles into
1154/// [`KnowledgeStatus`], separated from it so that the in-file tests drive the arithmetic
1155/// without a repository.
1156struct Judgement {
1157    last_derived: Option<LedgerMark>,
1158    last_closed: Option<LedgerMark>,
1159    closed_without_derivation: usize,
1160    judged: bool,
1161    stopped: bool,
1162    verdict: (Freshness, Option<i64>, String),
1163    findings: Vec<String>,
1164}
1165
1166/// The judgement, over a ledger already read and thresholds already taken.
1167fn judge(events: &[Value], knowledge_on_end: bool, thresholds: Thresholds, now: i64) -> Judgement {
1168    let closes: Vec<&Value> = events
1169        .iter()
1170        .filter(|e| e["event"].as_str() == Some("session.closed"))
1171        .collect();
1172    let derivations: Vec<&Value> = events
1173        .iter()
1174        .filter(|e| e["event"].as_str() == Some("knowledge.derived"))
1175        .collect();
1176    let derived_episodes: std::collections::BTreeSet<&str> = derivations
1177        .iter()
1178        .filter_map(|e| e["episode"].as_str())
1179        .collect();
1180    let last_closed = closes.last().map(|e| mark(e, "session"));
1181    let last_derived = derivations.last().map(|e| mark(e, "episode"));
1182    let closed_without_derivation = closes
1183        .iter()
1184        .filter(|e| {
1185            e["session"]
1186                .as_str()
1187                .is_some_and(|s| !derived_episodes.contains(s))
1188        })
1189        .count();
1190
1191    let mut findings = Vec::new();
1192    let withheld = |findings: Vec<String>, why: &str| Judgement {
1193        last_derived: last_derived.clone(),
1194        last_closed: last_closed.clone(),
1195        closed_without_derivation,
1196        judged: false,
1197        stopped: false,
1198        verdict: (Freshness::Unknown, None, why.to_string()),
1199        findings,
1200    };
1201
1202    let Some(closed) = last_closed.clone() else {
1203        findings.push("no episode has closed here; nothing to judge".to_string());
1204        return withheld(findings, "no episode has closed here");
1205    };
1206    if !knowledge_on_end {
1207        findings.push(
1208            "session.knowledge_on_end is false; the deriver is switched off here and its silence is not judged".to_string(),
1209        );
1210        return withheld(findings, "the deriver is switched off");
1211    }
1212    if derivations.is_empty() {
1213        findings.push(
1214            "no knowledge derivation has run in this checkout yet, so the newest close is not judged; the first `majordomus knowledge derive` starts the judgement".to_string(),
1215        );
1216        return withheld(findings, "no derivation has run here yet");
1217    }
1218    if closed.episode.is_empty() {
1219        findings.push(
1220            "the newest session.closed line names no episode; nothing to compare a derivation against".to_string(),
1221        );
1222        return withheld(findings, "the newest close names no episode");
1223    }
1224    let verdict = thresholds.judge(&closed.ts, now);
1225    let covered = derived_episodes.contains(closed.episode.as_str());
1226    let stopped = !covered && verdict.0 == Freshness::Stale;
1227    if !covered {
1228        match verdict.0 {
1229            Freshness::Stale => findings.push(format!(
1230                "episodes close and no knowledge.derived followed the newest ({}, {}); the writer has stopped. fix: majordomus knowledge derive --episode {}",
1231                closed.episode, verdict.2, closed.episode
1232            )),
1233            Freshness::Unknown | Freshness::Invalid => findings.push(format!(
1234                "the newest closed episode ({}) has no derivation and cannot be judged: {}",
1235                closed.episode, verdict.2
1236            )),
1237            Freshness::Fresh | Freshness::Aging => findings.push(format!(
1238                "the newest closed episode ({}) awaits its derivation: {}",
1239                closed.episode, verdict.2
1240            )),
1241        }
1242    }
1243    Judgement {
1244        last_derived,
1245        last_closed,
1246        closed_without_derivation,
1247        judged: true,
1248        stopped,
1249        verdict,
1250        findings,
1251    }
1252}
1253
1254fn status(ctx: &Context, _: Empty) -> Result<KnowledgeStatus, CapabilityError> {
1255    let root = PathBuf::from(&ctx.index.repository.root);
1256    let read = Ledger::of(&root).read();
1257    let now = now();
1258    let policy = policy_of(ctx);
1259    let (knowledge_on_end, knowledge_on_compact) = policy
1260        .as_ref()
1261        .map(|p| (p.session.knowledge_on_end, p.session.knowledge_on_compact))
1262        .unwrap_or((true, true));
1263    let thresholds = thresholds_of(ctx);
1264    let queue = candidates_of(ctx, &read.events, now);
1265
1266    let Judgement {
1267        last_derived,
1268        last_closed,
1269        closed_without_derivation,
1270        judged,
1271        stopped,
1272        verdict: (freshness, age_minutes, freshness_reason),
1273        mut findings,
1274    } = judge(&read.events, knowledge_on_end, thresholds, now);
1275    if !read.corrupt.is_empty() {
1276        findings.push(format!(
1277            "{} ledger line(s) did not parse and were not judged; run `majordomus doctor`",
1278            read.corrupt.len()
1279        ));
1280    }
1281    findings.extend(queue.findings.iter().cloned());
1282
1283    Ok(KnowledgeStatus {
1284        present: !read.events.is_empty() || queue.total > 0,
1285        branch: queue.branch.clone(),
1286        last_derived,
1287        last_closed,
1288        closed_without_derivation,
1289        candidates: queue.total,
1290        on_this_branch: queue.on_this_branch,
1291        freshness,
1292        age_minutes,
1293        freshness_reason,
1294        judged,
1295        stopped_writer: stopped,
1296        knowledge_on_end,
1297        knowledge_on_compact,
1298        findings,
1299    })
1300}
1301
1302// --------------------------------------------------------------------- module
1303
1304/// The module: three queries, declared once, from which every projection — the MCP tools
1305/// and resources, the HTTP routes, the command line, the OpenAPI operations, the cockpit's
1306/// catalogue entry and the benchmark targets — is derived.
1307///
1308/// ```
1309/// use majordomus_cli::capability::builtin::knowledge_base::module;
1310/// let m = module();
1311/// let ids: Vec<&str> = m.capabilities.iter().map(|e| e.capability.id.as_str()).collect();
1312/// assert_eq!(ids, ["knowledge_base.candidates", "knowledge_base.record", "knowledge_base.status"]);
1313/// ```
1314pub fn module() -> ModuleDescriptor {
1315    module! {
1316        id: "knowledge_base",
1317        title: "Knowledge base",
1318        description: "What the knowledge deriver left for review and whether it is still writing: the candidate records awaiting promotion with the branch of the episode each came from, one record by id with every reference it names resolved against the index, the ledger and git, and the derivation status of this checkout judged against the policy's freshness thresholds. Read from the index and the ledger; written by nothing here — the deriver is the shell tool's, and a person promotes.",
1319        stability: Stability::BehaviorallyVerified,
1320        capabilities: [
1321            capability! {
1322                id: "knowledge_base.candidates",
1323                title: "Candidates awaiting review",
1324                description: "Every knowledge record with status candidate under the candidates source class: its class, its assertion, the episode it came from and that episode's branch, and how long it has waited — measured from the derivation that wrote it, then the commit that added it, then its own date — judged against session.freshness; against the policy's cap. A candidate whose episode this checkout does not know is reported with no branch rather than hidden. Ordered by id.",
1325                input: Empty,
1326                output: KnowledgeCandidates,
1327                stability: Stability::BehaviorallyVerified,
1328                exposure: Exposure {
1329                    mcp: Some(McpExposure {
1330                        tool: Some("majordomus_knowledge_candidates".into()),
1331                        resource: Some(McpResource { uri: KNOWLEDGE_CANDIDATES_URI.into(), name: "knowledge-candidates".into() }),
1332                    }),
1333                    http: get("/api/v1/knowledge/candidates"),
1334                    cli: Some(CliExposure { path: vec!["knowledge".into(), "candidates".into()] }),
1335                },
1336                tags: ["knowledge", "continuity"],
1337                handler: candidates,
1338            },
1339            capability! {
1340                id: "knowledge_base.record",
1341                title: "One knowledge record",
1342                description: "One knowledge record by id — candidate or curated — with every reference it names resolved: a session against the tracked session records and the ledger, a task or decision against the ledger and the task store, a commit against git, a file or test against the index and the tree, a rule, an ADR, an issue or another record against the index. Which references dangle is the question a reviewer asks before promoting, and the one the integrity validator asks of every record. A `task:none` or `decision:none` reference is refused by name.",
1343                input: KnowledgeRecordInput,
1344                output: KnowledgeRecord,
1345                stability: Stability::BehaviorallyVerified,
1346                exposure: Exposure {
1347                    mcp: mcp("majordomus_knowledge_record"),
1348                    http: get("/api/v1/knowledge/record"),
1349                    cli: Some(CliExposure { path: vec!["knowledge".into(), "record".into()] }),
1350                },
1351                tags: ["knowledge"],
1352                handler: record,
1353            },
1354            capability! {
1355                id: "knowledge_base.status",
1356                title: "Whether the deriver is still writing",
1357                description: "The freshness half of the stopped-writer judgement (ADR 0052, applied to the knowledge deriver by ADR 0091): the newest knowledge.derived line, the newest session.closed line, how many closed episodes no derivation names, the candidate counts, and — once a derivation has run in this checkout and the switch is on — whether the newest closed episode went underived past session.freshness.stale_minutes. Episode ids are compared, never line order. Whether the close path still calls the deriver is read from the source by the shell validator, not here.",
1358                input: Empty,
1359                output: KnowledgeStatus,
1360                stability: Stability::BehaviorallyVerified,
1361                exposure: Exposure {
1362                    mcp: Some(McpExposure {
1363                        tool: Some("majordomus_knowledge_status".into()),
1364                        resource: Some(McpResource { uri: KNOWLEDGE_STATUS_URI.into(), name: "knowledge-status".into() }),
1365                    }),
1366                    http: get("/api/v1/knowledge/status"),
1367                    cli: Some(CliExposure { path: vec!["knowledge".into(), "status".into()] }),
1368                },
1369                tags: ["knowledge", "continuity", "session"],
1370                // Short-lived: the ledger is written by another process, and a reader that
1371                // cached it for a minute would report a writer that had just written as stopped.
1372                cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(2) },
1373                handler: status,
1374            },
1375        ],
1376    }
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381    use super::*;
1382    use serde_json::json;
1383
1384    /// The declaration is the only place these names exist, and every projection — the MCP
1385    /// tool, the HTTP route, the command line, the OpenAPI operation, the benchmark target —
1386    /// is derived from it. A refactor that dropped an exposure or renamed a route would
1387    /// still compile; this is the assertion that would not.
1388    #[test]
1389    fn the_declaration_yields_the_projections_it_claims() {
1390        let m = module();
1391        assert_eq!(m.id.as_str(), "knowledge_base");
1392        let expected: &[(&str, &str, &str, &str)] = &[
1393            (
1394                "knowledge_base.candidates",
1395                "majordomus_knowledge_candidates",
1396                "/api/v1/knowledge/candidates",
1397                "knowledge candidates",
1398            ),
1399            (
1400                "knowledge_base.record",
1401                "majordomus_knowledge_record",
1402                "/api/v1/knowledge/record",
1403                "knowledge record",
1404            ),
1405            (
1406                "knowledge_base.status",
1407                "majordomus_knowledge_status",
1408                "/api/v1/knowledge/status",
1409                "knowledge status",
1410            ),
1411        ];
1412        let ids: Vec<&str> = m
1413            .capabilities
1414            .iter()
1415            .map(|e| e.capability.id.as_str())
1416            .collect();
1417        let want: Vec<&str> = expected.iter().map(|(id, _, _, _)| *id).collect();
1418        assert_eq!(
1419            ids, want,
1420            "the module declares a different set of capabilities"
1421        );
1422        for (executable, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
1423            let exposure = &executable.capability.exposure;
1424            assert_eq!(
1425                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
1426                Some(*tool),
1427                "{id} lost or renamed its MCP tool"
1428            );
1429            assert_eq!(
1430                exposure.http.as_ref().map(|h| h.path.as_str()),
1431                Some(*path),
1432                "{id} lost or renamed its HTTP route"
1433            );
1434            assert_eq!(
1435                exposure.cli.as_ref().map(|c| c.path.join(" ")),
1436                Some((*cli).to_string()),
1437                "{id} lost or renamed its command line"
1438            );
1439        }
1440    }
1441
1442    fn line(event: &str, ts: &str, extra: Value) -> Value {
1443        let mut v = json!({ "ts": ts, "event": event, "head": "h", "branch": "master", "by": "t" });
1444        if let Some(map) = extra.as_object() {
1445            for (k, val) in map {
1446                v[k] = val.clone();
1447            }
1448        }
1449        v
1450    }
1451
1452    const T: Thresholds = Thresholds {
1453        fresh_minutes: Some(720),
1454        stale_minutes: Some(2880),
1455    };
1456    // 2026-08-29T10:40:00Z, the instant the continuity examples use
1457    const NOW: i64 = 1_788_000_000;
1458
1459    /// A ledger nobody has written, and a ledger in which no episode closed, are not judged
1460    /// and say why; nothing about them is a stopped writer.
1461    #[test]
1462    fn nothing_closed_is_not_judged() {
1463        let j = judge(&[], true, T, NOW);
1464        assert!(j.last_derived.is_none() && j.last_closed.is_none());
1465        assert_eq!(j.closed_without_derivation, 0);
1466        assert!(!j.judged && !j.stopped);
1467        assert_eq!(j.verdict.0, Freshness::Unknown);
1468        assert!(j.findings[0].contains("no episode has closed"));
1469    }
1470
1471    /// The upgrade gate: a close with no derivation anywhere in the ledger is the day the
1472    /// deriver arrived, not a stopped writer. The finding says which command starts the
1473    /// judgement.
1474    #[test]
1475    fn a_close_before_any_derivation_is_withheld_not_red() {
1476        let events = [line(
1477            "session.closed",
1478            "2026-01-01T00:00:00Z",
1479            json!({ "session": "e1", "outcome": "closed" }),
1480        )];
1481        let j = judge(&events, true, T, NOW);
1482        assert_eq!(j.last_closed.unwrap().episode, "e1");
1483        assert_eq!(
1484            j.closed_without_derivation, 1,
1485            "counted, even while not judged"
1486        );
1487        assert!(!j.judged && !j.stopped);
1488        assert!(j.findings[0].contains("majordomus knowledge derive"));
1489    }
1490
1491    /// The invariant: once a derivation has run here, the newest close must be named by one
1492    /// or be younger than the stale threshold. Episode ids are compared, never line order —
1493    /// the derivation of e2 precedes its close in the ledger and still covers it.
1494    #[test]
1495    fn a_covered_close_is_fine_and_an_uncovered_stale_one_is_a_stopped_writer() {
1496        let covered = [
1497            line(
1498                "knowledge.derived",
1499                "2026-01-01T00:00:00Z",
1500                json!({ "episode": "e2", "written": 1, "unchanged": 0, "skipped": 0, "paths": "" }),
1501            ),
1502            line(
1503                "session.closed",
1504                "2026-01-01T00:00:01Z",
1505                json!({ "session": "e2", "outcome": "closed" }),
1506            ),
1507        ];
1508        let j = judge(&covered, true, T, NOW);
1509        assert_eq!(j.last_derived.unwrap().episode, "e2");
1510        assert_eq!(j.closed_without_derivation, 0);
1511        assert!(j.judged && !j.stopped, "{:?}", j.findings);
1512        assert!(j.findings.is_empty());
1513
1514        let uncovered = [
1515            line(
1516                "knowledge.derived",
1517                "2026-01-01T00:00:00Z",
1518                json!({ "episode": "e1", "written": 0, "unchanged": 0, "skipped": 0, "paths": "" }),
1519            ),
1520            line(
1521                "session.closed",
1522                "2026-01-02T00:00:00Z",
1523                json!({ "session": "e2", "outcome": "closed" }),
1524            ),
1525        ];
1526        let j = judge(&uncovered, true, T, NOW);
1527        assert_eq!(j.last_closed.unwrap().episode, "e2");
1528        assert_eq!(j.closed_without_derivation, 1);
1529        assert!(j.judged && j.stopped);
1530        assert_eq!(j.verdict.0, Freshness::Stale);
1531        assert!(j.findings[0].contains("the writer has stopped"));
1532        assert!(j.findings[0].contains("--episode e2"));
1533
1534        // the switch off withholds the judgement and says so
1535        let j = judge(&uncovered, false, T, NOW);
1536        assert!(!j.judged && !j.stopped);
1537        assert!(j.findings[0].contains("knowledge_on_end is false"));
1538
1539        // a close younger than the threshold is awaited, not reported
1540        let young = [
1541            line(
1542                "knowledge.derived",
1543                "2026-01-01T00:00:00Z",
1544                json!({ "episode": "e1", "written": 0, "unchanged": 0, "skipped": 0, "paths": "" }),
1545            ),
1546            line(
1547                "session.closed",
1548                "2026-08-29T10:00:00Z",
1549                json!({ "session": "e2", "outcome": "closed" }),
1550            ),
1551        ];
1552        let j = judge(&young, true, T, NOW);
1553        assert!(j.judged && !j.stopped);
1554        assert_eq!(j.verdict.0, Freshness::Fresh);
1555    }
1556
1557    /// The queue entry time is the oldest derivation naming the path, so a re-run that
1558    /// rewrites the same file does not restart the clock.
1559    #[test]
1560    fn the_oldest_derivation_naming_a_path_is_when_it_entered_the_queue() {
1561        let events = [
1562            line(
1563                "knowledge.derived",
1564                "2026-01-01T00:00:00Z",
1565                json!({ "episode": "e1", "paths": ".ai/repo/knowledge/candidates/a.md .ai/repo/knowledge/candidates/b.md" }),
1566            ),
1567            line(
1568                "knowledge.derived",
1569                "2026-02-01T00:00:00Z",
1570                json!({ "episode": "e1", "paths": ".ai/repo/knowledge/candidates/a.md" }),
1571            ),
1572        ];
1573        let times = derivation_times(&events);
1574        assert_eq!(
1575            times
1576                .get(".ai/repo/knowledge/candidates/a.md")
1577                .map(String::as_str),
1578            Some("2026-01-01T00:00:00Z")
1579        );
1580        assert_eq!(
1581            times
1582                .get(".ai/repo/knowledge/candidates/b.md")
1583                .map(String::as_str),
1584            Some("2026-01-01T00:00:00Z")
1585        );
1586        assert!(!times.contains_key("elsewhere.md"));
1587    }
1588
1589    /// The tracked session record wins over the ledger, and an episode neither knows has no
1590    /// branch.
1591    #[test]
1592    fn an_episode_is_the_session_reference_and_nothing_else() {
1593        assert_eq!(
1594            episode_of(&["decision:t-1".into(), "session:e1".into()]),
1595            "e1"
1596        );
1597        assert_eq!(episode_of(&["task:t-1".into()]), "");
1598    }
1599
1600    /// The words a reader filters on are the words the answer serialises.
1601    #[test]
1602    fn every_age_source_and_resolution_prints_as_the_word_it_serialises_to() {
1603        for a in [AgeSource::Derivation, AgeSource::Commit, AgeSource::Date] {
1604            assert_eq!(
1605                serde_json::to_string(&a).unwrap(),
1606                format!("\"{}\"", a.as_str())
1607            );
1608        }
1609        for r in [
1610            ReferenceResolution::Object,
1611            ReferenceResolution::External,
1612            ReferenceResolution::Historical,
1613            ReferenceResolution::Missing,
1614        ] {
1615            assert_eq!(
1616                serde_json::to_string(&r).unwrap(),
1617                format!("\"{}\"", r.as_str())
1618            );
1619        }
1620    }
1621
1622    /// A close that names no episode cannot be compared with any derivation, and a close
1623    /// whose time does not parse cannot be aged: both are said, neither is a stopped writer.
1624    #[test]
1625    fn a_close_that_names_no_episode_or_no_readable_time_is_not_a_stopped_writer() {
1626        let derived = line(
1627            "knowledge.derived",
1628            "2026-01-01T00:00:00Z",
1629            json!({ "episode": "e1", "paths": "" }),
1630        );
1631        let nameless = [
1632            derived.clone(),
1633            line(
1634                "session.closed",
1635                "2026-01-02T00:00:00Z",
1636                json!({ "outcome": "closed" }),
1637            ),
1638        ];
1639        let j = judge(&nameless, true, T, NOW);
1640        assert!(!j.judged && !j.stopped);
1641        assert!(
1642            j.findings[0].contains("names no episode"),
1643            "{:?}",
1644            j.findings
1645        );
1646
1647        let unreadable = [
1648            derived,
1649            line("session.closed", "yesterday", json!({ "session": "e2" })),
1650        ];
1651        let j = judge(&unreadable, true, T, NOW);
1652        assert!(j.judged && !j.stopped);
1653        assert_eq!(j.verdict.0, Freshness::Invalid);
1654        assert!(
1655            j.findings[0].contains("cannot be judged"),
1656            "{:?}",
1657            j.findings
1658        );
1659    }
1660
1661    /// A derivation line without a time is skipped rather than dated to nothing.
1662    #[test]
1663    fn a_derivation_without_a_time_dates_nothing() {
1664        let mut undated = line("knowledge.derived", "", json!({ "paths": "a.md" }));
1665        undated.as_object_mut().unwrap().remove("ts");
1666        assert!(derivation_times(&[undated]).is_empty());
1667    }
1668
1669    /// The log's names take the time of the commit line above them; a name with no commit
1670    /// line above it, or under one whose time does not parse, takes none.
1671    #[test]
1672    fn a_name_in_the_log_takes_the_time_of_its_commit_line() {
1673        let added = added_from_log(
1674            "orphan.md\ncommit:1788000000\n\na.md\ncommit:never\n\nb.md\ncommit:0\n\na.md\n",
1675        );
1676        assert_eq!(
1677            added,
1678            [("a.md".to_string(), "1970-01-01T00:00:00Z".to_string())]
1679                .into_iter()
1680                .collect(),
1681            "the oldest addition wins and the undated names are left out"
1682        );
1683        assert!(added_from_log("").is_empty());
1684        // and a directory git cannot read is no answer, not an error
1685        let nowhere = tempfile::tempdir().unwrap();
1686        assert!(added_times(nowhere.path(), &["a.md"]).is_empty());
1687    }
1688
1689    fn object(kind: &str, identity: &str, path: &str, metadata: Value) -> Object {
1690        Object {
1691            kind: kind.into(),
1692            identity: identity.into(),
1693            uri: crate::model::uri_for(kind, identity),
1694            title: None,
1695            description: None,
1696            metadata,
1697            body: String::new(),
1698            content: String::new(),
1699            media_type: "text/markdown",
1700            provenance: crate::model::Provenance {
1701                path: path.into(),
1702                directory: String::new(),
1703                source_class: kind.into(),
1704                section: None,
1705                bytes: 0,
1706                member: None,
1707            },
1708        }
1709    }
1710
1711    /// A context over a synthetic repository whose index also holds `extra`.
1712    fn context_with(repo: &crate::synthetic::SyntheticRepository, extra: Vec<Object>) -> Context {
1713        let mut index = repo.index().unwrap();
1714        index.objects.extend(extra);
1715        index.objects.sort_by(|a, b| a.uri.cmp(&b.uri));
1716        let registry = crate::capability::CapabilityRegistry::builder()
1717            .with_modules(super::super::modules())
1718            .build()
1719            .unwrap();
1720        Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry))
1721    }
1722
1723    /// Every prefix the schema admits resolves against the thing it names, or says why it
1724    /// does not; a reference with no prefix, with nothing after it, or with a prefix the
1725    /// schema does not admit resolves to nothing, with the reason.
1726    #[test]
1727    fn every_reference_resolves_against_what_it_names_or_says_why_not() {
1728        use ReferenceResolution::{External, Missing, Object as Obj};
1729        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1730        let root = repo.root().to_path_buf();
1731        let state = root.join(STATE_DIR);
1732        std::fs::create_dir_all(state.join("completed")).unwrap();
1733        std::fs::write(state.join("current.yaml"), "id: t-current\n").unwrap();
1734        std::fs::write(state.join("completed/t-done.md"), "# done\n").unwrap();
1735        std::fs::create_dir_all(root.join("notes")).unwrap();
1736        std::fs::write(
1737            root.join("notes/plain.txt"),
1738            "on disk, in no source class\n",
1739        )
1740        .unwrap();
1741        let ctx = context_with(
1742            &repo,
1743            vec![object(
1744                "session",
1745                "s1",
1746                ".ai/repo/sessions/s1.md",
1747                json!({ "session_id": "s1", "branch": "master" }),
1748            )],
1749        );
1750        // the last rule, so that every rule before it is compared and passed over
1751        let rule = ctx
1752            .index
1753            .objects
1754            .iter()
1755            .rev()
1756            .find(|o| o.kind == "rule")
1757            .expect("the synthetic repository holds rules")
1758            .clone();
1759        let document = ctx
1760            .index
1761            .objects
1762            .iter()
1763            .find(|o| o.provenance.path.starts_with("docs/"))
1764            .expect("and documents")
1765            .clone();
1766        let events = [json!({ "session": "s2" }), json!({ "task_id": "t-ledger" })];
1767        let resolve = |reference: &str| resolve(&ctx, &root, &events, reference);
1768
1769        let rule_ref = format!("rule:{}", rule.identity);
1770        let document_ref = format!("file:{}", document.provenance.path);
1771        let cases: Vec<(&str, ReferenceResolution, Option<&str>, &str)> = vec![
1772            ("noprefix", Missing, None, "has no prefix"),
1773            ("task:", Missing, None, "`task:` names nothing"),
1774            ("session:s1", Obj, Some("majordomus://session/s1"), ""),
1775            ("session:s2", External, Some("ledger"), ""),
1776            (
1777                "session:s3",
1778                Missing,
1779                None,
1780                "no session record and no ledger line",
1781            ),
1782            ("task:t-ledger", External, Some("ledger"), ""),
1783            ("decision:t-current", External, Some("task store"), ""),
1784            ("task:t-done", External, Some("task store"), ""),
1785            (
1786                "task:t-nowhere",
1787                Missing,
1788                None,
1789                "no ledger line and no task record",
1790            ),
1791            (
1792                "commit:0123abc",
1793                Missing,
1794                None,
1795                "this history has no commit 0123abc",
1796            ),
1797            (&document_ref, Obj, Some(document.uri.as_str()), ""),
1798            (
1799                "file:notes/plain.txt",
1800                External,
1801                Some("notes/plain.txt"),
1802                "",
1803            ),
1804            (
1805                "test:../outside.sh",
1806                Missing,
1807                None,
1808                "no such path in the tree",
1809            ),
1810            ("file:nowhere.md", Missing, None, "no such path in the tree"),
1811            (&rule_ref, Obj, Some(rule.uri.as_str()), ""),
1812            (
1813                "adr:0001",
1814                Missing,
1815                None,
1816                "the index holds no adr with the identity 0001",
1817            ),
1818            (
1819                "ticket:1",
1820                Missing,
1821                None,
1822                "`ticket:` is not a reference prefix",
1823            ),
1824        ];
1825        for (reference, resolution, target, reason) in cases {
1826            let (got, at, why) = resolve(reference);
1827            assert_eq!(got, resolution, "{reference}: {why}");
1828            assert_eq!(at.as_deref(), target, "{reference}");
1829            assert!(why.contains(reason), "{reference}: {why}");
1830        }
1831    }
1832
1833    /// A file the tree no longer holds resolves through git history, as historical: at the
1834    /// newest commit that touched it, or at a commit the reference pins. A path no commit
1835    /// ever held, a pin at a commit that did not hold it, and a glob still resolve to nothing
1836    /// (I1987: OSCILLA's notes cited tests/engine.cjs after its V2 removed it).
1837    #[test]
1838    fn a_deleted_file_resolves_through_history_and_one_that_never_existed_does_not() {
1839        use ReferenceResolution::{Historical, Missing};
1840        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1841        let root = repo.root().to_path_buf();
1842        // an identity and no hooks of the machine's: the CI runner has none, a laptop may
1843        let git = |args: &[&str]| {
1844            let out = std::process::Command::new("git")
1845                .arg("-C")
1846                .arg(&root)
1847                .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
1848                .args([
1849                    "-c",
1850                    "commit.gpgsign=false",
1851                    "-c",
1852                    "core.hooksPath=/dev/null",
1853                ])
1854                .args(args)
1855                .output()
1856                .expect("git");
1857            assert!(
1858                out.status.success(),
1859                "git {args:?}: {}",
1860                String::from_utf8_lossy(&out.stderr)
1861            );
1862            String::from_utf8_lossy(&out.stdout).trim().to_string()
1863        };
1864        git(&["init", "-q"]);
1865        std::fs::create_dir_all(root.join("tests")).unwrap();
1866        std::fs::write(root.join("tests/engine.cjs"), "the engine\n").unwrap();
1867        git(&["add", "tests/engine.cjs"]);
1868        git(&["commit", "-q", "-m", "an engine"]);
1869        let added = git(&["rev-parse", "--short=10", "HEAD"]);
1870        git(&["rm", "-q", "tests/engine.cjs"]);
1871        git(&["commit", "-q", "-m", "V2 removes it"]);
1872        let removed = git(&["rev-parse", "HEAD"]);
1873        assert!(!root.join("tests/engine.cjs").exists());
1874
1875        let ctx = context_with(&repo, vec![]);
1876        let resolve = |reference: &str| resolve(&ctx, &root, &[], reference);
1877
1878        let (got, at, why) = resolve("file:tests/engine.cjs");
1879        assert_eq!(got, Historical, "{why}");
1880        assert!(got.resolves(), "a historical reference resolves");
1881        assert_eq!(at, Some(format!("{removed}^:tests/engine.cjs")));
1882        let pinned = format!("file:tests/engine.cjs@{added}");
1883        let (got, at, why) = resolve(&pinned);
1884        assert_eq!(got, Historical, "{pinned}: {why}");
1885        assert_eq!(at, Some(format!("{added}:tests/engine.cjs")));
1886        let short = &removed[..10];
1887        for (reference, reason) in [
1888            ("file:tests/smoke.cjs".to_string(), "never existed"),
1889            (
1890                format!("file:tests/engine.cjs@{short}"),
1891                "pinned where it was already gone",
1892            ),
1893            ("test:tests/*.cjs".to_string(), "a glob is one literal path"),
1894            ("file:../engine.cjs".to_string(), "outside the repository"),
1895        ] {
1896            let (got, at, why) = resolve(&reference);
1897            assert_eq!(got, Missing, "{reference} ({reason})");
1898            assert_eq!(at, None, "{reference}");
1899            assert!(why.contains("no such path"), "{reference}: {why}");
1900        }
1901    }
1902
1903    /// A record whose every reference answers is resolved, its relations included; one
1904    /// whose relation dangles is not, even when everything it was derived from answers.
1905    #[test]
1906    fn a_record_is_resolved_only_when_its_relations_answer_too() {
1907        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1908        let record_of = |target: &str| {
1909            object(
1910                KIND,
1911                "k1",
1912                ".ai/repo/knowledge/curated/k1.md",
1913                json!({
1914                    "status": "accepted",
1915                    "provenance": { "derived_from": ["file:.ai/manifest.yaml"] },
1916                    "relations": [{ "type": "relates_to", "target": target }]
1917                }),
1918            )
1919        };
1920        let ctx = context_with(&repo, vec![record_of("file:docs/DOC_0.md")]);
1921        let r = record(&ctx, KnowledgeRecordInput { id: "k1".into() }).unwrap();
1922        assert!(r.resolved, "{r:?}");
1923        assert_eq!(r.relations[0].resolution, ReferenceResolution::Object);
1924
1925        let ctx = context_with(&repo, vec![record_of("file:gone.md")]);
1926        let r = record(&ctx, KnowledgeRecordInput { id: "k1".into() }).unwrap();
1927        assert!(!r.resolved);
1928        assert_eq!(r.relations[0].resolution, ReferenceResolution::Missing);
1929    }
1930
1931    /// The branch of a checkout git calls detached, or that git cannot read, is DETACHED;
1932    /// a policy that cannot be read is no policy rather than an error.
1933    #[test]
1934    fn a_detached_head_and_an_unreadable_policy_are_named_not_guessed() {
1935        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1936        let mut index = repo.index().unwrap();
1937        index.repository.git = GitState::Available(crate::git::GitInfo {
1938            toplevel: repo.root().to_path_buf(),
1939            head: Some("0123abc".into()),
1940            branch: None,
1941            working_tree: "clean".into(),
1942        });
1943        let registry = crate::capability::CapabilityRegistry::builder()
1944            .with_modules(super::super::modules())
1945            .build()
1946            .unwrap();
1947        let ctx = Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry));
1948        assert_eq!(branch_of(&ctx), "DETACHED");
1949        assert!(policy_of(&ctx).is_some(), "the synthetic policy reads");
1950
1951        std::fs::write(
1952            repo.root().join(".ai/repo/policy.yaml"),
1953            "version: [unclosed\n",
1954        )
1955        .unwrap();
1956        assert!(policy_of(&ctx).is_none(), "an unreadable policy is none");
1957        std::fs::remove_file(repo.root().join(".ai/manifest.yaml")).unwrap();
1958        assert!(
1959            policy_of(&ctx).is_none(),
1960            "and so is a root that is no repository"
1961        );
1962    }
1963
1964    /// The branch of an episode comes from the tracked record first and the ledger's start
1965    /// line second; a line or a record that does not name both says nothing.
1966    #[test]
1967    fn an_episode_branch_needs_both_an_episode_and_a_branch() {
1968        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1969        let ctx = context_with(
1970            &repo,
1971            vec![
1972                object(
1973                    "session",
1974                    "a",
1975                    "s/a.md",
1976                    json!({ "session_id": "a", "branch": "recorded" }),
1977                ),
1978                object("session", "b", "s/b.md", json!({ "session_id": "b" })),
1979                object("session", "c", "s/c.md", json!({ "branch": "nobody" })),
1980            ],
1981        );
1982        let events = [
1983            json!({ "event": "session.started", "session": "a", "branch": "started" }),
1984            json!({ "event": "session.started", "session": "d", "branch": "from-ledger" }),
1985            json!({ "event": "session.started", "session": "e" }),
1986            json!({ "event": "session.started", "branch": "orphan" }),
1987        ];
1988        let branches = episode_branches(&ctx, &events);
1989        assert_eq!(
1990            branches,
1991            [
1992                ("a".to_string(), "recorded".to_string()),
1993                ("d".to_string(), "from-ledger".to_string()),
1994            ]
1995            .into_iter()
1996            .collect()
1997        );
1998    }
1999
2000    /// Ledger lines that do not parse are counted in the status, never silently dropped.
2001    #[test]
2002    fn a_ledger_line_that_does_not_parse_is_reported() {
2003        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2004        let ledger = Ledger::of(repo.root()).path().to_path_buf();
2005        std::fs::create_dir_all(ledger.parent().unwrap()).unwrap();
2006        std::fs::write(&ledger, "not json\n").unwrap();
2007        let ctx = repo.context().unwrap();
2008        let s = status(&ctx, Empty {}).unwrap();
2009        assert!(
2010            s.findings.iter().any(|f| f
2011                == "1 ledger line(s) did not parse and were not judged; run `majordomus doctor`"),
2012            "{:?}",
2013            s.findings
2014        );
2015    }
2016
2017    /// A candidate's wait starts at the derivation that wrote it, else at the commit that
2018    /// added it, else at its own date, and a candidate with none of the three has no age.
2019    /// One whose episode this checkout does not know is unattributed, and a queue longer
2020    /// than the policy's cap is a finding; the list is in the canonical order.
2021    #[test]
2022    /// ADR 0118: records derived from observations are grouped by what they are about —
2023    /// candidates and rejected records alike, each episode once, in order of subject — and a
2024    /// record that says nothing about what it is about, or is verified, belongs to no group.
2025    fn observations_are_grouped_by_what_they_are_about() {
2026        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2027        let dir = ".ai/repo/knowledge/candidates";
2028        let record = |id: &str, status: &str, about: &str, episode: &str| {
2029            let mut meta = json!({ "status": status, "class": "lesson",
2030                                   "provenance": { "derived_from": [format!("session:{episode}")] } });
2031            if !about.is_empty() {
2032                meta["about"] = json!(about);
2033                meta["route"] = json!("project");
2034            }
2035            let mut o = object(KIND, id, &format!("{dir}/{id}.md"), meta);
2036            o.provenance.source_class = CANDIDATES_CLASS.into();
2037            o
2038        };
2039        let ctx = context_with(
2040            &repo,
2041            vec![
2042                record("e2-b", "candidate", "lib/a.sh", "e2"),
2043                record("e1-a", "superseded", "lib/a.sh", "e1"),
2044                record("e3-c", "candidate", "lib/a.sh", "e2"),
2045                record("e1-d", "candidate", "docs/x.md", "e1"),
2046                record("e1-e", "candidate", "", "e1"),
2047                record("e1-f", "verified", "zzz", "e1"),
2048            ],
2049        );
2050        let q = candidates_of(&ctx, &[], NOW);
2051        let groups: Vec<(&str, usize, usize, usize)> = q
2052            .groups
2053            .iter()
2054            .map(|g| {
2055                (
2056                    g.about.as_str(),
2057                    g.candidates.len(),
2058                    g.rejected.len(),
2059                    g.episodes.len(),
2060                )
2061            })
2062            .collect();
2063        assert_eq!(groups, [("docs/x.md", 1, 0, 1), ("lib/a.sh", 2, 1, 2)]);
2064        let a = &q.groups[1];
2065        assert_eq!(a.rejected, ["e1-a"], "the rejection stays in the group");
2066        assert_eq!(a.episodes, ["e1", "e2"], "each episode once, in order");
2067        assert_eq!(a.route, "project");
2068        let carried = q.candidates.iter().find(|c| c.id == "e2-b").unwrap();
2069        assert_eq!(
2070            (carried.about.as_str(), carried.route.as_str()),
2071            ("lib/a.sh", "project")
2072        );
2073        let plain = q.candidates.iter().find(|c| c.id == "e1-e").unwrap();
2074        assert!(
2075            plain.about.is_empty() && plain.route.is_empty(),
2076            "nothing said, nothing carried"
2077        );
2078    }
2079
2080    #[test]
2081    fn a_candidate_is_aged_from_the_best_evidence_there_is_and_the_queue_is_held_to_its_cap() {
2082        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
2083        let root = repo.root().to_path_buf();
2084        let git = |args: &[&str]| {
2085            let ok = std::process::Command::new("git")
2086                .current_dir(&root)
2087                .args([
2088                    "-c",
2089                    "user.email=t@example.com",
2090                    "-c",
2091                    "user.name=t",
2092                    "-c",
2093                    "commit.gpgsign=false",
2094                ])
2095                .args(args)
2096                .status()
2097                .unwrap()
2098                .success();
2099            assert!(ok, "git {args:?}");
2100        };
2101        let dir = ".ai/repo/knowledge/candidates";
2102        git(&["init", "-q", "-b", "master"]);
2103        std::fs::create_dir_all(root.join(dir)).unwrap();
2104        std::fs::write(root.join(dir).join("c-committed.md"), "committed\n").unwrap();
2105        git(&["add", "-A"]);
2106        git(&["commit", "-q", "-m", "a hand-copied candidate"]);
2107        let policy = root.join(".ai/repo/policy.yaml");
2108        let text = std::fs::read_to_string(&policy).unwrap();
2109        std::fs::write(
2110            &policy,
2111            format!(
2112                "{text}session:\n  freshness:\n    fresh_minutes: 720\n    stale_minutes: 2880\nknowledge:\n  candidates_max_files: 2\n"
2113            ),
2114        )
2115        .unwrap();
2116
2117        let candidate = |id: &str, date: Option<&str>, derived_from: &[&str]| {
2118            let mut meta = json!({ "status": "candidate", "class": "fact",
2119                                   "provenance": { "derived_from": derived_from } });
2120            if let Some(d) = date {
2121                meta["date"] = json!(d);
2122            }
2123            let mut o = object(KIND, id, &format!("{dir}/{id}.md"), meta);
2124            o.provenance.source_class = CANDIDATES_CLASS.into();
2125            o
2126        };
2127        let ctx = context_with(
2128            &repo,
2129            vec![
2130                candidate("c-undated", None, &[]),
2131                candidate("c-dated", Some("2026-03-01"), &[]),
2132                candidate("c-derived", Some("2026-01-05"), &["session:e9"]),
2133                candidate("c-committed", Some("2026-02-01"), &["session:unknown"]),
2134            ],
2135        );
2136        let events = [
2137            line(
2138                "knowledge.derived",
2139                "2026-01-01T00:00:00Z",
2140                json!({ "episode": "e9", "paths": format!("{dir}/c-derived.md") }),
2141            ),
2142            line(
2143                "session.started",
2144                "2026-01-01T00:00:00Z",
2145                json!({ "session": "e9", "branch": "feature" }),
2146            ),
2147        ];
2148
2149        let q = candidates_of(&ctx, &events, NOW);
2150        let ids: Vec<&str> = q.candidates.iter().map(|c| c.id.as_str()).collect();
2151        assert_eq!(ids, ["c-committed", "c-dated", "c-derived", "c-undated"]);
2152        let by = |id: &str| q.candidates.iter().find(|c| c.id == id).unwrap();
2153
2154        assert_eq!(by("c-derived").age_source, AgeSource::Derivation);
2155        assert_eq!(by("c-derived").branch.as_deref(), Some("feature"));
2156        assert_eq!(by("c-committed").age_source, AgeSource::Commit);
2157        assert_eq!(
2158            by("c-committed").branch,
2159            None,
2160            "an episode nobody knows has no branch"
2161        );
2162        assert_eq!(by("c-dated").age_source, AgeSource::Date);
2163        assert_eq!(
2164            by("c-dated").freshness,
2165            Freshness::Stale,
2166            "{:?}",
2167            by("c-dated")
2168        );
2169        assert_eq!(by("c-undated").age_source, AgeSource::Date);
2170        assert_eq!(by("c-undated").freshness, Freshness::Unknown);
2171        assert_eq!(by("c-undated").branch, None);
2172
2173        assert_eq!(q.total, 4);
2174        assert_eq!(q.unattributed, 3);
2175        assert_eq!(q.cap, Some(2));
2176        assert!(q.over_cap);
2177        assert!(
2178            q.findings.iter().any(
2179                |f| f.starts_with("3 candidate(s) name an episode this checkout does not know")
2180            ),
2181            "{:?}",
2182            q.findings
2183        );
2184        assert!(
2185            q.findings
2186                .iter()
2187                .any(|f| f
2188                    .starts_with("4 candidates await review, over the 2 the policy calls the cap")),
2189            "{:?}",
2190            q.findings
2191        );
2192    }
2193}