Split out of #1118 / PR #1137, after four review rounds each catching the CLI asserting a wrong reason. Priority P2.36 — severity low, category observability.
Problem
run_proof returns dict[req_id, list[(gate, outcome)]] and nothing else. When that dict is empty, the caller cannot tell why, because the reasons are computed inside the runner and then discarded:
| cause |
where it happens |
reported? |
| ledger empty |
_requirements_for_run → [] |
no |
| all requirements excluded by status (SATISFIED on a scoped run, WAIVED always) |
runner.py:261-263 |
no |
| scope filter excluded every requirement |
runner.py:339-342, collected into scope_skipped |
computed, then only logged (runner.py:435) |
--gate excluded every obligation |
runner.py:355-356 |
no |
enabled_gates config excluded them |
runner.py:359-360 |
no |
| requirement has no obligations |
— |
no |
scope_skipped is the closest: the runner builds the list and hands it to _report_scope_skipped for logging, but never returns it.
Why it matters
#1118 made cf proof run distinguish "nothing to verify" from "verified and passed". Doing that well needs the reason, and the CLI can only guess at it. Four consecutive review rounds on #1137 caught a guess that was confidently wrong:
- claiming scope when the ledger was empty
- claiming scope in
--full mode, where scope is never evaluated
- claiming scope when the requirements were all WAIVED, so
run_proof short-circuited before scope was computed
- claiming scope when
--gate unit had excluded every obligation of an in-scope requirement
The CLI now stops asserting a cause and lists the candidates. That is honest but worse for the user than naming the actual reason.
Acceptance criteria
Notes
There are ~24 call sites of run_proof, so widening the return type is the main cost. An additive approach (a second return value, or an out-parameter object) avoids touching all of them.
Problem
run_proofreturnsdict[req_id, list[(gate, outcome)]]and nothing else. When that dict is empty, the caller cannot tell why, because the reasons are computed inside the runner and then discarded:_requirements_for_run→[]runner.py:261-263runner.py:339-342, collected intoscope_skippedrunner.py:435)--gateexcluded every obligationrunner.py:355-356enabled_gatesconfig excluded themrunner.py:359-360scope_skippedis the closest: the runner builds the list and hands it to_report_scope_skippedfor logging, but never returns it.Why it matters
#1118 made
cf proof rundistinguish "nothing to verify" from "verified and passed". Doing that well needs the reason, and the CLI can only guess at it. Four consecutive review rounds on #1137 caught a guess that was confidently wrong:--fullmode, where scope is never evaluatedrun_proofshort-circuited before scope was computed--gate unithad excluded every obligation of an in-scope requirementThe CLI now stops asserting a cause and lists the candidates. That is honest but worse for the user than naming the actual reason.
Acceptance criteria
run_proofreports why it produced no results — a structured reason alongside the results, not a log linecf proof runnames the actual reason instead of listing candidates_report_scope_skippedkeeps working, or its logging is subsumedNotes
There are ~24 call sites of
run_proof, so widening the return type is the main cost. An additive approach (a second return value, or an out-parameter object) avoids touching all of them.