Skip to content

fix(evidence): complete the fixture diagnostic contract - #811

Merged
jeremi merged 9 commits into
mainfrom
fix/evidence-fixture-diagnostics
Aug 22, 2026
Merged

fix(evidence): complete the fixture diagnostic contract#811
jeremi merged 9 commits into
mainfrom
fix/evidence-fixture-diagnostics

Conversation

@jeremi

@jeremi jeremi commented Aug 22, 2026

Copy link
Copy Markdown
Member

What this changes

Evidence fixture diagnostics currently stop at a verdict. A failing fixture
names the contract that broke and nothing else, so an adopter learns that a
case failed but never how far it got or what the run actually observed. This
completes the diagnostic contract in three parts.

A closed expected-versus-observed diagnosis per case. Each case in the
--explain --explain-format json document now carries a result drawn from a
closed set (match, no-match, ambiguous, evidence-unavailable,
source-unavailable, service-unavailable, bundle-refused,
selector-refused), a bounded classification of any matched value
(boolean-true, integer, structured, and so on), a reasonCode naming
the evaluator outcome that produced the observation, and findingCodes
naming the authored expectation that disagreed.

A case count that is always present. evaluatedCases was emitted only on
a passing run. It is now the number of cases the run got through in both
outcomes, so a failing document is distinguishable from a document produced by
a broken toolchain. Both were previously a document with no count.

Selecting one fixture or one case. evidence evaluate --case <id>, and
evidencectl fixtures run --fixture <path> [--case <id>]. A selector that
names nothing referenced by the project is refused.

evidencectl fixtures run --explain now asks evidence for the structured
form and relays it, instead of asking for the text form and relaying the
string. It totals evaluatedCases from those documents and interprets no
Evidence semantics of its own, which keeps the adopter-tooling boundary in
AGENTS.md intact.

Breaking change

evidencectl fixtures run --json emits each fixture's trace as a JSON
object rather than as a string holding the text rendering. evidencectl is
adopter tooling outside the frozen Version 1 runtime contract, and the field
only ever appears under --explain.

Security-sensitive review notes

This touches data minimization, so the trace surface is the review
subject.

  • Every field added is a shape, a count, an identifier, or a member of a
    closed enumeration. None carries a source, a selector, SQL, a credential, or
    a governed result value.
  • categoryClasses identifies an allowed result by its zero-based concept
    position in the requirement and its zero-based value position in that
    concept's captured governed codelist, and only after the output is proven to
    belong to that codelist. An arbitrary string stays the shape string. The
    trace never substitutes raw category text or a concept identifier for an
    ordinal.
  • A fixture's own diagnosticsExclude canaries are still checked against both
    rendered forms on every run, including a run that stopped on an error.
    a_failing_run_is_refused_when_its_trace_holds_a_declared_canary and
    an_explained_fixture_run_discloses_no_protected_selector_value cover this.
  • The fixture selector is operator input, so select_fixture_paths refuses an
    unknown selector without rendering the rejected value back.
    fixture_selection_refuses_non_exact_names_without_rendering_them covers
    this.
  • print_diagnostics no longer renders the relayed trace with
    unwrap_or_default. A default there printed (no output captured), which
    is a different fact from an absent trace, and an operator could not tell the
    two apart.

Owning areas

The change spans crates/, products/, and docs/site/, which the
one-owning-area rule normally discourages. Both non-crate areas are
consequences of the crate change rather than separate work:

  • docs/site/src/content/docs/reference/cli/** and
    docs/site/src/data/generated/cli-reference.json are generated outputs.
    AGENTS.md requires regenerating them with the change, not as a follow-up.
    Every reference page carries the catalog SHA-256, so adding any flag moves
    the whole catalog; all 80 pages plus cli-reference.json are regenerated by
    node scripts/generate-cli-reference.mjs, none hand-edited.
  • products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md
    is held in parity with the CLI surface by
    products/evidence/scripts/check-config-key-paths.sh.

Verification

Run on the pinned 1.95.0 toolchain that CI uses, not the host default:

Gate Result
cargo fmt --check pass
cargo check --locked --workspace --all-targets pass
cargo clippy --workspace --all-targets -- -D warnings pass
cargo test --locked --workspace pass, with one pre-existing flake noted below
products/evidence/scripts/check-contracts.sh pass
products/evidence/scripts/check-source-neutrality.sh pass
products/evidence/scripts/check-verifier-portability.sh pass
products/evidence/scripts/check-config-key-paths.sh pass
products/evidence/scripts/check-authoring-schema.sh pass
products/evidence/scripts/check-authoring-no-io.sh pass
docs/site npm run check:cli-reference pass

Three registry-mint healthcheck tests
(server::tests::healthcheck_*_over_a_live_socket) fail intermittently on a
loaded machine. They are unrelated to this change, which touches no
registry-mint source: the helper at crates/registry-mint/src/server.rs
wraps a full live-socket round trip in a hard Duration::from_secs(1) and
panics with Elapsed(()) when the budget is missed. Three consecutive runs of
cargo test --locked -p registry-mint --lib on this branch gave 148 passed,
148 passed, then 146 passed with 2 failed. Flagged rather than fixed, since it
is outside this change's scope.

jeremi added 9 commits August 22, 2026 12:51
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`fix(evidence): retain failed fixture trace count` made the JSON report fall
back to the traced case count when a run fails, but left this test asserting
the field is absent. Assert the count instead.

A consumer needs `evaluatedCases` to be an integer unconditionally. Without it
a failing fixture is indistinguishable from a broken toolchain: both produce a
document with no count.

Nine is the fixture's own number, not the failure output's. `no-match` is the
ninth case in `products/evidence/fixtures/acceptance/adult-status/fixtures/cases.yaml`,
so the mutated run reaches nine cases before it stops. The count is also
asserted against `cases.len()`, which is the invariant a reader depends on.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`print_diagnostics` rendered the relayed trace with `unwrap_or_default`, so a
rendering failure would have printed `(no output captured)` through `indented`.
That is a different fact from an absent trace, and an operator reading the
diagnostic could not tell the two apart.

The trace is parsed out of `evidence` standard output before it is stored, so
re-rendering it cannot fail. State that instead of defaulting past it.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`--case` on `evidence evaluate`, `--fixture`/`--case` on `evidencectl fixtures
run`, and the reworded `--explain` help changed the public Clap command tree.
Every reference page carries the catalog SHA-256, so the whole catalog moves
when any command does, and the committed pages still held the previous digest
and the previous `--explain` text.

Regenerated with `node scripts/generate-cli-reference.mjs` from `docs/site`.
No page is hand-edited.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi merged commit f4f9df4 into main Aug 22, 2026
37 checks passed
@jeremi
jeremi deleted the fix/evidence-fixture-diagnostics branch August 22, 2026 11:38

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9cf01e9992

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1233 to +1235
if selected_case.is_some_and(|selected| selected != id) {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope privacy assertions to the selected case

When --case selects a case that produces no signed evidence, such as no-match in the adult-status acceptance fixture, the other successful cases are skipped here but the fixture-wide privacy_expectation.evidence_contains check still runs against the now-empty successful_values. Consequently, a case whose own expected outcome passes exits with fixture required disclosure is absent; single-case runs need to avoid full-suite presence assertions or evaluate them against case-appropriate data.

Useful? React with 👍 / 👎.

Comment on lines +1059 to +1062
evaluated_cases: summary
.ok()
.map(|summary| summary.evaluated_cases)
.or_else(|| (trace.case_count() > 0).then(|| trace.case_count())),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude fixture-scope entries from evaluatedCases

When evaluation fails after all real cases have settled—for example, during the final privacy-expectation check—trace.fail appends the synthetic (fixture) entry, and this fallback counts it as an evaluated case. A selected no-match run therefore reports evaluatedCases: 2 even though only one fixture case ran; derive the failure count from real case attempts rather than the total trace-entry count.

Useful? React with 👍 / 👎.

Comment on lines +392 to +396
let trace = structured_trace(&stdout);
StepOutcome {
passed: true,
evaluated_cases: evaluated_cases(&stdout),
stdout,
evaluated_cases: structured_evaluated_cases(trace.as_ref())
.or_else(|| evaluated_cases(&stdout)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject missing structured traces when explain is requested

When an --explain evaluation exits successfully but emits malformed or incompatible JSON—for example, because --evidence-bin points to a mismatched wrapper or binary—structured_trace returns None, yet this branch still marks the fixture as passed and silently omits the requested trace. This makes a broken diagnostic toolchain indistinguishable from an unexplained successful run; explained evaluations should fail unless the required structured document and case count parse successfully.

Useful? React with 👍 / 👎.

Comment on lines +2952 to +2955
diagnose_reference_match(
trace,
expected,
context.bundle,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Diagnose entity-reference count mismatches after comparison

For a reference case with entityReferenceCount, this diagnosis runs before the actual count comparison, reducing both expected and observed values to the same List class. If the derived list contains the wrong number of entity references, the later count check fails without updating the diagnosis, so the failed case has no findingCodes entry despite an authored expectation disagreeing; record or override the finding when that count comparison fails.

Useful? React with 👍 / 👎.

Comment on lines +4087 to +4089
None if expected.contains_key("outputGate") || expected.contains_key("error") => {
ResultClass::ServiceUnavailable
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify exact reference errors by their public outcome

When a reference case states an exact error without also repeating publicProblem, this branch classifies every expectation as service-unavailable, even though validate_reference_error maps errors such as source_protocol_error to source.unavailable and derivation-input failures to evidence.unavailable. As a result, passing cases such as truncated-pager in the DHIS2 reference fixture emit a spurious public-problem-mismatch; derive the expected class from the exact error and execution context instead of assigning the service class wholesale.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant