ADR-064: One Canonical Gate Algorithm and Exit-Decision Precedence¶
Date: 2026-08-30
Status: Accepted — substantially implemented. Stage 2 (the
--exit-code-scheme removal itself) landed 2026-09-04 — see the
"Update (2026-09-04)" note further down; the one remaining open item is
the unrelated typed-request gate.* pack field (--pack stays CLI-only,
ADR-049 D8). ExitDecision's three-axis
core (compatibility gate, contract coverage, analysis assurance) shipped
additively as PR G1 (#789, abicheck/policy/exit_decision.py) before this
ADR was written. Of this ADR's own two-stage plan (see "Staged landing,
additive first" below), stage 1a landed complete:
resolve_scan_exit_decision/resolve_release_exit_decision
(abicheck/policy/exit_decision_precedence.py) are pure functions
reproducing the remaining axes' full precedence (evidence-contract error,
budget overflow, not-comparable, the mode-dependent
removed-required-library rank, and a release's independent operational-error
axis), unit-tested against the real code they model. Stage 1b landed
partially: ExitDecision.to_dict now serializes all five ADR-064 fields
(report schema 2.47/1.22), scan's NOT_COMPARABLE outcome persists a real
diff.exit block, and the release fan-out's JSON summary gains an exit
block reproducing _exit_compare_release's own precedence — verified,
never assumed, to always agree numerically with that (deliberately
untouched) function's real, independently-tested output (see "Stage 1b,
further split" below for exactly what landed and why the numbers can never
diverge). Update (2026-08-31): the programmatic ScanResult API's own
_BudgetOverflow/_EvidenceContractError catches
(service_scan.run_scan/_run_scan_one_member) now also persist a real
ExitDecision into ScanResult.report["exit"]
(abicheck.workflows.scan_abort_result.scan_abort_result_fields — a
workflows-classified module, not policy, since shaping ScanResult's
own fields is report-shape work abicheck/policy/AGENTS.md reserves for a
different layer than the gate decision itself, which still resolves through
abicheck.policy.exit_decision_precedence.resolve_scan_exit_decision
unchanged; Codex review, PR #967),
closing that half of the gap this section used to describe as fully open —
these two abort exceptions previously left report at its default empty
dict, unlike NOT_COMPARABLE, which already built one. Prior contributions
across a late _BudgetOverflow (the post-compare deadline check, which
can fire after a real gate/coverage/assurance decision already exists) are
preserved too (in both the baseline-compare and audit-only branches), via
_BudgetOverflow.prior_decision/abicheck.workflows.scan_abort_result.
attach_prior_on_budget_overflow/audit_prior_decision, rather than
discarded in favor of a budget-only decision. Landed (2026-08-31): the
native scan CLI's own equivalent. cli_scan.py's two abort catches now
call the new _emit_scan_abort_report helper — but only for --format
json (or a --write json=... secondary output); before this, such an
invocation that hit either abort produced empty stdout/no secondary file,
so a consumer trying to parse it was already broken, and adding real
content on that path changes no exit code and adds no output where any
consumer could have depended on emptiness of a working JSON path. The
payload is a minimal ScanOutcome.to_dict()-compatible envelope
(top-level verdict/exit_code, the exit decision under diff.exit) —
deliberately not scan_abort_result_fields(...)["report"]'s own shape,
which is the typed API's ScanResult.report nesting, a different
envelope; workflows/aggregate/gate.py's GateInfo.from_scan_report
requires a top-level exit_code and would raise _MalformedGate without
one (Codex review, fresh evidence) — an earlier revision of this fix used
that wrong shape before the gap was caught. --format text is deliberately
unchanged: bo.message/ce.message already read as the human-facing
explanation, and there is no ScanOutcome to feed _render_text at this
point (most of its fields were never computed) — inventing prose for that
gap remains a separate, open question this update does not attempt. Update
(2026-09-01): the first slice of the "full cross-front-end parity pass"
this section names as still open has landed — the composite Action's own
scan verdict mapping (action/run.sh) previously folded an
_EvidenceContractError abort into the generic ERROR bucket a CLI usage
error gets, since both produce the identical Error: ... stderr shape; it
now recognizes the native CLI's own verdict: "EVIDENCE_CONTRACT_ERROR"
JSON envelope and publishes a matching, distinguishable verdict (see
"Staged landing, additive first" below, item 1's own end-of-list "Update"
for the full account). Update (2026-09-02): the effective-format-override
gap that same parity pass's review rounds found — extra-args: --format
json overriding a format: text/markdown step's nominal format, which
action/run.sh's JSON-detection sites (_STDOUT_JSON_FILE,
_json_report_src's OUTPUT_FILE branch) previously missed — is fixed; see
"Staged landing, additive first" below, item 1's own end-of-list note.
Landed (2026-09-02): the release fan-out's GateOptions unification.
See "Staged landing, additive first" below, item 1's own final update for
the account — it landed as additive stage-1b wiring, not atomic-stage work
as this section originally assumed (see "Consequences" below for the
correction). Update (2026-09-03): the single-binary half of the
--format text gap closed — cli_scan.py's _EvidenceContractError catch
site now always prints a stable stderr marker line, and action/run.sh's
_evidence_contract_gated() falls back to matching it when no JSON report
exists (see "Staged landing, additive first" below, item 1's own newest
update for the full account, including why _BudgetOverflow never needed
this — its exit code is already unambiguous). Update (2026-09-03, second
round): that stderr-marker channel turned out to be unsound, not just
under-anchored — two Codex review rounds on the landing PR (#1032) found it
forgeable regardless of anchoring (a whole-line grep -Fxq still loses to a
crafted INPUT_NEW_LIBRARY path containing an embedded newline, echoed
verbatim into a wholly unrelated "Failed to load --binary" error). Fixed by
moving the signal off stderr entirely onto a private marker file
action/run.sh creates and names itself
($ABICHECK_EVIDENCE_CONTRACT_MARKER_FILE, never from any INPUT_* value)
— see cli_scan_helpers.write_evidence_contract_marker's own docstring (as
it stood before round 4, below) for the full account of both rounds. The
stderr marker line is still printed for human debugging but is no longer
consulted for classification. Update (2026-09-03, third round): that
marker-file path itself leaked — passed as an inherited environment
variable, it stayed visible to every subprocess abicheck spawns during
evidence collection (build-tool queries over the analyzed checkout), so
a PR-controlled build script could read it and forge the marker directly.
Fixed by popping the variable out of this process's own os.environ at
cli_scan_helpers' import time — before scan's own body, and so before
any subprocess of this run, ever executes — rather than reading it live at
exception time. Update (2026-09-03): the typed-API half of the parity pass closed.
CompareRequest/ScanRequest gained severity_preset/exit_code_scheme
fields, resolved through the identical abicheck.policy.
release_gate_options.GateOptions object (resolve_release_gate_options(
None, ...)) the release fan-out already resolves its own gate
configuration from — not a second, parallel resolution — so a typed
compare/scan caller now reaches the same severity-aware exit-code scheme
--severity-preset/--exit-code-scheme already give the native CLI.
CompareResult gained exit_decision (the canonical ExitDecision, same
resolver the compare CLI's own report exit block uses). Neither typed
request gained per-category severity_<category> fields — investigated and
deliberately not added, since neither compare nor scan --against itself
exposes them as CLI flags (only the directory/package release fan-out does,
and only .abicheck.yml for the other two, which a typed caller has no
equivalent of); adding them would have been new surface beyond CLI parity,
not parity itself. See cli-cleanup-phase-two.md's "PR 4" section, own
2026-09-03 update, for the full account (that plan record is retired —
see docs/contribute/plans/index.md — this ADR's own text is the
surviving summary), including the new
abicheck/workflows/scan_gate_options.py leaf module and the
api_types.py debt-baseline move this needed.
Update (2026-09-03, fourth round — the single-binary --format text gap
closed for good): a fourth Codex review round on the same PR found the
marker-file design (round 2/3) unsound at its root, not merely
under-hardened — /proc/<pid>/environ reflects a process's initial
environment block regardless of any later os.environ/unsetenv
mutation, so even popping the variable at import time (round 3) left it
recoverable by a child process reading its own ancestor's /proc entry.
No channel derived from text (stderr) or from the environment can close
this class; the fix replaces the whole marker mechanism with a dedicated
process exit code (cli_scan.py's _EXIT_EVIDENCE_CONTRACT_ERROR = 7,
workflows/scan_abort_result.py's _SCAN_ABORT_VERDICTS and
policy/exit_decision_precedence.py's resolve_scan_exit_decision
default updated to match) — this process's own choice, made once at its
own sys.exit() call and reported to its trusted parent shell by the OS
kernel via wait(), which no subprocess this run spawns can alter.
action/run.sh's _evidence_contract_gated() helper and the whole
marker-file apparatus (write_evidence_contract_marker,
$ABICHECK_EVIDENCE_CONTRACT_MARKER_FILE) are deleted outright — the
case $ABICHECK_EXIT in ... 7) ... dispatch needs no predicate, no JSON
report, and no stderr/environment signal at all. This closes the
single-binary half of the --format text gap completely (not just
hardens it further); the --artifact-set member-level signal (below)
remains a separate, open question — the case-code approach may or may not
generalize to it, and that design question has not been attempted here.
Update (2026-09-04): both remaining open items closed — stage 2 landed.
The --artifact-set member-level evidence-contract signal question (left
open above as "may or may not generalize") was re-investigated against the
current code rather than assumed: run_scan_set already unconditionally
rejects --severity-preset/--exit-code-scheme for every set input
(_reject_comparison_only_fields), so the severity-scheme meaning of exit
1 never applied to --artifact-set in the first place, and the only
two producers of exit 1 on that path were the evidence-contract-error
floor and the sibling BUNDLE_INCOMPLETE floor — so the dedicated 7
(the same code the single-binary path above already uses) generalizes
cleanly with no new signal needed:
service_scan._aggregate_scan_set_verdict now returns ("EVIDENCE_CONTRACT_ERROR",
7) for a set member's abort the same way the single-binary path does, and
action/run.sh's existing case $ABICHECK_EXIT in ... 7) ... arm now
prints a set-aware message. See docs/reference/exit-codes.md's
scan --artifact-set callout and cli-cleanup-phase-two.md's own
2026-09-04 update (retired plan record) for the full account.
Stage 2 itself — the --exit-code-scheme removal — also landed
(2026-09-04), per ADR-064's own design below: --exit-code-scheme,
.abicheck.yml's top-level exit_code_scheme: key, the kind: gate pack
schema's gate.exit_code_scheme assignable field, and
CompareRequest/ScanRequest's exit_code_scheme typed-API fields are all
deleted outright — there is no manual override anywhere any more. The
already-resolved, purely-derived value (ResolvedCompareConfig.
exit_code_scheme, GateOptions.exit_code_scheme, the JSON report's
gate.exit_code_scheme/effective_config_fields["gate.exit_code_scheme"],
and the unrelated, still-live scoped_exit_code_scheme result field) keeps
its name and shape everywhere it already existed as an output — only its
settability was removed, exactly this section's "Deleting the selector...
Decision" below describes. See cli-cleanup-phase-two.md's "PR 4/PR G2"
section (retired plan record) for the file-by-file account.
Still open: a typed request's own gate.* pack field (--pack stays a
CLI-only selector, ADR-049 D8) — unrelated to either item above, and not
attempted here. See cli-cleanup-phase-two.md's "PR 4 — one gate
algorithm" section (retired plan record), which this ADR formalizes
rather than restates.
Decision maker: Nikolay Petrov
Context¶
--exit-code-scheme auto|legacy|severity (compare, scan --against) is
not a spelling choice between equivalent renderings of the same result — it
selects between two different gate algorithms that can disagree on the
same comparison: a compatibility-based one, deriving 0/2/4 straight from
the NO_CHANGE/COMPATIBLE/RISK vs. API_BREAK vs. BREAKING verdict,
and a severity-based one, deriving 0/1/2/4 from which severity category
(addition/quality, potential-breaking, ABI-breaking) actually carries an
error-level finding under the configured policy (see
cli-cleanup-phase-two.md's "PR 4" table, retired plan record,
for the full side-by-side). A compatible addition can block CI under one
severity policy can demote a real ABI break to 0. auto already picks
severity-based whenever a severity policy is actually configured and falls
back to compatibility-based otherwise — the manual legacy/severity
spellings exist only to override that inference, and CLI cleanup phase
two's broader review found no first-party caller, doc, or Action recipe
that ever needs to. Deleting the selector without a documented successor
algorithm would silently change users' CI outcomes on the next release,
which is why the plan gates the removal behind its own ADR rather than
folding it into a routine mechanical cleanup PR.
Two more forces make this larger than a two-value enum:
- The axes multiplied since the flag was designed. Contract coverage
(ADR-049 Phase 7) and analysis assurance (P0.4) each add their own
orthogonal
1, folded withmax().scanadds an evidence-contract-error floor (1) and a budget-overflow floor (5), and neither precedes the gate at one fixed point — the evidence-contract-error check (scan_engine.py's_check_scan_evidence_contract) always precedes the baseline compare (and therefore its severity computation) entirely, but--budgetis deadline-guarded at two separate points: candidate- snapshot collection, which precedes the evidence-contract check too (correcting a fresh review finding against an earlier draft of this section, which had claimed budget overflow always precedes the gate the same way evidence-contract error does), and the baseline compare's own deadline plus the final, unconditional post-comparison check — both of which run after severity computation, discarding whatever it decided rather than preceding it. See "Budget exceeded is not one precedence slot" under Decision below for the exact line references. A release comparison adds a removed-required-library code (8) whose precedence relative to the gate is mode-dependent, not a fixed rank (docs/reference/exit-codes.md,abicheck compare(multi-library) section).NOT_COMPARABLE(16for nativecompare,6forscan --against,9forcompat check) dominates the release's gate/removed-library pair in both modes, but does not dominatescan's own budget overflow. - A flat
max()over "the number" cannot explain a tie. A caller reading a bare exit1cannot tell whether it came from an error-level addition, an incomplete contract-coverage domain, or an incomplete analysis-assurance requirement without re-deriving the answer from several separately-read report fields.
PR G1 already landed the additive, lower-risk half of the fix: a canonical
ExitDecision/resolve_exit_decision (abicheck/policy/exit_decision.py)
that wraps today's three-axis fold (compatibility gate or scoped gate,
contract coverage, analysis assurance, plus scan's crosscheck-promotion
axis) into one explainable object, bit-for-bit preserving every existing
call site's returned code. Its own module docstring is explicit that the
three remaining axes — not_comparable, scan's budget/evidence-contract
floors, and the release's mode-dependent removed-library rank — are "real,
further work for PR G2, not attempted here — extending this module before
that design is settled would risk exactly the kind of partially-verified,
cross-cutting change this codebase's own conventions warn against." This
ADR is that settled design.
Decision¶
Remove the manual algorithm selector. Keep both gate algorithms. Make
today's auto inference the only behaviour, expressed as one canonical
precedence order every command's ExitDecision resolution reproduces, with
the axes that don't apply to a given command simply absent rather than
special-cased:
usage/config error (outside the report entirely — 64 everywhere)
scan budget exceeded (scan only, exit 5 — ONLY the candidate-
(candidate-collection stage) snapshot-collection deadline, scan_engine.py
:1180-1221; this specific stage runs BEFORE
the evidence-contract check below, so an
overflow here preempts it — see "Budget
exceeded is not one precedence slot" below)
scan evidence-contract error (scan only, exit 7 for a single ARTIFACT —
ADR-037 D5, cli_scan.py's own dedicated
_EXIT_EVIDENCE_CONTRACT_ERROR, 2026-09-03
fourth round; still exit 1 for an
--artifact-set member's abort, since that
path never reaches cli_scan.py's
single-binary catch site at all — see the
"deliberately not attempted in this slice"
note further down)
scan budget exceeded (scan only, exit 5 — the baseline-compare
(later stages) deadline or the final, unconditional check;
both run only once the evidence-contract
check above has already passed, and this
axis dominates not-comparable below when
both would apply in the same run)
not comparable (dominates the removed-library/gate pair
below, but never dominates either budget
slot above — ADR-050 D2)
removed required library ─┐ mode-dependent rank, not a fixed slot — see
ABI / API / policy gate ─┘ "Removed-required-library is mode-dependent"
coverage & assurance floors (max-folded on top; never lowers the above)
clean
Budget exceeded is not one precedence slot — it is two, and a resolver
that treats it as one gets the wrong answer for a real, reachable case
(Codex review, fresh evidence, against the real line order in
scan_engine.py). run_scan_core's deadline-guarded candidate-snapshot
collection (scan_engine.py:1180-1221) raises _BudgetOverflow — and,
critically, this runs before _check_scan_evidence_contract
(scan_engine.py:1229) is even called. A pinned deep scan that both lacks
the source evidence its depth requires and overruns the budget while
still building the candidate snapshot never reaches the evidence-contract
check at all — the real outcome is exit 5, not exit 1. Only the later
budget checks (the baseline compare's own deadline scope, and the final,
unconditional _check_scan_budget call after the comparison completes)
run after the evidence-contract check has already had its chance to fire,
and it is only against those that evidence-contract error legitimately
wins. (_EvidenceContractError also has a second, earlier raise site —
scan_engine.py:852's abi3 precondition check inside _run_abi3_audit,
which still runs after the candidate-collection budget guard — mapping to
the same evidence_contract_error=True input as the :1229 site,
consistent with this precedence; named here so the two raise sites are not
mistaken for one.) abicheck/policy/exit_decision_precedence.py's
resolve_scan_exit_decision models this as two separate boolean inputs
(budget_overflow_before_evidence_check and budget_overflow) rather
than one, precisely so a future caller cannot collapse them back into a
single, incorrectly-ordered axis.
auto's existing inference rule is the policy, restated, not changed:
a severity preset, an explicit --severity-* flag, a .abicheck.yml
severity: block, or a kind: gate pack in effect selects the
severity-based gate; otherwise the compatibility-verdict-based gate applies.
The user configures policy (do they have a severity map or not); they no
longer choose an implementation of how policy is scored.
Removed-required-library is mode-dependent, not a fixed precedence slot¶
Today's contract (docs/reference/exit-codes.md's release table,
tests/test_compare_release.py::test_removed_and_breaking_exits_4_not_8)
already encodes a real behavioural switch that ExitDecision's resolver
must reproduce exactly, not collapse into one row:
- Legacy scheme (the resolved scheme is compatibility-based for this
run — either no severity map is in effect, or, until stage 2 removes it,
--exit-code-scheme legacywas explicitly forced despite one): an ABI/API break or an operationalERRORwins; removed-library (8) is checked only when neither applies. - Severity-aware scheme (the resolved scheme is severity-based —
a severity map is in effect and nothing forced the other way):
removed-library (
8) takes precedence over the aggregated0/1/2/4.
An earlier draft of the plan this ADR formalizes gave removed-library a
fixed rank; a review round against scan_engine.py/cli_compare_release*.py
corrected it. Encoding the wrong fixed rank here would silently flip CI
outcomes for every release comparison that removes a library while also
carrying a lower-severity break — exactly the class of change this ADR
exists to make an explicit, reviewed decision about rather than a side
effect of a refactor.
Numbers are not unified across commands — only the precedence is¶
ExitDecision unifies which reason wins, never the numeric code a
command emits for that reason. Every command keeps its own, already-documented
exit-code scheme: NOT_COMPARABLE is 16 for native compare, 6 for
scan --against, 9 for compat check — three different numbers for the
identical reason today, and this ADR does not renumber any of them. A
resolver that emitted one global number per reason would silently break
every script and CI Action that recognises scan's 6, while this ADR is
scoped to removing the algorithm selector, not to a command-numbering
migration. Concretely: resolve_exit_decision/its PR G2 extension answers
"which axis determined this outcome" as an ExitReason; each command's own,
already-existing code table maps that reason to its own number.
docs/reference/exit-codes.md becomes a rendering of this resolver plus
each command's mapping, not a second, independently-hand-kept table.
GateOptions — the release fan-out's own prerequisite rewrite¶
The directory/package release fan-out still threads six raw
preset/category/scheme strings through four functions
(_resolve_release_severity_config, _compute_release_severity_exit_code,
_fold_release_global_severity, and the per-library JSON write) instead of
building one typed object the way compare/scan already share via
ResolvedCompareConfig (CLI cleanup phase two's PR B, finalized
2026-08-28). Folding that rewrite into PR G2 — rather than attempting it as
a standalone PR B follow-up — was a deliberate scope decision recorded in
the plan doc: it touches the identical exit-code-computation logic this
ADR's ExitDecision unification is already rewriting, and building it
ahead of this ADR risked colliding with a design that did not yet exist.
Staged landing, additive first¶
Consistent with PR G1's own precedent and this codebase's "fix the cause, generalize the test, land additively where possible" convention, PR G2 lands in two stages rather than one atomic change:
- Additive, itself two independently-landable sub-steps — no flag is removed in either:
- 1a — pure resolvers. Extend
ExitDecision/resolve_exit_decisionto compute the remaining axes (evidence-contract error, budget overflow, not-comparable, removed-required-library's mode-dependent rank, and a release's independent operational-error axis) as pure, independently unit-tested logic — verified against the real code they reproduce, but not yet called from it. -
1b — wiring. Call those resolvers from
scan_engine.py/cli_compare_release_helpers.pyand persist the result intoscan's and the release fan-out's own reportexitblock for explanatory purposes — every existing call site's actually returned exit code stays bit-for-bit unchanged, exactly as PR G1 did for the first three axes. Landed partially:ExitDecision.to_dictnow serializes all five ADR-064 fields (report schema 2.47/1.22, bothcompareandscan);scan'sNOT_COMPARABLEoutcome (ProfileMismatchError/ScopeMismatchError) persists a realdiff.exitblock viaresolve_scan_exit_decision, since that outcome already builds and emits a report today; and the release fan-out's JSON summary gains an unconditionalexitblock (resolve_release_exit_decision_for_report,abicheck/policy/exit_decision_precedence.py) reproducing_exit_compare_release's own precedence, including the legacy-scheme aggregation gap this section used to describe as open (a_compute_release_legacy_exit_codehelper, the "worst verdict among non-ERROR/non-not_comparablelibraries" this paragraph called for) — but landed as a separate, report-only resolver rather than a rewrite of_exit_compare_releaseitself, since that function's exact signature and numeric outputs are pinned directly bytests/test_exit_code_integrity.py, which CI gates depend on; rewriting it in place to delegate to the new resolver risked exactly the kind of silent exit-code regression this ADR exists to prevent for a function with that much test weight resting on its current shape. The two are proven, not merely assumed, to always agree numerically (tests/test_exit_code_integrity.py'sTestReleaseExitDecisionForReportAgreesWithRealExit— every legacy-scheme code the new resolver can produce caps at the same4the real function's own operational-"ERROR"floor does, so the two cannot diverge oncode, only on whichreasons/contributions a report reader sees). Landed (2026-08-31), typed-API half: the programmaticScanResultAPI's own_BudgetOverflow/_EvidenceContractErrorcatches (service_scan.run_scan/_run_scan_one_member) now persist a realExitDecisionintoScanResult.report["exit"](abicheck.workflows.scan_abort_result.scan_abort_result_fields,tests/test_scan_abort_result.py) —ScanResultalready existed as a real return value at these two abort points (it is whatrun_scan's docstring calls "the single object the CLI and library callers consume"), so giving its already-present, always-emptyreportfield real content needed no new design decision, only the same wiringNOT_COMPARABLEalready got. The shaping logic (the verdict/exit_code pairing and the{"exit": ...}wrapping) lives in this newworkflowsmodule rather than thepolicypackage that resolves the underlyingExitDecision—abicheck/policy/AGENTS.mdreserves "how is it reported" for a different layer, and an earlier revision had put it inexit_decision_precedence.pyitself before a review round caught the boundary violation (PR #967).SCAN_SCHEMA_VERSIONbumped to1.23for the newly nonemptyreport.exitshape. Landed (2026-08-31), prior-decision follow-up: carrying aprior_decisionacrossscan_engine.py's own later_BudgetOverflowraise site (the post-compare deadline check, which runs after a real gate/coverage/assurance decision already exists) —_BudgetOverflownow carries aprior_decision: dict[str, object] | Noneattribute, set byabicheck.workflows.scan_abort_result. attach_prior_on_budget_overflow(a context manager wrapping that one call site, catching viahasattrduck typing rather than importing the private exception class into the unclassifiedscan_engine.py);service_scan.py's two catch sites forwardexc.prior_decisionthrough toscan_abort_result_fields, which reconstructs it viaExitDecision.from_dictbefore handing it toresolve_scan_exit_ decision's ownprior_decisionparameter (tests/ test_scan_abort_result.py::TestAttachPriorOnBudgetOverflow). Landed (2026-08-31), native-CLI half:cli_scan.py'sscan_cmdcallsrun_scan_coredirectly (not throughservice_scan.run_scan), and used to only write a stderr message plussys.exit/ClickExceptionat these two abort points — noScanOutcome/report was ever constructed on this path, unlikeNOT_COMPARABLE, which the CLI's own code path already built one for. The open design question was whether a machine-readable--format jsonscan invocation should get a minimal JSON report on this abort path too, instead of empty stdout, and from what partial state (most ofScanOutcome's fields are never computed at the earliest, candidate-collection-stage budget overflow). Resolved narrowly rather than by constructing a partialScanOutcome: a new_emit_scan_abort_reporthelper prints a minimalScanOutcome.to_dict()-compatible envelope (top-levelverdict/exit_code/scan_schema_version, the exit decision nested underdiff.exit, matching whereNOT_COMPARABLE/a baseline compare already publish theirs) — but only whenfmt == "json"; a--format jsoninvocation on this path previously produced empty stdout, which was already unusable to any consumer parsing it as JSON, so this adds content only where none existed and changes neither exit code (tests/test_cli_scan_abort_report.py).--format textis unchanged:bo.message/ce.messagealready read as the human-facing explanation, and inventing prose to fillScanOutcome's missing fields for a text rendering remains a separate, unaddressed question. Landed (2026-08-31), four follow-up fixes found by review on the slices above: (1) the audit path (run_scan_core's no-baseline branch) had the same late-budget- overflow gap the baseline-compare branch's own fix closed —_audit_exit_codenever built adiff_summary, so a late overflow in audit mode had nothing to preserve either._audit_exit_codenow returns a third element,abicheck.workflows.scan_abort_result. audit_prior_decision's{"exit": ...}shape built from the same compatibility/crosscheck contributions it already computes, fed toattach_prior_on_budget_overflowviadiff_summary or audit_prior— without changing audit mode's own (non-aborting) report, which still carriesdiff: null(cli_scan_helpers.py's text renderer keys off exactly that presence/absence, so populating it unconditionally would have been a real regression, not merely a schema-version bump). (2)cli_scan._emit_scan_abort_reportonly wrote to the primary--format/--output; the documented--format text --write json=...combination (the GitHub Action's own text-primary/JSON- secondary pattern) silently produced no secondary artifact on abort. It now also writes tosecondary_outputwheneversecondary_fmt == "json", independent of the primary format (tests/ test_scan_abort_result.py::TestAuditPriorDecision,tests/ test_cli_scan_abort_report.py's secondary-output tests). (3) The first cut of_emit_scan_abort_reportreusedscan_abort_result_fields(...)["report"]directly — the typed API'sScanResult.reportnesting,{scan_schema_version, exit}with no top-levelverdict/exit_code— which is a different envelope from the CLI's ownScanOutcome.to_dict()contract. A saved--format jsonabort report fed toworkflows/aggregate/gate.py'sGateInfo.from_scan_report(which requires a top-levelexit_code) would have raised_MalformedGaterather than reading the budget/ evidence decision it carries (Codex review, fresh evidence). Fixed by building the envelope-compatible payload described above instead (TestAbortPayloadIsAggregateCompatibleintests/ test_cli_scan_abort_report.py, exercisingGateInfo.from_scan_reportandworkflows/aggregate/load.parse_report_verdictdirectly against a real abort payload). (4) That fix alone was still not enough for the realaggregatepipeline:workflows/aggregate/load._load_report_fileonly callsGateInfo.from_scan_reportafterparse_report_verdictsucceeds, and neither"BUDGET_OVERFLOW"nor"EVIDENCE_CONTRACT_ERROR"is aVerdictenum member, so the abort still read as an unavailable/verdictless report a warn/optional/ discovered-target policy could silently tolerate, exactly the "unmodeled" gap the review caught by exercising_load_report_fileitself rather than its two callees in isolation. Fixed the same way_load_report_filealready handles a compare-release operational"ERROR"verdict and a nativenot_comparableresult: two new sentinels (_SCAN_BUDGET_OVERFLOW_VERDICT/_SCAN_EVIDENCE_CONTRACT_ERROR_VERDICTinworkflows/aggregate/ contracts.py) force a blockingGateInfobefore the generic verdict-parsing branch, the same "real failure, never silently tolerated" treatment_OPERATIONAL_ERROR_VERDICTalready gets — unlike_BOOTSTRAP_VERDICT/_NEW_TARGET_VERDICT, which are legitimately-tolerated fall-throughs. A same-day follow-up caught this forced gate's ownexit_code: the first cut hardcoded scan's raw private code (5 for budget overflow) straight into the forcedGateInfo, bypassingGateInfo.from_scan_report's own normalization (every scan exit outside{0, 2, 4}folds to1,COVERAGE_INCOMPLETE_EXIT) — the aggregate's own published contract has no exit 5, so this leaked scan's numbering intoAggregateResult.exit_code(Codex review, fresh evidence: a legacy scan payload with the same verdict already correctly returned 1, while the new sentinel branch returned 5 for the identical failure). Fixed by usingCOVERAGE_INCOMPLETE_EXITfor both abort verdicts' gateexit_code(stillblocking_categories=("budget_overflow",)/("evidence_contract_error",)), matchingGateInfo.from_scan_report's own rule exactly. Verified against the real end-to-end path this time, not just the two readers:tests/test_aggregate_migration_ coverage.pyexercises_load_report_filedirectly, andtests/ test_cli_scan_abort_report.py::TestAbortPayloadThroughRealAggregateruns a realscan --format jsonabort through the realaggregate_reports_dir. (5) A further review round caught the forced gate itself inventing a compatibility verdict: settingcompatibility_verdict=Verdict.BREAKINGfor the forced abort gate (mirroring_OPERATIONAL_ERROR_VERDICT) madeAggregateResult. to_dict()reportcompatibility.verdict: "BREAKING", a completeanalyzed_targetscount, and an affected profile for a scan that never actually compared anything (Codex review, fresh evidence). Fixed by keeping the targetcompatibility_verdict=None(unavailable) for a scan abort specifically, while its forced gate still counts towardAggregateResult.exit_code()/blocking_targetsregardless of required/optional declaration via a newAggregateResult. _forced_gate_targetsfold — the unavailable-but-gated shapeoperational_error/not_comparabledon't need, since those keep the syntheticBREAKINGverdict this fix removes only for scan aborts. (6) A sixth round caught the sticky PR comment reading the same abort envelope as a clean, zero-findings comparison:pr_comment_scan.from_scanonly special-casedNOT_COMPARABLE's{"reason": ...}shape, so the abort envelope's emptyfindings/additions/qualitybuckets rendered "No ABI changes" — under--on=changesthis could delete a prior sticky failure comment (Codex review, fresh evidence). Fixed via a newpr_comment_scan_abort.scan_abort_incomplete_reasonhelper (split into its own leaf module for the same no-growth-budget reason as the scan-engine helpers above), giving the abort the identical single blocking "analysis incomplete"FindingtreatmentNOT_COMPARABLEalready gets. (7) A seventh round caught the aggregate gate downgrading a real prior break: a late_BudgetOverflowpreserves the ordinary compatibility/coverage/assurance/crosscheck contributions already computed before it fired in the report'sdiff.exit(attach_prior_on_budget_overflow, (4) above) -- but_load_report_file's forced-blocking branch never read them, unconditionally floor-setting the gate toCOVERAGE_INCOMPLETE_EXIT(1) even whencompatibility_contributionwas4(a real ABI break already found) (Codex review, fresh evidence). Fixed via a new_scan_abort_prior_exithelper that reads the largest valid preserved contribution fromdiff.exitand folds it withmax()against the coverage floor; an early abort (every contribution genuinely0) still floors at1unchanged, and a malformed/ out-of-scheme contribution is ignored rather than trusted. (8) An eighth round caught the preserved contract-coverage/analysis- assurance contributions folded into the gate but dropped from their own orthogonal reports:AggregateResult.contract_coverage_exit/.analysis_assurance_exit(and their..._targetslists) read_LoadedReport.contract_coverage_exit/.analysis_assurance_exitdirectly, never the gate -- and those two fields only ever read the differently-named, oldercontract_coverage_exit_contribution/analysis_assurance_exit_contributionfields a scan-abort payload never carries, so a late abort that preserved a real1on either axis silently reported0with an empty target list on both (Codex review, fresh evidence). Fixed via a new_scan_abort_exit_ axishelper that reads each axis separately fromdiff.exitand folds it into the corresponding_LoadedReportfield. (9) A ninth round caught the same two helpers missingscan --artifact-set's own abort shape entirely:ScanSetResult.to_dict()has nodiffkey at all -- its own top-levelverdictcan equally read"BUDGET_ OVERFLOW"(_aggregate_scan_set_verdict: any member overflowing makes the whole set report one), but each member's own preserved decision nests instead atper_artifact[i].report.exit(ScanArtifactResult.to_dict()wrapping the typed API's ownScanResult.reportenvelope, notScanOutcome'sdiff.exit) (Codex review, fresh evidence). Reading only the single-binary shape silently downgraded a real member break to the generic abort floor and omitted the coverage/assurance axes for a set-level abort. Fixed by generalizing_scan_abort_exit_blockinto_scan_abort_exit_blocks, which returns every exit-decision block a report may carry (the singlediff.exit, plus everyper_artifact[i].report.exit); both consumers now foldmax()across all of them instead of reading one. (10)/(11) A tenth and eleventh round, on the same commit, caught two further shapes_scan_abort_exit_blocksstill missed: (10) the typed API's ownScanResult.to_dict()dumped directly (no native CLI involved) has nodiffkey at all -- its preserved decision nests at the document root's ownreport.exit, a third shape distinct from bothdiff.exitand an artifact-set member'sper_artifact[i]. report.exit; (11) ascan --artifact-setset-level abort firing after every member already finished normally (the shared budget expiring during the post-member bundle audit,run_scan_set's ownper_artifact=per_artifactbranch there) preserves real, completed member results inper_artifact-- but a completed member never aborted, so its ownScanResult.reportis empty with no nestedexitblock at all; its real result lives only in its own bare top-levelexit_code(Codex review, fresh evidence for both). Fixed by extending_scan_abort_exit_blocksto also read rootreport. exit, and to synthesize a minimal{"compatibility_contribution": exit_code}block from a member with no nested decision -- both fold through the samemax()machinery as a real block, rather than a separate code path. This closes every envelope shape this codebase's own report producers can actually emit (native CLI single-binary, typed-API single-result, artifact-set member abort, artifact-set member completed-without-abort); a further exotic shape would need its own review round to surface, same as these five did. (12) A twelfth round caught a different kind of gap in the same file, not another envelope shape:_aggregate_scan_set_verdict(ADR-056 D3,service_scan.py) deliberately keeps a stronger realAPI_BREAK/BREAKINGverdict at a set's own root even when another member aborted withEVIDENCE_CONTRACT_ERRORalongside it -- a real break must never be hidden behind an evidence-completeness verdict -- but that left the rootverdictstring with no way to say which member aborted, so the loader'sblocking_categoriessilently droppedevidence_contract_errorfor that target despite the member never completing a comparison (the real severity, exit 2/4, was already correct throughGateInfo.from_scan_report's mapped-code branch; only the category label was missing) (Codex review, fresh evidence). Fixed by a new_member_abort_categorieshelper that reads eachper_artifactmember's own bareverdictfield directly and folds any abort category it names into the gate, independent of which verdict won at the root. Unlike (1)-(11), this was not another envelope shape_scan_abort_exit_blocksneeded to recognize -- it was the set-level verdict-blending logic itself dropping a category label it never carried into anyexitblock to begin with, so it needed its own read ofper_artifact[*].verdictrather than another fold over_scan_abort_exit_blocks's output. (13) A thirteenth round, immediately after, caught the fix above only reaching one of_load_report_file's two abort-handling branches:_aggregate_scan_set_verdict's own step 1 makes any member'sBUDGET_OVERFLOWdominate the set-levelverdictunconditionally, even when a different member aborted withEVIDENCE_CONTRACT_ERRORfor an unrelated reason -- but the root-abort branch (the one keyed onraw_scan_verdictmatching a synthetic abort string directly) hardcoded only the single category matching that string and returned before_member_abort_categorieswas ever consulted, so a sibling member'sevidence_contract_errorcategory was still silently dropped in exactly the case (12)'s fix didn't reach (Codex review, fresh evidence). Fixed by unioning_member_abort_categoriesinto that branch'sblocking_categoriestoo, the same way (12) already does for the normal-verdict branch. Still open: the release fan-out'sGateOptionsunification and a full cross-front-end parity pass (typed API, Action).Update (2026-09-01): first slice of the Action-side parity pass. The composite Action's own
scanverdict mapping (action/run.sh) had exactly the gap this precedence work exists to close:cli_scan.pyraises_EvidenceContractErroras aclick.ClickException(stderrError: <message>, exit 1) — the identical shape a bad flag or a crash produces — sorun.sh's_is_cli_errorcheck (grep -qE '(^Usage:|^Error:|...)') matched it unconditionally and folded a well-formed, evidence-incomplete scan into the same genericERRORbucket a syntax typo gets, even though the native CLI's--format jsonpath already writes a real, distinguishableverdict: "EVIDENCE_CONTRACT_ERROR"envelope for this exact abort (_emit_scan_abort_report/scan_abort_result_fields, landed earlier in this same stage). Fixed by a new_evidence_contract_gated()helper (mirrors_coverage_gated/_assurance_gated's own JSON-first pattern, reading the report's top-levelverdictfield, which_json_report_src's existing freshness/fingerprint checks already keep from false-positiving on a stale prior report) consulted ahead of_is_cli_errorin the exit-1 dispatch, giving the abort its ownEVIDENCE_CONTRACT_ERRORverdict with a job-summary line andaction.ymloutput documentation, mirroringNOT_COMPARABLE/BUDGET_OVERFLOW's existing treatment — including the same unconditional step-failure block those two verdicts needed of their own (splitting a new verdict out of the genericERRORbucket means it no longer matches that bucket's ownFINAL_EXIT=1, so it needs an explicit twin or the step would silently start passing). Deliberately not givenBUDGET_OVERFLOW's own_maybe_post_pr_commentskip — an initial version of this fix copied that skip by analogy and a Codex review round (fresh evidence) caught that the analogy doesn't hold: unlikeBUDGET_OVERFLOW(which genuinely has no report to reuse, sincerun_scan_core's deadline-guarded candidate-snapshot collection raises before_emit_scan_reportever runs), reachingEVIDENCE_CONTRACT_ERRORalready proves_evidence_contract_gatedfound a populated, readable JSON report — that is the only way it can have returned true — andpr_comment_scan_abort. scan_abort_incomplete_reason(above) already renders that exact envelope as a blocking "analysis incomplete" finding, its own docstring naming the GitHub Action as one of the paths meant to reach it. The skip would have left a previous sticky BREAKING/API_BREAK comment stale and misleading instead of updating it, and — for the rarer case where no JSON report exists yet at this point — a re-run to obtain one is cheap for this specific abort, since_EvidenceContractError's own precondition check fires before any source evidence collection begins, unlike a real budget-limited scan. Whenever no JSON report exists at all for this wrapper to read, this classification is unavailable and the run still reads asERROR, same as before this fix — the one acknowledged gap noted above this update. Three successive review rounds each found the previous restatement of exactly when a JSON report exists incomplete (pr-comment: falsewith no other JSON source; an--artifact-setscan, which suppresses the auto-injected sidecar unconditionally; and the run's ownextra-argsalready supplying a non-JSON--write, which also suppresses the injection so as not to clobber the caller's own flag) —action.yml'sverdict/exit-codeoutput descriptions stopped trying to enumerate the condition in prose after the third finding and instead point ataction/run.sh's own JSON-sidecar-injection logic as the one authoritative source, rather than a fourth prose restatement this file's own history shows keeps finding one more uncovered combination. A stricter instance of the same gap (Codex review, fresh evidence):--artifact-set(the Action'snew-library-setinput) skips the JSON secondary write unconditionally —action/run.sh's own injection guard requires-z "$SCAN_ARTIFACT_SET"regardless ofpr-comment— even thoughcli_scan._run_artifact_set's text renderer (_render_artifact_set_text) always prints a stable, parseableArtifact-set scan verdict: EVIDENCE_CONTRACT_ERROR (exit N)line (unlike the single-binary abort, which prints nothing distinguishing in text form at all —ScanSetResultalready exists as a real object here, so there is genuinely something to render). Left unread rather than parsed: teaching the wrapper to recognize a second, mode-specific text sentinel — after this same review round already found the first--evidence_contract_gatedaddition needed its own hostile-input test — was judged not worth the additional parsing surface and its own adversarial-input analysis for one narrower mode, when--format jsonalready produces this verdict at the set's top level in the common case (ScanSetResult.to_dict()'s own top-levelverdictfield is exactly the one_report_query'scompat_verdictquery already reads). A second review round found that claim itself incomplete (Codex review, fresh evidence):ScanSetResult's own aggregation (service_scan._aggregate_scan_set_verdict, pre-existing, untouched by this PR) reportsEVIDENCE_CONTRACT_ERRORat the set's top level only when it is the worst outcome across the set — a sibling library's realAPI_BREAK/BREAKINGkeeps that verdict at the top level instead (the aborted member's contribution still floors the overall exit code at 1, per that function's own docstring, but the wrapper's fail-on-api-break/fail-on-breaking inputs alone decide the step's outcome in that case, same as for an ordinary break, with the sibling abort visible only in the JSON report's ownper_artifactlist). So even under--format json, "unconditionally fails the step" holds for--artifact-setonly when the evidence-contract abort is the set's own worst outcome. Documented precisely inaction.yml's ownverdict/exit-codeoutput descriptions instead of parsingper_artifacthere too, for the same reason as the first gap: a second layer of JSON-array parsing logic earns its own hostile-input analysis and test, and this PR would rather record an accurate limitation than ship that under-tested.tests/test_action_run_sh_scan_evidence_contract_error.pycovers the exit-1 dispatch (including the "both signals present" case, since real stderr always satisfies_is_cli_errortoo) and the step-failure block. A fourth review round (Codex, fresh evidence) caught a message-accuracy gap, not a detection gap:_EvidenceContractErrorhas two independent raise sites inscan_engine.py-- the pinned-depth/missing-evidence check this wrapper's messages were written around, and_run_abi3_audit's own abi3-precondition check (--abi3targeting a binary that isn't a recognisable CPython extension module, unrelated to any depth pin) -- and the JSON envelope this wrapper reads carries only the verdict string, not which raise site fired. The::error::annotation, job summary, and final-exit message all named the depth/evidence cause unconditionally, misdiagnosing the abi3 case. Fixed by making all three generic (naming the axis -- "this scan's evidence contract could not be satisfied" -- and pointing at the command's own error message for the specific cause) rather than picking one cause to describe;action.yml's own verdict description updated the same way. This one needed no adversarial-input analysis, unlike the JSON-sidecar-condition gaps above -- it is a wording correction, not a new signal to parse. A fifth review round (Codex, fresh evidence) caught the identical narrow framing surviving in the adjacentexit-codeoutput description and the changelog fragment -- both still named only the pinned-depth cause for the scan exit-1 axis, and a leftoveraction/run.shcomment (the exit-1 dispatch's own "four possible sources" note) did too. Fixed the same way: named the axis generically, listed both raise sites where a concrete example was still useful, and swept the rest of this PR's own diff for the same narrow phrasing rather than waiting for a sixth round to find the next copy. A sixth review round (Codex, fresh evidence) found a fourth uncovered escape hatch in the same JSON-detection family, distinct from the three theverdictdescription already names:_STDOUT_JSON_FILE(the primary-output stdout capture immediately above_json_report_src) is gated on"${FORMAT:-}" == "json"-- this Action's ownformatinput, read before the CLI runs -- not on what the invocation actually produced.extra-args: --format jsonunderformat: textmakes the real CLI invocation emit JSON on stdout (the later flag wins, same as any CLI argv), but the wrapper's own capture never notices, since it never re-derives the effective format from the actual command it built. Unlike the fifth round, this is a real detection gap, not wording -- but it is the same shape of gap as the--write-collision findings (three and five), so it gets the same treatment for the same reason: teaching this wrapper to track an effective-format override needs its own parsing ofextra-argsand its own hostile-input test, the bar the first_evidence_contract_ gatedaddition was already held to, and this PR would rather record an accurate limitation than add that under-tested. Folded into theverdictdescription's existing generic pointer ataction/run.sh's own JSON-detection logic, now naming_STDOUT_JSON_FILEalongside_json_report_srcand updating the combination count from three to four. Fixed (2026-09-02): the effective-format-override gap itself. A new_effective_formathelper scansINPUT_EXTRA_ARGSthe same word-splitting way_extra_args_has_write_flag/_extra_args_write_json_pathalready scan it for their own flag, keeping the last--format/--format=occurrence (Click's own last-wins precedence) and falling back to the nominal$FORMATwhen extra-args carries none; computed once, into$_EFFECTIVE_FORMAT, right after extra-args are appended toCMD. Both sites this section names now gate on that value instead of the bare$FORMAT: the stdout-JSON capture (_STDOUT_JSON_FILE) and_json_report_src'sOUTPUT_FILEbranch, each falling back to${FORMAT:-}when$_EFFECTIVE_FORMATis unset so the several isolated-snippet tests that extract_json_report_srcwithout running the real command-assembly section keep behaving exactly as before (tests/test_action_run_sh_helpers.py::TestEffectiveFormat,tests/test_action_run_sh_pr_json.py). A same-PR review round (Codex, fresh evidence) found a third site of the identical class:_text_report_content-- the text-report counterpart_severity_gate_exit/_severity_gate_categoriesboth read through -- gated on the bare$FORMATtoo, so aformat: jsonstep whose ownextra-argsoverrode to--format text(withoutput-fileset) wrote real text to$OUTPUT_FILEthat this function still refused to read, silently losing the severity-gate line and publishing the genericERRORinstead ofSEVERITY_ERROR. Fixed the same way -- gated on${_EFFECTIVE_FORMAT:-${FORMAT:-}}(tests/ test_action_run_sh_helpers.py::TestTextReportContentEffectiveFormat). A fourth review round (Codex, fresh evidence) found the general- purpose$_EFFECTIVE_FORMATcomputation itself ran too late for two of its own consumers: it was computed once, afterextra-argsis appended toCMD-- but compare and scan mode's ownPR_JSONsidecar-injection decisions (--write json=$PR_JSON, added when the primary format isn't already JSON) run earlier, inside each mode's own block, and still checked the bare$FORMAT. Aformat: jsonstep whose ownextra-argsoverrode to a non-json format skipped the injection (nominally "already JSON, no secondary needed") while the real run produced no JSON at all -- the mirror image of the gap_STDOUT_JSON_FILEhad, one step earlier in the pipeline. Fixed by computing$_EFFECTIVE_FORMATa second time, right after each mode's own$FORMATis set (idempotent with the later, general-purpose computation, which still covers every mode with no injection decision of its own), and gating both injection sites on it (tests/test_action_run_sh_compare_pr_json_write.py:: TestCompareDoesNotInjectALosingWrite:: test_extra_args_overriding_json_away_still_injects_a_write,tests/test_action_run_sh_scan_pr_json_write.py, a new module mirroring the compare-mode one). A fifth review round (Codex, fresh evidence) found the fix had stopped one layer short: report detection was covered, report rendering was not. The step-summary "Format" row and its "Full report" markdown-vs-code-fence decision (whether the raw output embeds as rendered Markdown or inside a```fence) still read the nominal$FORMAT, so aformat: jsonstep overridden to--format markdownmislabeled the summary row and embedded real Markdown output inside a code fence, and the reverse override embedded raw JSON as if it were Markdown. Fixed by gating both on${_EFFECTIVE_FORMAT:-${FORMAT:-markdown}}too (tests/ test_action_run_sh_summary.py:: TestStepSummaryFullReportFencingUsesEffectiveFormat). Also identified, and deliberately deferred rather than fixed in this PR (recorded indocs/contribute/known-gaps.md): (1) CodeRabbit review, fresh evidence — none of_effective_format's three siblings (_extra_args_has_write_flag,_extra_args_write_json_path) or the realCMDassembly (CMD+=($INPUT_EXTRA_ARGS)) disable pathname expansion when splittingINPUT_EXTRA_ARGS, so a craftedextra-args: '*'in a workspace containing a flag-shaped filename could inject an unintended argument — real, but_effective_formatdeliberately matches its siblings' and the real command's own (equally unsafe) splitting on purpose, so hardening only the newest of the four sites would introduce a detection/execution divergence rather than close one; the fix needs all four sites (plus a hostile-glob test corpus) changed together. (2) Codex review, fresh evidence — extra-args supplying its own-o/--output(a different flag than--format, with no existing "effective value" helper the way--writehas_extra_args_write_json_path) can point the real primary report at a path this script's$OUTPUT_FILEtracking never learns about, leaving_json_report_srcwith nothing to find; closing this properly needs a new_effective_output_filehelper with the same freshness/fingerprint discipline_json_report_srcalready applies to$OUTPUT_FILE, not a narrow patch to one call site. A sixth review round (Codex, fresh evidence) found two more sites of the same class, both fixed in this PR since each was a narrow, in-scope correction (unlike the two deferred above, neither needed a new primitive): (1)format: sarifwith nooutput-file:input defaults$OUTPUT_FILEtoabicheck-results.sarif— the exact filename a workflow's ownupload-sarif: truestep looks for — but that default was keyed on the nominal$FORMAT, soextra-args --format json(or any other override away from sarif) still wrote real JSON into a file named as if it were SARIF, ready to be silently fed to acontinue-on-errorCodeQL upload step (action.yml's ownif: inputs.format == 'sarif'gate on that step reads the nominal Action input and cannot see a shell-local override, so this could not be closed by keying that step's own condition on the effective value either — the fix instead makes the mismatch fail loudly: no default-ois set when the effective format isn't sarif, so the upload step finds no file rather than the wrong one). (2)_emit_annotations's own "no JSON report is available" diagnostic — printed when_json_report_srccorrectly finds nothing — was itself gated on the nominal$FORMATinside that sameif, so aformat: jsonstep overridden to--format text(with its own--write markdown=...) correctly rendered no annotations but suppressed the very message explaining why. Both gated on${_EFFECTIVE_FORMAT:-${FORMAT:-...}}like every other site (tests/ test_action_run_sh_compare_pr_json_write.py:: TestSarifDefaultOutputFileUsesEffectiveFormat,tests/ test_action_run_sh_annotate_renderer.py:: test_effective_format_override_still_emits_the_diagnostic). A seventh review round (Codex, fresh evidence) found the SARIF fix above was itself incomplete: it only closed the no-explicit-output-filedefault-path case. An explicitoutput-file:+format: sarif+upload-sarif: true+ anextra-args --formatoverride still wrote real, non-SARIF content to that explicit path, andreport-path(the run's own output) was published unconditionally whenever the file existed, regardless of whether its content actually matched the format the upload step assumes. Fixed at the one point that actually gates the danger rather than at the two places content can land:report-path— precisely the valueaction.yml's upload-sarif step's ownif:condition requires be non-empty — is now withheld wheneverformat: sarifandupload-sarif: truewere both requested but the effective format isn't sarif, covering the default and explicit-path cases identically and needing no new Action output oraction.ymlchange (tests/ test_action_run_sh_compare_pr_json_write.py:: TestSarifUploadReportPathWithheldOnEffectiveFormatMismatch). An eighth review round (Codex, fresh evidence) caught a real bug in that very fix, not a new instance of the class: the::warning::diagnostic explaining the skipped upload was echoed inside the{ ... } >> "$GITHUB_OUTPUT"redirected block, so it never reached the Actions log at all — instead it was silently swallowed into the environment file as a bogus, undeclared record (its own embedded=, insidetitle=abicheck, makes it look like a key/value pair to the runner). Fixed by hoisting the mismatch check and its warning out of that block entirely, computed once into$_SARIF_UPLOAD_FORMAT_MISMATCHbefore the output-setting block reads it. The existing tests only inspected the raw$GITHUB_OUTPUTfile's contents and could not have caught this; extended to also assert the warning reaches run.sh's own stdout/stderr and never appears inside$GITHUB_OUTPUT. Still open: the release fan-out'sGateOptionsunification, the typed-API half of this parity pass, the--format textgap named above, and a real--artifact-setmember-level evidence-contract signal for the Action to consume.A CI-infrastructure fix, not a review finding: the new test file's own malicious-fixture test (and its siblings) passed their generated bash script via
subprocess.run([bash, "-c", script]), which failed onwindows-latestCI with a bash parse error (unexpected EOF while looking for matching \)') -- Windows reconstructs that argv vialist2cmdline(MSVCRT quoting rules) and Git Bash's own MSYS runtime then re-parses the resulting command line with its own, not-quite-identical rules, corrupting this file's large, quote-heavy scripts. An earlier revision patched only the interpolated paths withPath.as_posix(); that addressed a narrower instance of the same class and left this one, confirmed still failing with the identical error text. Fixed by porting the same fix two sibling test modules already use for this exact class of gap (test_action_run_sh_helpers.py's_run_harness,test_action_run_sh_py_safe_path.py's_run_bash_script): write the script to a real file and runbash`, which needs no argv reconstruction at all. A seventh review round (Codex, fresh evidence) found a real test- coverage gap, not a wording or detection gap: every exit-1 dispatch test in the new test file stubs
_evidence_contract_gatedout entirely, and the one test executing the real_report_query/_evidence_contract_gatedpipeline only supplied a near-miss verdict (expectingGATED=0) -- none of the five tests would have failed if that pipeline were broken to always return false, silently restoring the exact pre-fix misclassification for every genuineEVIDENCE_CONTRACT_ERRORreport. Fixed by adding a positive-path test supplying the exact sentinel string through the same real, extracted pipeline (factoring the report-writing/script- assembly the hostile-value test already did into a shared helper so both tests exercise one pipeline, not two copies), verified to actually catch the regression the same way every malicious-fixture test in this PR has been.An eighth review round (Codex, fresh evidence) found a real correctness/cost bug in pre-existing, untouched-by-this-PR code this PR's own comment had overclaimed about:
_can_reuse_primary_ json(the sticky-PR-comment JSON acquisition decision) requires$FORMAT == "json"before it will reuse an already-produced report -- so aformat: text/markdownrun whose own extra-args supplied--write json=PATH(exactly the faithful, unfiltered report_json_report_src's_extra_write_json_pathbranch already trusts, and exactly what let_evidence_contract_gatedclassify the verdict correctly in the first place) is rejected anyway, forcing_maybe_post_pr_commentinto a full rerun despite the JSON already sitting on disk. For the abi3_EvidenceContractErrorraise site specifically, that rerun happens after real candidate- snapshot extraction -- not the cheap, precondition-only rerun this PR's ownEVIDENCE_CONTRACT_ERROR-doesn't-get-BUDGET_OVERFLOW's- skip comment (added in the very first slice above) claimed for every raise site. Unlike the documentation-precision rounds above, this is fixed in code rather than recorded as a gap: dropped the blanket$FORMAT == "json"requirement and let_can_reuse_primary_ jsonrely purely on_json_report_src(whose own per-branch gating already restricts a non-format:-jsontrust to exactly the_extra_write_json_pathcase) -- a minimal, general fix to the shared acquisition helper every mode's PR comment goes through, not a special case for this one verdict. Corrected the now-inaccurate "rerun is cheap" framing in the earlier comment to match: reachingEVIDENCE_CONTRACT_ERRORalready proves a report exists, so with this fix the reuse-or-rerun fallthrough never actually reruns for this verdict at all. New test:test_reuses_extra_args_write_json_sidecar_under_a_non_json_formatintests/test_action_run_sh_pr_json.py, verified to catch the regression the same way.A ninth review round (Codex, fresh evidence) found the mirror image of the sixth round's finding, falsifying the one guarantee the
verdictdescription still asserted: "This Action's ownformat: jsoninput always qualifies" was itself wrong --extra- args: --format textunderformat: jsonoverrides the effective invocation to text output the same way--format jsonunderformat: text(the sixth round's finding) overrides it to JSON; neither direction is detected specially, soformat: jsonalone guarantees nothing about what_json_report_src/_STDOUT_JSON_FILEactually find. This is the fifth successive round to find one more uncovered combination in a claim this description made about when a JSON report exists, even after the fifth round (see above) already tried retreating from enumeration to a single narrower claim -- proof that the narrower claim was still an enumeration, just of one case instead of several. Fixed by removing the "always qualifies" guarantee entirely rather than adding a sixth caveat: the description now states plainly that the effective invocation decides, not any one input considered alone, and points ataction/run.sh's own logic with no shortcut claim standing in for it. No code change and no new test -- this is prose accuracy only, the same class the fourth round already established needs neither.Landed (2026-09-02): the release fan-out's
GateOptionsunification (abicheck/policy/release_gate_options.py'sGateOptions/resolve_release_gate_options-- this package's own home for deciding gate/severity effect, perabicheck/policy/ AGENTS.md; reached from the release fan-out'sfrontends-classifiedcli_compare_release_helpers.pythroughabicheck/workflows/gate.py's existing re-export facade, sincefrontends -> policyis forbidden). Before this, the release fan-out threaded six raw preset/category/scheme strings independently through_resolve_release_severity_config,_compute_release_severity_exit_code, and_fold_release_global_severity, each re-deriving the identicalSeverityConfigfrom the same strings -- exactly the shape PR B's own "finalized" note (cli-cleanup-phase-two.md's "PR 4" section, retired plan record) flagged as unsafe to fix reactively, ahead of this ADR's settled design.resolve_release_gate_optionsnow performs that resolution exactly once (folding a selectedkind: gatepack via the pre-existingapply_release_gate_pack, then applying the sameexit_code_scheme == "severity"-with-no-config-present fallback toPRESET_DEFAULTand forced-legacy-clears-severity correctionscompare_release_cmdused to apply inline at its own call site);_compute_release_severity_exit_code/_fold_release_global_severitynow take the resultingGateOptionsinstead of the raw strings, andGateOptions.severity is Noneis the one place "no severity setting is in effect" is decided, replacing the two duplicatedif release_exit_code_scheme == "legacy"/"severity"checks that used to appear at both the call site and inside each downstream function. Landed additively, contrary to this ADR's original "Consequences" framing (see that section's own correction below): the rewrite changes no CLI surface and no externally observable exit code -- verified by the existing severity/exit-code test suite (tests/test_config_review.py,tests/test_cov95_cli.py,tests/test_pack_application.py,tests/test_compare_release.py): their call sites were updated to build aGateOptionsinstead of passing the six raw strings directly, and every expected result stayed the same against the new call shape, so this did not need to wait for stage 2. Still open, unchanged by this landing: the typed-API half of the parity pass, the--format textgap named above, and a real--artifact-setmember-level evidence-contract signal for the Action to consume.Landed (2026-09-03): the single-binary half of the
--format textgap._BudgetOverflowwas never actually ambiguous on this axis -- its exit code 5 is unique amongscan's exit codes, soaction/ run.sh's exit-codecasealready maps it straight toBUDGET_OVERFLOWwith no JSON report needed. Only_EvidenceContractError(exit 1, shared with a genuine CLI usage error) had the gap.cli_scan.py's_EvidenceContractErrorcatch site now always prints one stable stderr marker line ahead of its existingError: <message>text, independent offmt/secondary_fmt-- not the message text itself, which differs across this exception's two raise sites (a pinned depth with no source evidence, and--abi3targeting a binary that isn't a recognisable CPython extension module) and so cannot be matched by one pattern; inventing a full text report remains the separate, still-open design question_emit_scan_abort_report's own docstring names, and this change does not attempt it.action/run.sh's_evidence_contract_gated()now falls back to greppingSTDERR_CONTENTfor that marker whenever_json_report_srcanswers nothing (the report-readable branch is unchanged and still wins when a report exists) -- the identical shape_assurance_gated()'s own stderr fallback already established for a sibling gap. Tests:tests/test_cli_scan_abort_report.py'stest_evidence_contract_error_text_format_has_no_json_report(the marker is now present even though the JSON report still isn't), andtests/test_action_run_sh_scan_evidence_contract_error.py's newtest_evidence_contract_gated_stderr_fallback_*tests -- including one running the real native CLI end-to-end and feeding its real stderr into the real, unmodified bash pipeline, proving the Python-side marker and the bash-side grep pattern actually agree rather than being two independently-drifting copies of one literal string, plus a near-miss/unrelated-error negative test and a report-wins-over-a-stale-marker precedence test. Still open, deliberately not attempted in this slice: the--artifact-setmember-level signal -- a member's_EvidenceContractErroris caught insideservice_scan._run_scan_one_memberand converted to a per-memberScanResultthere, so it never reaches this single-binary catch site or its stderr marker at all.run_scan_set's aggregateScanSetResult.to_dict()already surfaces the top-levelEVIDENCE_CONTRACT_ERRORverdict for--format json, but aformat: textartifact-set step has no analogous stderr signal, andaction/run.shdoesn't attempt a JSON secondary for--artifact-setat all today (SCAN_ARTIFACT_SETexplicitly skips the automatic--write json=...injection) -- a separate, not-yet-scoped piece of work. The typed-API half of the parity pass also remains open, unchanged.Superseded (2026-09-03, rounds 2-4): the stderr-marker mechanism described immediately above is gone. Round 2 (Codex) found the unanchored
grep -qmatch forgeable by a diagnostic merely containing the marker text; tightened to a whole-linegrep -Fxq. Round 3 (Codex) found even that forgeable, since a legal Unix filename may embed a newline -- a crafted path echoed into a wholly unrelated error message could still produce a standalone line identical to the marker; the signal moved to a private marker-file path ($ABICHECK_EVIDENCE_CONTRACT_MARKER_FILE)action/run.shcreates and names itself. Round 4 (Codex) found that forgeable too, at the root: the path leaked as an inherited environment variable to every subprocess this scan spawns during evidence collection, and even popping it fromos.environat import time left it recoverable via/proc/<pid>/environ, which reflects a process's initial environment regardless of later mutation. No channel derived from text or environment closes this class. The final design replaces all of it with a dedicated process exit code (cli_scan.py's_EXIT_EVIDENCE_CONTRACT_ERROR = 7) --_evidence_ contract_gated(),write_evidence_contract_marker(), and the marker-file/env-var machinery are deleted outright; thecase $ABICHECK_EXIT in ... 7) ...dispatch needs no predicate, no JSON report, and no stderr/environment signal. This closes the single-binary half of the gap completely.test_action_run_sh_ scan_evidence_contract_error.py's own module docstring has the condensed four-round account; this section's own status header (above) has the rest. The--artifact-setmember-level signal remains open and unattempted -- whether the exit-code approach generalizes to it is its own, separate design question. 2. Atomic. Once the report block agrees with today's real behaviour for every axis and every mode (verified by the axis-separated tests this ADR requires below), remove--exit-code-schemefromcompareandscan, correctaction.yml's prose (there is noexit-code-schemeAction input to remove — only itsverdictoutput description names the flag today), remove or replace.abicheck.yml'sexit_code_schemekey, and changepack_application.pyto read a resolvedgate.exit_code_schemepack field as policy (does this pack imply a severity map or not) rather than as an algorithm selector. Update CLI, typed Python API, Action, andaggregateparity tests together in this stage, per the plan's own "Merge criteria for every removal PR" checklist.
Splitting the stages is what lets a bisect over a red CI job land unambiguously on the atomic stage rather than on the (behaviourally inert) additive one, and lets the additive stage's tests double as the removal stage's regression baseline instead of being written under time pressure alongside the flag deletion itself.
Consequences¶
ExitReasongains members for the three new axes (naming to matchabicheck/scan_engine.py's existing verdict strings —EVIDENCE_CONTRACT_ERROR, a budget-overflow reason,NOT_COMPARABLE), aremoved_required_libraryreason whose precedence the resolver computes according to the mode-dependent rule above, not a static ordering table, and anoperational_errorreason for a release's own independent, tie-foldable axis (a library's dump/extract/compare failure, distinct from a real compatibility-gate finding even when both happen to tie).docs/reference/exit-codes.mdis updated, once the atomic stage lands, to state precedence via a link to this ADR's table instead of the prose spread across thecompare(multi-library),scan, andscan --againstsections today.- No
ChangeKind, schema-version, or report-field removal ships with the additive stage; the atomic stage bumps whichever report schema versions gain or lose theexit_code_scheme-related fields, per the plan's "Machine contracts" merge criterion. --exit-code-scheme legacy/severitycallers (CLI,.abicheck.yml, packs) lose the ability to force an algorithm that disagrees with their own configured policy. Per this plan's stated non-goals, no deprecation alias or transition window ships — the old spelling errors withNo such option, exit64, matching every other removal in this cleanup.- The release fan-out gains the
exit/reasons block parity withcomparevia stage 1b (resolve_release_exit_decision_for_report,abicheck/policy/exit_decision_precedence.py, per "Staged landing" above) — not the atomic stage; that block is additive and needs no algorithm-selector decision to exist. Correction (2026-09-02): this paragraph originally placed the release fan-out's internal severity/exit-code representation change (raw strings →GateOptions-shaped object) in the atomic stage too, reasoning that it was "the same rewrite that removes--exit-code-scheme". It landed instead as additive stage-1b work (see "Staged landing, additive first" above, item 1's own 2026-09-02 update) — the internal shape change and the CLI flag's removal turned out to be separable: nothing about replacing raw-string re-derivation with one resolved object depends on the flag still existing, and the rewrite provably changes no externally observable exit code for any invocation the CLI supports today (verified by the existing test suite's call sites, updated to build aGateOptionsinstead of passing the six raw strings directly, still producing every expected result unchanged) -- independent of whether--exit-code-schemeitself is later deleted. That eventual stage-2 removal is its own, separately visible exit-code change for the one, no-longer-supported invocation spelling (exit64, "No such option"), not something this internal rewrite causes or forecloses. So both steps change no externally observable exit code for the invocations each one leaves supported, which is the invariant this paragraph was stating; only which stage the internal rewrite belongs to was wrong.
Cross-references¶
- cli-cleanup-phase-two.md (retired plan record; see
docs/contribute/plans/index.md) — "PR 4 — one gate algorithm (--exit-code-schemeremoval)" was this ADR's source material; the plan's "Ordering" table tracked PR G1 (done, #789) and PR G2 (this ADR — stage 1a landed, stage 1b partially landed per this ADR's own status header above, stage 2 not yet implemented) as a pair. - ADR-049 — contract-coverage's own orthogonal exit contribution, folded on top of this ADR's precedence, never lowering it.
- ADR-050 — D2, the
NOT_COMPARABLEcontract this ADR's precedence table defers to. - ADR-037 — D5,
scan's evidence-contract check, the source of the evidence-contract-error axis. abicheck/policy/exit_decision.py— PR G1's already-implemented three-axis core this ADR extends.docs/reference/exit-codes.md— the per-command number tables this ADR's precedence resolver must reproduce exactly, not renumber.