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/report/release_document.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; the resolver is
abicheck/policy/disposition_acknowledgment.py's resolve_acknowledgments,
called by the ledger close in abicheck/policy/disposition_close.py's
finalize_ledger), 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
(an ExitDecision axis since 2026-10: exit.additions_review_contribution,
reason additions_review, report schema 5.13) — 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). That follow-up landed (2026-10) for a
single-pair compare, then for the directory/package fan-out and the typed
API's CompareRequest: .abicheck.yml's acknowledgment: block names the
records file (loaded only from an explicit --config, the trust boundary
contract.overlays has) and unacknowledged_additions; a --policy
document's own acknowledgment: block outranks it (ADR-049 D7); the
Action publishes ADDITIONS_UNACKNOWLEDGED; the release fan-out folds each
member's contribution with max; CompareRequest.acknowledgments_path/
acknowledgment_unacknowledged_additions carry both settings. See
Change acknowledgment for the user-facing
format and gate contract.
Break gate, matching strictness, and the remaining surfaces (2026-10-20,
the ADR-067 P8 slice). An acknowledged break now has an explicit gate
decision, the one D5 left to policy: acknowledgment.breaks: gate|accept
(abicheck/model/acknowledgment_policy.py, carried on AcknowledgmentPolicy,
resolved once by workflows/acknowledgment_inputs.py). gate (default) keeps
today's behaviour: the record is an overlay and the exit code is what the break
alone decides. accept records an acknowledged gating break as non_gating
under the acknowledgment application point
(abicheck/policy/acknowledgment_breaks.py) and drops it from the population
the compatibility exit is decided over; the verdict, the changes list and the
release recommendation are unchanged, and only that finding moves, so a sibling
break on the same run still gates. Matching is strict: a symbol record must
name a change_kind (PolicyError otherwise, G2); a record that matches more
than one change in a run is AmbiguousAcknowledgmentError (G3); an unscoped
record and an expired record each produce a warning, and an expired record
never matches. The surfaces are compare --acknowledgments FILE (overrides
acknowledgment.file) and the Action's acknowledgments input, both over the
same resolver, with CompareRequest.acknowledgment_breaks and
checker.compare(acknowledgment_breaks=...) for the typed API. Trust: a
discovered .abicheck.yml cannot supply breaks, and the Action's overlay
builder strips acknowledgment.file and breaks from a discovered document
(G5: a pull-request-planted records file had been reaching the explicit
--config overlay). Provenance on pull_request is the existing
protect-committed-baseline reusable check, documented in the user guide. The
capability reachability invariant (tests/test_policy_capability_reachability.py)
keeps each policy capability reachable from every front end that claims it.
Not in this slice: unused/expired acknowledgment rows in disposition_audit
(owned by the audit workstream), and the base/head policy delta (S4).
Owner (2026-10, behavior-preserving refactor). D1's "record before
disposing" is now owned by one module rather than by caller discipline:
abicheck/policy/finding_disposition/ runs the disposition steps in their
default order (dispose_findings, default_pipeline.py), always with a
disposition ledger (one is created when the caller supplies none), and every
suppression application point named above shares one primitive,
dispose_by_suppression. Root-type redundancy and deduplication live in
abicheck/compare/finding_redundancy.py/finding_dedup.py, finding identity
in abicheck/compare/finding_identity/. References above to
post_processing.ApplySuppression, checker._filter_suppressed_changes and
finding_identity.py describe where those mechanisms lived when each slice
landed.
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.
Addendum (2026-10-20): configured-rule inventory and the unused-rules gate¶
Amends D3 and adds a gate. The decisions above are not edited in place.
Problem. Every audit aggregate was a projection of the disposition
records, so a rule that disposed of no finding was absent from
disposition_audit: a zero-match suppression, a reclassify: rule that
matched nothing, an acknowledgment that covered nothing. A rule whose selectors
matched a finding an earlier rule had already claimed was also absent. "Record
before disposing" held for findings but not for the rules configured to act on
them.
Rule id (D3). A rule's rule_id is its optional id: when the document
states one, and otherwise rule-<12 hex>, a SHA-256 digest of its canonical
rule identity (abicheck/policy/rule_identity.py: sorted-key JSON of the
rule's selector and gate fields, dates in ISO 8601, never a Python repr()).
Amended 2026-10-23: the id-less form used to be the identity string itself,
which embedded repr() spellings such as datetime.date(2027, 4, 1); it is
now a portable token matching [A-Za-z0-9._-]+. A label is not used as the
fallback because its uniqueness depends on the whole policy, and the
per-change provenance must derive the same id from the rule alone. The
readable identity remains in the effective-configuration receipt (ADR-049 D7). Amended 2026-10-24 (report schema 6.6): an id-less rule's audit row and ledger provenance also carry that readable identity as rule_identity next to rule_id; suppression_audit.stale_rules renders the same identity and the parallel stale_rule_ids names each entry's rule_id, so stale entries join to audit rows; the release recommendation names an id-less rule as rule-<hex> (label: …), or by its identity when it has no label. label is a grouping tag and reason is
prose: both are reported as their own fields and are never the identifier.
id: is accepted in both the suppression and the reclassify: grammars, must
be a non-empty string, and must be unique within its document; a repeat is a
PolicyError. id: never affects matching. Two byte-identical entries are two
rules, and each row carries entry_index, its zero-based position in the file.
A reclassify: rule's identity no longer embeds an enum repr (its to_verdict
is covered by to).
Configured rules are recorded (D3's "may" becomes "must" for configured
rules). Every configured suppression, reclassify and acknowledgment rule has
exactly one audit row, zero-count rows included, in the JSON
disposition_audit.rules, reclassifications and acknowledgments
(abicheck/policy/rule_inventory.py, abicheck/policy/disposition_rules.py).
The rows are the one source for the human views, the suppression_audit
stale list and the gate, so they cannot disagree. Human views collapse
zero-count rows to one line; the JSON keeps every row.
Counting. matched_count keeps its meaning: findings the rule disposed
of. For a suppression, first-match credit. For a reclassify rule, the
verdict-changing decider, which the JSON also names changed_count. For an
acknowledgment, the findings it covered. shadowed_count is every other finding
the rule's selectors matched: one an earlier rule claimed, or a no-op
reclassification. selector_matched_count is their sum.
Status (per rule, 2026-10-21). Each row carries one of: active
(disposed of at least one finding); shadowed_only (matched, disposed of
none); expired (past its expires date, with nothing disposed of); unused
(matched nothing, and at least one detector that can produce one of the rule's
change kinds ran); not_evaluated (matched nothing, and none of those detectors
ran). The check runs in that order. A rule's candidate detectors are those that
can produce its kind/change_kind selector; a rule with no kind selector can
match any kind, so every detector that produces a kind is a candidate. A kind
that no registered detector produces leaves its rule not_evaluated. This keeps
D3's "absent is not removed": a rule is never unused unless a check that could
have matched it ran. Acknowledgment rows take the same statuses; ambiguous-match
handling is unchanged.
Which detectors can produce a rule's kind (resolved 2026-10-21). The status
above was first decided globally: any refused detector made every unmatched
rule not_evaluated, so unused was reported only when every detector ran.
Detectors do not declare their kinds in the registry, so the mapping is derived
once, from the source, into abicheck/policy/detector_kind_map.py
(scripts/gen_detector_kind_map.py). For each registered detector it lists the
ChangeKind values its function reaches through first-party calls and
references, and it lists the kinds no detector reaches. tests/test_detector_kind_map.py
fails on drift, on a missing detector or kind, and on any kind a detector emits
on the controlled mutation catalogue that its entry does not credit. The map is
an over-approximation where the walk cannot resolve a call, so it errs toward
unused. A refused detector no longer affects a rule it cannot have checked.
The ELF-only comparison that prompted this now reads a stale symbol-kind rule
as unused, and a DWARF-only kind as not_evaluated.
The unused_rules gate (D6's pattern). A top-level policy key
unused_rules: allow|warn|block (default allow, so no existing run changes)
and no command-line flag (the former --fail-on-unused-rules was removed; the
policy key is the only setting). block contributes 1 when at least one
configured rule has status unused. It is a fourth 0/1 exit axis folded with max
(ExitDecision.unused_rules_contribution, reason unused_rules), so it never
lowers a 2, 4, 7, 8 or 16, and it never changes what was observed
(abicheck/policy/unused_rules_gate.py). Only the unused status triggers it.
A scalar compare evaluates its own rules. For a directory or package release
the gate is evaluated over the union of members: a member could evaluate a rule
when its status is anything but not_evaluated. A rule is unused for the release
only when every member that could evaluate it left it unused, and at least one
could. A member that could not evaluate it is ignored. The release audit row
folds with the same rule (fold_rule_status), so the gate and the row agree. The
strictest member mode applies.
Schema. Report schema 6.3 (abicheck/schemas/__init__.py): the row fields
above, disposition_audit.unused_rules_review and
exit.unused_rules_contribution. suppression_audit.stale_rules is now a
projection of the same inventory and lists rule ids. Two changes are not purely
additive and are in the changelog: a configured rule with no finding now appears
in rules, reclassifications and acknowledgments, so a consumer that assumed
non-empty rows must tolerate zero-count rows; and a reclassify rule's rule_id
is its id: or canonical identity instead of its label or reason.
Reclassification's per-finding display label is unchanged. The
reclassified_by field on a finding keeps its label-or-reason-or-verdict value.
The audit keys reclassify rows on the configured rule, not on that display
string.
Still open. A kind that a pipeline step (not a registered detector) emits,
such as inline_body_changed, reads not_evaluated even when that step ran,
because no detector run can be credited with it. Recording pipeline steps as
producers would need the same per-step evaluation record, and is not decided
here. The row's optional list of candidate detectors that did not run is not
added to the JSON report. The acknowledgment breaks: gate|accept policy is
owned by its own change and is not decided here. (Partly superseded by
addendum 2 below.)
Addendum 2 (2026-10-21): observed kinds, and not_evaluated as a warning¶
Amends the per-rule status and the unused_rules gate above.
Investigation. Real gcc-built C comparisons were run ELF-only,
ELF+headers, ELF+DWARF and ELF+DWARF+headers, and every rule shape was
classified against the detectors that ran. No detector that ran was missing
from DETECTOR_KINDS; the map was not too narrow for detectors. The gaps
were elsewhere. (1) The kinds no registered detector emits (NON_DETECTOR_KINDS,
the kinds policy/detector_kind_map.py lists under NON_DETECTOR_KINDS, among them public_surface_shrank, emitted on every run
with a removal) could never read unused: a stale rule on them was always
not_evaluated, so block never fired for them. (2) Kinds only the DWARF
detector emits (struct_size_changed, struct_field_type_changed) are
correctly not_evaluated without DWARF, but nothing told the user so: under
warn/block such a rule was silent.
Observed kinds are proof of evaluation. A zero-match rule is also unused
when the run recorded a change of one of its kinds, in any disposition
(changes, suppressed_changes, redundant_changes,
out_of_surface_changes, reconciled_changes;
policy/disposition_rules.observed_kinds). The producer of that kind
demonstrably ran, and the rule was checked against real findings of its kind
and matched none. This is sound for detector and pipeline kinds alike and
needs no per-step evaluation record. A pipeline kind that the run did not
record still reads not_evaluated: a step that ran and found nothing is
still indistinguishable from one that did not run, which stays open.
missing_evidence. Every not_evaluated row carries missing_evidence
(JSON rows and the release fold): the candidate detectors that did not run,
each with its evidence gate's coverage_gap, or, for a pipeline kind, that no
registered detector produces it and its producer recorded no change of it.
not_evaluated is a warning, not a gate (minimal design). Under
unused_rules: warn and block, unused_rules_review.not_evaluated lists
every not_evaluated rule with its missing_evidence, and the Markdown audit
renders it as a warning. Under allow (the default) nothing changes. It never
contributes to gate_contribution: missing evidence narrows a conclusion and
never manufactures a failure (ADR-063), and a user who needs the evidence
present already has require-complete-analysis / the analysis-assurance
gate. A separate unused_rules_not_evaluated: block opt-in was considered and
not added: it would add a policy key, a digest field and a release-fold rule
for a case the assurance gate already owns. It stays a maintainer decision.
Schema. Additive inside disposition_audit (both objects allow
additional properties): missing_evidence on not_evaluated rows and
unused_rules_review.not_evaluated when the mode is not allow. No exit code
changes: a rule that moves from not_evaluated to unused under block can
newly raise a clean 0 to 1, which is the gate's documented meaning.