ADR-044: Reachability-Aware Suppression and the Effective Public ABI¶
Date: 2026-07-17
Status: Accepted — P0 slice implemented: pipeline-order correctness fix,
Suppression.reachability/allow_public_break, entity/cause namespace
split, suppression_would_hide_public_break diagnostic. P1 (first-class
detection) is now also implemented — see "P1 slice" below: L5 call-graph
evidence wired into MarkReachability/DetectInternalLeaks, the new
internal_symbol_required_by_public_api overlay ChangeKind, a third
reachability_kind value (symbol_availability), structured JSON/SARIF
reachability fields, PolicyFile.internal_namespaces, and the two remaining
checker.py suppression call sites routed through the diagnostic-emitting
helper. P2 is now also implemented — see "P2 — empirical validation"
below: the consumer_required_symbol_removed ChangeKind promoting
--used-by's missing-symbol check to a first-class suppressible finding,
the opt-in --verify-runtime old-consumer/new-library execution probe
(consumer_runtime_load_failed, RISK-tier), and worked examples
(case192/case193) exercising the headline scenario and its deliberate
counter-example end to end.
Verified: main@2e43d53 on 2026-08-04
Decision maker: Nikolay Petrov (@napetrov)
Context¶
A field review of an oneDAL integration (PR 3693) found that a blanket namespace suppression —
— silently hid a genuine ABI break: a public inline function
(oneapi::dal::train()) called through to an exported detail::
specialization that the new library removed. Old applications compiled
against the public header fail to load against the new library — a real
func_removed break — but abicheck's report showed nothing, because the
suppression matched the internal symbol before the tool had a chance to
notice a public entry point depended on it.
The review's conclusion, and the premise of this ADR, is that this is not primarily a oneDAL configuration mistake — a project cannot reasonably be expected to hand-enumerate every internal symbol a public inline/template function happens to reach — but a tool correctness gap: abicheck already has the pieces to tell "truly unreachable internal churn" apart from "internal implementation detail that is part of the effective public ABI because public code depends on it," and does not consult them before suppression runs.
The pipeline-order bug, confirmed against the current code¶
abicheck/post_processing.py's DEFAULT_PIPELINE (the sequence compare()
actually runs) has this shape today:
...
FilterNonPublicSurface()
DemoteOffPythonSurface()
ApplySuppression() # ← suppression removes raw evidence here
SuppressRenamedPairs()
FilterRedundant()
EnrichAffectedSymbols()
AttributeStdlibEmbedding()
DetectInternalLeaks() # ← too late: the removed symbol's changes
# were already filtered out by ApplySuppression
DemoteUnreachableInternalChurn()
...
DetectInternalLeaks (internal_leak.detect_internal_leaks) works by
scanning the surviving changes list for layout/identity-affecting
kinds (_LEAK_TRIGGERING_KINDS) whose root type is internal, then walking
the public surface to see if that type is reachable. If ApplySuppression
already removed the triggering func_removed/type_*_changed entries for
detail::train_ops_dispatcher<...> because they matched
oneapi::dal::**::detail::**, DetectInternalLeaks never sees them —
there is no evidence left to correlate with the public-reachability walk,
so INTERNAL_TYPE_LEAKS_VIA_PUBLIC_API never fires, and
DemoteUnreachableInternalChurn's "confirmed leak" check has nothing to
confirm. The class of change the review calls out —
internal_symbol_required_by_public_api, a symbol-availability leak
via DECL_CALLS_DECL, not a layout leak — is not even in
_LEAK_TRIGGERING_KINDS at all (see "What this ADR does not fix" below);
but the ordering bug independently defeats every leak kind the pipeline
does implement today, for any change matched by a broad rule that runs
before the detector sees it.
ApplySuppression.run() (and every step downstream of it that adds new
findings — DetectCppPatterns, DetectNamespacePatterns,
DetectTemplatePatterns, DetectInternalLeaks itself) hand-applies
ctx.suppression.is_suppressed(c) to its own new findings — a # Synthetic
leak findings must respect user suppression rules too comment in
DetectInternalLeaks.run() — but that only stops a synthetic leak finding
from surviving suppression; it does nothing to restore the raw evidence
ApplySuppression already deleted upstream, so the leak was never computed
in the first place.
The second gap: entity namespace vs. cause namespace¶
suppression.py's namespace selector (_matches_namespace) matches a
change if any of change.symbol, change.caused_by_type, or
change.qualified_name falls in the namespace:
def _matches_namespace(compiled, change):
return (
_ns_match(compiled, change.symbol)
or _ns_match(compiled, change.caused_by_type)
or _ns_match(compiled, change.qualified_name)
)
caused_by_type is set in two situations that must not be treated alike:
- Redundancy linking (
diff_filtering._mark_as_redundant): a derived change on a type is linked to its own root cause, both of which are the same entity — safe to match on either. - Cross-entity attribution (
internal_leak._build_leak_change,crosscheck.py's leak/dependency findings): a public symbol's finding carriescaused_by_typepointing at the internal type responsible.func_params_changed/var_type_changedon a public function/variable whose signature changed because an internal root type changed (diff_filtering._mark_as_redundant, called from_filter_redundantfor_DERIVED_CHANGE_KINDS) is exactly this shape:symbolis public,caused_by_typeis the internal root.
A rule like namespace: "oneapi::dal::**::detail::**" matches case 2 via
caused_by_type alone — suppressing a finding whose symbol is the public,
breaking entity, purely because its documented cause happens to live in an
internal namespace. The suppression author who wrote the rule almost
certainly meant "hide churn inside detail," not "hide any public finding
whose explanation happens to mention detail" — the ADR's oneDAL example
(kmeans::descriptor vs. kmeans::detail::descriptor_base) is this exact
failure mode.
The one rule that does not change¶
Same authority boundary this codebase has used since ADR-024 §D4/D5 and
restated in ADR-041: suppression must never manufacture confidence it does
not have. A suppression rule may remove noise; it may never be the reason
a real, public-reachable break goes unreported. Every mechanism this ADR
adds is a safety default on top of existing opt-in suppression syntax — no
existing narrowly-targeted suppression rule (symbol, symbol_pattern naming
one entity, type_pattern) changes behavior. Only the two broad selectors
(namespace, source_location) — the ones that can match an internal
symbol a suppression author never explicitly reasoned about — get a new
default, and it is an opt-out default (reachability: any restores the
old behavior for a rule the user has audited).
Decision — P0 slice (this change)¶
D1. Compute reachability before suppression runs, not after¶
New PipelineStep, MarkReachability, inserted into DEFAULT_PIPELINE
before ApplySuppression:
FilterReservedFieldRenames … EnrichSourceLocations
FilterNonPublicSurface()
DemoteOffPythonSurface()
MarkReachability() # ← new: tags every change, before suppression sees it
ApplySuppression() # ← now reachability-aware (D2)
SuppressRenamedPairs()
FilterRedundant()
...
DetectInternalLeaks() # unchanged position: the underlying evidence it
# needs is no longer gone by the time it runs,
# because MarkReachability ran first and a
# public-reachable change survives ApplySuppression
DemoteUnreachableInternalChurn()
...
This is deliberately not the full literal reordering the review
sketches (moving FilterRedundant/DetectInternalLeaks themselves ahead of
suppression). That reordering would break the invariant ADR-013 and ADR-004
established — "suppression runs before redundancy filtering, so a suppressed
change never contributes to the verdict whether root or derived" — and would
require every downstream step that already hand-applies
ctx.suppression.is_suppressed() to its own new findings
(DetectCppPatterns, DetectNamespacePatterns, DetectTemplatePatterns,
DetectInternalLeaks) to be re-audited for double-suppression or
under-suppression. The actual bug is narrower than "suppression runs at the
wrong pipeline position": it is "suppression has no reachability signal to
consult." Giving it that signal — computed once, up front, independent of
whatever else the pipeline does to the change list — fixes the reported
failure with a much smaller blast radius, and is the literal mechanism the
review's own "Recommended implementation" section describes
(public_reachable: bool metadata attached to each change before matching).
MarkReachability.run():
- Calls
internal_leak.compute_leak_paths(ctx.old)andcompute_leak_paths(ctx.new)once — this is a pure function of the snapshot (function/variable/type declarations), not of the change list, so it is safe to compute before any filtering has happened and does not duplicateDetectInternalLeaks's own later call (that call still needs to run after redundancy filtering to decide which triggering changes turn into a synthetic leak finding; this one only needs the raw reachable-type → path map). - For each change, resolves its root type
(
internal_leak._root_type_name_for_change, the same helperDetectInternalLeaks/DemoteUnreachableInternalChurnalready use) and looks it up in the merged old/new path map. When found, sets: Change.public_reachable = TrueChange.reachability_kind—"value_embedding"wheninternal_leak._path_is_value_propagatingholds for at least one matched path, else"pointer_or_signature"— mirroring theembedded_by_valueseverity-hint distinction_build_leak_changealready renders in prose, now available as structured metadata.Change.reachability_proof_path—internal_leak._format_pathof the shortest matched path, e.g."fn:oneapi::dal::train → base:oneapi::dal::detail::train_dispatch → oneapi::dal::kmeans::detail::train_ops_dispatcher<...>". A change whose root type is not internal, or is internal but unreachable from the public surface in either snapshot, keepspublic_reachable=Falseand the two fieldsNone— the common case, so this is a purely additive per-change annotation with no effect on a project with no internal-leak surface at all.
Three new fields on Change (checker_types.py), all defaulting to
False/None — same additive convention as frozen_namespace_violation
and surface_exclusion_reason, no schema/serialization version bump needed
since JSON/SARIF/JUnit reporters already round-trip Change via
dataclasses.asdict-style field enumeration.
Post-merge review rounds (Codex + CI), same change:
- Perf regression. The first-shipped
MarkReachabilityrancompute_leak_pathsunconditionally on everycompare()call — CI'sbenchmark_scaling.pybaseline-regression gate caught up to +5075% on type/struct-heavy scenarios, since this duplicated the identical walkDetectInternalLeaksalready performs later, on every comparison, even when no suppression file is configured to ever consult the tag. Fixed by skipping the step entirely whenctx.suppression is None(mirroringApplySuppression's own no-op check) and, within a run, computing the leak-path walk lazily — only the first time a change whose subject is internal-namespaced is actually seen. - Pointer-only layout churn false-flagged.
MarkReachabilityoriginally marked any internal type reachable via any path (including a pure pointer/reference indirection) aspublic_reachable. ButDetectInternalLeaksdeliberately does not treat a pure-layout change reached only through a pointer as a leak (it is not consumer-visible), andDemoteUnreachableInternalChurnwould still correctly demote such churn later — so tagging it reachable only refused a broad suppression rule and appended a spurioussuppression_would_hide_public_breakdiagnostic for churn that was always going to be demoted anyway. Fixed by mirroringDetectInternalLeaks's own_IDENTITY_VTABLE_KINDS/_path_has_indirectionjudgment insideMarkReachabilitybefore tagging. - Directly-public subjects are a known, deliberately unclosed gap —
attempted, then reverted. The internal-type-leak walk
(
compute_leak_paths) only ever records internal type names — it has no notion of "this change's own subject is already public." A broadsource_location/namespacerule matching a genuinely public function purely by file path (e.g. a public function physically declared under a path asource_location: "*/internal/*"glob matches, with no internal-namespaced name at all) is therefore not protected by the reachability gate. A fix was attempted: broadenMarkReachabilityso any change whose subject is not internal-namespaced is markedpublic_reachable = Truedirectly (no leak-path proof needed), with the leak-path walk only consulted for a subject that is internal-namespaced (also needing the internal-namespace check to widen to match whatSuppression._ns_matchchecks at match time —Change.qualified_nameand a demangled form of the raw symbol, since a mangled/extern "C"symbol reads as a single opaque segment otherwise). That fix also exposed a realallow_public_breakscoping bug — the gate applied to every rule regardless of selector breadth, so an ordinary narrowsymbol:waiver of a known removal suddenly neededallow_public_breaktoo, regressingtest_suppression.py's basic suppression tests; corrected by scoping the gate to broad selectors only (D2 as written reflects this correction).
The broadening itself was then reverted, one CI run later: it regressed
tests/test_libabigail_parity_extended.py::TestSuppressionParity::
test_suppress_by_source_location — a private helper (internal_fn, no
namespace-segment hint) declared under src/internal/helper.h, matched and
correctly suppressed by source_location: "*/internal/*". Both that case
and Codex's public-function example are, structurally, the same shape:
an unqualified/non-namespaced Visibility.PUBLIC symbol under a path a
source_location glob matches. AbiSnapshot's visibility model marks
every exported C/C++ symbol Visibility.PUBLIC regardless of whether the
maintainer considers it part of the contract — that gap is the entire
reason source_location-based suppression exists, to compensate for C/C++
having no true "this is private" linkage visibility. No signal in the name
or the snapshot distinguishes "genuinely public, accidentally path-matched"
from "genuinely private, correctly path-matched," so no naming heuristic
can close Codex's gap without also breaking the ordinary case. Reverted
MarkReachability back to the leak-path-only computation; kept the
allow_public_break broad-selector scoping (independently correct) and the
pointer-only-layout fix above. Closing this gap for real needs actual
dependency evidence — the L5 call-graph / consumer-import work already on
the P1/P2 roadmap below — not a heuristic on the symbol's own spelling.
-
Skip the walk for narrow-only suppression files too, not just no suppression at all (Codex). The
ctx.suppression is Noneskip above only covers the no suppression configured case — but a suppression file containing only narrow rules (symbol/symbol_pattern/type_pattern, the common case: a handful of exact waivers) with the default (or explicit"any")reachabilityis also provably indifferent to the tag: both_passes_reachability_gate(short-circuits onresolved == "any") and_passes_public_break_gate(short-circuits onnot self._is_broad_selector) return without ever readingChange.public_reachablefor such a rule. Running the public-surface walk for that file is exactly the same waste thectx.suppression is Nonefix targets. AddedSuppressionList.needs_reachability_evidence()— true iff at least one rule is broad or has an explicit non-"any"reachability— and gatedMarkReachabilityon it alongside the existingNonecheck. -
A third late-detector synthetic-finding gap, this time for genuinely public (not internal-leak) findings (Codex). The two already-fixed cases (
internal_leak._build_leak_change,diff_templates._leak_change) cover findings whose subject is an internal type reached via a public entry point.diff_namespaces.py'sDetectNamespacePatterns— also running afterApplySuppression— has a different shape:EXPERIMENTAL_REMOVED_WITHOUT_REPLACEMENT/EXPERIMENTAL_GRADUATED(function path) andSTD_REEXPORT_REMOVEDbuild freshChanges for a subject that is itself public (anexperimental::/ re-exported function graduating or vanishing), not merely reachable from one. Untagged, a broadnamespace: "lib::experimental::*"rule's defaultunreachable-onlyreachability silently suppressed the API-break finding with no diagnostic — the same failure mode this ADR exists to close, one detector later than the two already-fixed cases. Fixed the same way: taggedpublic_reachable=True/reachability_kind="direct_public_symbol"at construction time in_emit_experimental_change/_build_std_reexport_change— but only for the function-sourced path._index_funcs_by_stable_key/detect_std_reexport_removedfilter onVisibility.PUBLICbefore ever building aChange, so those findings' mere existence already proves the subject is public — the same reliable signal the two already-fixed cases have. The type-sourced path (_index_types_by_stable_key) has no such signal:RecordTypecarries no visibility field at all (unlikeFunction/Variable), and that index walks every type insnap.typesregardless of whether it is genuinely public or an internal type that merely happens to have an "experimental"-segment name — tagging it too would reintroduce exactly the unreliable-heuristic problem that got the broaderMarkReachabilitybroadening reverted earlier in this same review cycle, just via a different code path._emit_experimental_change/_findings_forgained an explicitsubject_is_publicparameter so the two call sites (funcs vs. types) state their own reliability instead of the function silently assuming one for both. Unlike a raw pre-existing change (suppressed viaApplySuppression, which can attachsuppression_would_hide_public_break), these late-detector findings suppress inline via their ownctx.suppression.is_suppressed(c)call and have no diagnostic path — the same established scope boundary the two already-fixed cases also have; not being silently suppressed is the fix, a diagnostic for this whole class of finding is a separate, pre-existing gap this change does not newly introduce or attempt to close. -
A fourth late-detector sweep, this time the whole
diff_templates.pymodule (Codex). Fresh evidence beyond the namespace-detector fix above:DetectTemplatePatterns(also running afterApplySuppression) has the identical gap forCPO_KIND_CHANGED— a public name flipping between function and CPO-variable form. Rather than fix that one kind and wait for a further round to find its siblings, audited every detectordetect_template_patternscalls:CPO_KIND_CHANGED,OVERLOAD_SET_REROUTED, andUNSPECIFIED_RETURN_NOW_NAMEDall filter their source snapshot walk toVisibility.PUBLICbefore ever building aChange, so all three got the same construction-timepublic_reachable=True/reachability_kind="direct_public_symbol"tag as the namespace-detector fix.MANDATORY_TEMPLATE_PARAM_ADDEDwas deliberately left untagged — its arity index merges observations from both public functions andsnap.typesunder one shared stem key with no way to tell which contributed a given finding, the same no-reliable-signal problem the type-sourced namespace-detector path has; tagging it would reintroduce the reverted heuristic bug one level deeper. Also sweptdetect_missing_instantiations(INSTANTIATION_MISSING_FROM_BINARY, runs viaDetectCppPatterns, same after-ApplySuppressionposition, sameVisibility.PUBLIC-filtered construction) even though Codex's report didn't name it, since it is the same reliable-signal shape found while already auditing the module. A broader sweep ofdiff_cpp_patterns.py's remaining detectors (SYCL_OVERLOAD_SET_REMOVED,CPU_DISPATCH_ISA_DROPPED,TAG_TYPE_RENAMED,DEFAULT_TEMPLATE_ARG_CHANGED,INLINE_BODY_REFERENCES_RENAMED_MEMBER,BUNDLE_SONAME_SKEW— severalBREAKING) for the same pattern remains open; scoped out of this round to avoid rushing verification of six more detectors across two large files without individually confirming each one's visibility-filtering the way every fix above required. - A fifth late-detector gap, back in
diff_namespaces.pyitself (Codex).detect_namespace_patterns()also runsdetect_inline_namespace_version_bump, which was missed by the third round's sweep of that same module (that round covered_emit_experimental_change/_build_std_reexport_changeonly). It buildsINLINE_NAMESPACE_VERSION_BUMPEDfrom_emit_version_bumps, which readsold_list[0]/new_list[0]out of an index keyed by version-stripped namespace segments — and that index's entries come from_collect_versioned_entries, which merges public-function-sourced (f.namefiltered toVisibility.PUBLIC) and type-sourced (t.name, unfiltered —RecordTypehas no visibility field) observations into one list per key, same shape asMANDATORY_TEMPLATE_PARAM_ADDED's arity index. The difference here: each entry is a(qualified_name, version, kind)tuple that already carries which source it came from, so — unlike the arity index — the signal survives into_emit_version_bumpsand just wasn't read. Fixed by checkingold_list[0][2] == "function" and new_list[0][2] == "function"(both sides, sinceold_q/new_qboth flow into the emittedChange) before taggingpublic_reachable=True/reachability_kind="direct_public_symbol"— a type-sourced bump stays untagged for the same no-visibility-field reason as the arity index. - The
diff_cpp_patterns.pysweep the fourth round deliberately deferred (Codex). Fresh evidence namedTAG_TYPE_RENAMEDspecifically:detect_tag_type_renamedbuilds itsChangefrom a type pairing, but gates the finding on symbol evidence (only_removed/only_added) explicitly scoped to_PUBLIC_VISper its own docstring — the finding only exists when real public-surface mangled symbols embed the tag's leaf name, the same "finding's mere existence already proves public reachability" signal the earlier leak-finding and namespace/template fixes rely on. Rather than fix only the named kind, finished the sweep the fourth round scoped out:detect_sycl_overload_set_removal(SYCL_OVERLOAD_SET_REMOVED) anddetect_cpu_dispatch_isa_dropped(CPU_DISPATCH_ISA_DROPPED) both build their grouped findings exclusively from_PUBLIC_VIS-filteredold_funcs/new_funcs(plus, for the ISA detector, the raw PE/Mach-O export table — public by definition), so both got the same construction-time tag.detect_default_template_arg_changed(DEFAULT_TEMPLATE_ARG_CHANGED) is the same shape (old_funcs/new_funcsscoped to_PUBLIC_VIS), also tagged.detect_inline_body_renamed_member(INLINE_BODY_REFERENCES_RENAMED_MEMBER) was audited and deliberately left untagged: its_find_public_pimpl_holdershelper infers "public" fromnot is_internal_type(name)— a naming/namespace heuristic, not aVisibility.PUBLICfilter — the exact shape of the heuristic that was tried and reverted earlier in this cycle (see the D1 "directly-public subjects" entry above); tagging it here would reintroduce that reverted bug through a different detector.detect_bundle_soname_skew(BUNDLE_SONAME_SKEW) turned out to be a false alarm on the original P2 list: it is invoked frombundle.py's separatecompare-release/bundle-cohort command, never fromDetectCppPatternsor any path that runs throughMarkReachability/ApplySuppressionat all, so the pipeline-order bug this ADR closes does not apply to it. - Self-review:
entity_namespacemissing from the D4 diagnostic's own selector display. With CI green and Codex quiet, an independent self-review pass (prompted by "is this a full implementation, what's left") re-read the diff cold rather than re-trusting the prior rounds' conclusions, and found_build_suppression_overreach_change(post_processing.py) still fell back throughrule.namespace or rule.cause_namespace or rule.source_location or rule.symbol or rule.symbol_pattern or rule.type_pattern or "?"—entity_namespace, the canonical spelling introduced by D3's namespace/cause split, was never added to this chain, even though the equivalent string-building inSuppressionAudit(suppression.py) already includes it. A rule written withentity_namespace:(not the legacynamespace:alias) that triggerssuppression_would_hide_public_breakwould render as"?"(or whichever unrelated field happened to be set) in the diagnostic instead of naming the actual rule — undermining D4's whole stated purpose of "explaining why and how to override it." No test caught this: the existing regression test used thenamespacealias and asserted only that"allow_public_break"appeared in the message, never the selector text itself. Also noticed while fixing it:rule.symbol/rule.symbol_pattern/rule.type_patternin that same fallback chain are unreachable dead code —would_withhold()requiresnot self._passes_public_break_gate(change), and that gate returnsTrueunconditionally whenever_is_broad_selectorisFalse, which is exactly the case whenever any of those three (primary narrow selectors) is set — so a rule naming one can never reach this diagnostic at all. Fixed by addingrule.entity_namespaceto the chain and dropping the three dead branches (only the four broad-shaped fields —namespace,entity_namespace,cause_namespace,source_location— can ever actually appear here), plus a new regression test usingentity_namespaceonly and asserting the rendered selector text, not just a substring of the fixed suffix. DEFAULT_INTERNAL_NAMESPACESis a hard-coded convention list; a project using a different one is invisible toMarkReachability(Codex, P2).MarkReachabilitycalledcompute_leak_paths(ctx.old/new, DEFAULT_INTERNAL_NAMESPACES)with the walk's own hard-coded default (detail/impl/internal/__detail/_impl) with no way to override it. A project whose internal-implementation convention uses a different segment — Codex's example:ns::priv::*— is never recognized as "internal" by the walk at all, so a change on a type in that namespace never getspublic_reachabletagged, regardless of whether it is genuinely reachable from a public type. A broadnamespace: "ns::priv::*"suppression rule (defaultreachability="unreachable-only") then suppresses the change with no diagnostic — exactly the failure mode this ADR exists to close, just for any internal-namespace convention outside the default five tokens. Verified this is not a heuristic gap like the reverted D1 "directly-public subjects" fix above — sibling pipeline stepsDetectInternalLeaksandDemoteUnreachableInternalChurn(both pre-dating this ADR) already accept anamespaces: tuple[str, ...] | Noneconstructor override for exactly this reason;MarkReachabilitywas simply the odd one out, hard-coding the default with no override hook at all. Fixed by givingMarkReachabilitythe identical constructor parameter, so it is at least structurally consistent with its siblings. This does not fully close the gap:DEFAULT_PIPELINEstill constructs all three steps with no arguments (confirmed — no caller anywhere threads a non-default value today), so every project is still limited to the same five-token default until a real configuration surface exists. Deliberately did not attempt to auto-derive "the" internal segment from a suppression rule's own namespace glob (e.g. extracting literal segments from"ns::priv::*") — a pattern's leading segments are often shared with unrelated public types (e.g."oneapi::dal::**::priv::**"— "oneapi"/"dal" are not internal markers), so blindly harvesting them would misclassify public types as internal project-wide, the same unreliable-heuristic failure mode as the reverted D1 fix, just reached from the opposite direction. Closing this for real needs a genuine project-level configuration surface (e.g. aPolicyFile.internal_namespaces:key) threaded consistently throughMarkReachability/DetectInternalLeaks/DemoteUnreachableInternalChurn/DetectNamespacePatterns— added to the P1 roadmap below as a concrete, scoped follow-up rather than attempted reactively in this round.- A sixth late-detector gap, this time entirely outside
post_processing.py(Codex). Fresh evidence:pattern_verdicts. apply_pattern_verdicts()— invoked fromchecker._apply_pattern_verdicts_step, well afterpost_processing.DEFAULT_PIPELINE(and thusMarkReachability/ApplySuppression) has already run — appends newOPAQUE_INVARIANT_BROKEN/HANDLE_TYPE_CHANGEDChanges thatchecker._filter_pattern_syntheticthen runs through its ownsuppression.is_suppressed(c)call, the same "late synthetic finding, no diagnostic path" shape as thediff_namespaces.py/diff_templates.py/diff_cpp_patterns.pysweeps above, just reached from a completely different module (--pattern-verdicts, ADR-027, not part of theDEFAULT_PIPELINEsteps this ADR had audited). Audited both kinds:OPAQUE_INVARIANT_BROKEN's subject type is only ever taggedOPAQUE_POINTERinold_idioms(a precondition for this finding) whenidioms. _recognise_opaque/_public_pointer_onlyfound a genuineVisibility.PUBLICfunction referencing it — the same reliable signal the otherVisibility.PUBLIC-filtered late-detector findings have — so taggedpublic_reachable=True/reachability_kind="direct_public_symbol"at construction.HANDLE_TYPE_CHANGED's subject is a typedef alias:AbiSnapshot.typedefsis a plaindict[str, str]with no visibility field at all (typedefs, unlikeFunction/Variable, carry none), so_recognise_handlewalking every declared typedef gives no reliable public/private signal for the alias itself — deliberately left untagged, same reasoning asMANDATORY_TEMPLATE_PARAM_ADDED. Added regression assertions for both (including the deliberately-untagged case) totest_pattern_verdicts.py. A wider audit of whether any other ADR-027/pattern-verdict-adjacent modules construct late synthetic findings the same way remains open — this round only confirmed the two kinds Codex's fresh evidence named. RecordType.originwas a real, overlooked signal — closes three "deliberately untagged" cases from earlier rounds (Codex). Every prior round asserted "RecordTypecarries no visibility field, so a type-sourced finding has no reliable public/internal signal" —MANDATORY_TEMPLATE_PARAM_ADDED, the type-sourced path of_emit_experimental_change/EXPERIMENTAL_GRADUATED/EXPERIMENTAL_REMOVED_WITHOUT_REPLACEMENT, and the type-sourced path of_emit_version_bumps/INLINE_NAMESPACE_VERSION_BUMPEDwere all left untagged on that basis. That premise was incomplete:RecordTypedoes carryorigin: ScopeOrigin(ADR-024 D1's Linkage × Origin surface model), andScopeOrigin.PUBLIC_HEADER— set only under ADR-024's opt-in--public-header/--public-header-dirscoping — is exactly the reliable signal these three sites were missing. Without that flag every type'soriginisScopeOrigin.UNKNOWN(perScopeOrigin's own docstring), so this degrades to the prior untagged behavior automatically for the common (no public-header set) case — not a regression, purely additive. Fixed all three:diff_namespaces._emit_experimental_change/_findings_for: replaced the staticsubject_is_public: boolparameter withold_origins/new_originsmaps (Nonefor the always-public function path,{qualified_name: ScopeOrigin}for the type path), looked up per finding against the specificold_q/new_qsubject.diff_namespaces._emit_version_bumps/_collect_versioned_entries: the per-entry"function"|"type"string became a plainis_public: bool(Truefor aVisibility.PUBLICfunction,origin == ScopeOrigin.PUBLIC_HEADERfor a type) — no other caller read the old string value.diff_templates.detect_mandatory_template_param_added/_arities: now returns a second{stem: bool}map alongside the arity-set map,Truewhen any contributing observation for that stem (function or type) was reliably public. Deliberately "any observation" rather than "the specific min-arity-driving one" — a stem with genuine public evidence should not be treated as safe-to-hide by a broad suppression rule even if a sibling internal instantiation also happened to share the stem name; this stays conservative in the direction this ADR cares about (never hides a real public break), unlike the reverted D1 heuristic which risked the opposite (falsely claiming public reachability with zero real evidence). Added_rec_public()/ScopeOrigin.PUBLIC_HEADERregression tests alongside each existingScopeOrigin.UNKNOWN-default case intest_diff_namespaces.py/test_diff_templates.py. LeftINLINE_BODY_REFERENCES_RENAMED_MEMBER(diff_cpp_patterns.py) untouched — its untagged reasoning is a different shape (a namespace heuristic risking false public claims, not a missing origin signal), not somethingScopeOriginfixes.- Self-review follow-up on the
RecordType.originfix above (two minor findings)._emit_experimental_change/_findings_for's newold_origins/new_originsparameters were typeddict[str, object] | None— loose enough to accept any value type and lose the point of adding a typed lookup in the first place; narrowed todict[str, ScopeOrigin] | None. Separately,detect_experimental_namespace_changesbuilt those maps with a plain{t.name: t.origin for t in old.types}comprehension, which silently lets a laterRecordTypesharing an exact qualified name overwrite an earlier one's origin — inconsistent withpattern_verdicts._exact_record's established "first match wins" exact-identity convention elsewhere in the reachability code. Replaced with a new_origin_by_name()helper usingdict.setdefaultfor first-occurrence-wins semantics, with a docstring citing the_exact_recordprecedent. Two duplicate-namedRecordTypes in one snapshot is unusual input either way; this is a consistency fix, not a response to an observed bug. AddedTestOriginByNameregression tests (test_simple_lookup,test_duplicate_name_first_occurrence_wins) totest_diff_namespaces.py. _emit_version_bumpsrequired BOTH sides public-header-tagged, silently hiding an asymmetric old-consumer break (Codex, fresh evidence).subject_is_public = old_list[0][2] and new_list[0][2]meant a type version bump (ns::__1::queue→ns::__2::queue) stayed untagged whenever only one side carriedScopeOrigin.PUBLIC_HEADERevidence — e.g. the type moved out of the scoped public-header set, or--public-headerscoping only covered one snapshot. But the old side alone already proves the break: an application linked against the old public symbol breaks regardless of whether the new symbol also has public-header evidence — the same "old-side-only" reasoningEXPERIMENTAL_REMOVED_WITHOUT_REPLACEMENTalready uses (checks onlyold_origins, D1 above). Changedandtoor— either side's public-header evidence is now sufficient, matchingMANDATORY_TEMPLATE_PARAM_ADDED's "any observation" conservatism (stay tagged reachable when any reliable evidence exists, never require all of it). Addedtest_old_side_public_alone_is_reachable/test_new_side_public_alone_is_reachabletotest_diff_namespaces.py.- Late detectors dropped the withheld-rule diagnostic even after their
findings were correctly kept (Codex, fresh evidence).
DetectCppPatterns,DetectTemplatePatterns, andDetectNamespacePatternseach build freshChangeobjects afterApplySuppressionalready ran, so they filter their own findings through suppression by hand — but did so via the plainSuppressionList.is_suppressed()boolean, which silently discards the "matched but withheld by the reachability gate" informationSuppressionList.evaluate()reports. The finding stayed correctly kept (not suppressed — that part of the D1/D3 fixes above was never wrong), but theSUPPRESSION_WOULD_HIDE_PUBLIC_BREAKdiagnosticApplySuppressionwould have produced for the same rule never appeared, leaving users with no explanation of why their matching rule didn't apply. Audited every post-ApplySuppressionstep with the same shape and found a fourth,DetectInternalLeaks(not named in the report but the identical bug). Fixed all four via a new shared_merge_findings_respecting_suppression()helper that callsevaluate()and appends the same_build_suppression_overreach_change()diagnosticApplySuppressionitself builds, replacing each detector's own hand-rolled dedup-and-filter loop. AddedTestLateDetectorSuppressionDiagnostic(test_diff_templates's CPO case, plus a newDetectInternalLeakscase) and updated the two existing late-detector tests (test_experimental_removed_without_replacement_survives_broad_suppression,test_cpo_kind_changed_survives_broad_suppression) to assert the diagnostic now appears — their prior comments explicitly called this out as a documented, not-yet-closed limitation; this round closes it. Deliberately left open:checker.py's ownis_suppressed()call sites (_filter_suppressed_changes,_apply_surface_metrics,_filter_pattern_synthetic— the last being the ADR-027--pattern-verdictspath D3 above already partially audited) have the same shape but a different call signature (SuppressionList+suppressed: list[Change]directly, notPipelineContext) and were not part of Codex's report; converting them needs its own signature-compatible helper and individual verification, not a blind find-and-replace — tracked as a follow-up, not fixed in this round. MarkReachabilityitself never tagged a directly-public-header type's own change (Codex, fresh evidence).internal_leak.compute_leak_pathsonly ever records internal types found while walking outward from the public surface — a type that IS the public surface (e.g. a header-only type never referenced by an exported function/variable, so nothing walks "into" it from elsewhere) never becomes a key in its result, so a raw change on that type's own layout got no tag at all, even thoughRecordType.origin == ScopeOrigin.PUBLIC_HEADER(ADR-024's opt-in--public-headerscoping) is exactly the reliable signal already consulted for the late-detector findings indiff_namespaces.py/diff_templates.py. Fixed by building an origin-by-name map (reusingdiff_namespaces._origin_by_name) alongside the existing leak-path walk and taggingpublic_reachable=True/reachability_kind="direct_public_symbol"directly for a change whose root type carries that origin, before falling back to the leak-path check. Without--public-headerevery origin isScopeOrigin.UNKNOWN, so this degrades to the prior behavior automatically — purely additive, not a regression. Explicitly not the reverted "any non-internal-namespaced subject" heuristic this class's own docstring warns against:ScopeOrigin.PUBLIC_HEADERis an explicit opt-in tag, not a naming guess. Addedtest_public_header_type_own_change_is_reachable/test_non_public_header_type_own_change_stays_untaggedtotest_reachability_aware_suppression.py.- The public-header direct-tag above only looked at
RecordType(Codex, fresh evidence).Function/Variable/EnumTypeall carry the sameScopeOriginfield — a public-header function/variable/enum's own change had the identical gap theRecordTypefix above closes. Extended the direct-tag lookup to all four declaration kinds via a small_public_header_names()helper, plus owner-stripping forENUM_MEMBER_REMOVED/ENUM_MEMBER_ADDED/ENUM_MEMBER_VALUE_CHANGED/ENUM_LAST_MEMBER_VALUE_CHANGED—diff_types.pybuilds these findings'symbolas"EnumName::member", and unlikeSTRUCT_FIELD_*kinds this isn't stripped by the shared_root_type_name_for_change(deliberately left that shared helper alone rather than changing its existing behavior for the unrelated leak-path check). Addedtest_public_header_variable_own_change_is_reachable/test_public_header_enum_member_change_is_reachabletotest_reachability_aware_suppression.py. checker._filter_pattern_synthetichad the exactis_suppressed()vs.evaluate()diagnostic gap thepost_processing.pylate-detector fix above closed (Codex, fresh evidence) — this is the ADR-027--pattern-verdictspath (D3 above), a separate module invoked fromchecker._apply_pattern_verdicts_stepwell afterMarkReachabilityruns, so itsOPAQUE_INVARIANT_BROKEN/HANDLE_TYPE_CHANGEDsynthetics never got the withheld-rule diagnostic either. Unlike the fourpost_processing.pydetectors, this function's signature doesn't takePipelineContext(a plainSuppressionList+suppressed: list[Change]instead) — the exact reason this call site was left as an open P1 roadmap item in the round above. Fixed anyway since the change was small and self-contained:_filter_pattern_syntheticnow callsevaluate()and appends the same_build_suppression_overreach_change()diagnostic (imported frompost_processing.py; no import cycle —post_processingdoes not importchecker). Addedtest_lost_opaqueness_withheld_broad_rule_gets_diagnostictotest_pattern_verdicts.py. Narrows P1 roadmap item 6 to just the remaining twochecker.pycall sites (_filter_suppressed_changes,_apply_surface_metrics) — neither builds a fresh synthetic finding a suppression rule could plausibly want to match-but-withhold the same way (they filter pre-existing/aggregate findings, not late detector output), so closing them is lower priority than this one was.
D2. Suppression gains a reachability guard¶
New Suppression fields:
- namespace: "oneapi::dal::**::detail::**"
reachability: unreachable-only # default for namespace / source_location
reason: "Private implementation details"
reachability: "unreachable-only" | "any" | "public-only".- Default is selector-dependent, not a single global default: a rule
is broad (defaults
"unreachable-only") when it has a broad, pattern-shaped selector (namespace/entity_namespace/cause_namespace/source_location) and no primary narrow selector (symbol/symbol_pattern/type_pattern— the mutually-exclusive trio the loader already treats as a rule's main selector). Otherwise it defaults"any"— unchanged behavior. - A primary narrow selector present alongside a broad one exempts the
rule from "broad" (post-review correction, Codex):
symbol: "ns::detail::T", source_location: "*/internal/*"already names the exact audited entity — thesource_locationaddition can only narrow which changes on that one entity match (selectors combine with AND semantics), never introduce an unaudited match the baresymbol:selector wouldn't already have matched, so it keeps the narrow-selector "unchanged behavior" guarantee rather than suddenly requiringallow_public_break. member_nameis deliberately not a primary selector for this purpose: alone it matches a bare trailing name across any containing type/namespace (per its own docstring, "independent of the containing type"), sonamespace: "**::detail::**", member_name: "value_type"still counts as broad — the namespace filter there is doing the real scoping work, not merely narrowing an already-pinned-down match. This is the one case the ADR's first-shipped, coarser "any broad selector present makes the whole rule broad" rule was actually protecting against; narrowing the rule to exempt only the primary trio preserves that protection while fixing thesymbol+source_locationcase."unreachable-only": the rule does not match a change withpublic_reachable=True."any": no reachability filtering (today's behavior)."public-only": inverse — matches onlypublic_reachable=Truechanges; the review's own "unusual, mainly debugging" case (e.g. temporarily silencing an in-progress leak investigation without touching genuinely private noise).allow_public_break: bool = False. When a broad rule would suppress a change that is bothpublic_reachable=Trueand a member ofBREAKING_KINDS | API_BREAK_KINDS, the match is refused — the change stays in the report — unlessallow_public_break: trueis set on that rule. This gate is scoped to broad selectors only, matchingreachability's own broad/narrow split (post-review correction — the first-shipped version applied it to every rule regardless of selector shape, which meant an ordinary, deliberatesymbol: "_ZN3foo..."waiver of a known, intentional removal would also needallow_public_break: truethe moment that symbol happened to read as public-reachable — defeating the basic "suppress one exact symbol I already reasoned about" use case suppression exists for in the first place; caught bytest_suppression.pyregressing whenMarkReachabilitywas broadened per D1's note below). A narrow rule (symbol/symbol_pattern/type_pattern/member_name) is exempt from this gate entirely — naming one exact symbol/type is already the deliberate, audited action, independent of whether that symbol turns out to be public or an internal type that leaks. A rule matching a non-breaking (COMPATIBLE/RISK) public-reachable change is also unaffected regardless of selector shape — this gate exists for exactly the failure mode the review reports (aBREAKINGfinding silently disappearing behind an unaudited glob), not to relitigate ordinary suppression of aRISKfinding or of a symbol the author named exactly.- A match refused by either gate is recorded (D4) rather than silently dropped, so a suppression author sees why their rule did not apply.
D3. Split entity namespace from cause namespace¶
namespace(kept as the primary spelling for backward compatibility) is now an explicit alias for a new canonical field,entity_namespace: it matches onlychange.symbol/change.qualified_name— notchange.caused_by_type.- New
cause_namespacefield: matches onlychange.caused_by_type, using the identical glob/ancestor-walk semantics_ns_matchalready implements. entity_namespaceandcause_namespacemay be combined on one rule (conjunctive, like every other selector pair) to express "suppress a finding on this internal entity and caused by this internal namespace" — the genuinely-safe case the old singlenamespacefield conflated with the unsafe one.- Loading both
namespaceandentity_namespaceon the same rule is a load-time error (same "exactly one spelling" disciplinesymbol/symbol_pattern/type_patternalready enforce) — they are the same field under two names, not two independent selectors.
This is a behavior change to the pre-existing namespace field's
semantics (it no longer matches via caused_by_type), not merely an
addition. It is deliberately not shipped behind a compatibility flag: per
this repo's conventions (no backwards-compatibility shims for a correctness
fix), and because the old behavior is the review's headline false-negative
— a namespace rule that happens to over-match through caused_by_type was
never a feature anyone could have been relying on for a correct result, by
construction. tests/test_frozen_namespace.py had exactly one test asserting
the old via-caused_by_type match (test_namespace_suppresses_caused_by_type_match);
it is updated by this change to assert the new, safer behavior
(test_namespace_does_not_match_caused_by_type) plus a new counterpart test
for cause_namespace — the one place in this repo's own test suite that
depended on the old semantics is also the one place demonstrating exactly
why they were unsafe.
D4. suppression_would_hide_public_break diagnostic¶
New ChangeKind.SUPPRESSION_WOULD_HIDE_PUBLIC_BREAK (COMPATIBLE_WITH_RISK
— it is not itself an ABI break; it is advisory metadata about a
suppression decision). Emitted by ApplySuppression for every
(rule, change) pair where a rule matched a change's selectors but the
match was refused by D2's reachability or allow_public_break gate:
Namespace suppression "oneapi::dal::**::detail::**" matched
oneapi::dal::kmeans::detail::train_ops_dispatcher<...> but was not applied:
the symbol is public-reachable via oneapi::dal::train() →
oneapi::dal::detail::train_dispatch() →
oneapi::dal::kmeans::detail::train_ops_dispatcher<...>. Add
`allow_public_break: true` to this rule to suppress it anyway.
— the exact report shape the review's "Recommended implementation" section
asks for. This rides as an ordinary Change appended to the change list
(so it is visible in every existing report format — Markdown/JSON/SARIF/
JUnit — with no per-format plumbing), not a bolted-on side channel; a
project that wants CI to fail loudly when this fires can already do so via
--severity-risk error (existing severity-gating mechanism, ADR-009),
requiring no new CLI surface for this slice.
What the P0 slice did not fix (closed by the P1 slice below)¶
The oneDAL dispatcher case (func_removed on an internal template
specialization reached only via DECL_CALLS_DECL from a public inline
function — no layout evidence, so internal_leak.py's
_LEAK_TRIGGERING_KINDS/BFS-over-RecordType walk structurally cannot see
it) was not closed by the P0 slice. MarkReachability reused only
internal_leak.compute_leak_paths, which walks type-layout reachability
(inheritance, by-value fields, signatures) — it had no access to the L5
semantic call graph (source_graph.py). The P1 slice below closes this gap.
P1 slice: call-graph reachability, the overlay kind, and remaining plumbing¶
Implemented as a follow-up change on the same branch, closing P1 items 1, 2, 5, and 6 below in full and item 4 in full; item 3 (propagation-aware edge semantics) is closed to the extent described under its own entry.
- Item 1 (call-graph evidence). New
internal_leak.compute_call_graph_leak_paths(snap, internal_namespaces)walks the optional L5 source graph'sDECL_CALLS_DECL/DECL_REFERENCES_DECLedges from every public entry (buildsource.source_graph.is_public_dependency_node), returninginternal_decl_name -> [formatted proof paths]— the call-graph sibling tocompute_leak_paths's layout walk, reusingsource_graph_findings._dependency_reachability/_dependency_path/_format_dependency_path(all three already existed, unwired for this purpose).MarkReachabilitynow consults this as a second, independent evidence source: a change untouched by the layout walk (no field/base/ signature evidence at all) can still be taggedpublic_reachable=Truevia a pure call/reference edge. Requires an embedded L5 graph (--sources/--build-info, or the L2 header-only graph built automatically for--depth headersand above since G29 Phase A); returns{}and changes no behavior otherwise, mirroringpoi.resolve_changed_paths_public_impact's own degrade contract. - Item 2 (overlay
ChangeKind). Newinternal_symbol_required_by_public_api(BREAKING_KINDS, registered inchange_registry_suppression.pyalongside the P0 diagnostic to stay underchange_registry.py's line cap). Built by newinternal_leak.detect_call_graph_leaks/_build_call_graph_leak_change, wired into the existingDetectInternalLeakspipeline step alongsidedetect_internal_leaks. Triggers only on a change whose own kind is alreadyBREAKING_KINDS(artifact-proven; notAPI_BREAK_KINDS— see the post-merge review round below) and whose subject is internal-namespaced and call-graph-reachable — per the authority rule (ADR-028 D3/ADR-041), the graph edge composes with and explains an already-proven break; it never manufactures one, exactly likeINTERNAL_TYPE_LEAKS_VIA_PUBLIC_API's ownBREAKINGclassification. - Item 3 (edge semantics) — partially closed.
reachability_kindgrew a third real value,"symbol_availability", for the call-graph case — no longer the "two-value approximation" the P0 slice shipped with. The finerDECL_CALLS_DECLvs.DECL_REFERENCES_DECLdistinction the item also names is preserved as text insidereachability_proof_path(via_format_dependency_path's--[EDGE_KIND]-->annotation) rather than a further split ofreachability_kinditself — a deliberate stopping point, not an oversight: a machine-readable call-vs-reference sub-enum is a reasonable further increment but wasn't required to close the item's core ask (distinguishing symbol-availability edges from the two layout-based kinds). Left as a candidate future refinement. - Item 4 (structured report fields).
public_reachable/reachability_kind/reachability_proof_pathnow appear as first-class fields (not just inside thesuppression_would_hide_public_breakdiagnostic's prose) in JSON (reporter._change_to_dictand_to_json_leaf's_leaf_entry— the latter handles rootTYPE_*changes, the category the layout walk tags most often, and was easy to miss since it's a separate hand-rolled dict) and SARIF (sarif._result_for'sproperties, camelCased per that format's convention). JUnit was left untouched — it doesn't surfacecaused_by_type/correlated_change_kindeither, so adding reachability fields there would be new precedent, not parity. - Item 5 (configurable internal-namespace convention). New
PolicyFile.internal_namespaces: list[str](parsed identically tofrozen_namespaces), threaded via a newPipelineContext.internal_namespacesfield throughPostProcessingPipeline.run()(appended after the existing optional parameters, not inserted mid-signature — a Codex review on the PR caught that an earlier draft inserted it beforescope_to_public_surface, which would have silently broken any positional caller of that parameter) toMarkReachability/DetectInternalLeaks/DemoteUnreachableInternalChurn. Deliberately not threaded intoDetectNamespacePatterns'sexperimental_namespaces— despite this item's own wording grouping all four steps together, that parameter governs an unrelated convention (theexperimental::graduation namespace, a different default token set), and conflating the two would reintroduce a bug, not fix one. - Item 6 (remaining
checker.pycall sites)._filter_suppressed_changesand_apply_surface_metricsnow callSuppressionList.evaluate()and append the same_build_suppression_overreach_change()diagnosticApplySuppression/_filter_pattern_syntheticalready produce, instead of the booleanis_suppressed().
Post-merge review round (Codex), same P1 change:
- Mangled symbol vs. demangled label — item 1 was inert on real binaries
(fresh evidence).
compute_call_graph_leak_pathskeyed its result dict bynode.label— the L5 graph's demangled qualified name for aSOURCE_DECLARES-backed decl (ns::detail::train_ops_dispatcher), or, for a call-graph-only fallback node, either the mangled name or a#sha256:-suffixed qualified name depending on provenance. Butdiff_symbols.pybuilds a realFUNC_REMOVEDChangewithsymbol=the mangled linker name (_ZN2ns6detail19train_ops_dispatcherEv), and_root_type_name_for_changereturns that verbatim for a function-shaped kind — sodetect_call_graph_leaks's lookup byc.symbolalmost never matchedcompute_call_graph_leak_paths's label-keyed result for a real, castxml/clang-parsed C++ removal; the whole item 1/2 mechanism only appeared to work in unit tests that hand-construct aChange.symbolequal to the graph label. Worse,detect_call_graph_leaksalso pre-filtered its triggering-change candidates withis_internal_type(root, ...)— a check that splits on"::"— which a bare mangled name (no::at all) always fails, rejecting every real candidate before the (already-broken) lookup even ran. Fixed both:compute_call_graph_leak_pathsnow also resolves each internal target's own exported symbol via itsSOURCE_DECL_MAPS_TO_SYMBOLedge (the samebinary_symbol://identitysource_graph.localize_symbol()already uses for the reverse direction) and records the proof paths under that mangled key too, alongside the existing label key — a node with no such edge (no linkage, e.g. fully inlined) gets no mangled key, but noFUNC_REMOVED-shapedChangecould ever look one up anyway.detect_call_graph_leaksdropped its redundantis_internal_typepre-filter entirely: a hit in the call-path dict is already sufficient proof of "internal and call-graph-reachable", sincecompute_call_graph_leak_pathsgates its own key insertion onis_internal_type(node.label, ...)(the qualified name, which does have::segments) before ever adding either key. Addedtest_result_also_keyed_by_mangled_exported_symbol/test_func_removed_matches_via_mangled_symbol_not_labeltotest_internal_leak.py, reproducing the real-world mangled-vs-label shape the prior tests' hand-picked matching names had masked. - Item 4's new fields needed a schema version bump (Codex). The three
new per-finding JSON fields (
public_reachable/reachability_kind/reachability_proof_path) are additive optional keys perabicheck/schemas/__init__.py's own documented policy ("additive changes — new optional keys… bump the MINOR component"), the same discipline every prior additive field (2.1 through 2.5) already followed with its own changelog comment — missed here even though the schema'sadditionalProperties: truemeant no test caught it (unregistered keys validate anyway). BumpedREPORT_SCHEMA_VERSION(originally to"2.6"; renumbered to"2.7"when rebasing ontomain's own unrelated2.6bump forreviewer_action, #595) with a matching changelog comment, added the three fields (withreachability_kind's enum) tocompare_report.schema.json, and re-synced the publisheddocs/reference/schemas/v1/copy viascripts/publish_schemas.py. - Header-graph mode still had the mangled-vs-label gap;
API_BREAK_KINDStriggers were a category error (Codex, fresh evidence, two findings). (1) The mangled-symbol-key fix above only helps when the L5 graph carries aSOURCE_DECL_MAPS_TO_SYMBOLedge — the build-integrated L4/L5 path (source_graph.py) creates one, but the header-only path (header_graph.py, built automatically at--depth headersand above, no real build at all) never does, so the mismatch this review round already fixed once still applied for header-graph-only snapshots. Fixed by also trying each trigger's ownChange.qualified_name(set byEnrichSourceLocationsfromFunction.name— the same demangled name a graph node'slabelcarries in either mode, independent of graph provenance) as a fallback lookup key in bothMarkReachabilityanddetect_call_graph_leaks, alongside the existing mangled-symbol key. (2)detect_call_graph_leaks's trigger set wasBREAKING_KINDS | API_BREAK_KINDS, butAPI_BREAK_KINDSis theSOURCE_CONTRACTevidence tier — "a source-level break that needs a recompile… not necessarily a shipped ABI break" perchecker_policy.py's own docstring — and most of its members (e.g.inline_function_removed, whose own inline comment reads "no exported symbol") have no removed linker symbol at all. Composing one into this overlay's "can fail to resolve this symbol at load time" description was a false binary-load-time claim for a change that was never one — the same category of mistake_LEAK_TRIGGERING_KINDS's own hand-curated (not "every breaking-shaped kind") trigger set was designed to avoid. Restricted the trigger set toBREAKING_KINDSonly. Extendedtest_internal_leak.pywithtest_header_graph_mode_matches_via_qualified_name(noSOURCE_DECL_MAPS_TO_SYMBOLedge, mangledChange.symbol+qualified_nameset, matching only via the fallback key) andtest_api_break_kind_is_not_a_trigger(anAPI_BREAK_KINDSmember with call-graph evidence produces no overlay). - A third mangled-label shape, this time at classification, not key
matching (Codex, fresh evidence).
augment_graph_with_calls(call_graph.py) adds a fallbacksource_declnode — with noSOURCE_DECL_MAPS_TO_SYMBOLedge at all — for a callee that has no other node in the graph yet, labelling it viafunction_decl_identity, which returns the raw mangled name for any ordinary (non-extern "C") C++ function. A bare mangled name has no::segments, sois_internal_type(node.label, ...)rejected it beforecompute_call_graph_leak_pathseven reached the dual-key logic the first fix in this round added — the entry was silently dropped at classification, one step earlier than either of the two previously-fixed shapes. Fixed by demangling (abicheck.demangle.demangle, already used elsewhere in this codebase for the same mangled↔qualified correlation problem) only for this classification check when the label looks mangled (startswith("_Z")) — the stored key stays the original mangled label unchanged, since that already equals a realFUNC_REMOVED'sChange.symboldirectly (both are the same canonical Itanium-mangled linker symbol), so no further key-matching change was needed once classification correctly recognizes it as internal. Addedtest_mangled_only_label_demangled_for_classification/test_mangled_label_not_internal_after_demangling_stays_droppedtotest_internal_leak.py(the latter confirming demangling only changes what counts as internal, not a blanket allowance for every mangled label). - A fourth internal-namespace-threading gap:
DetectTemplatePatterns(Codex, fresh evidence). Item 5's PolicyFile.internal_namespaces work threadedctx.internal_namespacesthroughMarkReachability/DetectInternalLeaks/DemoteUnreachableInternalChurn, deliberately excludingDetectNamespacePatterns(a different, unrelatedexperimental_namespacesconvention).DetectTemplatePatternsis a distinct, genuine fourth case that was simply missed:detect_internal_template_leaks's own_INTERNAL_TEMPLATE_NAMESPACES(detail/impl/internal/__detail/_impl, plus__internal) is the same internal-implementation convention the other three steps use, butDetectTemplatePatterns.run()calleddetect_template_patterns(ctx.old, ctx.new)with no namespaces argument at all — a project with a custom convention (e.g.priv) would have itsMarkReachabilitytag corrected butINTERNAL_TEMPLATE_LEAKS_VIA_PUBLIC_APIstill blind to it. GaveDetectTemplatePatternsthe identicalnamespacesconstructor parameter andself._namespaces or ctx.internal_namespaces or _INTERNAL_TEMPLATE_NAMESPACESfallback the other three steps use. Addedtest_pipeline_internal_namespaces_reaches_detect_template_patternstotest_reachability_aware_suppression.py. - A fourth node-label shape: hash-suffixed identities (Codex, fresh
evidence).
function_decl_identity(source_graph.py) has a third branch beyond mangled-name and bare-qualified-name: a declaration with no distinct mangled name (e.g.extern "C") gets"{qualified_name}#sha256:{digest}".compute_call_graph_leak_pathsstored this raw hash-suffixed string as its only key for such a node, which a realChange.symbol/qualified_namenever carries — the same key-mismatch bug class as the mangled-label case, one shape later. Fixed by also indexing the hash-stripped qualified name (splitting off"#sha256:...") alongside the existing label/mangled-symbol keys. Addedtest_hash_suffixed_label_also_keyed_by_stripped_nametotest_internal_leak.py. - The call-graph entry set itself over-reached (Codex, fresh
evidence). Every fix above was about matching a target symbol
correctly once a walk from some public entry already reached it — this
one is about which nodes get to seed the walk at all.
compute_call_graph_leak_pathsseeded entries viais_public_dependency_node(shared withcrosscheck.py's advisorypublic_to_internal_dependencycheck): exported-symbol-mapped or public-header-visible, with no further distinction. But an ordinary, out-of-line exported function (e.g.api()defined in a.cppfile) is public in exactly that sense while its body — and therefore its own internal calls, e.g. tons::detail::helper()— is compiled into the library's binary only, never into any consumer's; a consumer links againstapi()'s exported symbol alone and never sees, references, or embedshelper(). Ifhelper()is removed, either the library's own build breaks (the vendor's problem, nothing to do with any external consumer) orapi()'s recompiled body simply stops calling it — never a consumer-visible break. Treating every exported function as a valid call-graph entry (as the shared predicate does) therefore either manufactured a spurious "still reachable" narrative on a genuinely safe-to-suppress internal change, or — viapost_processing.MarkReachability'spublic_reachabletag — blocked a broad internal-namespace suppression rule from ever applying to the common case, since most functions in most libraries are ordinary, out-of-line, non-template. The real criterion is whether the entry's own body is emitted into every including translation unit (true for inline functions/methods and templates, false for an ordinary out-of-line definition) — but that distinction, while computed at the L4SourceAbiSurfacelayer (three separatereachable_declarations/reachable_templates/reachable_inline_bodiesbuckets), was lost when folded into the L5 graph: an inline function generates two entities sharing one identity (a plain "function" declaration entity clang.py always emits, plus a sibling "inline" body entity), both collide onto the same graph node id, andadd_node's first-writer-wins dedup keeps only the "function" entity'sattrs["decl_kind"](iterated first) — so even a decl_kind-based check would silently never see "inline" for exactly the functions that matter. Fixed in two parts, scoped tointernal_leak.py's call-graph walk only (deliberately not touchingcrosscheck.py's advisory, RISK-only, non-suppression-gating check, which has no comparable precision requirement): (1)build_source_graphnow computes the inline/template identity set up front and stampsattrs["consumer_compiled_body"]on every decl node from that set membership — so the attr is correct regardless of which sibling entity wins the id race; (2) a newis_consumer_compiled_public_entrypredicate (buildsource/source_graph.py) layers that attr on top ofis_public_dependency_node, defaulting permissively toTruewhen the attr is absent (e.g. aheader_graph.pynode, which by construction only ever gets outgoing call/reference edges from in-header bodies in the first place, so the over-reach cannot arise there) —compute_call_graph_leak_pathsnow seeds its walk from this predicate instead. Addedtest_ordinary_function_decl_node_marked_not_consumer_compiled/test_inline_function_decl_node_marked_consumer_compiled_despite_id_collisiontotest_source_graph.py(the id-collision case specifically) andtest_ordinary_out_of_line_exported_entry_is_not_a_leak_path/test_inline_entry_with_explicit_flag_is_still_a_leak_pathtotest_internal_leak.py. - Restricting the entry set was not enough on its own — the walk also
over-reached (Codex, fresh evidence). The fix above stops an ordinary
out-of-line exported function from seeding the call-graph walk, but
compute_call_graph_leak_pathsstill computed reachability via the sharedsource_graph_findings._dependency_reachability, which expands every edge transitively from an already-validated entry with no further restriction. So a public inlinewrap()calling an ordinary out-of-line exportedapi()(consumer_compiled_body: false), which itself calls an internalns::detail::helper(), still hadhelper()show up inwrap()'s reachable set:api()is a legitimate entry-adjacent node (a consumer really does link againstapi()'s exported symbol, so it must stay recorded as reachable), but whateverapi()calls happens entirely inside the library's own binary — the walk must stop expanding past such a node, not just refuse to start from one. Fixed by replacing the shared (and, forcrosscheck.py's broader advisory use, correctly unrestricted) reachability helper with a new,internal_leak.py-local_consumer_compiled_reachability: a BFS that records every direct successor of a node but only continues expanding past a successor whose ownconsumer_compiled_bodyis true (defaulting permissively when the attr is absent, same rule as the entry predicate). It also returns each entry's predecessor-edge map so_reconstruct_pathcan rebuild the displayed proof path by replaying the same restricted walk, rather than calling the shared, unrestricted_dependency_patha second time and risking a displayed route the restriction above would not itself take.crosscheck.py'spublic_to_internal_dependency(RISK-only, never suppression-gating) is untouched — it still uses the original unrestricted_dependency_reachability/_dependency_path, since its broader "does any decl-dependency edge exist at all" question has no comparable precision requirement. Addedtest_walk_stops_expanding_past_non_consumer_compiled_intermediatetotest_internal_leak.py. Post-merge review (Codex), one more node shape the "permissive default" missed. The fix above's permissive default (treat a node as consumer-compiled whenconsumer_compiled_bodyis simply absent) was scoped too broadly: it also covered a real, build-integratedcall_graph.pyfallback node —augment_graph_with_callscreates one, taggedprovenance="call_graph", for a caller/callee identity with no other declaration node backing it (e.g. a project helper function the L4 declarations pass never separately captured) — which has noconsumer_compiled_bodyattr at all, unlike the deliberateFalsethe previous fix's own test used. A public inlinewrap()calling such an intermediate (demo::helper_a, this exact fallback shape) which itself calls an internalhelper()still hadhelper()read as reachable, since "no attr at all" fell through to the permissive branch. Fixed by narrowing the exception: the permissive default now only yields to a conservativeFalsewhen the node'sprovenanceis specifically thecall_graph.pyfallback tag (_CALL_GRAPH_FALLBACK_PROVENANCE) — every other attr-less node (header-graph nodes, type nodes, synthetic test fixtures with no provenance at all) keeps the original permissive default, since "no signal either way" is not the same claim as "known to be an uncertain build-integrated declaration." (An earlier version of this fix tried an allowlist — permissive only for a recognizedheader_graph.pyprovenance tag, conservative for everything else — but that regressed a wide swath of the existing test suite, whose synthetic fixtures never set a provenance at all; the blocklist framing above is the one that closes the real gap without disturbing tests that carry no opinion on the question.) Shared the fix as a newsource_graph.is_consumer_compiled_nodepredicate consumed by bothis_consumer_compiled_public_entry(the entry check) andinternal_leak._is_consumer_compiled_node(the walk's own expand-past-this-node check), so the two can never drift out of sync again. Addedtest_walk_stops_at_call_graph_fallback_node_with_no_signaltotest_internal_leak.py.
Roadmap (not committed — scope/sequence per the usual planning process)¶
P1 is implemented (above); P2 remains open, numbering mirrors the original review's priority tiers.
P2 — empirical validation¶
- ~~Consumer import manifests:
--consumer-binary/--consumer-dir, ELF undefined-dynamic-symbol / PE-import / Mach-O-undefined-symbol collection from a baseline-built consumer, producing aconsumer_required_symbol_removedfinding when the candidate library no longer exports something a real consumer's baseline build referenced.~~ Closed — but not the way this item's own wording assumed. This infrastructure already existed:compare --used-by APP(ADR-005/ADR-043,appcompat.py) already collects a real consumer binary's ELF undefined symbols / PE imports / Mach-O undefined symbols and diffs them against the new library's export table — this item's roadmap text was written without apparent awareness of it, so no new--consumer-binary/--consumer-dirflags or extraction code were needed. The genuine gap was narrower: a missing symbol was only a bespoke string inAppCompatResult.missing_symbols, special-cased by each reporter format (reporter.py/sarif.py/junit_report.py), never a real suppressibleChange/ChangeKindthe wayPE_ORDINAL_RETARGETEDalready is. AddedChangeKind.CONSUMER_REQUIRED_SYMBOL_REMOVED(BREAKING_KINDS) and, inscope_diff_to_app, promoted every missing symbol not already represented by a library-diffChange(via the existinguncovered_missing_symbolsdedup — the same helper_scoped_severity_summaryalready uses to avoid double-counting) into one, following the_check_pe_ordinal_imports/PE_ORDINAL_RETARGETEDprecedent exactly.AppCompatResult.missing_symbols(the raw string list) is untouched for backward compatibility with existing reporter code; the newChanges are purely additive intobreaking_for_app.AppCompatResult.verdict's existing missing-symbols-force-BREAKINGshortcut is also untouched (no exit-code/verdict behavior change) — this is enrichment (a realChangeKind, docs, evidence tier, suppressibility in principle), not a severity change. Scoped to--used-byonly (the ADR's literal "consumer's baseline build" framing); the sibling--required-symbol/plugin-host scoping paths have the identical ad-hoc-string shape and are a natural, structurally-identical follow-up, not attempted in this round. - ~~Old-consumer/new-library execution harness (
LD_BIND_NOW=1, optionally ASan/UBSan) as an opt-in validation capability alongside the static scanner, not a replacement for it.~~ Closed for theLD_BIND_NOWcore; ASan/UBSan deliberately deferred, per the item's own "optionally". Newabicheck/runtime_probe.pymodule +--verify-runtimeflag (opt-in, valid only with--used-by): runs each consumer binary once against the old library and once against the new one, both times withLD_BIND_NOW=1andLD_LIBRARY_PATHpointed at the respective library. Deliberately narrow detection: the only signal recognized is glibc's ownsymbol lookup error: ... undefined symbol: Xon stderr — the dynamic linker's unambiguous statement that eager binding failed to resolve a real symbol. An app's own exit code or general crash behavior is explicitly not interpreted (too noisy/unreliable — an app can exit nonzero for reasons that have nothing to do with the library). NewChangeKind.CONSUMER_RUNTIME_LOAD_FAILED(RISK_KINDS, neverBREAKINGon its own — an execution environment can fail for unrelated reasons, so this only corroborates the static scanner, per the authority rule) fires only when the app ran cleanly against the old library but the linker names a missing symbol against the new one (RuntimeProbeResult.regressed_symbol). Linux-only (LD_BIND_NOW/LD_LIBRARY_PATHare glibc/ELF mechanisms; macOS'sDYLD_*env vars are stripped by SIP for most binaries, Windows has no equivalent) — skips silently on any other platform or when OLD/NEW aren't real binaries (mirrorsabicheck/bundle.py's ELF-only degrade precedent), never raises. Folded into the same_apply_used_by_scopingworst-wins exit-code/verdict machineryPE_ORDINAL_RETARGETED/CONSUMER_REQUIRED_SYMBOL_REMOVEDalready use, via a syntheticChangeappended tobreaking_for_app— no separate reporting path. Post-merge review (Codex), same change: the new scoped-onlyChanges (PE_ORDINAL_RETARGETED/CONSUMER_REQUIRED_SYMBOL_REMOVED/CONSUMER_RUNTIME_LOAD_FAILED) rendered through the plain_change_to_dict/_result_forpath (notappcompat_to_json's own override) reportedevidence_status: artifact_provenpurely from theirBREAKING/RISKcategory — even though none of them come from an artifact-level library diff at all; the evidence is the consumer's own import table or execution. A pre-existing gap forPE_ORDINAL_RETARGETEDthat the two new sibling kinds simply inherited. Fixed by adding anevidence_status_overrideparameter tosarif._result_for(mirroringreporter._change_to_dict's existing one) and passingEvidenceStatus.CONSUMER_PROVENat bothscoped_only_changesrender sites (JSON's_fold_scoped_compat_into_text, SARIF'sto_sarif). Post-merge review (Codex), one more finding after threading suppression through: the suppression fix above only dropped the suppressed symbol from the synthesizedChange; the same symbol was left inAppCompatResult.missing_symbols(the raw string list), which_compute_appcompat_verdictchecks independently and unconditionally forcesVerdict.BREAKINGon — and which the scoped exit-code floor and missing-label text output also read directly. Suppressing the overlayChangealone was therefore cosmetic: the verdict/exit code/report text still failed on a symbol the user had explicitly (and correctly) waived. Fixed by also removing the symbol frommissing_symbolsitself when its overlay is suppressed — this overlay is the suppressible representation of a missing symbol (the entire point of promoting it out of a bespoke string), so a suppressed overlay must remove the raw string from every consumer of it, not just theChangelist.symbol_coverageis deliberately computed from the pre-suppression count: it is a factual metric about the export table, not a gate, and should not be made to lie because a finding was waived. Post-merge review (Codex), three more findings on the same P2 change: (a)runtime_probe._run_oncepassed a barePathstraight tosubprocess.run([str(app_path)], ...)— for a relative app name with no/(e.g. a--used-by appinvocation from the app's own directory), that argv[0] shape makes the OS searchPATH, not the current directory, exactly like an unqualified shell command; with.typically absent fromPATHthis silently raisedOSErroron both runs and the probe never fired at all. Fixed by resolving to an absolute path first (app_path.resolve()), same aslib_pathalready was. (b) BothCONSUMER_REQUIRED_SYMBOL_REMOVED(scope_diff_to_app) andCONSUMER_RUNTIME_LOAD_FAILED(_apply_used_by_scoping) are synthesized after the comparison pipeline's own suppression pass has already run overdiff.changes— neither function received the activeSuppressionListat all, so an exactsymbol:/change_kind:suppression rule for either overlay finding could never actually suppress it; the scoped gate would keep failing on a change the user had explicitly (and correctly) waived. Fixed by threadingsuppressionthroughscope_diff_to_app(and itscheck_appcompat/MCP-server call sites) and_apply_used_by_scoping, evaluating it against each synthesizedChangebefore appending — mirrors how the base diff was already suppressed, just applied a second time to findings that did not exist yet at that point. (c)_apply_used_by_scopingappended theCONSUMER_RUNTIME_LOAD_FAILEDfinding toscoped.breaking_for_appafterscope_diff_to_apphad already computedscoped.verdictfrom the static scope alone — so a run with a clean static scope but a real runtime regression still reported the staleCOMPATIBLEverdict instead ofCOMPATIBLE_WITH_RISK, even though the finding list itself was correct. Fixed by recomputingscoped.verdictvia_compute_appcompat_verdictimmediately after the append. Post-merge review (Codex), one more finding: the reachability gate itself, not just whether suppression ran at all. Both overlays' suppression evaluation (fixed above) still leftpublic_reachableat theChangedataclass default (False) — but unlike an ordinary internal-namespaceChange, where "maybe internal, maybe not" is exactly the ambiguityMarkReachability's walk exists to resolve, these two overlays have no such ambiguity at all:CONSUMER_REQUIRED_SYMBOL_REMOVEDonly ever exists because a real--used-byconsumer's own undefined-symbol requirement genuinely resolved to nothing in the new library, andCONSUMER_RUNTIME_LOAD_FAILEDonly ever exists because the dynamic linker itself failed to resolve a symbol for a real, executed consumer binary — both are consumer-proven by construction. Left at the default,Suppression._passes_reachability_gate's"unreachable-only"default for a broadnamespace/source_locationrule readspublic_reachable=Falseand matches, silently suppressing a break that can never actually be safe to hide the way an ordinary internal-namespace change sometimes is — precisely the failure mode this whole ADR exists to prevent, just missed for these two synthesized overlays specifically. Fixed by constructing both withpublic_reachable=True(a newreachability_kindvalue,"consumer_proven") before suppression evaluation, and switching both call sites from the cheaperis_suppressedtoevaluateso a broad rule withheld by theallow_public_breakgate (CONSUMER_REQUIRED_SYMBOL_REMOVEDisBREAKING, so this gate applies to it) still emits the sameSUPPRESSION_WOULD_HIDE_PUBLIC_BREAKdiagnosticApplySuppressionproduces for changes it sees directly, reusingpost_processing._build_suppression_overreach_changerather than duplicating its construction (mirrorschecker.py's own_filter_suppressed_changes, already routed throughevaluateper P1 item 6).CONSUMER_RUNTIME_LOAD_FAILEDisRISK-tier, sowould_withholdnever fires for it by design (aRISK-classified, reachability-mismatched change is the rule correctly declining to apply, not an overreach worth a diagnostic) — the sameevaluate-based plumbing is still used for consistency and to stay correct if that kind's tier ever changes. A narrowsymbol:/change_kind:rule is unaffected either way (exempt from both gates, unchanged behavior). Post-merge review (Codex), two more findings on thepublic_reachablefix itself: (a) the compare-report JSON schema'sreachability_kindenum only listed the four public-surface-walk values (direct_public_symbol/value_embedding/pointer_or_signature/symbol_availability) — a report containing either overlay now emitsreachability_kind: "consumer_proven", which failed schema validation even though the report still advertised a passingreport_schema_version. Fixed by adding"consumer_proven"to the enum (an additive change per the schema's own stability policy,abicheck/schemas/__init__.py) and bumpingREPORT_SCHEMA_VERSION(originally to2.7; renumbered to2.8in the same rebase mentioned above), re-synced todocs/reference/schemas/v1/viascripts/publish_schemas.py. (b) Unrelated to the reachability fix but on the same file:runtime_probe._run_once'ssubprocess.run(..., text=True)has noerrors=handling, and a real executable's stderr is arbitrary bytes with no guarantee of being valid UTF-8 (or the locale's encoding) — a non-UTF-8 byte would raiseUnicodeDecodeErrorafter the child process exits, escaping this best-effort probe entirely and aborting the wholecompareinvocation instead of degrading to aRuntimeProbeOutcome, exactly the failure mode the surroundingtry/except OSErrorwas meant to prevent. Fixed by addingerrors="replace"so malformed bytes are substituted, not fatal — the symbol-lookup-error regex still matches the valid ASCII segments around them. Added a real-subprocess regression test (not a mockedsubprocess.run, since the decoding itself is what's under test) totest_runtime_probe.py, and ajsonschema-validating regression test totest_cov95_cli.pyfor the enum fix. Post-merge review (Codex), one more finding onruntime_probe.py:_SYMBOL_LOOKUP_ERROR_RE's capture group was\S+, which does not stop at a comma — glibc appends", version X"after the bare name for a versioned undefined-symbol failure (e.g."undefined symbol: foo, version FOO_1.0"), so the captured group was"foo,"(trailing comma included), not the real import/export name"foo". The synthesizedconsumer_runtime_load_failedfinding therefore carried a symbol that could never match an exact suppression rule for the real symbol, and--verify-runtimereported the wrong name for every versioned import. Fixed by changing the capture group from\S+to[^,\s]+(stop at a comma or whitespace, whichever comes first) — the unversioned case is unaffected since there is no comma to stop at. Addedtest_versioned_symbol_lookup_error_strips_version_suffixtotest_runtime_probe.py. Post-merge review (Codex), two more findings, one per module. (a)internal_leak.py:demangle()returns the full signature (e.g.ns::api::foo(ns::detail::T*)), not just the qualified name — the classification check at the call-graph fallback-node site (see the third-Codex-round fix above) passed this straight tois_internal_type, whose segment scan findsdetailinside the parameter type and misclassifies an ordinary public function (ns::api::foo) as an internal leak target merely because it takes/returns an internal type — even thoughfooitself lives in the publicns::apinamespace. A laterfunc_removedonfoowould then spuriously also emitinternal_symbol_required_by_public_api, describing a genuinely public symbol's removal as if it were an internal one leaking through. Fixed by a new_strip_signature_paramshelper (tracks paren depth, cuts at the first depth-0(— the function's own parameter-list opening, not a nested one from e.g. a function-pointer parameter type) applied before classification; the stored lookup key is unaffected, only the classification input. AddedTestStripSignatureParams(unit-level) andtest_public_fn_with_internal_param_type_not_misclassified(integration-level) totest_internal_leak.py. (b)runtime_probe.py: a dynamic-linker failure for a reason other than an undefined symbol — most commonly a missing, unrelated dependency ("error while loading shared libraries: libfoo.so.2: cannot open shared object file") — does not match_SYMBOL_LOOKUP_ERROR_RE, so it fell through took=Trueregardless of the actual (nonzero) exit code. Anoldprobe run that never actually loaded (for a reason unrelated to the library under comparison) would then still satisfyregressed_symbol'sold.okguard, letting anewrun's real undefined-symbol failure report a false regression against a baseline that was never clean to begin with. Fixed by adding a second, narrower regex (_LOADER_ERROR_RE) for this exact, version-stable glibc substring, returningok=False(nomissing_symbol, since the failure isn't attributable to a specific symbol) when it matches — deliberately narrow, so the module's own documented "an app's own nonzero exit code is common and meaningless on its own" principle stays intact for every other failure shape; this is one more unambiguous linker statement, the same class as the symbol-lookup-error case already handled, not a general exit-code interpretation. Addedtest_loader_failure_for_unrelated_reason_is_not_oktotest_runtime_probe.py. A independent review pass (not Codex) of the full diff also found two documentation-only inaccuracies:examples/case192_.../README.mddescribed theinternal_symbol_required_by_public_apioverlay as "RISK-classified" — it is actuallyBREAKING(change_registry_suppression.py), confirmed byChangeKind.INTERNAL_SYMBOL_REQUIRED_BY_PUBLIC_API in BREAKING_KINDSat runtime; and this ADR's own "Consequences" section still read "No new CLI flags; no schema/serialization version bump" — true for the original P0 slice that bullet was written for, but directly contradicted by the P1/P2 slices documented earlier in this same file (REPORT_SCHEMA_VERSIONbumped twice,--verify-runtimeadded). Both fixed. Post-merge review (Codex), one more finding on the direct-public-symbol check itself: "When the public-header declaration is a C++ function/variable,_public_header_names()records the demangled/qualified model name, while a removal/type-changerootis still often the linker symbol (for example_ZN...), and only functions may havec.qualified_namefilled byEnrichSourceLocations. This direct-public branch therefore misses a public-headerns::detail::api()removal, so a broad namespace suppression such asnamespace: "**::detail::*"treats the hardFUNC_REMOVEDas unreachable and suppresses it with no overreach diagnostic." Verified directly:_root_type_name_for_change(c)isc.symbolverbatim for a function-shaped change, anddiff_symbols.pysetsChange.symbolto the mangled linker name forFUNC_REMOVED/FUNC_ADDED/etc., while_public_header_names()collectsFunction.name, which is demangled — soroot in public_header_namesnever matches for a real C++ symbol. A standalone public entry point that nothing else embeds or calls (so neither the layout walk nor the call-graph walk independently proves it reachable) therefore fell throughMarkReachability's direct-public-symbol check entirely, leavingpublic_reachableat its defaultFalseand letting a broadnamespace/source_locationrule hide the removal with nosuppression_would_hide_public_breakdiagnostic. Fixed by also checkingc.qualified_name(populated byEnrichSourceLocations, which runs earlier inDEFAULT_PIPELINE, from the demangledFunction.namefor exactly theFUNC_REMOVED/FUNC_REMOVED_ELF_ONLYkinds this matters for) againstpublic_header_namesalongsideroot. Addedtest_public_header_cxx_function_removal_is_reachabletotest_reachability_aware_suppression.py. Post-merge review (Codex), one more finding onruntime_probe.py: "When OLD and NEW are different files in the same directory, both_run_oncecalls compute the sameLD_LIBRARY_PATHand execute the same app with an identical environment, so--verify-runtimenever actually switches the consumer from the old library to the new one." Confirmed:_run_oncesetLD_LIBRARY_PATHto justlib_path.resolve().parent— whenold_libandnew_libare two different files sitting in the same directory (a realistic layout: versioned build artifacts, or a symlink the "old" build already left pointing at the SONAME the consumer'sDT_NEEDEDactually requests), the dynamic loader resolves the requested SONAME to whichever file in that shared directory happens to match first — independent of which ofold_lib/new_libthis specific_run_oncecall was asked to test. A regression that only shows up against the new library could silently probe the old one twice and report a false clean bill of health. Fixed by staging each side's library, alone, into its own freshtempfile.TemporaryDirectory(), under the name the dynamic linker will actually look up (DT_SONAMEwhen present — read via the existingelf_metadata.parse_elf_metadata, since a file's on-disk name can differ from its embedded SONAME — falling back to the file's own basename on any parse failure, empty SONAME, or a defensively-rejected path-shaped SONAME), and listing that staging directory first onLD_LIBRARY_PATH(the library's real parent directory is still appended after it, so any other same-directory runtime dependency stays resolvable) — this forces each run to resolve to exactly the intended file regardless of what else sits alongside it. Addedtest_same_directory_old_new_libraries_resolve_independentlyandTestLoadName(four cases: SONAME present, parse failure, empty SONAME, path-shaped SONAME) totest_runtime_probe.py. Post-merge review (Codex), two more findings, one per module. (a)buildsource/source_graph.py: "When a build-source pack includes Kythe/CodeQL call edges, the adapters createsource_declnodes with provenance"kythe"/"codeql"and noconsumer_compiled_bodyattribute; this fallback therefore treats those attr-less intermediate callees as consumer-compiled and keeps expanding through their bodies... reintroducing the overreach this predicate was meant to avoid for external graph backends." Confirmed:graph_backends.py'singest_kythe_entries/ingest_codeql_call_results/ingest_codeql_extends_resultscreate exactly the fallback-node shape the prior Codex-review fix closed forcall_graph.py's own fallback tag (is_consumer_compiled_nodetreating "noconsumer_compiled_bodyattr" as safe-to-continue unlessprovenance == "call_graph") — but taggedprovenance="kythe"/"codeql"instead, which the blocklist never covered, so an inline public wrapper → ordinary out-of-line helper →detail::leafchain imported from an external indexer still walked straight through the helper. Fixed by generalizing_CALL_GRAPH_FALLBACK_PROVENANCE(a single string) into_NO_CONSUMER_COMPILED_SIGNAL_PROVENANCES(a frozenset of{"call_graph", "kythe", "codeql"});is_consumer_compiled_nodenow checks membership in that set. Addedtest_walk_stops_at_kythe_codeql_node_with_no_signal(parametrized over both provenances) totest_internal_leak.py, alongside the existingtest_walk_stops_at_call_graph_fallback_node_with_no_signalit mirrors. (b)runtime_probe.py: "When the consumer binary has legacyDT_RPATHwithoutDT_RUNPATH, glibc searches that RPATH beforeLD_LIBRARY_PATH... so putting the staged directory first here does not force the old/new library selection." Confirmed againstld.so(8)'s documented search order: a legacyDT_RPATH(present with noDT_RUNPATH) is searched beforeLD_LIBRARY_PATH;DT_RUNPATH(what a modern toolchain actually emits) is searched after it — so the same- directory staging fix just above cannot force the loader's choice for a binary built with this legacy shape, and both the old and new probe runs could silently resolve through the embedded RPATH instead, defeating--verify-runtime's one purpose for exactly the binaries most likely to need it (older/vendored build setups). Rather than attempt a fragile override (e.g.LD_PRELOAD, which would load both copies of the library into one process and risk misattributing which copy's global/static state an undefined-symbol failure actually reflects — a correctness risk this probe's own "airtight claim, not inferred" design principle rules out), added_has_legacy_rpath_without_runpath()(reads the app's ownDT_RPATH/DT_RUNPATHviaparse_elf_metadata) and a new early-exit inrun_runtime_probe: this exact shape now declines withattempted=Falseand an explanatoryskipped_reason, instead of silently reporting a result the module cannot actually attribute to the library under test. ADT_RUNPATHpresent alongside aDT_RPATHmakesld.soignore theDT_RPATHentirely, so that combination is unaffected. Addedtest_legacy_rpath_without_runpath_skipsandtest_runpath_alongside_rpath_does_not_skiptotest_runtime_probe.py. - ~~New worked examples exercising this ADR's headline scenario end-to-end
(public inline dispatch to an exported internal specialization; the same
case under a blanket namespace suppression, asserting the break survives
and the diagnostic fires; a safe pimpl counter-example) — the review's
examples A/B/D are the most valuable regression coverage and are natural
examples/case*/additions now that P1 item 1's call-graph reachability is wired (previously blocked on it).~~ Closed for A/B combined into one case, plus a deliberate negative-space counter-example in place of the literal pimpl case.examples/case192_call_graph_break_survives_suppressionships the full A/B scenario in one case (a public inline dispatcher's call into a removed internal specialization:BREAKINGunmodified, refused under a broadnamespace: "demo::detail::**"suppression rule with thesuppression_would_hide_public_breakdiagnostic naming the proof path, thenNO_CHANGEonce the same rule addsallow_public_break: true).examples/case193_ordinary_exported_fn_call_not_reachableis the counter-example this round actually needed more: not the type-layout pimpl shape (already covered bycase118-case120's public-surface scoping), but the call-graph walk's own negative-space check — an ordinary out-of-line exported function's internal call is not public-reachable, so the identical broad suppression rule applies cleanly with no diagnostic at all. Building it is what surfaced the transitive-traversal over-reach documented in the P1 slice above (a third example, a literal pimpl-via-graph case, remains a nice-to-have follow-up, not attempted in this round). Both ship hand-builtAbiSnapshotpairs with an embedded L5 graph (scripts/gen_reachability_examples.py), validated compiler-free bytests/test_reachability_examples.py.
Consequences¶
- A suppression file with only narrow (
symbol/symbol_pattern/type_pattern) rules sees no behavior change from D2/D4 — the reachability guard's default only engages fornamespace/source_locationrules. - An existing broad
namespace/source_locationsuppression rule that happens to also match public-reachable churn will, after this change, stop suppressing that subset of findings by default (they reappear in the report, tagged with the new diagnostic explaining why) — this is the intended fix, but is a visible behavior change for any suppression file relying on the old any-reachability default.allow_public_break: trueis the escape hatch for a rule the maintainer has actually reviewed. namespace'scaused_by_typematching is removed outright (D3); a rule that depended on it (none found in this repo's own test suite/examples at authoring time) needscause_namespaceinstead.SUPPRESSION_WOULD_HIDE_PUBLIC_BREAKis a newChangeKind, following the standard four-step procedure (/CLAUDE.md"Adding a new ChangeKind"). This P0 slice itself added no new CLI flags and no schema/serialization version bump; both since happened as the P1/P2 slices landed —REPORT_SCHEMA_VERSIONbumped to2.7(P1 item 4's structuredpublic_reachable/reachability_kind/reachability_proof_pathfields) and again to2.8(P2's"consumer_proven"reachability_kindenum member; both numbers shifted up by one from their original2.6/2.7when this branch was rebased ontomain's own unrelated2.6bump forreviewer_action, #595), and--verify-runtimeis a new P2 CLI flag. Seeabicheck/schemas/__init__.py's own changelog for the exact history.
References¶
abicheck/post_processing.py—DEFAULT_PIPELINE,PipelineContext,MarkReachability,ApplySuppression,DetectInternalLeaks,DemoteUnreachableInternalChurnabicheck/internal_leak.py—compute_leak_paths,compute_call_graph_leak_paths,detect_internal_leaks,detect_call_graph_leaks,_LEAK_TRIGGERING_KINDS,_root_type_name_for_changeabicheck/suppression.py—Suppression,SuppressionListabicheck/checker_types.py—Changeabicheck/checker.py—_filter_suppressed_changes,_apply_surface_metricsabicheck/policy_file.py—PolicyFile.internal_namespacesabicheck/reporter.py,abicheck/sarif.py— structured reachability fieldsabicheck/buildsource/source_graph.py/source_graph_findings.py— the L5 graph and_dependency_reachability/_dependency_path/_format_dependency_paththe P1 slice's call-graph walk reuses- ADR-004 — Report filtering and deduplication (redundancy-before-verdict invariant this ADR deliberately does not disturb)
- ADR-013 — Suppression system design (pipeline-ordering rationale this ADR amends)
- ADR-024 — Public ABI surface resolution (audit-ledger / never-silently-drop
convention this ADR follows for
suppression_would_hide_public_break) - ADR-028 — Build-source evidence pack (the authority rule the P1 overlay
kind's
BREAKINGclassification relies on: L3-L5 evidence may explain/ correlate an artifact-proven break, never manufacture one) - ADR-041 — Compiler-facts semantic impact graph (
PUBLIC_API_INTERNAL_DEPENDENCY_ADDED, the L5 graph schema the P1 slice's call-graph walk reuses)