ADR-042: Formal separation of CompatibilityDecision and GateDecision¶
Status¶
Accepted — implemented for the JSON/SARIF/compare-release gate summaries
and html_report.py's CI Gate card (abicheck.severity.GateDecision/
compute_gate_decision). mcp_server.py and junit_report.py still
compute an exit code inline via compute_exit_code at some call sites —
see "Rollout" below.
Context¶
A post-#549/#551 reporting review found the same pattern recurring: independent renderers computed a report's "does this block CI?" answer from one code path and its "which category is actually responsible?" answer from a different code path, and the two could disagree:
- JSON's
severity.blocking_categorieswas derived from the (possibly--show-only-filtered) display change set, whileseverity.exit_codewas correctly derived from the unfiltered gate set — hiding the one category actually responsible for a nonzero exit code reportedblocking: truenext toblocking_categories: []. compare-release --format json's per-libraryfindingslist only ever walked the three legacy verdict buckets (breaking/api_break/risk), so a library gated by--severity-addition errorreported a nonzeroseverity.exit_codewith an emptyfindingslist.
Both bugs had the same root cause: "is this compatible?" and "does this
block CI?" are two different questions, and the codebase had no single type
for the second one — every caller re-derived it by categorizing changes and
checking severity_config.level_for(category) == ERROR inline, with enough
copies (reporter._build_severity_json, sarif._severity_gate_properties,
cli_compare_release._release_gating_buckets) that they drifted apart.
This is the same shape of problem ADR-036 solved for the verdict axis
(ReportModel, DiffResult._effective_verdict_for_change) — but ADR-036's
"canonical report severity = the verdict axis" is exactly the assumption
this review found broken: once SeverityConfig is active, "blocks CI" is
no longer a function of the verdict axis alone (an addition, verdict
COMPATIBLE, can block; a breaking kind, verdict BREAKING, can pass under
a demoted preset). ADR-036 remains correct for the display/bucketing
question it addresses; this ADR is scoped to the gate question layered on
top of it once severity configuration is in play.
Decision¶
-
CompatibilityDecisionis a name, not a new type. It is a plain alias for the existingVerdictenum (abicheck.severity.CompatibilityDecision = Verdict).Verdictalready answers exactly "is this ABI/API compatible?" and nothing else — ADR-036 already established it as the canonical axis for that question. Introducing a second enum with the same five members would just be another thing to keep in sync; the alias exists purely so call sites that want to say "compatibility decision" explicitly can, without touching any existingVerdictusage anywhere in the codebase (zero behavior change). -
GateDecisionis new (abicheck.severity.GateDecision, a frozen dataclass):scheme("legacy"|"severity"),exit_code,blocking(exit_code != 0),blocking_categories(theIssueCategorynames actually responsible — always empty under"legacy", which has no per-category configuration to single one out). -
One computation function,
compute_gate_decision, replaces every hand-rolled "categorize, then filter tolevel == ERROR" call site.exit_code(via the existingcompute_exit_code) andblocking_categories(via the existingcategorize_changes) are derived from the samechanges/kind_sets/policy_filearguments in one call, so they cannot independently drift the way two separate call sites could.reporter._build_severity_json,sarif._severity_gate_properties, andcli_compare_release._release_gating_bucketsall now call it instead of reimplementing the categorize-and-filter loop. -
Renderers should read gate status from
GateDecision, never infer it fromCompatibilityDecision/Verdictwording — aCOMPATIBLEverdict does not implyblocking=Falseonce severity configuration promotes an addition toerror, and aBREAKINGverdict does not implyblocking=Trueunder a demoted preset. This is the same discipline ADR-036 established for the display axis, extended to the gate axis. -
No public-API break.
Verdict,DiffResult,compute_exit_code, andcategorize_changesare all unchanged and still directly usable —GateDecision/compute_gate_decisionare additive, andreporter.py'sd["severity"]/sarif.py'sseverityGateJSON/SARIF shapes are byte-for-byte unchanged (schema stays 2.3; this is an internal implementation refactor, not a schema bump).
Consequences¶
- The class of bug that motivated this ADR (gate exit code and blocking category list computed from different inputs) is now structurally prevented at the three sites that were actually affected, rather than fixed one-off each time a new renderer reimplements the pattern.
html_report.py's CI Gate card already routes throughcompute_gate_decision(from the same commit that introduced this ADR) — an earlier draft of this document listed it as a remaining candidate, which was corrected once the discrepancy was noticed.mcp_server.pystill has two exit-code call sites usingcompute_exit_codedirectly (its severity-aware HTML/JSON tool path already usescompute_gate_decision), andjunit_report.py's per-change_is_failurestill callsclassify_effective_changedirectly — the latter only ever needed a per-change category, not a whole-reportblocking_categorieslist, so there was no duplicated-computation bug to fix there. They are candidates to adoptGateDecisionfor API consistency in a future pass, not because they are currently wrong.compare-release's per-libraryfindingsprojection (_release_gating_buckets) needs the actualChangeobjects per blocking category, not justGateDecision's category names, so it callscategorize_changesa second time to look up the change lists for the categoriescompute_gate_decisionnames as blocking. This is a deliberate, small duplication traded for keepingGateDecisionitself lean (names and counts, not full object references) — seecli_compare_release._release_gating_buckets.
Rollout¶
Not a phased rollout in the ADR-036 sense (no golden-snapshot risk — the
JSON/SARIF/release-JSON shapes are unchanged). Remaining candidates to adopt
GateDecision are opportunistic follow-ups, not required work:
mcp_server.py's two remainingcompute_exit_codecall sites could switch tocompute_gate_decision, matching its already-converted severity-aware HTML/JSON path, if a future MCP tool surface wantsblocking_categoriestoo.junit_report.py's_is_failurecould adoptGateDecisionfor API consistency, though it only ever needed a per-change category.