Change acknowledgment¶
compare reads acknowledgment records — explicit, reviewable statements
that a specific, already-detected change was seen and intentionally accepted.
That is unlike a suppression, which claims the finding is a
false positive or out of scope.
Records come from one of two places:
--acknowledgments FILEon the command line, or the Action'sacknowledgmentsinput. This names the records for one run..abicheck.yml'sacknowledgments.file, which names the records a project commits.
# .abicheck.yml
acknowledgments:
file: abi/acknowledgments.yml # the records (format below)
unacknowledged_additions: block # allow (default) | warn | block
breaks: gate # gate (default) | accept
A relative file resolves against the project root. --acknowledgments
overrides acknowledgments.file when both are given.
Trust boundary. A record can accept a finding, and breaks: accept can
lower a gating exit, so both are trusted only from input an operator named:
- the
--acknowledgmentsflag and the Action'sacknowledgmentsinput are operator input, so they supply records even with no config at all; acknowledgments.fileandacknowledgments.breaksapply only from an explicitly named--config(or the Action'sbuild-config). An auto-discovered.abicheck.ymlis one the pull request under review can edit, so itsfileandbreaksare noted on stderr and not applied;unacknowledged_additionsapplies from any config, becausewarn/blockcan only add to what a run reports.
This applies to a single-pair compare and to a directory/package comparison,
where every library is reviewed against the same records and the release exits
1 if any library has an unacknowledged addition under block.
From the typed Python API, set CompareRequest(acknowledgments=...,
acknowledgment_unacknowledged_additions="block", acknowledgment_breaks="accept").
A policy document's own acknowledgments: block outranks the
configured settings as one whole block, on the CLI and in the API alike.
Acknowledgment is not suppression. A suppressed finding disappears from the report and the gate before the verdict is computed. An acknowledged finding keeps its verdict class and stays in the report. Whether an acknowledged break still gates the exit code is the separate
breakssetting below — the verdict still reads as a break either way. See Disposition audit for how the two dispositions are reported side by side.
Why a separate mechanism from suppression¶
vision.md's change-governance model draws this
line explicitly: "Changes can be acknowledged with explicit, reviewable
context bounded to specific findings, components, and release ranges... A
baseline refresh or a broad ignore rule is not an acknowledgment of
everything it happens to cover."
Concretely, an acknowledgment record:
- must name one specific finding — its canonical
finding_id, or an exactsymboltogether with thechange_kindit accepts. It never takes a pattern, a namespace glob, or a source-location glob. A rule using one of those broader selectors is a suppression, not an acknowledgment, and the loader rejects it outright; - carries a required, non-empty
reason— an acknowledgment with no stated reason is exactly the kind of accidental broad acceptance the vision's invariant warns against; - may be scoped to a component and a release range (
baseline/candidateversion labels, the same labels longitudinal history (abicheck project history) tracks). A record that names a component or candidate a run does not supply never matches; it is never resolved "to the nearest" acknowledgment.
Matching is strict, so an acknowledgment accepts exactly what it names:
- A
symbolrecord must name achange_kind. Without one it would accept every change on that symbol — a return-type change and a parameter change, say — including ones the author never reviewed. The loader refuses it. Usefinding_idto name one finding without a kind. - A base
change_kindalso accepts its*_elf_onlyvariant. A record namingfunc_removedaccepts afunc_removed_elf_onlyfinding too: the variant is chosen by whether headers were supplied, not by the change. A record naming the*_elf_onlykind accepts only that kind. Seechange_kindand ELF-only variants. - A record matches one finding per run. A
finding_idis not unique per finding by design, so one record can, in principle, match two distinct findings in the same run. That is a hard error, never a silent double acceptance: narrow the record withchange_kind,componentorcandidate. - An unscoped record warns. A record with no
component,baselineorcandidatematches that finding in every release. That is legal, and the warning says so; scope it, or give it anexpiresdate. - An expired record never matches. Its finding is no longer acknowledged and gates as usual, and the run prints a warning naming the record and its expiry date.
An ambiguous match — more than one loaded record matching the same change — is a hard error too, not a silently-resolved pick: that case requires review.
File format¶
version: 1
acknowledgments:
- symbol: "_ZN3Foo6removeEv"
change_kind: func_removed # required with symbol
component: libfoo
candidate: "2.0.0"
reason: "Deliberate removal — replaced by Foo::erase(); see #482"
reference: "https://github.com/example/libfoo/issues/482"
expires: 2026-12-31
- finding_id: "e99c3be122c2ddf3" # changes[].canonical_finding_id in the JSON report
reason: "Planned public addition for the 2.0 release"
Same YAML envelope and loader machinery as
suppressions (version: 1, one top-level
list key) — but a narrower key set: finding_id, symbol, change_kind,
component, baseline, candidate, reason, reference, expires. Any
suppression-only broad-selector key (symbol_pattern, type_pattern,
namespace, entity_namespace, cause_namespace, source_location,
member_name, binding) is a load error.
Copy a finding_id from the report's changes[].canonical_finding_id in
-o json=… output: it is backend-independent, so the record still matches
after an --ast-frontend switch.
Breaks: breaks: gate | accept¶
An acknowledged break — a change classified BREAKING or API_BREAK — is
handled by acknowledgments.breaks:
gate(the default) keeps the break's gate contribution. Acknowledging it changes the record, not the exit code: the run still exits4(or2). This is what every run did before the setting existed.acceptlets an acknowledged break stop contributing to the exit code. Its disposition moves fromgatingtonon_gating, recorded under theacknowledgmentapplication point, with the record that accepted it. Only that break is affected: an unacknowledged sibling break on the same run still exits4.
Under accept the verdict is still BREAKING, the finding stays in the
report, and the release recommendation still reads it as a major-class break.
Accepting a break changes what gates the run, never what the comparison found.
breaks is a gate setting, so it has the same trust boundary as the records:
it applies from an explicit --config, from a --policy document's own
acknowledgments: block, or from a typed CompareRequest. An auto-discovered
.abicheck.yml cannot set accept.
The additions review gate¶
A project configures whether an unacknowledged public addition is
flagged with acknowledgments.unacknowledged_additions in .abicheck.yml
(above). A --policy document may state the same setting in its own
acknowledgments: block; when it does, it wins over .abicheck.yml. With a
gate set and no file, nothing is acknowledged, so every public addition
counts.
allow(the default): no existing run changes.warn: every unacknowledged public addition is listed in thedisposition_audit.unacknowledged_additions_reviewreport block, but the exit code is unaffected.block: the same list contributes an orthogonal1to the exit code — raising a clean0to1, never lowering a real ABI/API-break exit2/4— the same fold contract coverage and analysis assurance already use. The report'sexitblock names it asadditions_review_contribution(reasonadditions_review), and the GitHub Action publishes the verdictADDITIONS_UNACKNOWLEDGEDwhen it is the only thing that gated the run. This never reclassifies the addition itself: itsChangeKindand verdict class are untouched either way.
Protecting the records on pull requests¶
On a pull_request trigger the checkout is the pull request's own tree, so a
pull request can edit the records file and accept its own findings. Gate that
file with the same reusable check that protects committed baselines
(protect-committed-baseline):
a pull request that touches the records fails unless it carries the bypass
label, which a reviewer adds only after reading the change.
jobs:
protect-acknowledgments:
uses: abicheck/abicheck/.github/workflows/protect-committed-baseline.yml@<PINNED_COMMIT_OR_RELEASE>
with:
protected-paths: |
abi/acknowledgments.yml
bypass-label: acknowledgments-reviewed
Add a paths: filter naming the records file to the triggering workflow, as the
baseline reference shows, so the check runs when the file changes. The check
protects only the path it is given: list every records file a project uses.
What the report shows¶
Every acknowledged finding's record id and the additions-review result
appear in the disposition_audit report block — see
Disposition audit for the full shape. The record id is
component::baseline::candidate::subject, where a symbol record's subject is
symbol[change_kind].