Skip to content

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 pointspost_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_entry emits 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 from changes and 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_all does 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_id folds raw source_location/description (not backend-stable), and report_canonical_finding_id is 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.
  • DetectorRegistry learns a not_evaluated result distinct from an empty one; headline counts stop reading disabled detectors as zero.
  • allow_public_break stays 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 is suppressed, counted and rule-attributed in the audit like any other suppression, with the supplied reason preserved 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 infers waiver from it. A new optional intent: field on a suppression rule (waiver | false_positive) records that distinction explicitly; a rule without it is reported as intent: 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-suppression changes list, 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 narrow allow_public_break rule 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.