Contract-Aware Compatibility¶
Plain abicheck compare already applies its own scoping — public-surface
filtering (on by default), suppressions, and redundancy collapsing all
narrow what actually gets scored — but none of that asks about a
declared contract: it's about whether a change is public/reachable or
explicitly waived, not whether it belongs to a promise you've made about
what stays stable. Contract-aware compatibility
(compare --contract-evaluation, ADR-049) adds that narrower, more useful
question on top: does this change even touch the compatibility contract
you actually promised?
This page is the mental model — what a "contract" means here, the three domains you can select, the outcomes a finding can land in, and why this can't be used to quietly hide a real break. For the exact field vocabulary and precedence rules, see Compatibility Evaluation Config; for the command syntax and CI recipes, see Contract Evaluation.
A contract is not the same as the discovered surface¶
A plain compare operates over everything it found. Contract-aware
compatibility introduces a third, narrower circle:
flowchart LR
subgraph All["Everything abicheck detected"]
subgraph Domain["Everything in the selected contract domain"]
subgraph Proven["Everything abicheck could confirm either way"]
end
end
end
- Everything detected — the ordinary
compareoutput: every difference between old and new, regardless of what it means for compatibility. - Everything in the declared domain — the subset that belongs to the compatibility contract you selected (see modes below) — a header-only internal type is outside this circle even though abicheck detected the change to it.
- Everything abicheck could prove either way — of the findings that are or aren't in the domain, only some come with evidence strong enough to state the answer confidently. The rest are honestly reported as unresolved rather than guessed at.
This is why a finding's fate under --contract-evaluation isn't a single
in/out flag — it's the combination of where it falls (in the domain or
not) and how sure abicheck is (proven or not).
Three contract modes¶
--contract public|exports|all selects which evidence domain a finding is
judged against (the legacy --scope-public-headers/--no-scope-public-headers
flag maps onto public/all; an explicit --contract always outranks it):
| Mode | What's in the contract | Evidence it consults |
|---|---|---|
public (default alias) |
The library's declared public headers — the source-level API surface | Header AST, scoped the same way plain compare's public-surface scoping already works |
exports |
Whatever the binary's own observed export table actually exports (.dynsym/PE export directory/Mach-O export trie) |
The export table alone decides which declarations are roots — no header-origin/publicness filtering. But header (or debug-info) declaration data still matters afterward: the closure walk from those roots over the record/enum/typedef graph needs typed declarations to resolve, so giving it headers can turn an otherwise-UNKNOWN_UNRESOLVED type edge into a provable one. |
all |
Everything detected — no exclusion, with two exceptions | Nothing extra; every entity-level finding is trivially IN_CONTRACT unless it's specifically excluded by a committed --post-manifest (see below). A NOT_APPLICABLE finding (below) is unaffected by mode entirely — it was never a domain-membership question to begin with. |
Decision table:
| Situation | Recommended mode |
|---|---|
| Public SDK with a maintained, documented header surface | public |
| C ABI / export-map–driven library where the export table is the contract (no reliable header surface, or headers are broader than what you actually promise) | exports |
| Diagnosing the full detected surface, or a deliberate rollback/audit where nothing should be excluded | all |
public and exports are genuinely different sources of truth, not two
views of the same thing: a symbol can be in your public headers but not
actually exported (stripped, versioned out) — that's out of contract under
exports but in contract under public — or vice versa (an
implementation-detail symbol exported for a narrow, undocumented reason).
all isn't quite unconditional, in two independent ways. A
separately-opted-in --post-manifest (a committed, narrower public-symbol
list) is checked before the all mode shortcut for public/all — a
finding whose symbol the manifest specifically excludes still comes back
PROVEN_OUT_OF_CONTRACT, not IN_CONTRACT, under either of those two
modes. exports mode is the one exception: it dispatches to its own
export-table-rooted decision before the manifest check ever runs, so a
--post-manifest is advisory only there — an observed export the manifest
omits can still resolve IN_CONTRACT under --contract exports. This only
matters if you're also using --post-manifest. Separately, and
unconditionally, across all three modes: a
mode-independent check (loader/SONAME/security-hardening/deployment-floor
kinds — the same curated set behind the NOT_APPLICABLE row above) runs
before any mode dispatch at all, so those findings stay NOT_APPLICABLE
under all too — not because they're excluded, but because they were never
a domain-membership question in the first place. Without --post-manifest,
every entity-level finding under all is IN_CONTRACT, which is the
practical reading most users need.
What a finding's relevance can be¶
Each finding gets a contract_relevance and lands in one of two buckets
abicheck calls compatibility_evaluation_status — EVALUATED or
NOT_EVALUATED:
contract_relevance |
Bucket | Meaning |
|---|---|---|
IN_CONTRACT |
EVALUATED |
Provably part of the selected domain. Policy scores it normally. |
NOT_APPLICABLE |
EVALUATED |
Not a domain-membership question at all (e.g. a SONAME/loader/security-hardening finding) — scored normally, same as always. |
PROVEN_OUT_OF_CONTRACT |
NOT_EVALUATED |
Confidently excluded — abicheck can prove this entity is outside the domain. |
UNKNOWN_UNRESOLVED |
NOT_EVALUATED |
Can't tell, because the evidence needed to decide is missing or incomplete. |
UNKNOWN_UNPROVEN |
NOT_EVALUATED |
Reserved by the vocabulary (ADR-049 D1) for "searched but genuinely ambiguous" — today's evaluator doesn't have a per-domain "did we search everything" signal precise enough to emit this distinct from UNKNOWN_UNRESOLVED, so it downgrades every such case to UNKNOWN_UNRESOLVED instead. Don't expect to see this value in a real report yet. |
Only IN_CONTRACT/NOT_APPLICABLE findings reach compatibility policy —
everything else has compatibility_decision: null in the report: not a
sixth, compatible-leaning verdict, just "policy didn't run on this."
What actually proves exclusion under exports mode¶
PROVEN_OUT_OF_CONTRACT is not a guess — under exports mode it requires
every one of these to hold (ExportSurface.exclusion_is_provable):
- An export table was actually observed on the authoritative side (removal → old side; addition → new side).
- At least one export-table entry resolved to a real, typed declaration.
- Every export-table entry resolved to something — no unaccounted export.
- No unresolved type edge reachable from an export root (a signature/field naming a type the snapshot can't account for).
If any of these fails, the answer degrades to UNKNOWN_UNRESOLVED rather
than a confident exclusion — the same fail-closed principle public mode's
header-origin/private-namespace/system-header checks already use.
Why this isn't a convenient way to hide a break¶
Four properties hold regardless of mode, and are worth internalizing before you turn this on for a real gate:
- Contract exclusion never hides the finding. A
PROVEN_OUT_OF_CONTRACT/UNKNOWN_UNRESOLVEDfinding stays inchanges, rendered with the reason code that explains why it didn't gate — contract relevance by itself removes nothing. (A different, independent mechanism,--suppress, can still remove a matching finding fromchangesintosuppression.suppressed_changes, and public-surface/redundancy filtering have their own audit ledgers — those are unrelated waiver steps, not something contract evaluation does.) - Suppression cannot reach a contract-coverage failure. A
CoverageFailureis structurally not aChange— nokind, nosymbol, nosource_locationfor--suppressto match against — so--suppresscannot silence "we don't have enough evidence to judge this domain," only individual findings. contract.unresolved: warnaccepts, it doesn't erase. Akind: contractpack setting this zeroes the orthogonal contract-coverage exit contribution — butcontract_coverage_failuresstays populated in the report either way. Accepting incomplete assurance is a policy decision you can audit later, not a way to make the gap vanish.- Missing evidence never becomes "compatible."
UNKNOWN_UNRESOLVEDandUNKNOWN_UNPROVENareNOT_EVALUATED, notCOMPATIBLE— the run-level verdict simply doesn't include them, and (unlesscontract.unresolved: warnis set) the separate contract-coverage axis raises the exit code by exactly this kind of gap.
Three runnable examples¶
1. Private implementation removal¶
# Plain compare, with default public-surface scoping disabled -- the
# internal type change is a real, gating BREAKING finding. (Plain compare's
# OWN default scoping, --scope-public-headers, would filter this exact case
# too -- disabling it here isolates contract evaluation, not ordinary
# surface scoping, as what's making the difference between the two runs.)
abicheck compare old.json new.json --no-scope-public-headers
# verdict: BREAKING
# Contract-aware, public mode -- visible, but not evaluated
abicheck compare old.json new.json --no-scope-public-headers \
--contract-evaluation --contract public
# verdict: NO_CHANGE (nothing EVALUATED changed)
# the finding is still in `changes`, with:
# "contract_relevance": "PROVEN_OUT_OF_CONTRACT",
# "compatibility_evaluation_status": "NOT_EVALUATED",
# "compatibility_decision": null,
# "gate_contribution": 0
2. Missing evidence for the selected domain¶
# --contract exports needs an observed export table; a header-only JSON
# snapshot pair has none, so exclusion can never be proven either way.
abicheck compare old.json new.json --contract-evaluation --contract exports
# exit code: 1 (contract coverage incomplete, folded orthogonally)
# the finding: "contract_relevance": "UNKNOWN_UNRESOLVED"
# report: "contract_coverage_failures": [{"provider": "export_table", ...}]
3. Accepting incomplete assurance explicitly¶
# accept-unresolved.yml
id: accept_unresolved
version: 1
kind: contract
assignments:
contract.unresolved: warn
abicheck compare old.json new.json --contract-evaluation --contract exports \
--pack accept-unresolved.yml
# exit code: back to whatever the ordinary gate says (0 here)
# report still carries the SAME non-empty "contract_coverage_failures" list
# — the gap is accepted, not hidden.
What the report shows, per finding and per run¶
{
"kind": "func_removed",
"contract_relevance": "PROVEN_OUT_OF_CONTRACT",
"contract_reason_code": "terminal_authoritative_exclusion",
"contract_assurance": "complete",
"compatibility_evaluation_status": "NOT_EVALUATED",
"compatibility_decision": null,
"gate_contribution": 0
}
Run-level, independent of any one finding:
{
"verdict": "NO_CHANGE",
"contract_coverage_failures": [],
"contract_coverage_exit_contribution": 0
}
See Output Formats for the full report shape
these fields sit inside, and Exit Codes for
exactly how contract_coverage_exit_contribution folds into the process
exit code.
Reason codes¶
Every NOT_EVALUATED/excluded decision carries one of a fixed set of stable
reason codes — see contract_relevance_types.CONTRACT_REASON_CODES for the
exhaustive, machine-checked list. The two you'll see most often are
terminal_authoritative_exclusion (a confident exclusion) and
required_evidence_incomplete (an unresolved one); the rest cover narrower
cases (identity ambiguity, all-mode's trivial inclusion, the legacy-alias
variants, and explicit-consumer-evidence promotion — see Consumer proof
paths
for that last one).
See also¶
- Contract Evaluation — commands, flags, and CI recipes
- Compatibility Evaluation Config — the full field vocabulary and precedence
- CI Gating — where this stage sits in the overall pipeline
- Exit Codes → Contract-coverage contribution
- Verdicts → Contract evaluation and the verdict