Skip to main content

majordomus_cli/capability/builtin/
evidence.rs

1//! The `evidence` module: from a claim to the run that proves it, and back.
2//!
3//! `docs/CLAIMS.yaml` binds a claim to a test by path. [`crate::evidence`] binds that path
4//! to the runs actually recorded against it. These capabilities are the one declaration of
5//! that join, and every surface — the command line, the HTTP API and its OpenAPI operation,
6//! the MCP tool a client reads — is derived from it. There is no second evidence model
7//! behind any of them.
8//!
9//! # Both directions
10//!
11//! A relation that can only be walked one way is half a relation. `evidence.claim` answers
12//! "what proves this claim"; `evidence.test` answers "what does this test prove", listing
13//! every claim that names it. They read the same derivation, so the two answers cannot
14//! disagree.
15//!
16//! # Why the recorder is here and not exposed over the network
17//!
18//! `evidence.record` writes a tracked file, and this executable's MCP and HTTP surfaces are
19//! read-only — that is a property of the server, not an accident of what has been built.
20//! So it declares a command line and nothing else, which
21//! [`Visibility::Developer`](crate::capability::Visibility) is exactly the word for: offered
22//! to whoever runs the executable and to nobody over a network. Declaring it as a capability
23//! rather than as a hand-written command is what keeps it inside the registry, the command
24//! graph and the generated reference instead of in the inventory of commands nothing derives.
25//!
26//! ```
27//! use majordomus_cli::capability::builtin::evidence;
28//!
29//! let m = evidence::module();
30//! assert_eq!(m.id.as_str(), "evidence");
31//! let ids: Vec<&str> = m
32//!     .capabilities
33//!     .iter()
34//!     .map(|e| e.capability.id.as_str())
35//!     .collect();
36//! assert_eq!(
37//!     ids,
38//!     [
39//!         "evidence.report",
40//!         "evidence.claim",
41//!         "evidence.test",
42//!         "evidence.record",
43//!         "evidence.stamp"
44//!     ]
45//! );
46//!
47//! // the recorder writes a tracked file, so it is offered to whoever runs the executable
48//! // and to nobody over a network; that is what keeps the server read-only
49//! let record = m
50//!     .capabilities
51//!     .iter()
52//!     .find(|e| e.capability.id.as_str() == "evidence.record")
53//!     .unwrap();
54//! assert!(record.capability.exposure.mcp.is_none());
55//! assert!(record.capability.exposure.http.is_none());
56//! assert_eq!(
57//!     record.capability.exposure.cli.as_ref().unwrap().path,
58//!     ["evidence".to_string(), "record".to_string()]
59//! );
60//! ```
61//!
62//! # Why the stamp is offered only to whoever runs the executable
63//!
64//! `evidence.stamp` writes nothing, but it reads a file whose path its caller names (the
65//! report it digests) and measures the checkout it runs in. A network client must not choose
66//! a path this executable reads, so the stamp too declares a command line and nothing else.
67
68//! # Example
69//!
70//! The capabilities, and every projection — the command line, the HTTP operation, the MCP
71//! tool — is derived from that one declaration.
72//!
73//! ```
74//! use majordomus_cli::capability::builtin::evidence::module;
75//! let m = module();
76//! assert_eq!(m.id.as_str(), "evidence");
77//! assert_eq!(m.capabilities.len(), 5);
78//! ```
79
80use schemars::JsonSchema;
81use serde::{Deserialize, Serialize};
82
83use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
84use crate::capability::handler::{CapabilityError, Context};
85use crate::capability::model::{
86    BenchmarkPolicy, CachePolicy, CapabilityKind, CliExposure, Exposure, McpResource, Stability,
87    WaiverReason,
88};
89use crate::capability::module::ModuleDescriptor;
90use crate::evidence::{
91    self, freshness, ClaimProof, EvidenceDropped, EvidenceProducer, EvidenceProvenance,
92    EvidenceReport, EvidenceRunRecord, Execution, Ledger, LedgerTarget, Origin, ProofState,
93    RecordRequest, StampRequest, TreeState,
94};
95use crate::{capability, module};
96
97use super::{get, mcp};
98
99/// The URI under which the whole evidence report is read as an MCP resource.
100pub const EVIDENCE_URI: &str = "majordomus://evidence";
101
102// ---------------------------------------------------------------- inputs
103
104#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
105#[serde(deny_unknown_fields)]
106/// Which part of the matrix to answer for, and at which revision.
107///
108/// Every filter narrows, and they compose; saying nothing asks for the whole matrix at the
109/// working tree. A key nothing recognises is refused rather than dropped, because a
110/// misspelled filter that read as "no filter" would answer a question nobody asked and look
111/// like a clean one.
112///
113/// ```
114/// use majordomus_cli::capability::builtin::evidence::EvidenceReportInput;
115/// use majordomus_cli::evidence::{ProofState, TreeState};
116///
117/// let all: EvidenceReportInput = serde_json::from_str("{}").unwrap();
118/// assert!(all.state.is_none() && all.status.is_none() && !all.findings_only);
119/// assert!(all.presented.is_none(), "the working tree unless a commit is named");
120///
121/// let narrowed: EvidenceReportInput =
122///     serde_json::from_str(r#"{"state": "stale", "findings_only": true}"#).unwrap();
123/// assert_eq!(narrowed.state, Some(ProofState::Stale));
124/// assert!(narrowed.findings_only);
125///
126/// let at: EvidenceReportInput =
127///     serde_json::from_str(r#"{"presented": "HEAD", "presented_tree": "dirty"}"#).unwrap();
128/// assert_eq!(at.presented.as_deref(), Some("HEAD"));
129/// assert_eq!(at.presented_tree, Some(TreeState::Dirty));
130///
131/// assert!(serde_json::from_str::<EvidenceReportInput>(r#"{"states": "stale"}"#).is_err());
132/// ```
133pub struct EvidenceReportInput {
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    /// Only claims in this proof state (`proven`, `inputs_unchanged`, `stale`, `failing`,
136    /// `not_run`, `unrunnable`, `no_test`). Absent: every claim.
137    pub state: Option<ProofState>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    /// Only claims declaring this status (`guaranteed`, `advisory`, `planned`, `rejected`).
140    /// Absent: every claim.
141    pub status: Option<String>,
142    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143    /// Only the claims whose declared status the evidence does not support, with the
144    /// findings. The tallies still count the whole matrix, so a filtered answer never
145    /// misreports how much of it was examined.
146    pub findings_only: bool,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    /// Judge at this revision, as committed, rather than at the working tree: `HEAD`, or
149    /// any name of the checked-out commit. It must be the checked-out commit; the ledger is
150    /// read as that commit holds it, and a run the working ledger holds that the commit's
151    /// does not can only withhold `proven`. Absent: the working tree.
152    pub presented: Option<String>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    /// What the caller knows about the presented commit's tree (`clean`, `dirty`,
155    /// `unknown`). It can only weaken the measured state, never assert a clean tree over a
156    /// dirty checkout, and it means nothing without `presented`.
157    pub presented_tree: Option<TreeState>,
158}
159
160impl BenchmarkCases for EvidenceReportInput {
161    /// The whole matrix and the findings, at the working tree. No case fills `presented`:
162    /// the benchmark repository is not a git repository, so every revision it could name is
163    /// refused, and such a case would time the refusal and be counted as coverage of the
164    /// judgement it never reached.
165    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
166        vec![
167            NamedCase::new("all", EvidenceReportInput::default()),
168            NamedCase::new(
169                "findings",
170                EvidenceReportInput {
171                    findings_only: true,
172                    ..Default::default()
173                },
174            ),
175        ]
176    }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
180#[serde(deny_unknown_fields)]
181/// One claim of the matrix, named by the id it is declared under.
182///
183/// The id is required, and there is no spelling of this input that means "every claim" —
184/// that question belongs to `evidence.report`. An id the matrix does not declare is a
185/// not-found from the handler rather than an empty proof.
186///
187/// ```
188/// use majordomus_cli::capability::builtin::evidence::EvidenceClaimInput;
189///
190/// let input: EvidenceClaimInput =
191///     serde_json::from_str(r#"{"claim": "evidence-both-directions"}"#).unwrap();
192/// assert_eq!(input.claim, "evidence-both-directions");
193///
194/// // no id is a usage error, never a request for all of them
195/// assert!(serde_json::from_str::<EvidenceClaimInput>("{}").is_err());
196/// ```
197pub struct EvidenceClaimInput {
198    /// The claim id, as `docs/CLAIMS.yaml` spells it.
199    pub claim: String,
200}
201
202impl BenchmarkCases for EvidenceClaimInput {
203    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
204        // a claim this repository actually has, so the case measures the join rather than
205        // the refusal; a hard-coded id would be a second declaration of the matrix
206        let claim = ctx
207            .index
208            .objects
209            .iter()
210            .find(|o| o.kind == "claim")
211            .map(|o| o.identity.clone())
212            .unwrap_or_else(|| "no-claim".to_string());
213        vec![NamedCase::new("first-claim", EvidenceClaimInput { claim })]
214    }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
218#[serde(deny_unknown_fields)]
219/// One test, by its stable identity or by the path a claim names it with.
220///
221/// Both spellings reach the same test. `docs/CLAIMS.yaml` names a path, the ledger keys on
222/// an identity, and a caller holding one of them should not have to know the other; the
223/// handler resolves a path to the identity and reads the same derivation either way.
224///
225/// ```
226/// use majordomus_cli::capability::builtin::evidence::EvidenceTestInput;
227/// use majordomus_cli::evidence::{Runner, TestId};
228///
229/// let by_path: EvidenceTestInput =
230///     serde_json::from_str(r#"{"test": "test/cases/84_distribution_model.sh"}"#).unwrap();
231/// let id = TestId::of(&by_path.test).unwrap();
232/// assert_eq!(id.as_string(), "suite:84_distribution_model");
233/// assert_eq!(id.runner, Runner::Suite);
234///
235/// // an identity is not a path: `TestId::of` reads paths, and the handler parses the
236/// // `<runner>:<name>` form itself
237/// let by_id: EvidenceTestInput = serde_json::from_str(r#"{"test": "crate:why"}"#).unwrap();
238/// assert!(TestId::of(&by_id.test).is_none());
239/// ```
240pub struct EvidenceTestInput {
241    /// `suite:84_distribution_model`, `crate:why`, or the path itself
242    /// (`test/cases/84_distribution_model.sh`), which is resolved to the same identity.
243    pub test: String,
244}
245
246impl BenchmarkCases for EvidenceTestInput {
247    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
248        let test = ctx
249            .index
250            .objects
251            .iter()
252            .filter(|o| o.kind == "claim")
253            .find_map(|o| {
254                o.metadata
255                    .get("test")
256                    .and_then(|v| v.as_str())
257                    .and_then(evidence::TestId::of)
258            })
259            .map(|t| t.as_string())
260            .unwrap_or_else(|| "suite:00_yaml_flatten".to_string());
261        vec![NamedCase::new("first-test", EvidenceTestInput { test })]
262    }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
266#[serde(deny_unknown_fields)]
267/// A run to record into the ledger.
268///
269/// Paths to what the runners already wrote, not a run to perform: the recorder reads a
270/// report, carries into each result what that run measured about its own checkout, and
271/// merges it. The reports are optional and may be given together; an absent origin is
272/// `local`, and an absent ledger is the tracked one.
273///
274/// ```
275/// use majordomus_cli::capability::builtin::evidence::EvidenceRecordInput;
276///
277/// let input: EvidenceRecordInput =
278///     serde_json::from_str(r#"{"suite": "tmp/report.tsv"}"#).unwrap();
279/// assert_eq!(input.suite.as_deref(), Some("tmp/report.tsv"));
280/// assert!(input.crate_output.is_none() && input.origin.is_none());
281///
282/// // what was not given is not written back as null: the shape a client reads is the
283/// // shape it sent
284/// assert_eq!(
285///     serde_json::to_string(&input).unwrap(),
286///     r#"{"suite":"tmp/report.tsv"}"#
287/// );
288/// ```
289pub struct EvidenceRecordInput {
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    /// The runner's TSV report: `MJ_TEST_REPORT=<file> bash test/run.sh`.
292    pub suite: Option<String>,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    /// A file holding `cargo test`'s output, for the crate's own integration tests.
295    pub crate_output: Option<String>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    /// Where the run happened: `local` (the default), `ci` or `release`.
298    pub origin: Option<String>,
299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300    /// A measurement per report: the file `majordomus evidence stamp` wrote at the end of
301    /// that run, as `<file>` or `<producer>=<file>` (`suite`, `crate`, `coverage`). Without a
302    /// producer word, the producer is the file's own, else the only report given.
303    pub provenance: Vec<String>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    /// A coverage summary: what `scripts/rust-coverage --summary-json` wrote.
306    pub coverage: Option<String>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    /// The ledger to merge into: `repo` (the default, the tracked ledger) or `local` (the
309    /// ignored one under `.ai/local/`, which moves no verdict).
310    pub ledger: Option<LedgerTarget>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    /// Also write the run record to this file.
313    pub run_record: Option<String>,
314}
315
316impl BenchmarkCases for EvidenceRecordInput {
317    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
318        // nothing: the capability is waived from the benchmark because it writes a tracked
319        // file, and timing it in a loop would rewrite the repository's evidence
320        Vec::new()
321    }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325#[serde(deny_unknown_fields)]
326/// What a run asks to have measured about the checkout it leaves: its producer, its report,
327/// and its own untracked outputs, which are not a change to what it tested.
328///
329/// ```
330/// use majordomus_cli::capability::builtin::evidence::EvidenceStampInput;
331/// use majordomus_cli::evidence::EvidenceProducer;
332///
333/// let input: EvidenceStampInput =
334///     serde_json::from_str(r#"{"producer": "suite", "exclude": ["dist"]}"#).unwrap();
335/// assert_eq!(input.producer, Some(EvidenceProducer::Suite));
336/// assert_eq!(input.exclude, ["dist"]);
337/// assert_eq!(serde_json::to_string(&input).unwrap(), r#"{"producer":"suite","exclude":["dist"]}"#);
338/// ```
339pub struct EvidenceStampInput {
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    /// The producer whose run this is: `suite`, `crate` or `coverage`.
342    pub producer: Option<EvidenceProducer>,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    /// The report the run wrote; a local recording needs it named.
345    pub report: Option<String>,
346    #[serde(default, skip_serializing_if = "Vec::is_empty")]
347    /// The run's own untracked outputs, as repository paths: a file, or a directory with
348    /// everything under it. A pattern, the whole checkout or a path holding tracked files is
349    /// refused.
350    pub exclude: Vec<String>,
351}
352
353impl BenchmarkCases for EvidenceStampInput {
354    /// Nothing: the benchmark repository (`src/synthetic.rs`) is not a git work tree, so
355    /// every case would time a refusal ("there is no commit to measure") rather than a
356    /// measurement. The capability is waived as depending on an external `git`.
357    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
358        Vec::new()
359    }
360}
361
362// ---------------------------------------------------------------- outputs
363
364#[derive(Debug, Clone, Serialize, JsonSchema)]
365/// One claim with everything the repository can say about its proof, and the way back out
366/// to the test that carries it.
367///
368/// The proof is flattened in, so a client reads one claim rather than an evidence wrapper
369/// around one; the executions and the other claims the same test proves sit beside it.
370///
371/// ```
372/// use majordomus_cli::capability::builtin::evidence::ClaimEvidence;
373/// use majordomus_cli::evidence::ClaimProof;
374///
375/// let proof: ClaimProof = serde_json::from_value(serde_json::json!({
376///     "id": "evidence-both-directions",
377///     "claim": "a claim and its test answer for each other",
378///     "status": "guaranteed",
379///     "source": null,
380///     "implementation": null,
381///     "test_path": "test/cases/84_distribution_model.sh",
382///     "test": "suite:84_distribution_model",
383///     "state": "not_run",
384///     "meaning": "no run of this test has been recorded",
385///     "execution": null,
386///     "changed": [],
387///     "reproduce": "bash test/run.sh 84_distribution_model"
388/// }))
389/// .unwrap();
390///
391/// let answer = ClaimEvidence {
392///     proof,
393///     executions: Vec::new(),
394///     also_proves: vec!["evidence-record-is-not-networked".to_string()],
395/// };
396/// let json = serde_json::to_value(&answer).unwrap();
397/// assert_eq!(json["id"], "evidence-both-directions");
398/// assert!(json.get("proof").is_none(), "the claim is flattened in, not nested");
399/// assert_eq!(json["also_proves"], serde_json::json!(["evidence-record-is-not-networked"]));
400/// // a list even where the ledger keeps one: retention is a decision that may change,
401/// // and a client that read a bare object would break when it did
402/// assert_eq!(json["executions"], serde_json::json!([]));
403/// ```
404pub struct ClaimEvidence {
405    /// The claim, joined to its evidence.
406    #[serde(flatten)]
407    pub proof: ClaimProof,
408    /// Every execution the ledger holds for this claim's test — today one, the latest, and
409    /// a list because the ledger's retention is a decision that may change and a client
410    /// that read a bare object would break when it did.
411    pub executions: Vec<Execution>,
412    /// The other claims the same test proves. A test shared by twelve claims is a test
413    /// whose failure is twelve findings, and a reader of one of them should be able to see
414    /// the other eleven without searching.
415    pub also_proves: Vec<String>,
416}
417
418#[derive(Debug, Clone, Serialize, JsonSchema)]
419/// One test, and what it proves.
420///
421/// The other direction of [`ClaimEvidence`], read from the test's end: its identity, its
422/// source and whether that source is still the one that ran, the command that runs it
423/// again, and every claim that names it.
424///
425/// ```
426/// use majordomus_cli::capability::builtin::evidence::TestEvidence;
427/// use majordomus_cli::evidence::TestId;
428///
429/// let id = TestId::of("apps/majordomus-cli/tests/why.rs").unwrap();
430/// let answer = TestEvidence {
431///     test: id.as_string(),
432///     runner: id.runner,
433///     source: id.source(),
434///     present: true,
435///     reproduce: id.reproduce(),
436///     execution: None,
437///     digest_matches: None,
438///     proves: Vec::new(),
439/// };
440/// assert_eq!(answer.test, "crate:why");
441/// assert_eq!(answer.reproduce, "cargo test --test why");
442///
443/// // nothing recorded: the two provenance keys are absent rather than null, so "no
444/// // execution" cannot be read as an execution that said nothing
445/// let json = serde_json::to_value(&answer).unwrap();
446/// assert!(json.get("execution").is_none() && json.get("digest_matches").is_none());
447/// assert_eq!(json["proves"], serde_json::json!([]));
448/// ```
449pub struct TestEvidence {
450    /// The stable identity: `suite:<case>` or `crate:<binary>`.
451    pub test: String,
452    /// Which runner owns it.
453    pub runner: evidence::Runner,
454    /// Its own source, repository-relative.
455    pub source: String,
456    /// Whether that source is in this checkout.
457    pub present: bool,
458    /// The command that runs this one test.
459    pub reproduce: String,
460    /// The latest recorded execution, when there is one.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub execution: Option<Execution>,
463    /// Whether the test's source still hashes to what the execution recorded. `None` when
464    /// there is no execution, or the source is not there to hash.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub digest_matches: Option<bool>,
467    /// Every claim that names this test, with the state each is in.
468    pub proves: Vec<ClaimProof>,
469}
470
471#[derive(Debug, Clone, Serialize, JsonSchema)]
472/// What a recording did: how much of the run reached the ledger, and under what provenance.
473///
474/// The recorder decides nothing, and this report says so. A result no runner in this
475/// repository owns is named in `unknown` rather than written under a guessed identity, and
476/// a failure is recorded like a pass — the ledger holds what happened, so `passed` is a
477/// count and never a filter.
478///
479/// ```
480/// use majordomus_cli::capability::builtin::evidence::RecordReport;
481///
482/// let report = RecordReport {
483///     recorded: 3,
484///     passed: 2,
485///     commit: "06fa258913a1b2c3d4e5f60718293a4b5c6d7e8f".to_string(),
486///     working_tree: "clean".to_string(),
487///     unknown: vec!["docs/CLAIMS.yaml".to_string()],
488///     dropped: vec![],
489///     ledger: majordomus_cli::evidence::LEDGER_PATH.to_string(),
490///     run_record: serde_json::from_value(serde_json::json!({
491///         "schema": 1, "id": "local:06fa258913a1:20260926T120000Z", "origin": "local",
492///         "commit": "06fa258913a1b2c3d4e5f60718293a4b5c6d7e8f", "working_tree": "clean",
493///         "recorded_at": "2026-09-26T12:00:00Z", "ledger": "repo"
494///     }))
495///     .unwrap(),
496/// };
497/// // the failing test is one of the three recorded, not a fourth thing that was dropped
498/// assert_eq!(report.recorded - report.passed, 1);
499/// assert!(report.ledger.starts_with(".ai/repo/"));
500///
501/// let json = serde_json::to_value(&report).unwrap();
502/// assert_eq!(json["unknown"].as_array().unwrap().len(), 1);
503/// assert_eq!(json["working_tree"], "clean");
504/// // a suite-only recording listed nothing, and its document is what it always was
505/// assert!(json.get("dropped").is_none());
506/// ```
507pub struct RecordReport {
508    /// How many executions were written.
509    pub recorded: usize,
510    /// How many of them passed.
511    pub passed: usize,
512    /// The commit they were recorded against.
513    pub commit: String,
514    /// The tree's state at the time.
515    pub working_tree: String,
516    /// Results the run named that no runner in this repository owns.
517    pub unknown: Vec<String>,
518    /// What the reports held that no claim can name yet: the crate's unit-test binary, its
519    /// doctests. Listed rather than ignored, and absent from the document when empty.
520    #[serde(default, skip_serializing_if = "Vec::is_empty")]
521    pub dropped: Vec<EvidenceDropped>,
522    /// Where the ledger was written: the path of the target the recording named.
523    pub ledger: String,
524    /// The recording as a typed run.
525    pub run_record: EvidenceRunRecord,
526}
527
528// ---------------------------------------------------------------- handlers
529
530fn root_of(ctx: &Context) -> std::path::PathBuf {
531    std::path::PathBuf::from(&ctx.index.repository.root)
532}
533
534fn internal(e: crate::error::Error) -> CapabilityError {
535    CapabilityError::Internal(e.to_string())
536}
537
538/// The whole joined report, read fresh: the ledger is a file that changes outside this
539/// process, and an answer kept past a change of it would be the stale evidence this module
540/// exists to name. The executor keeps the capability's answer per checkout state only
541/// (`CachePolicy::Checkout`), which an edit of the ledger moves.
542fn full_report(ctx: &Context) -> Result<EvidenceReport, CapabilityError> {
543    let root = root_of(ctx);
544    let ledger = Ledger::load(&root).map_err(internal)?;
545    Ok(evidence::report(&ctx.index, &ledger))
546}
547
548/// The joined report at the checked-out commit, as committed: the ledger that commit holds,
549/// and the runs the working ledger holds beside it, which can only withhold `proven`.
550fn report_at_commit(
551    ctx: &Context,
552    rev: &str,
553    given: Option<TreeState>,
554) -> Result<EvidenceReport, CapabilityError> {
555    let root = root_of(ctx);
556    let presented =
557        freshness::presented_commit(&root, rev, given).map_err(CapabilityError::InvalidInput)?;
558    let commit = presented.commit().unwrap_or_default().to_string();
559    let ledger = freshness::ledger_at(&root, &commit).map_err(internal)?;
560    let held = freshness::uncommitted(&root, &ledger).map_err(internal)?;
561    Ok(evidence::report_at(&ctx.index, &ledger, &presented, &held))
562}
563
564fn report(ctx: &Context, input: EvidenceReportInput) -> Result<EvidenceReport, CapabilityError> {
565    let mut r = match (&input.presented, input.presented_tree) {
566        (Some(rev), given) => report_at_commit(ctx, rev, given)?,
567        (None, Some(_)) => {
568            return Err(CapabilityError::InvalidInput(
569                "`presented_tree` weakens the measured tree of a presented commit, and no \
570                 commit was presented: name one with `presented`"
571                    .to_string(),
572            ))
573        }
574        (None, None) => full_report(ctx)?,
575    };
576    // the tallies are taken before the filter: a filtered answer says how much of the
577    // matrix it looked at, never how much it returned
578    if input.findings_only {
579        let named: std::collections::BTreeSet<&str> =
580            r.findings.iter().map(|f| f.claim.as_str()).collect();
581        r.claims.retain(|c| named.contains(c.id.as_str()));
582    }
583    if let Some(state) = input.state {
584        r.claims.retain(|c| c.state == state);
585    }
586    if let Some(status) = &input.status {
587        r.claims.retain(|c| &c.status == status);
588    }
589    Ok(r)
590}
591
592fn claim(ctx: &Context, input: EvidenceClaimInput) -> Result<ClaimEvidence, CapabilityError> {
593    let r = full_report(ctx)?;
594    let Some(proof) = r.claims.iter().find(|c| c.id == input.claim).cloned() else {
595        return Err(CapabilityError::NotFound(format!(
596            "`{}` is not a claim of docs/CLAIMS.yaml",
597            input.claim
598        )));
599    };
600    let also_proves = proof
601        .test
602        .as_ref()
603        .map(|t| {
604            r.claims
605                .iter()
606                .filter(|c| c.test.as_ref() == Some(t) && c.id != proof.id)
607                .map(|c| c.id.clone())
608                .collect()
609        })
610        .unwrap_or_default();
611    let executions = proof.execution.clone().into_iter().collect();
612    Ok(ClaimEvidence {
613        proof,
614        executions,
615        also_proves,
616    })
617}
618
619fn test(ctx: &Context, input: EvidenceTestInput) -> Result<TestEvidence, CapabilityError> {
620    // a path and an identity both name the same test; a client that has one should not
621    // have to know the other's spelling
622    let id = evidence::TestId::of(&input.test)
623        .or_else(|| {
624            let (prefix, name) = input.test.split_once(':')?;
625            let runner = match prefix {
626                "suite" => evidence::Runner::Suite,
627                "crate" => evidence::Runner::Crate,
628                _ => return None,
629            };
630            // the same rule the path form applies: one grammar, not two that disagree
631            evidence::TestId::named(runner, name)
632        })
633        .ok_or_else(|| {
634            CapabilityError::InvalidInput(format!(
635                "`{}` names no test: give `suite:<case>`, `crate:<binary>`, or the path of a \
636                 case under test/cases/ or an integration test under apps/majordomus-cli/tests/",
637                input.test
638            ))
639        })?;
640
641    let root = root_of(ctx);
642    let r = full_report(ctx)?;
643    let key = id.as_string();
644    let proves: Vec<ClaimProof> = r
645        .claims
646        .iter()
647        .filter(|c| c.test.as_deref() == Some(key.as_str()))
648        .cloned()
649        .collect();
650    let ledger = Ledger::load(&root).map_err(internal)?;
651    let execution = ledger.latest(&key).cloned();
652    let digest_matches = execution.as_ref().and_then(|e| e.digest_matches(&root));
653    Ok(TestEvidence {
654        test: key,
655        runner: id.runner,
656        source: id.source(),
657        present: root.join(id.source()).exists(),
658        reproduce: id.reproduce(),
659        execution,
660        digest_matches,
661        proves,
662    })
663}
664
665fn record(ctx: &Context, input: EvidenceRecordInput) -> Result<RecordReport, CapabilityError> {
666    let root = root_of(ctx);
667    let origin = match input.origin.as_deref() {
668        None => Origin::Local,
669        Some(w) => Origin::parse(w).ok_or_else(|| {
670            CapabilityError::InvalidInput(format!(
671                "`{w}` is not an origin: it is `local`, `ci` or `release`"
672            ))
673        })?,
674    };
675    // A CI run names itself from its environment; a local run has no run to name, and a
676    // local recording inside a CI job's shell is still not that job's evidence.
677    let run = match origin {
678        Origin::Ci => evidence::RunRef::from_env(|k| std::env::var(k).ok()),
679        Origin::Local | Origin::Release => None,
680    };
681    let ledger = input.ledger.unwrap_or_default();
682    let given: Vec<EvidenceProducer> = [
683        (EvidenceProducer::Suite, input.suite.is_some()),
684        (EvidenceProducer::Crate, input.crate_output.is_some()),
685        (EvidenceProducer::Coverage, input.coverage.is_some()),
686    ]
687    .into_iter()
688    .filter_map(|(p, is)| is.then_some(p))
689    .collect();
690    let provenance =
691        EvidenceProvenance::resolve(&input.provenance, &given).map_err(CapabilityError::Refused)?;
692    let req = RecordRequest {
693        suite: input.suite.map(std::path::PathBuf::from),
694        crate_output: input.crate_output.map(std::path::PathBuf::from),
695        run,
696        provenance,
697        coverage: input.coverage.map(std::path::PathBuf::from),
698        ledger,
699        run_record: input.run_record.map(std::path::PathBuf::from),
700        ..RecordRequest::new(origin)
701    };
702    let got = evidence::record(&root, &req).map_err(refused_or_internal)?;
703    Ok(RecordReport {
704        recorded: got.recorded,
705        passed: got.passed,
706        commit: got.commit,
707        working_tree: got.working_tree,
708        unknown: got.unknown,
709        dropped: got.dropped,
710        ledger: ledger.path().to_string(),
711        run_record: got.run_record,
712    })
713}
714
715/// A refusal of the evidence surface is the caller's to fix; anything else is this
716/// executable's.
717fn refused_or_internal(e: crate::error::Error) -> CapabilityError {
718    match e {
719        crate::error::Error::InvalidSurface { reason, .. } => CapabilityError::Refused(reason),
720        other => internal(other),
721    }
722}
723
724fn stamp(ctx: &Context, input: EvidenceStampInput) -> Result<EvidenceProvenance, CapabilityError> {
725    let req = StampRequest {
726        producer: input.producer,
727        report: input.report.map(std::path::PathBuf::from),
728        exclude: input.exclude,
729        run: evidence::RunRef::from_env(|k| std::env::var(k).ok()),
730    };
731    evidence::stamp(&root_of(ctx), &req).map_err(refused_or_internal)
732}
733
734// ---------------------------------------------------------------- the module
735
736/// The `evidence` module: claims joined to the runs recorded against them.
737///
738/// The declaration is the whole of what the capabilities are — every projection, from
739/// the HTTP route to the MCP tool to the word a person types, is read out of what it
740/// returns. Nothing here caches: the ledger is a file that changes outside this process,
741/// and a cached answer would be exactly the stale evidence the module exists to name.
742///
743/// ```
744/// use majordomus_cli::capability::builtin::evidence::module;
745/// use majordomus_cli::capability::CachePolicy;
746///
747/// let m = module();
748/// let report = m
749///     .capabilities
750///     .iter()
751///     .find(|e| e.capability.id.as_str() == "evidence.report")
752///     .unwrap();
753/// assert_eq!(report.capability.exposure.http.as_ref().unwrap().path, "/api/v1/evidence");
754/// assert_eq!(
755///     report.capability.exposure.mcp.as_ref().unwrap().tool.as_deref(),
756///     Some("majordomus_evidence")
757/// );
758///
759/// // `evidence.test` is spelled `majordomus evidence proves`: `test` is a word the fish
760/// // completion adapter refuses, and the identity is not the command
761/// let test = m
762///     .capabilities
763///     .iter()
764///     .find(|e| e.capability.id.as_str() == "evidence.test")
765///     .unwrap();
766/// assert_eq!(test.capability.exposure.cli.as_ref().unwrap().path[1], "proves");
767///
768/// // the ledger changes outside this process: kept, if at all, per checkout state only
769/// assert!(m.capabilities.iter().all(|e| matches!(
770///     e.capability.cache,
771///     CachePolicy::Disabled | CachePolicy::Checkout { .. }
772/// )));
773/// ```
774pub fn module() -> ModuleDescriptor {
775    module! {
776        id: "evidence",
777        title: "Evidence",
778        description: "What actually ran, against which commit, and whether it still proves anything. The claims matrix binds a claim to a test by path; the ledger under .ai/repo/evidence records the latest execution of every test with the commit, the tree state, the digest of the test's own source, the time, the origin and the command that runs it again. Joining the two answers, per claim, whether the repository can honestly call it proven — and distinguishes a run recorded against this very commit from one whose inputs merely have not changed since, because collapsing those two is how a green badge stops meaning anything.",
779        stability: Stability::BehaviorallyVerified,
780        capabilities: [
781            capability! {
782                id: "evidence.report",
783                title: "Every claim against the evidence recorded for it",
784                description: "The whole claims matrix joined to the ledger and judged at the presented revision: the working tree by default, or the checked-out commit as committed, read from the ledger that commit holds. Per claim: the proof state, the sentence explaining how that state was derived, why when the state alone does not say, the execution behind it, the files that changed since it, and the command that produces it again. A run on a commit the presented revision does not contain is stale, a test that declined to run is not run, and a run the working ledger holds that the presented commit's does not can only withhold proven. The tallies count the whole matrix even when the answer is filtered, and the findings name every claim that declares a guarantee the evidence does not support. Kept per checkout state, never longer: the ledger is a file that changes outside this process, and an edit of it, of a test or of any tracked file, a new untracked file, a commit or a new ref is a new answer.",
785                input: EvidenceReportInput,
786                output: EvidenceReport,
787                stability: Stability::BehaviorallyVerified,
788                exposure: Exposure {
789                    mcp: Some(crate::capability::model::McpExposure {
790                        tool: Some("majordomus_evidence".into()),
791                        resource: Some(McpResource { uri: EVIDENCE_URI.into(), name: "evidence".into() }),
792                    }),
793                    http: get("/api/v1/evidence"),
794                    cli: Some(CliExposure { path: vec!["evidence".into(), "show".into()] }),
795                },
796                tags: ["evidence", "claims", "tests", "verification", "provenance"],
797                cache: CachePolicy::Checkout { max_entries: 8 },
798                handler: report,
799            },
800            capability! {
801                id: "evidence.claim",
802                title: "What proves this claim",
803                description: "One claim with its proof state, the execution behind it — outcome, duration, commit, tree state, digest, time and origin — the command that reproduces it, and the other claims the same test proves. A claim the matrix does not declare is a not-found rather than an empty answer, because a typo that read as 'this claim has no evidence' is the one answer this capability must never give.",
804                input: EvidenceClaimInput,
805                output: ClaimEvidence,
806                stability: Stability::BehaviorallyVerified,
807                exposure: Exposure {
808                    mcp: mcp("majordomus_evidence_claim"),
809                    http: get("/api/v1/evidence/claim"),
810                    cli: Some(CliExposure { path: vec!["evidence".into(), "claim".into()] }),
811                },
812                tags: ["evidence", "claims", "verification", "provenance"],
813                cache: CachePolicy::Checkout { max_entries: 32 },
814                handler: claim,
815            },
816            capability! {
817                id: "evidence.test",
818                title: "What this test proves",
819                description: "One test — by identity or by the path a claim names it with — with its latest execution, whether its source still hashes to what that execution recorded, the command that runs it again, and every claim that names it. This is the reverse of evidence.claim and reads the same derivation, so the two directions cannot disagree.",
820                input: EvidenceTestInput,
821                output: TestEvidence,
822                stability: Stability::BehaviorallyVerified,
823                exposure: Exposure {
824                    mcp: mcp("majordomus_evidence_test"),
825                    http: get("/api/v1/evidence/test"),
826                    cli: Some(CliExposure { path: vec!["evidence".into(), "proves".into()] }),
827                },
828                tags: ["evidence", "tests", "verification", "provenance"],
829                cache: CachePolicy::Disabled,
830                handler: test,
831            },
832            capability! {
833                id: "evidence.record",
834                kind: CapabilityKind::Command,
835                title: "Record a run that happened",
836                description: "Reads what the runs already wrote — the suite's TSV report, cargo test's output, a coverage summary — carries into each result what that run measured about its own checkout (the commit and the tree, from the measurement `evidence stamp` wrote), with the digest of the test's own source, the time and the origin, and merges it into the ledger. It records; it decides nothing: a case that failed is a case the runner said failed, and a test no report named is left exactly as it was, so recording one case never erases the evidence for the rest. It refuses a tree with no commit, a measurement of another commit or of another report, a local measurement that names no report, and a CI report without a measurement; a local report recorded without one carries the tree unknown, which never reads proven. Every recording returns a run record (its commit, the tree each report's run measured, its totals and what was absent, dropped or unknown, and a coverage summary bound to its commit), and --ledger local writes only the ignored local ledger. A crate binary that ran no test, or only a filtered subset, is recorded as a skip, and what no claim can name yet (the crate's own unit tests, its doctests) is listed as dropped.",
837                input: EvidenceRecordInput,
838                output: RecordReport,
839                stability: Stability::BehaviorallyVerified,
840                exposure: Exposure {
841                    mcp: None,
842                    http: None,
843                    cli: Some(CliExposure { path: vec!["evidence".into(), "record".into()] }),
844                },
845                tags: ["evidence", "tests", "provenance"],
846                cache: CachePolicy::Disabled,
847                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::Destructive },
848                handler: record,
849            },
850            capability! {
851                id: "evidence.stamp",
852                title: "Measure the tree a run left behind",
853                description: "The one measurement a runner takes at the end of its run, for evidence record --provenance: the commit; the tree, ignoring the evidence ledger and the run's own untracked outputs, never a tracked change; the producer; its toolchain; the recorder's version; the report's digest; the host; the CI run. It writes nothing. It is offered only on the command line because it reads a path its caller names.",
854                input: EvidenceStampInput,
855                output: EvidenceProvenance,
856                stability: Stability::BehaviorallyVerified,
857                exposure: Exposure {
858                    mcp: None,
859                    http: None,
860                    cli: Some(CliExposure { path: vec!["evidence".into(), "stamp".into()] }),
861                },
862                tags: ["evidence", "tests", "provenance"],
863                cache: CachePolicy::Disabled,
864                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::ExternalDependency },
865                handler: stamp,
866            },
867        ],
868    }
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    /// One capability's declared projections: its id, the MCP tool it answers as, the HTTP
876    /// path it is served at, and the words of its command line. Named because the tuple is
877    /// read four times below and an anonymous one of this width is a type nobody can talk
878    /// about.
879    type Projection<'a> = (&'a str, Option<&'a str>, Option<&'a str>, &'a [&'a str]);
880
881    /// The declaration is the only place these names exist; every projection derives from
882    /// it. This is the assertion a refactor that dropped an exposure would fail.
883    #[test]
884    fn the_declaration_yields_the_projections_it_claims() {
885        let m = module();
886        assert_eq!(m.id.as_str(), "evidence");
887        let expected: &[Projection<'_>] = &[
888            (
889                "evidence.report",
890                Some("majordomus_evidence"),
891                Some("/api/v1/evidence"),
892                &["evidence", "show"],
893            ),
894            (
895                "evidence.claim",
896                Some("majordomus_evidence_claim"),
897                Some("/api/v1/evidence/claim"),
898                &["evidence", "claim"],
899            ),
900            (
901                "evidence.test",
902                Some("majordomus_evidence_test"),
903                Some("/api/v1/evidence/test"),
904                &["evidence", "proves"],
905            ),
906            ("evidence.record", None, None, &["evidence", "record"]),
907            ("evidence.stamp", None, None, &["evidence", "stamp"]),
908        ];
909        let ids: Vec<&str> = m
910            .capabilities
911            .iter()
912            .map(|e| e.capability.id.as_str())
913            .collect();
914        let want: Vec<&str> = expected.iter().map(|(id, _, _, _)| *id).collect();
915        assert_eq!(ids, want);
916        for (e, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
917            let x = &e.capability.exposure;
918            assert_eq!(
919                x.mcp.as_ref().and_then(|m| m.tool.as_deref()),
920                *tool,
921                "{id}"
922            );
923            assert_eq!(x.http.as_ref().map(|h| h.path.as_str()), *path, "{id}");
924            assert_eq!(
925                x.cli.as_ref().map(|c| c.path.clone()),
926                Some(cli.iter().map(|s| s.to_string()).collect::<Vec<_>>()),
927                "{id} lost or renamed its command line"
928            );
929            // the ledger changes outside this process, so an answer is kept, if at all, only
930            // per checkout state, which an edit of the ledger moves
931            assert!(
932                matches!(
933                    e.capability.cache,
934                    CachePolicy::Disabled | CachePolicy::Checkout { .. }
935                ),
936                "{id} may cache only per checkout state: the ledger changes outside this process"
937            );
938        }
939    }
940
941    /// The recorder writes a tracked file. It must be a command, and it must be offered to
942    /// whoever runs the executable and to nobody over a network — this server is read-only,
943    /// and a write reachable over MCP or HTTP would end that.
944    #[test]
945    fn only_the_recorder_writes_and_it_is_not_reachable_over_the_network() {
946        for e in module().capabilities {
947            let id = e.capability.id.as_str().to_string();
948            let x = &e.capability.exposure;
949            if id == "evidence.record" {
950                assert!(
951                    !e.capability.kind.is_read_only(),
952                    "{id} is declared a query"
953                );
954                assert!(x.mcp.is_none(), "{id} is reachable over MCP");
955                assert!(x.http.is_none(), "{id} is reachable over HTTP");
956                assert_eq!(
957                    e.capability.visibility,
958                    crate::capability::Visibility::Developer,
959                    "{id} must be a developer capability"
960                );
961                assert!(
962                    matches!(e.capability.benchmark, BenchmarkPolicy::Waived { .. }),
963                    "{id} must not be timed in a loop: it writes a tracked file"
964                );
965            } else if id == "evidence.stamp" {
966                // a read, but of a path its caller names: a network client must not choose a
967                // file this executable reads, so it is offered only on the command line; and
968                // it is waived, because the benchmark repository is not a git work tree
969                assert!(e.capability.kind.is_read_only(), "{id} writes");
970                assert!(x.mcp.is_none(), "{id} is reachable over MCP");
971                assert!(x.http.is_none(), "{id} is reachable over HTTP");
972                assert_eq!(
973                    e.capability.visibility,
974                    crate::capability::Visibility::Developer,
975                    "{id} must be a developer capability"
976                );
977                assert!(
978                    matches!(
979                        e.capability.benchmark,
980                        BenchmarkPolicy::Waived {
981                            reason: WaiverReason::ExternalDependency
982                        }
983                    ),
984                    "{id} is waived for its external git"
985                );
986            } else {
987                assert!(e.capability.kind.is_read_only(), "{id} writes");
988                assert!(
989                    x.mcp.is_some() && x.http.is_some(),
990                    "{id} lost a projection"
991                );
992            }
993        }
994    }
995
996    /// Every read capability carries a command line, and every command line it carries is
997    /// under one word. A capability that claimed a path `cli.rs` does not declare would
998    /// fail the projection closure; this is the half that says the module meant to have one.
999    #[test]
1000    fn every_capability_is_reachable_from_the_command_line() {
1001        for e in module().capabilities {
1002            let cli = e
1003                .capability
1004                .exposure
1005                .cli
1006                .as_ref()
1007                .unwrap_or_else(|| panic!("{} has no command line", e.capability.id));
1008            assert_eq!(
1009                cli.path.first().map(String::as_str),
1010                Some("evidence"),
1011                "{} is not under `majordomus evidence`",
1012                e.capability.id
1013            );
1014            assert_eq!(cli.path.len(), 2, "{}", e.capability.id);
1015        }
1016    }
1017
1018    /// A tree state means something only about a presented commit, and a revision the
1019    /// repository cannot name is refused with its name rather than judged at something else.
1020    #[test]
1021    fn a_presented_revision_is_checked_before_anything_is_judged() {
1022        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1023        let ctx = repo.context().unwrap();
1024        match ctx.execute(
1025            "evidence.report",
1026            serde_json::json!({ "presented_tree": "dirty" }),
1027        ) {
1028            Err(CapabilityError::InvalidInput(m)) => {
1029                assert!(m.contains("presented"), "{m}")
1030            }
1031            other => panic!("a tree state with no commit was accepted: {other:?}"),
1032        }
1033        // the benchmark repository is not a git repository, so no revision resolves there
1034        match ctx.execute(
1035            "evidence.report",
1036            serde_json::json!({ "presented": "HEAD", "presented_tree": "clean" }),
1037        ) {
1038            Err(CapabilityError::InvalidInput(m)) => assert!(m.contains("HEAD"), "{m}"),
1039            other => panic!("a revision that names nothing was judged: {other:?}"),
1040        }
1041        // and the working tree is still the default
1042        let v = ctx
1043            .execute("evidence.report", serde_json::json!({}))
1044            .unwrap();
1045        assert_eq!(v["presented"]["revision"], "working_tree");
1046    }
1047
1048    /// What a crate run held that no claim can name yet reaches the recording's answer, in
1049    /// the order the output held it, and a recording with nothing dropped carries no
1050    /// `dropped` at all, so a suite-only recording answers as it did before the list existed.
1051    #[test]
1052    fn the_recording_answers_with_what_it_dropped() {
1053        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1054        let git = |args: &[&str]| {
1055            let out = std::process::Command::new("git")
1056                .arg("-C")
1057                .arg(repo.root())
1058                .args(args)
1059                .output()
1060                .unwrap();
1061            assert!(out.status.success(), "git {args:?}");
1062        };
1063        git(&["init", "-q"]);
1064        git(&["config", "user.email", "t@example.com"]);
1065        git(&["config", "user.name", "t"]);
1066        git(&["add", "-A"]);
1067        git(&["commit", "-q", "-m", "fixture"]);
1068        let ctx = repo.context().unwrap();
1069
1070        let reports = tempfile::tempdir().unwrap();
1071        let log = reports.path().join("crate.log");
1072        std::fs::write(
1073            &log,
1074            "     Running unittests src/lib.rs (target/debug/deps/majordomus_cli-1)\n\
1075             test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
1076                Doc-tests majordomus_cli\n\
1077             test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n",
1078        )
1079        .unwrap();
1080        let v = ctx
1081            .execute(
1082                "evidence.record",
1083                serde_json::json!({ "crate_output": log.to_string_lossy() }),
1084            )
1085            .unwrap();
1086        assert_eq!(v["recorded"], 0, "{v}");
1087        let dropped: Vec<(&str, &str)> = v["dropped"]
1088            .as_array()
1089            .unwrap_or_else(|| panic!("no dropped list: {v}"))
1090            .iter()
1091            .map(|d| {
1092                (
1093                    d["producer"].as_str().unwrap_or("?"),
1094                    d["what"].as_str().unwrap_or("?"),
1095                )
1096            })
1097            .collect();
1098        assert_eq!(
1099            dropped,
1100            [
1101                ("crate", "unittests src/lib.rs"),
1102                ("crate", "doc-tests majordomus_cli")
1103            ]
1104        );
1105
1106        let tsv = reports.path().join("run.tsv");
1107        std::fs::write(&tsv, "99_ghost\tok\t1\tparallel\n").unwrap();
1108        let v = ctx
1109            .execute(
1110                "evidence.record",
1111                serde_json::json!({ "suite": tsv.to_string_lossy() }),
1112            )
1113            .unwrap();
1114        assert!(v.get("dropped").is_none(), "{v}");
1115    }
1116
1117    /// Each ledger a report reads — the one a presented commit holds, the working copy
1118    /// beside it, and the working tree's own — is refused when it cannot be read, rather
1119    /// than read as a ledger that recorded nothing.
1120    #[test]
1121    fn an_unreadable_ledger_is_refused_at_every_revision() {
1122        use crate::evidence::LEDGER_PATH;
1123        let repo = crate::synthetic::SyntheticRepository::small().unwrap();
1124        let git = |args: &[&str]| {
1125            let out = std::process::Command::new("git")
1126                .arg("-C")
1127                .arg(repo.root())
1128                .args(args)
1129                .output()
1130                .unwrap();
1131            String::from_utf8_lossy(&out.stdout).trim().to_string()
1132        };
1133        git(&["init", "-q"]);
1134        git(&["config", "user.email", "t@example.com"]);
1135        git(&["config", "user.name", "t"]);
1136        git(&["add", "-A"]);
1137        git(&["commit", "-q", "-m", "fixture"]);
1138        let ctx = repo.context().unwrap();
1139        let at_head = serde_json::json!({ "presented": "HEAD" });
1140
1141        let v = ctx.execute("evidence.report", at_head.clone()).unwrap();
1142        assert_eq!(v["presented"]["revision"], git(&["rev-parse", "HEAD"]));
1143        assert_eq!(v["presented"]["tree"], "clean");
1144
1145        let refused = |input: serde_json::Value| match ctx.execute("evidence.report", input) {
1146            Err(CapabilityError::Internal(m)) => m,
1147            other => panic!("an unreadable ledger was judged past: {other:?}"),
1148        };
1149        // the working copy cannot be read: a run it may hold cannot be ruled out
1150        std::fs::create_dir_all(repo.root().join(".ai/repo/evidence")).unwrap();
1151        std::fs::write(repo.root().join(LEDGER_PATH), "not a ledger").unwrap();
1152        assert!(refused(at_head.clone()).contains("cannot be ruled out"));
1153        assert!(refused(serde_json::json!({})).contains(LEDGER_PATH));
1154        // and the ledger the commit holds cannot be read either
1155        git(&["add", "-A"]);
1156        git(&["commit", "-q", "-m", "an unreadable ledger"]);
1157        assert!(refused(at_head).contains(LEDGER_PATH));
1158    }
1159}