ADR-067: Change-Intent Acknowledgment and the Policy-Disposition Audit¶
Date: 2026-09-05
Status: Accepted — S1, S2 and S3 implemented; S4 not implemented.
Design record for the vision's "intent and configuration accountability"
decisions (vision.md). The audit half (D1–D4) extends
ADR-013/024/044/049/063/064 and the report owners and is sequenced first,
without waiting for history (ADR-066); the acknowledgment half (D5–D7) is
the one genuinely new mechanism, landed by S3 (see below); S4's base/head
policy-delta and suppression-growth warnings remain unimplemented. What S1
landed, for the scalar single-pair compare path: one conserved
disposition ledger (abicheck/policy/disposition_ledger.py,
abicheck/policy/disposition_close.py) that all five suppression
application points record into
(abicheck/post_processing.py, abicheck/checker.py,
abicheck/appcompat.py); the raw-versus-effective counts and rule
provenance in every scalar projection — JSON, --stat JSON, one-line,
review digest, PR comment, SARIF, JUnit and HTML
(abicheck/report/disposition_audit.py, abicheck/reporter.py,
abicheck/reporter_markdown.py, abicheck/report/render_text.py,
abicheck/pr_comment_render.py, abicheck/sarif.py,
abicheck/junit_report.py, abicheck/html_report.py) under report schema
2.50; a
not_evaluated detector state (abicheck/detector_registry.py,
abicheck/detectors.py); and abicheck/semver.py's release
recommendation reading the conserved delta instead of the
post-suppression change list — the one behavior change, carried with a
migration note. What S2 added on top: the release/bundle fan-out's JSON/
Markdown reports and --output-dir summary.json sidecar gain a folded
disposition_audit block over every library
(abicheck/cli_compare_receipt.py's release_disposition_audit_block,
abicheck/report/disposition_audit.py's fold_disposition_audits);
abicheck aggregate --format json gains the same folded block plus a
disposition_audit_missing_targets list (aggregate schema 1.9,
abicheck/workflows/aggregate/disposition_axis.py); reclassification is
now actually recorded through the ledger
(DispositionLedger.resolve_reclassifications, surfaced as
reclassified_total/reclassifications); an out_of_contract/
unresolved_relevance scope exclusion gains a scope_reasons breakdown by
contract-relevance reason code; and the consumer-scoped path
(abicheck/appcompat.py) reaches full parity with the other four
application points — both scope_diff_to_app (--used-by) and
scope_diff_to_required_symbols (--required-symbol(s)) now synthesize
the same suppressible, ledger-recorded CONSUMER_REQUIRED_SYMBOL_REMOVED
overlay for a missing symbol/entrypoint no diff Change names, sharing one
recording primitive
(abicheck/policy/disposition_close.record_and_maybe_suppress_overlay).
Report schema 3.0 -> 3.1. One S2-adjacent gap remains, outside S2's own
scope: scan reports carry no disposition_audit block at all (that
command has no scalar compare-shaped audit to fold from), so
aggregate's own fold can only distinguish "no block present" (a
scan-sourced or pre-schema-2.51 target, listed in
disposition_audit_missing_targets) from "an audit exists" — aggregate
does not itself supply the missing block for scan.
What S3 landed: D5's acknowledgment records — a distinct disposition
from suppression (an acknowledgment is intentional acceptance; a
suppression is a claimed false positive or scope exclusion), sharing the
same SelectorSet grammar and YAML loader shape suppression already uses
(abicheck/policy/acknowledgment.py's Acknowledgment/
AcknowledgmentList), but restricted to a bounded selector subset
(finding_id/exact symbol, optionally narrowed by change_kind, plus a
component/baseline/candidate release scope) — every suppression-only
broad selector (symbol_pattern, namespace, source_location, ...) is a
hard load error, and an ambiguous match across two loaded records is a hard
error too, never a silent nearest-match (both directly enforce D5 and
vision.md's "a baseline refresh or a broad ignore rule is not an
acknowledgment" invariant). Acknowledgment is recorded through the
identical ledger primitive suppression already uses
(DispositionLedger.resolve_acknowledgments, DispositionRecord.
acknowledged_by — a D2 overlay attribute, exactly like reclassified_by,
never a seventh terminal disposition), surfaced in the disposition_audit
report block (schema 3.10: acknowledged_total/acknowledgments) the same
way reclassification already is. D6's additions review gate
(abicheck/policy/acknowledgment_gate.py) is a policy file acknowledgment:
block (unacknowledged_additions: allow|warn|block, default allow — no
existing run changes; abicheck/policy_file_acknowledgment.py), evaluated
as a fourth orthogonal 0/1 exit axis folded with max() beside contract
coverage and analysis assurance
(policy.acknowledgment_gate.fold_additions_review_exit) — it never
reclassifies an addition's ChangeKind or verdict, per D6's own "policy
acceptance, never reclassification" requirement. Record ids share B's
component/release-label scheme (Acknowledgment.record_id, built from the
same component/baseline/candidate strings
workflows.history.HistoryEntry.version already carries), without waiting
on B-S2's own versioning-policy resolution. Engine-level wiring only in
this slice: checker.compare(acknowledgments=...) (the typed Python API)
and the CLI's own exit-code fold (frontends.cli.runtime.
_exit_with_severity_or_verdict) both honor a supplied
AcknowledgmentList; no native compare/scan CLI flag to load an
acknowledgment document from a path exists yet — that front-end wiring is
left to a follow-up, the same staged "engine primitive, then CLI surface"
sequencing ADR-049's own contract-coverage axis used (Phase 5 landed the
ledger; Phase 7 wired the exit). See
Change acknowledgment for the user-facing
format and gate contract.
Implementation is sequenced in
plans/vision-api-abi-evolution.md
(workstream "Policy-disposition audit and change acknowledgment").
Decision maker: maintainer (product decision recorded in vision.md);
accepted as recorded above — the "technical sign-off pending" note in an
earlier draft of this line referred to review of this ADR document itself,
which review and merge have since completed (S1/S2 landing implies sign-off
occurred).
Context¶
Suppression has one selector grammar but four application points —
post_processing.ApplySuppression.apply() over the main change list,
checker._filter_suppressed_changes() and checker._filter_pattern_synthetic()
over separately produced changes, and appcompat.py's consumer-overlay
pass — sharing one selector grammar with reclassification (abicheck/policy/selectors.py,
SelectorSet, ADR-063 Phase 9), per-rule reason/label/expires/
reachability/allow_public_break/finding_id fields, an in-memory
ledger (DiffResult.suppressed_changes), and a SuppressionAudit of
stale, expired, and high-risk rules. Reclassification stamps
Change.reclassified_by; out-of-surface changes have their own ledger
under the JSON scope block (ADR-024); redundancy/dedup has
redundant_count; contract coverage failures are structurally
unsuppressible (ADR-049 Phase 5). The effective configuration digest
records the policy and suppression content hashes.
What is missing is the audit contract on top of these mechanisms:
- The JSON suppression ledger drops the rule that matched and its reason
(
reporter._suppressed_change_entryemits kind/symbol/description only), so "which rule hid this and why" is computed and then lost. - Reclassified and reconciled changes have no disposition-keyed ledger; the one-line format and the review digest carry no suppression totals; the PR comment's suppression note is a trailing blockquote.
- There is no first-class acknowledged state. The nearest is
allow_public_break, after which the break disappears fromchangesand the release recommendation silently degrades to "no bump needed" with no trace that a major-worthy break was accepted. - A disabled detector is recorded as
enabled=True, changes_count=0(DetectorRegistry.run_alldoes not distinguish "ran, empty" from "did not run"), so the headline reads "0 changes" where it should read "not evaluated". - Neither finding id is a sound acknowledgment key on its own:
report_finding_idfolds rawsource_location/description(not backend-stable), andreport_canonical_finding_idis backend-stable but explicitly not unique per finding. - The suppression file's path is not recorded (only a content hash), and nothing compares a base revision's policy with a head revision's.
Decision¶
D1 — Immutable normalized changes precede every disposition¶
The observed change set — each change with its evidence, selected scope, and contract relevance — is recorded once, before suppression, reclassification, scope exclusion, acknowledgment, deduplication, or display filtering acts on it, and is never mutated by any of them. Every downstream consumer that needs raw inputs (bundle-internal detectors, consumer impact, history) reads this set, so a suppression can never starve a sibling or consumer finding by deleting its input (this generalizes G38 Phase 14's fix for public-surface scoping).
Clarification (2026-09): the observation and its evaluated relevance are
two objects, not one immutable record. A documentation review found a
real tension between this decision's own wording ("the observed change
set... with its evidence, selected scope, and contract relevance... is
recorded once... and never mutated") and D4's requirement to show how a
different resolved policy/suppression context changes dispositions over
the same detected facts (a PR's base-vs-head policy diff) — contract
relevance is itself the output of evaluating a detected fact against a
particular resolved contract mode (ADR-049's CompatibilityEvaluationConfig),
so if it were baked into the one immutable record, evaluating a second
resolved context (D4's base/head comparison, or ADR-066's later
re-evaluation of old history under a revised policy) would have nowhere to
put its answer without violating "never mutated." The correct model,
already implicit in ADR-049's own replay/re-evaluation design
(contract_replay.py's replay_original_decisions()/
reevaluate_from_evidence()): an immutable observation (the detected
change, its evidence, and its selected scope — never contract relevance)
plus, separately, an immutable evaluation result for one resolved
context (contract relevance, compatibility decision, gate contribution —
everything this decision's own "contract relevance" phrase was describing).
Re-evaluating under a different resolved context (a different policy
revision, a different contract mode, a later ADR-066 history re-read)
produces another evaluation-result object keyed by its own context, and
never mutates the observation or rewrites an earlier evaluation-result's own
receipt — the same "replay vs. reevaluate, never in place" split ADR-049
Phase 4 already established for its own persisted context. D1/D2's
"immutable" language throughout this ADR should be read with this split
already applied.
D2 — One change, one terminal disposition, many matches¶
Each atomic detected change carries exactly one terminal effective gate
disposition for counting: gating, non_gating (evaluated by policy,
contributes nothing to the gate), suppressed, out_of_contract,
unresolved_relevance, or deduplicated. Two transformations are
recorded as independent attributes on the same change, never as
alternatives to that disposition: reclassified (from → to, with the
rule) and acknowledged (with the record). A change reclassified from
COMPATIBLE to BREAKING is reclassified and gating; the reverse
is reclassified and non_gating; an acknowledged break is acknowledged
and whatever the policy makes its gate contribution (D5). Disposition
counts therefore sum to the detected total, while reclassified and
acknowledged counts are reported as overlays on that total, not added to
it. The change may additionally record every rule that matched, the
winning rule and why it won (precedence), and every consumer it affects.
Presentation is a separate, per-view attribute — shown, collapsed, or
filtered by --show-only, a report mode, or truncation — recorded
alongside the disposition, never in place of it: a change that is
suppressed and then omitted from a filtered view keeps its suppressed
disposition and still counts in that view's suppression total (which is
what D3 requires). Derived impact findings and grouped presentation rows
never inflate the raw totals. Totals reconcile across scalar, bundle,
aggregate, and compact reports, and across every presentation state — an
executable invariant.
These stay distinct and are named as such in the audit: technical compatibility (the verdict class); policy acceptance (the gate); an acknowledgment of intent; a suppression as a claimed false positive or a waiver (the rule's declared kind); proven out-of-contract; unresolved relevance; reclassification; deduplication/root-cause grouping; display-only filtering. An acknowledgment is not proof of compatibility, and a suppression is not proof the tool was wrong.
Clarification (2026-09): counting units stay separate across
aggregation. D2's "disposition counts sum to the detected total" invariant
needs one further precision a documentation review found this decision does
not yet state explicitly: a logical change can be observed more than once
along more than one independent axis — e.g. the same underlying signature
change detected in 3 compiler profiles of a declared build matrix, and
separately shown (via ADR-057's consumer graph / --use-cases attribution)
to affect 2 named consumers. That single scenario must never collapse
ambiguously into "1 change," "3 changes," or "6 changes" reported
interchangeably depending on which view happens to be read. Instead, four
counting domains stay separate, each internally consistent and reconciling
only within itself (D2's reconciliation invariant applies per domain, not
across domains): the logical finding count (one, keyed by canonical
identity — ADR-045/049's finding_identity — regardless of how many
profiles or consumers observed it); the profile-specific observation
count (three, one per build-matrix member that actually detected it,
per ADR-065's per-member acquisition/selection model); the affected
consumer count (two, from the consumer/use-case join, orthogonal to how
many profiles detected the underlying change); and the presentation
group count (however many rows a report's own grouping/root-cause
collapsing renders it as — D2's own "derived impact findings and grouped
presentation rows never inflate the raw totals" already governs this one).
A report may show any of these four numbers, but must always label which
one it is showing, and must never sum across domains (a report claiming
"6 changes" by multiplying profiles by consumers states nothing that answers
a real question).
Clarification (2026-09): technical classification survives policy
reclassification as a separately exposed value. D2 already records
reclassified as a from→to attribute distinct from the terminal
disposition; this clarification states the report-facing consequence
explicitly, since a documentation review found no requirement anywhere in
this ADR that a rendered report actually show the distinction rather than
only retain it internally. A report must be able to expose, for any
reclassified change, all three of: the original technical
classification (the raw detector ChangeKind's default_verdict — what
the underlying binary/source fact is, unaffected by any policy), the
effective classification under the resolved policy (D2's reclassified
to value, or the unreclassified original when no rule matched), and the
gate contribution (D5's acknowledgment-adjusted, D2's disposition-driven
number that actually reaches the exit code) — as three separately labeled
values, never collapsed into one "severity" field that silently reports
only the effective or only the gate-facing number. A reader auditing "did
policy hide a real technical break" needs the first value available next to
the third; a report that only ever shows the effective/gate value cannot
answer that question even when the underlying data (D2's reclassified
attribute) already has the answer.
D3 — The raw-versus-effective summary is unsuppressible and survives every view¶
Every report projection, including the one-line, review-digest, PR-comment, SARIF/JUnit, and aggregate views, carries: the detected total, the effective (gating) total, the per-disposition counts with rule provenance (rule id, source file, reason, expiry), and the analysis coverage. A view may collapse the detail; it cannot omit these counts. An ordinary per-finding suppression cannot suppress its own audit record or a coverage failure (the ADR-049 structural pattern, reused). The audit-warning severity may be configured separately, and that setting is itself recorded in the effective configuration.
When configuration disabled extraction or a detector before detection,
the audit reports the disabled scope or capability and the loss of
assurance — a not_evaluated detector state, never a fabricated
suppressed-change count and never "0 changes".
D4 — Policy provenance and widening are visible¶
The report records the resolved policy and suppression sources (paths and content hashes, extending the existing effective-config digest). Where a trustworthy base and head revision of the policy are available (a PR), the audit shows how the widened rules changed dispositions over the same detected facts. Suspicious suppression volume or rule expansion is reported against configurable thresholds with the denominator stated; a rule is never asserted erroneous merely because it matches many changes; with no prior audit available the report says so rather than inventing growth.
D5 — Acknowledgments are explicit, bounded, and reviewable¶
An acknowledgment is a record — in the suppression file's own YAML
(reusing SelectorSet, never a second grammar) or a sibling file the same
loader owns — with: the exact finding(s) or transition, or a tightly
constrained set; a component/variant/domain scope; a baseline/candidate
pair or release range; a reason; an optional expiry and reference. It is
matched by the canonical finding identity plus the entity/transition
discriminators needed to make that match unique for the run; an
ambiguous or unknown identity requires review and is never resolved to the
nearest old acknowledgment. A broad regex is a suppression, not an
acknowledgment.
An acknowledged change keeps its verdict class, stays in the report and in the release recommendation ("a known, accepted break" is still a MAJOR- class break), and contributes to the gate according to policy — so a project can accept an intentional break without the report pretending it is compatible.
D6 — Unacknowledged public additions are a configurable review gate¶
A project may configure allow, warn, or block for public additions
that carry no acknowledgment. The default is allow, so no existing run
changes. This folds through the existing gate/exit precedence (ADR-064)
as policy acceptance, never as a reclassification of the addition into a
break.
D7 — A baseline refresh is not an acknowledgment¶
Updating a baseline is storage activity. It does not acknowledge the delta it absorbs. A project that opts into a reviewed-baseline protocol scopes the approval to exact content digests and retains the prior delta and its audit trail; the plan owns that protocol's shape.
Consequences¶
- The suppression JSON ledger gains rule provenance (additive schema bump); reclassified/reconciled changes gain a disposition ledger; the compact views gain the raw-versus-effective row. Golden reports change additively.
DetectorRegistrylearns anot_evaluatedresult distinct from an empty one; headline counts stop reading disabled detectors as zero.allow_public_breakstays a suppression control, not an acknowledgment: it is precisely the gate that lets a broad namespace/source-location rule suppress a public-reachable break (Suppression._passes_public_break_gate), and D5 says a broad rule is a suppression. Its disposition issuppressed, counted and rule-attributed in the audit like any other suppression, with the suppliedreasonpreserved verbatim. The flag itself only bypasses the public-break gate; it does not say whether the author meant an intentional waiver or a claimed detector false positive, so the audit never inferswaiverfrom it. A new optionalintent:field on a suppression rule (waiver|false_positive) records that distinction explicitly; a rule without it is reported asintent: unspecified, which is the migration default for every existing rule and changes nothing about matching. The release recommendation reads the audit rather than the post-suppressionchangeslist, so a suppressed major-class break is surfaced as "suppressed (intent: waiver / false_positive / unspecified), not compatible" instead of today's silent "no bump" — a behavior change for runs using the flag, sequenced with a migration note. A project that wants a bounded acknowledgment writes an acknowledgment record (D5); an existing narrowallow_public_breakrule that already names one exact finding may be migrated to one explicitly by the S3 migration, never implicitly.- The finding-identity workstream (ADR-063 Phase 2B) gains a concrete consumer: an acknowledgment key that must be unique per run and stable across backends.
Relationship to existing decisions¶
Extends ADR-013 (suppression), ADR-024 (surface traceability), ADR-044 (reachability-aware suppression), ADR-049 (contract relevance, coverage ledger, config precedence), ADR-063 Phase 9 (one selector grammar), ADR-064 (gate/exit precedence), ADR-036/061 (report document). Integrates with ADR-066 through shared record ids. Introduces no second selector grammar, no second gate algorithm, and no new report framework.
Implementation slices¶
S0: inspect ledgers and ordering; agree the counting/identity/outcome
contract; amend the owning ADRs' status notes. S1: conserve changes and
expose raw-versus-effective counts with rule provenance on the native
compare path, including a 100-suppressed-removals fixture. S2: carry the
audit through bundle/consumer/aggregate and every projection; cover
reclassification, scoping, and upstream-disabled detectors. S3 landed:
explicit acknowledgment records and the additions review gate; share
records with history (see "What S3 landed" above). S4: base/head
policy-delta analysis and suppression-growth warnings.
Tests (contract)¶
100 removals plus a wildcard waiver (counts and rule visible on a passing
run); all additions acknowledged versus unacknowledged under each of
allow/warn/block; expired and stale acknowledgments; overlapping rules
with a stated winner; a finding reappearing in a later unrelated
transition; source-path and backend spelling changes that must not break
the acknowledgment key; scope exclusion with unknown reachability;
disabled detection with no raw facts (reads not_evaluated); a suppression
that would starve a sibling or consumer finding; report truncation
preserving the audit row; a policy widened in a PR; a baseline overwritten;
report/CLI/API/Action parity. Properties: policy and view changes never
mutate the raw fact/change set; audit counts conserve normalized changes
and never count matched rules as separate removals.