G38 — Bundle facts model, persisted multi-library graphs, and multibuild-variant comparability¶
Origin: External review of ADR-023 (bundle-aware multi-binary analysis)
against a real oneDAL checkout (libonedal_core.so +
libonedal_thread.so/libonedal_sequential.so/libonedal_dpc.so/
libonedal_parameters*.so behind one shared include/oneapi/dal/ header
tree). The review reproduced a real bundle-integrity break (an internal
_daal_*-style C symbol renamed in the thread provider, consumer left
unchanged) and confirmed compare's bundle layer (abicheck/bundle.py)
catches it correctly from live .so files — but found the layer cannot
answer the same question from a stored dump, cannot express build
variants (CPU vs. ONEDAL_DATA_PARALLEL) without silently unioning them,
and treats an internal C boundary's binary-name match as if it proved
signature compatibility. See ADR-023's own "Amendment" block at the top of
that file for the earlier (2026-07-29), narrower correction this plan
builds on.
ADR: No new ADR is proposed. This plan is scoped as an amendment/
extension to ADR-023
(bundle layer) and ADR-050
(comparability contract, which already owns the "two snapshots must agree
on their extraction recipe before being compared" invariant this plan
extends to bundle-level and multibuild-level comparisons). If Phase 2
below (the persisted BundleFacts schema) grows real design disagreement
during implementation, split it into its own ADR at that point — this plan
does not pre-empt that.
Type: Initiative plan (cross-cutting; touches abicheck/bundle.py,
abicheck/bundle_models.py, abicheck/bundle_manifest.py,
abicheck/serialization.py, abicheck/model.py, abicheck/comparability.py,
abicheck/environment_matrix.py, abicheck/diff_cxx_rules.py,
abicheck/checker_policy.py, abicheck/change_registry.py,
abicheck/cli_scan_baseline.py, abicheck/reporter.py).
Effort: XL (phased — see "Phases" below). Risk: medium — Phase 1
(taxonomy) and Phase 4 (finding-severity split) are additive and low-risk;
Phase 2 (persisted BundleFacts) is a real schema addition with its own
version bump and round-trip contract; Phase 3 (multibuild pairing) changes
default behavior for any caller that today silently unions variants (none
do today — see "Why this is additive" below — but a future multibuild
consumer must not reintroduce a union by default).
Problem¶
ADR-023 shipped a real, working bundle layer: compare_bundle() builds a
ResolutionGraph from live .so files (ProviderEntry/ConsumerEntry
per symbol, DT_NEEDED edges, gnu.version_r/gnu.version_d) and detects
five real cross-DSO break patterns (intra-bundle removed symbol, signature
drift across a C boundary, cross-DSO type drift, template-instantiation
manifest drift, provider migration). Reproducing this end-to-end against a
real oneDAL build (a modified libonedal_thread.so mutating one exported
C symbol name, all other siblings byte-identical) confirms the layer
produces the correct causal chain: provider delta, consumer delta (none),
bundle-level bundle_intra_dep_removed. That part of ADR-023 works as
designed.
Four gaps remain, each independently reproducible and each with a distinct root cause — not one gap wearing four names:
compare_bundle()only ever reopens live.sofiles.BundleSnapshotis built directly from filesystem paths insideabicheck/bundle.py; there is no serializedBundleFactsobject adump-produced snapshot set carries, and nocompare --against <stored bundle>path exists at all.analyze(extract(live))andanalyze(load(dump))are not the same function today because the second one doesn't exist. This means a stored baseline — the normalscan --against/CI workflow for every other surface this tool supports — cannot get a bundle-level verdict; only a live-directory-vs-live-directorycompareinvocation can.- No build-variant (multibuild) model for the bundle layer. oneDAL
ships CPU-only and
ONEDAL_DATA_PARALLEL(SYCL) builds from the same source tree.environment_matrix.pyalready models a build matrix forcompare --env-matrix(SYCL/CUDA constraints, runtime floors) at the per-library level, but nothing pairs two bundle snapshots per variant and rejects a mismatched pairing — a naive "run the bundle layer once per available build" caller would have to invent variant fingerprinting and pairing from scratch, and get it wrong the same way a union would (see "Why a union is wrong" below). - A C-linkage symbol match is treated as proof of signature
compatibility, not just proof of binary-name compatibility.
bundle_intra_dep_signature_changedis keyed off a provider's own per-libraryfunc_params_changed/func_return_changedfinding — which already requires DWARF/header evidence for that provider. That part is sound. What's missing is the negative case: when neither side has that evidence (a stripped provider, or a provider only ever dumped at L0), the bundle layer has no way to say "this consumer's import still resolves by name, but nothing establishes the signature agrees" — it silently reports nothing, which reads as "compatible." - The rationale for separate public-ABI and bundle-integrity findings is
undocumented.
bundle_library_removed/bundle_intra_dep_removedetc. already exist as their own kinds (not reusingBREAKING_KINDS' public-surface language) — this part of ADR-023's design is already correct. What's missing is the reporter/ policy-facing documentation of why an internal, non-public symbol can still beBREAKINGat the bundle level, which is a recurring point of confusion when a--policyprofile scoped to "public API only" doesn't suppress abundle_intra_dep_removedfinding on an internal symbol (by design — see Phase 1).
Why a union is wrong (multibuild)¶
If a declaration/export is present in the DPC build and accidentally
missing from the CPU build, unioning the two variants' facts before
diffing reports "present" in both old and new — hiding a real, CPU-only-build
regression. The correct model pairs each variant independently
(CPU old ↔ CPU new, DPC old ↔ DPC new, ...) and aggregates only after
each pair has its own verdict. A variant present in old with no matching
variant in new is NOT_COMPARABLE/a build-coverage regression for that
variant specifically — never silently dropped or paired with the nearest
available build. comparability.py's existing check_contracts_comparable
already enforces exactly this discipline for a single old/new snapshot pair
(refusing to compare two snapshots whose scope_fingerprint/
profile_fingerprint disagree); this plan extends the same discipline one
level up, to a set of variant-tagged bundle snapshots, rather than
inventing a second comparability mechanism.
Why this is additive, not a behavior change¶
No shipped caller invokes the bundle layer once per build variant today —
compare/compare-release runs it exactly once, against whatever one
directory pair was given. So there is no existing "silent union" to fix;
the risk this plan calls out is prospective (a naive multibuild extension
would reach for a union), not a regression already shipped. Phases 1-4 add
new fields/kinds/modules; none change the meaning of an existing
BundleFinding, ChangeKind, or exit code for a single-variant
compare/compare-release invocation.
Goal & acceptance criteria¶
Mirrors the acceptance bar the originating review proposed, restated as this plan's phases so each criterion has an owning phase rather than floating as an unattached checklist:
- Removing a sibling-consumed internal C symbol produces one causal bundle-integrity finding naming consumer, import, old provider, and failed new resolution. Already true today (verified against a real oneDAL reproduction) — not a gap this plan closes, recorded here only so the acceptance list is complete and the "already works" part isn't lost in a plan about what doesn't.
- The same finding reproduces from a stored bundle dump, not only from two live directories. — Phase 2.
- An unused private export removal does not produce the same severity as
(1). Already true —
bundle_library_removed/bundle_intra_dep_removedonly fire when a sibling actually consumes the symbol; an unconsumed export removal falls through to the existing per-libraryfunc_removed, whose severity is governed by the ordinary public-surface/suppression rules, unaffected by this plan. - CPU and DPC variants are evaluated independently, with a missing matching variant reported as its own finding rather than silently dropped or unioned. — Phase 3.
- A same-name C symbol with no signature evidence on either side is reported as binary-name-compatible, signature-unverified — a distinct finding from a verified-compatible signature match. — Phase 4.
- Live-directory and stored-dump bundle comparisons produce identical findings and evidence for the same underlying facts (the same parity invariant ADR-050/G32 already hold single-snapshot comparisons to). — Phase 2's acceptance test.
Design¶
Phase 1 — Finding-taxonomy documentation (no code change)¶
Add a short section to docs/reference/change-kinds.md (the curated,
narrative change-kind guide — this topic's existing fact owner per
docs/_meta/topics.yaml's ownership split) explaining, in plain language,
that a bundle_* kind answers "does the shipped bundle still work
end-to-end" and is deliberately not filtered by a public-surface-only
policy scope the way BREAKING_KINDS/API_BREAK_KINDS are — an internal,
non-public symbol can still be bundle_intra_dep_removed because a sibling
DSO's dlopen genuinely fails. This is a documentation-only phase (no
ChangeKind/registry change) since the underlying behavior is already
correct; it exists purely to close the confusion the review's item 4
identified. Lowest risk, do first.
Phase 2 — Persisted BundleFacts and compare --against <bundle dump>¶
Implementation status (2026-08-23): the model, the mandatory parity test,
and the producer (--bundle-facts-out) are shipped; the CLI consumer half
is deliberately deferred. (Update: the CLI consumer half shipped later —
Phase 13 built its Python-API driver, Phase 17 gave it a real CLI surface,
compare --old-bundle-facts. This note is kept as the historical record of
this phase's own original scope, not a still-open item.) Two real
deviations from this section's original design, both discovered during
implementation rather than planned up front:
- No
BundleArtifactFacts/persistedResolutionGraph. The sketch below assumed the resolution graph needed its own serialized form. It doesn't:abicheck/bundle.pyalready hasbuild_bundle_snapshot_from_metadata()— a pre-existing primitive (built for a different, still-unshipped snapshot-first product-baseline use case) that reconstructs a fully-functionalBundleSnapshot(cross-DSODT_NEEDED/version-table resolution included) from bareElfMetadataalone, with no binaries read. SinceAbiSnapshot.elfalready is thatElfMetadatafor every ELFdump,BundleFacts.per_library_snapshotsalone is sufficient to reconstruct everythingBundleArtifactFacts/ResolutionGraphwould have stored — persisting them separately would only add a second, redundant representation that could drift from_compute_resolution_graph()'s real behavior. Implemented inabicheck/bundle_facts.py, notabicheck/bundle_models.py(that file stays a leaf with respect toabicheck.bundle;bundle_facts.pyis its own leaf-with-respect-to-bundle.pymodule, importing it only lazily inside function bodies to avoid a realbundle_facts <-> serializationimport cycle the first draft of this module hit — see that module's own comment for why theto_dict/from_dictpair had to move toserialization.pyinstead of living next to the dataclass). - CLI consumer wiring is not attempted.
compare_release_cmd's directory/package fan-out (_prepare_compare_release_inputs,_compare_release_libraries) is built entirely around resolving live binaries on both sides for the per-library diff pass — substituting aBundleFactsfile for the old side would mean a second, parallel per-library comparison loop (service.compare_snapshots()against each storedAbiSnapshotinstead ofservice.run_compare()against a live path), which is a genuine, separate feature with its own option surface (most ofcompare's ~40 release-fan-out flags — headers, debug-info, PDB, jobs — lose their old-side meaning once the old side is already a resolved snapshot). This section's own text already anticipated deferring this exact decision ("this plan does not re-litigate the ongoing CLI-cleanup-phase-two convergence, it plugs into whichever entry point that work has converged on"); building it reactively now, without that convergence, risked exactly the kind of drive-by CLI-dispatch change this codebase's own "known gaps over risky reactive patches" convention exists to avoid.abicheck.bundle_facts. compare_bundle_from_facts()is fully implemented, tested against the mandatory dump/live parity invariant, and documented as a Python API in Multi-Binary Releases — only the CLI surface to feed it a stored-facts old side is not yet wired. - Three review-driven fixes closed after initial implementation
(Codex review, same day).
write_bundle_facts_out()'s producer only ever captureddiff_pairs(matched libraries), silently omitting a library removed in the new release from the persisted baseline — it now also captures every unmatched old library directly viaparse_elf_metadata(), matching what a livebuild_bundle_snapshot()does for the identical case.capture_bundle_facts()gained alibrary_pathsparameter that probes and persists real filesystem soname aliases (symlink target basenames, hard-linked siblings) at capture time, andbuild_bundle_snapshot_from_metadata()/_compute_resolution_graph()gained a matchingextra_aliasesparameter to replay them at reconstruction time with no filesystem access — closing a gap where a provider without a usableDT_SONAMEcould resolve differently from a stored baseline than from a live comparison.bundle_facts_from_dict()now rejects aschema_versionnewer than this reader supports, mirroringsnapshot_from_dict()'s existing hard rejection forAbiSnapshot. The alias-probing helpers live in the pre-existingabicheck.bundle_sonameleaf module (notbundle.pyitself, which was already near the AI-readiness file-size hard cap).
The rest of this section is kept as originally written (this plan's own
amendment convention appends corrections rather than retconning the
original text); read BundleArtifactFacts/resolution_graph below as the
originally-proposed shape, superseded by the simpler, already-shipped one
described above.
New model, additive to abicheck/bundle_models.py:
@dataclass
class BundleFacts:
"""Serializable projection of everything compare_bundle() derives from
live .so files, decoupled from filesystem paths — the bundle-level
counterpart to AbiSnapshot for a single library."""
schema_version: int
variant_fingerprint: str # see Phase 3 — always present, "default" for a non-multibuild bundle
artifacts: list[BundleArtifactFacts] # per-DSO: soname, aliases, build-id/hash, path label (ADR-032 D7 redaction rules apply)
resolution_graph: ResolutionGraph # already exists — just needs schema_version + serialization
per_library_snapshots: dict[str, AbiSnapshot] # one per bundle member — see below for why this is mandatory, not optional
manifest: InstantiationManifest | None
BundleArtifactFacts carries exactly what ResolutionGraph's
ProviderEntry/ConsumerEntry already compute per-library (exported
symbols, undefined/imported symbols with gnu.version_r, DT_NEEDED,
RPATH/RUNPATH) — this phase does not add new extraction, it adds
serialization of facts bundle.py already derives in memory and
discards after compare_bundle() returns.
per_library_snapshots is required, not a nice-to-have, and its absence
from an earlier draft of this schema was a real gap in the parity
invariant itself (caught in review — see the PR that introduced this
plan). compare_bundle()'s cross-DSO findings are not derived from the
resolution graph alone: bundle_intra_dep_signature_changed,
bundle_intra_type_changed, and bundle_provider_changed are each keyed
off a per-library DiffResult (func_params_changed/
func_return_changed/type_*_changed/func_removed+func_added pairs —
see ADR-023's "Per-library diff is unchanged" section, steps 3-5). A
BundleFacts carrying only artifact metadata and the resolution graph has
nowhere for compare_bundle_from_facts() to get those per-library diffs
from when the old side is a stored dump rather than a live directory —
it would have to re-derive them from AbiSnapshots it doesn't have, which
defeats the entire point of Phase 2. Each entry in
per_library_snapshots is exactly the AbiSnapshot dump already
produces for that library today (no new extraction), keyed by the same
library identity ResolutionGraph's provider/consumer entries use, so
compare_bundle_from_facts() can run the existing per-library diff between
old_facts.per_library_snapshots[lib] and a freshly-dumped new-side
snapshot before applying the same cross-DSO rules compare_bundle()
already implements — one shared per-library-diff-then-bundle-rules code
path for both entry points, not two.
Wiring:
serialization.pygainssave_bundle_facts/load_bundle_facts, mirroringsave_snapshot/load_snapshot's existing envelope (snapshot_io.py's plain/gzip/zstd detection, atomic writes) rather than inventing a second I/O layer.- There is no existing directory-level dump mechanism to attach a
BundleFactsproducer to — a real gap in an earlier draft of this plan, caught in review.dump(cli.py'sdump_cmd) produces exactly one snapshot from one binary; its--dump-manifestoption is an input (a YAML describing translation units to merge into that one snapshot), not a directory fan-out or an output mechanism. The only existing directory/package fan-out lives incompare's release path (cli_compare_release.py), and it does not persist each library's generated snapshot today — it discards them after diffing. Phase 2 therefore needs a genuine new producer, not a flag bolted onto something that already walks a directory in the wrong shape. This is deliberately scoped as a new flag on the existingcomparerelease fan-out, not a new root command — a bareabicheck dump-bundlewould need to separately clear the root's own admission bar (rootAGENTS.md's "Adding a new top-level command" criteria; a directory-of-libraries operand a user already thinks of as acompare-release-shaped input, with a real usage scenario beyond one PR, is exactly the class of thing that bar exists to keep off the root surface unless it independently earns a place there).compare's release fan-out (cli_compare_release.py) already walks every library in a directory and already produces each side'sAbiSnapshotin memory before diffing and discarding it — an opt-in--bundle-facts-out <path>flag on that existing command persists what it already computes (per_library_snapshotsplus the resolution graph) into oneBundleFactsfile for the old-side directory, rather than inventing a second directory-walking entry point.compare release-1.0/ release-2.0/ --bundle-facts-out old.bundlefactsis therefore both the producer and, in the same invocation, a normal live-vs-live comparison — the flag is additive output, not a new mode. compare --against <old-dir-or-bundle-facts> <new-dir>(or thecompare-release-shaped equivalent, per whatevercli_compare_release.pylooks like when this lands — this plan does not re-litigate the ongoing CLI-cleanup-phase-two convergence, it plugs into whichever entry point that work has converged on by the time this phase starts) accepts either a live directory (today's behavior, unchanged) or aBundleFactsfile for the old side. The new side may still be live (the commonscan-style workflow: compare a stored baseline bundle against what's on disk today).checker_policy/comparability.py: aBundleFacts.variant_fingerprintmismatch between old and new is refused the same way a single-snapshotscope_fingerprintmismatch already is (ScopeMismatchError's bundle-level sibling), rather than silently comparing incompatible bundles.
Acceptance test (the mandatory parity invariant, restated executably):
compare_bundle(old_dir, new_dir).bundle_findings
==
compare_bundle_from_facts(load_bundle_facts(dump_bundle(old_dir)), new_dir).bundle_findings
dump_bundle names the new directory-level producer above, not the
single-artifact dump command — see the "Wiring" note on why the latter
cannot produce this.)
including evidence and affected_libraries, not just ChangeKind values —
mirroring the existing single-snapshot dump/live parity tests this repo
already runs for dump/scan --against (see tests/test_dump_scan_l3_
comparability.py for the established pattern this test follows).
What is deliberately NOT attempted in Phase 2: a fully lossless,
extractor-agnostic bundle archive format (the review's §9 bundle-dump-
vNext.tar.zst sketch — content-addressed shared headers, per-artifact
.json.zst, optional raw-binary retention). That is a real, separate
storage-architecture project on the scale of ADR-059 (snapshot compression)
or the docs/contribute/plans/g32-comparability-contract-and-multi-tu-
manifest.md multi-TU manifest work, not a sub-step of making the bundle
layer stored-data-capable. BundleFacts above is scoped to "enough to
reproduce compare_bundle()'s existing analysis without live binaries" —
not to "a general-purpose reanalysis substrate for extractors that don't
exist yet." If a future need for the latter materializes, it gets its own
plan, informed by whatever BundleFacts looked like in production by then.
Phase 3 — Multibuild variant pairing¶
Implementation status (2026-08-23): the pairing primitive, the
ChangeKind, and its finding-construction helper are shipped; the CLI/config
surface that discovers real per-variant BundleFacts and feeds them to
pair_variants is deliberately deferred, the same posture Phase 2's own
implementation-status note already took for its CLI consumer half.
(Update: unlike Phase 2's CLI consumer half, this one did not later ship —
Phase 15's own correction found it is not needed at all. The declarative CI
pipeline that would have called pair_variants()
(check-project.yml/G30's bundles:/profiles: schema) always resolves
its baseline live, in-job, so it never needs to pair two already-captured
BundleFacts documents in the first place; see Phase 15's "Why
bundle_variants_config.py/pair_variants/BundleVariantSpec stay
unwired" note. This deferral is therefore permanent, not pending.)
abicheck/bundle_multibuild.pyimplementsvariant_fingerprint,VariantOutcome,VariantComparison,pair_variants, andcoverage_regression_findingsper this section's design below, with two real deviations. First:variant_fingerprinttakes explicit, named coordinates (target_triple,compiler_family,feature_toggles) rather than a rawBuildEvidence | None/EnvironmentMatrix | Nonepair. Telling a genuine logical-identity feature toggle (ONEDAL_DATA_PARALLEL) apart from build state that legitimately drifts release to release (an ABI-relevant-Ddefine, a raised-std=) cannot be done reliably from raw build evidence alone — both can appear as an indistinguishableBuildOption/CompileUnitentry — so that judgement call is pushed to the caller instead of embedded as a heuristic parse, which would risk silently reintroducing the union failure mode from either direction (see the function's own docstring for the full reasoning). Second:compiler_versionis not a parameter at all, unlike the design sketch below — a real gap in that sketch, caught in review (Codex): a routine toolchain upgrade between releases (GCC 13 -> 14 building the identical variant) is the same class of legitimately- drifting build state as an ABI-relevant define or a raised-std=, not variant identity, so fingerprinting it would makepair_variantsread an ordinary compiler bump as two different, unmatched variants —OLD_ONLY+NEW_ONLY— silently skipping every real per-library comparison for that variant and replacing it with a spuriousbundle_variant_coverage_regressedfinding.target_triple/compiler_familystay in the fingerprint (a target or compiler-family switch is a real, deliberate distribution-channel decision, not routine drift the way a version bump is). The function's own contract (which coordinates are fingerprinted, which are deliberately excluded and why) is otherwise exactly as designed below.ChangeKind.BUNDLE_VARIANT_COVERAGE_REGRESSED/"bundle_variant_coverage_regressed"is registered inchecker_policy.py/change_registry_buildsource.py(notchange_registry.py, which is at the AI-readiness 2000-line hard cap — default verdictRISK, per this section's design), classified intests/canonical_identity_contract.py'sUNVERIFIEDbucket (matching every other pre-existingbundle_*kind — none of them have had their construction call sites individually verified against theTYPE_BEARING/VALUE_INSENSITIVEcriteria yet), and covered bytests/test_bundle_multibuild.py(determinism/sensitivity cases, the never-union Hypothesis property, the missing-variant case, andcoverage_regression_findings's own finding construction).- Not shipped:
pair_variants' new-only coverage-expansion outcome is modelled (VariantOutcome.NEW_ONLY) but, per this section's own design, deliberately never produces aChangeKind— nothing renders it into the reporter'sbundle.json/bundle.mdyet (that's this phase's own "Reporter" row in "Files & surfaces", still open). Also not shipped: any CLI/config surface that discovers a release's real build variants, extractsBundleFactsper variant, and callspair_variants— see Phase 15's later correction (this deferral turned out to be permanent, not pending: the declarative CI pipeline that would have called it never needs to, since it always resolves its baseline live rather than pairing two already-capturedBundleFactsdocuments) — andcomparability.py's bundle-level fingerprint-mismatch refusal is likewise not yet wired to this module'svariant_fingerprint.
New module, abicheck/bundle_multibuild.py:
Kept as originally written, per this plan's own amendment convention (see
Phase 2's identical note above) — the shipped signature is the explicit-
coordinate one this section's own "Implementation status" note above
describes (target_triple/compiler_family/feature_toggles —
deliberately no compiler_version, see that note), not the
(evidence, env) sketch below.
def variant_fingerprint(evidence: BuildEvidence | None, env: EnvironmentMatrix | None) -> str:
"""Stable fingerprint over LOGICAL VARIANT IDENTITY only — which
distinct build configuration this is (target triple, feature toggles
such as ONEDAL_DATA_PARALLEL that mean "this is the DPC build, not the
CPU build") — never over versioned build STATE that legitimately
drifts release to release. Deliberately EXCLUDES four things:
1. Artifact membership (which libraries actually shipped) — see below.
2. EnvironmentMatrix.runtime_floors (a deployment/comparison-policy
input used to classify symbol-version and deployment findings).
3. Within SyclConstraints/CudaConstraints, the declared-deployment-
policy fields those dataclasses also carry
(SyclConstraints.min_pi_version, CudaConstraints.driver_range).
4. C/C++ standard and ABI-affecting flags/defines — a THIRD, and the
most consequential, instance of the same "policy/state leaking into
an identity key" class of bug the two exclusions above already fix,
caught in yet another review round: an earlier draft of this
docstring listed these as fingerprinted fields. That is wrong for a
different reason than (2)/(3) — these genuinely are build facts,
not policy — but `comparability.py`'s own machinery
(`_unexplained_profile_fields`, `language_standard_probe_upgrade_
corroborated`, `language_standard_content_divergence_corroborated`)
exists specifically to let a *corroborated* language-standard or
macro-defines change between old and new be compared and classified
(`cxx_standard_floor_raised`, `abi_relevant_build_flag_changed`),
not to refuse the comparison. Fingerprinting these fields for
*pairing* would make `pair_variants` reject exactly the same-variant,
drifted-build-state comparisons the existing engine is already
designed to run — the same CPU variant that raised its `-std=`
between releases would read as two different, unmatched variants,
replacing a real, classified finding with a generic coverage
regression. `variant_fingerprint` therefore reads only what
distinguishes one *logical* variant from another (which feature
build this is), and leaves everything about how that variant was
compiled — which can and does change release to release — to the
ordinary per-library comparability/diff layers to classify once
`pair_variants` has matched the pair."""
def pair_variants(
old: dict[str, BundleFacts], new: dict[str, BundleFacts]
) -> list[VariantComparison]:
"""Pairs by fingerprint equality (not nearest-match). A variant present
on both sides is diffed normally. A variant present only in OLD is a
real coverage regression (`bundle_variant_coverage_regressed`) — the
release stopped building a variant a consumer may still depend on. A
variant present only in NEW is coverage EXPANSION, not regression — a
real gap in an earlier draft of this function, caught in review: an
added DPC build on a previously CPU-only release is new coverage, not
a build that "went missing," and treating it identically would emit a
RISK-classified regression finding for what is actually good news. New-
only variants get their own, differently-named outcome (no regression
finding; recorded as an addition in the `VariantComparison` list so the
reporter can still show "3 variants compared, 1 newly added," but nothing
here reads as a regression). Neither shape is ever dropped or paired
with a mismatched variant."""
Artifact membership must not be part of the fingerprint (a real design
error in an earlier draft of this plan, caught in review). If the set of
libraries a build produces were folded into variant_fingerprint, an
ordinary library addition/removal/rename between old and new would change
the CPU (or DPC) variant's own identity, so pair_variants would see two
different fingerprints and treat them as two non-comparable singletons —
exactly the failure mode Phase 3 exists to prevent, just reached from a
different direction than a union. That would silently replace
bundle_library_removed/bundle_library_added and any real cross-library
regression inside that variant with a generic coverage-regression finding,
for the ordinary case of a library being added or dropped from a release.
variant_fingerprint is scoped to stable build-axis coordinates only (what
compiles the variant); which libraries that build actually produced is
versioned output to be diffed inside the matched pair (that's exactly
what bundle_library_removed/bundle_library_added already do), never a
component of whether two variants are "the same variant."
pair_variants is the enforcement point for "never union" — it has no
code path that merges two variants' facts before diffing; it only ever
returns one-to-one pairs or explicit non-comparable singletons. Each pair's
own compare_bundle() call and finding set stay completely independent;
aggregation (a release-wide worst-of verdict across variants) is a
reduction over already-computed per-variant results, done by the caller
(the same "worst-of, computed by the caller, never inside the comparison
primitive" shape compute_verdict/compute_exit_code already use for
per-library verdicts).
New ChangeKind: bundle_variant_coverage_regressed (category: Bundle /
structural, default verdict: RISK, not BREAKING — a missing variant is
a build-coverage gap the user needs to see, not by itself proof the
missing variant's ABI broke, since it may simply have been dropped from
the release intentionally; a real per-variant ABI break inside a matched
pair still uses the existing bundle_* kinds unchanged). Fires only for an
old-only variant (per pair_variants' asymmetric handling above) — a
new-only variant is coverage expansion, not a ChangeKind at all;
it is recorded only in the VariantComparison list the reporter renders,
never emitted as a finding.
Phase 4 — C-boundary signature-evidence gate¶
Implementation status (2026-08-23): the detector, the ChangeKind, and
its registry/test-completeness wiring are shipped, as a standalone
companion module (the same posture Phase 3's bundle_multibuild.py took),
with two real deviations from the design below.
abicheck/bundle_signature_evidence.pyimplementsfind_unverified_signature_findings(old, new, per_library_results, old_snapshots, new_snapshots) -> list[BundleFinding](signature widened 2026-08-24, see the "Update" note below) as a leaf module — it is not wired intobundle.compare_bundle()itself, becauseabicheck/bundle.pyis exactly at the AI-readiness 2000-line hard cap (confirmed viawc -l) and cannot accept new code without an offsetting removal. A caller invokes this function separately, alongsidecompare_bundle(), and merges the twolist[BundleFinding]results — the same standalone-companion shapebundle_multibuild.pyestablished for Phase 3'scoverage_regression_findings. Deliberately does not importabicheck.bundle(a small, 3-member_CONFIRMED_SIGNATURE_CHANGE_KINDSfrozenset is duplicated locally rather than imported, to stay a strict leaf modulebundle.py— or a future caller — imports, never the reverse).- Deviation from the design text below: the evidence check is scoped to
the provider's own snapshot only, not "the consumer, where
applicable" as this section's design sketch says. A consumer has no
DWARF/header declaration of its own for a symbol it only imports (calls)
rather than defines — its own
AbiSnapshot.function_map/variable_maphas no entry for an externally-defined symbol at all, so "the consumer's evidence for this symbol" is not a fact that exists to check. The design sketch's parenthetical was read as anticipating a shape this codebase's actual per-libraryAbiSnapshotconstruction doesn't produce, rather than as a requirement to build a new evidence source; the check below implements the well-founded half (the provider's own declaration evidence) and treats the consumer-side clause as inapplicable rather than approximated. - Registered as
ChangeKind.BUNDLE_INTRA_DEP_SIGNATURE_UNVERIFIED/"bundle_intra_dep_signature_unverified"inchecker_policy.py/change_registry_buildsource.py(notchange_registry.py, at the same 2000-line cap Phase 3's kind avoided — default verdictRISK, per this section's design), classified intests/canonical_identity_contract.py'sUNVERIFIEDbucket (matching every otherbundle_*kind), tieredL0inscripts/evidence_tiers.py(the detectable-at signal is the same C-linkage resolution match every otherbundle_*kind uses — the finding's own content records that deeper evidence was unavailable, which is a fact about the finding, not about the minimum tier needed to produce it), and covered bytests/test_bundle_signature_evidence.py(both-sides-ELF-only fires; sufficient-evidence-both-sides doesn't; one-side-insufficient still fires; no consumer / symbol absent from old (addition) / snapshot missing skip; a confirmed diff-level signature change takes precedence over this kind firing on the same symbol; variable, not just function, symbols; a single unresolved parameter type is sufficient insufficiency even with a known return type; one finding per consumer library; no crash on an entirely empty snapshot).
Update (2026-08-24): wired into the real compare --release CLI path —
superseding the "Not shipped: reporter wiring / any real caller" line this
note replaces. find_unverified_signature_findings previously had no
caller outside its own test module — the standalone-companion posture
above described where the detector lived, not that anything invoked it.
_run_bundle_analysis/_collect_bundle_result (cli_compare_release_
helpers.py, the real compare_bundle() call site for compare --release,
bundle analysis on by default) now accept old_snapshots/new_snapshots:
dict[str, AbiSnapshot] and, when both are non-empty, call the detector and
fold its output into the same bundle_findings list compare_bundle()
already populates — the pre-existing BundleFinding.to_change()/
render_bundle_findings_markdown() rendering already handles it
generically, no reporter changes needed (an earlier accounting of this
plan's own remaining gaps had incorrectly listed reporter wiring as a
separate missing piece; it was not — see this note). The maps are built
from a new per-library stash: _compare_one_library (cli_compare_
release.py) now also captures the new-side AbiSnapshot (alongside the
pre-existing old-side one) and each library's own bundle-canonical key
under _new_snapshot/_bundle_key, gated behind the same collect_diff_
results flag the old-side stash already used — now triggered whenever
bundle analysis is enabled (the default), not only for --bundle-facts-
out/--format junit. Accepted tradeoff, stated in that gate's own
docstring: both sides' AbiSnapshots are now held in memory for every
default release compare, not only the old side for the narrower
pre-existing cases — the same memory-conscious gate mechanism, now paying
that cost more often because the feature this phase describes needs it.
Regression coverage: tests/test_cli_compare_release_bundle_signature_
wiring.py (both _run_bundle_analysis and _collect_bundle_result
directly, with a monkeypatched build_bundle_snapshot mirroring
tests/test_bundle.py's own established pattern; confirmed to fail
against the pre-wiring code).
A CodeRabbit review on the wiring PR caught a real, pre-existing key-
mismatch bug this new caller made reachable for the first time.
_confirmed_provider_symbols (the "a real, diff-confirmed change outranks
an unverified one" precedence check) keyed its set by Path(result.library
).name — DiffResult.library's raw on-disk basename — while the main
loop's own provider_lib comes from new.resolution.provides, keyed by
the bundle-canonical name (libfoo.so, binary_utils._canonical_library_
key, version-stripped). For any normally-versioned real SONAME (e.g.
libfoo.so.1.2.3) the two never match, so the precedence check silently
never fired — invisible in every existing unit test, which deliberately
uses matching bare names throughout ("libcore.so" everywhere), but live
for the very first real caller this update introduces. Fixed by widening
the function's signature to (old, new, per_library_results, old_
snapshots, new_snapshots) and resolving each DiffResult's basename back
to its bundle-canonical key via a new _basename_to_bundle_key(old)
helper (built from old.libraries) before comparing. Regression coverage:
tests/test_bundle_signature_evidence.py::TestFindUnverifiedSignature
Findings::test_no_finding_when_confirmed_change_present_for_a_versioned_
library (a genuinely versioned on-disk path alongside a bare-name
DiffResult.library, mirroring what a real compare --release stashes;
confirmed to fail — reproducing the spurious duplicate finding — against
the pre-fix code).
Two further Codex review findings, both fixed. (1) The exact-equality
check for the recursion-depth-cap sentinel (spelling == "...") missed
composite wrapped forms ("... *"/"... &"/"... &&", from
pdb_parser.py/dwarf_snapshot.py wrapping a depth-capped inner type in a
pointer/reference) — fixed by switching to the same substring check
"?" already uses. (2) consumer_libs was computed from a bare,
name-only, set-wide consumers_of(symbol) lookup, the same limitation
bundle._detect_unresolved_intra_dependency's own docstring documents for
its own naive alternative — two unrelated libraries sharing a same-named
export could pair a consumer with a provider it has no real DT_NEEDED
path to. Fixed by restricting to reachable consumers, via a new shared
leaf module, abicheck/bundle_resolution_reachability.py (the DT_NEEDED
BFS extracted out of bundle.py, which both modules now import — this
also dropped bundle.py from exactly the 2000-line hard cap to 1975,
creating headroom rather than costing it). Deliberately narrower than
_detect_unresolved_intra_dependency's full contract: symbol-version/
default-binding matching is not attempted, since that needs a
per-consumer resolution shape (iterate consumers, resolve each one's own
specific requirement) rather than this function's provider-centric one
(iterate providers, gather their consumers) — folding it in would be a
real restructuring of the main loop, left open rather than attempted as
an extension of the same review-driven patch. Both regressions confirmed
to fail against the pre-fix code.
A third finding on the same recursion-sentinel area, correcting the
previous fix's own reasoning. That fix's substring check on "..." was
itself unsafe, unlike the sibling "?" check it was modeled after
(Codex review, fresh evidence): a real, complete C/C++ type spelling can
legitimately contain the literal substring "..." — a variadic
function-pointer parameter type like "void (*)(int, ...)" is
fully-resolved evidence, not truncated. Fixed by matching only the
sentinel's own finite shape via an anchored regex (the bare sentinel,
optionally followed by one or more space-prefixed */&/&& wrapper
suffixes for nested pointer/reference wrapping) instead of a blanket
substring check. Confirmed to fail against the pre-fix substring-check
code.
A fourth finding, on a gap in _symbol_evidence_sufficient() unrelated
to type-spelling parsing: unknown variadicness read as sufficient
evidence (Codex review, fresh evidence). Function.is_variadic is a
real tri-state field (bool | None), and diff_symbols._check_variadic_
change() itself skips (skip_none=True) whenever either side is None
— an older snapshot/dumper that never populated it is indistinguishable
from one that positively determined "not variadic". Without this module
also treating unknown variadicness as insufficient, a real fixed-arity/
variadic transition landing on an unknown side produced neither a
confirmed diff-level finding nor this module's own risk finding — total
silence on a real, calling-ABI-relevant unknown. Fixed by also requiring
is_variadic is not None. Confirmed to fail against the pre-fix code.
A fifth finding, the identical shape as the fourth for a different
tri-state field. Function.contract_attributes (calling-convention
attributes such as stdcall/ms_abi/vectorcall, list[str] | None)
had the same gap: diff_symbols._check_contract_attributes_change()
itself skips whenever either side is None, so a real calling-convention
transition landing on an unknown side produced neither a confirmed
diff-level finding nor this module's own risk finding. Fixed by also
requiring contract_attributes is not None. Confirmed to fail against
the pre-fix code.
A sixth finding, back on the recursion-sentinel regex -- two more real
composite forms, plus a wholly separate, unconditional placeholder
(Codex review, fresh evidence). pdb_parser.py's qualifier wrapping
renders the depth-capped sentinel with a prefix, not a suffix
("const ..."), and its array wrapping appends "[]" ("...[]",
possibly further wrapped, e.g. "...[] *") -- neither matched the
regex from the third finding. Separately, dwarf_snapshot.py's
DW_TAG_subroutine_type handling and pdb_parser.py's procedure/
member-function branches both render any function/subroutine type as
the fixed literal "fn(...)", unconditionally, regardless of recursion
depth -- a placeholder the sentinel-only regex could never match by
construction, since it isn't a wrapped sentinel at all. Fixed by
widening the regex to accept an optional const/volatile prefix
and [] among the suffix forms, and by separately recognizing the
exact "fn(...)" literal. Confirmed all four new cases fail against
the pre-fix code.
A seventh finding closed the module's own previously-documented
"deliberately narrower" residual gap: symbol-version/default-binding
matching, which turned out not to need the feared restructuring
(Codex review, fresh evidence). consumers_of(symbol) matches by bare
name only, so a consumer requiring foo@V2 could still pair with a
ProviderEntry whose only definition is foo@V1 -- a provider that
cannot actually satisfy that consumer at all (a real resolution
failure, not a signature-mismatch risk this module exists to flag). An
earlier revision of this docstring assumed closing this needed
_detect_unresolved_intra_dependency's own per-consumer resolution
shape (iterate consumers, resolve each one's own specific requirement)
rather than this module's provider-centric one; on closer look it does
not -- a new _consumer_matches_provider() predicate, evaluated per
(consumer, provider_entry) pair inside the existing provider-centric
loop, mirrors the sibling function's version/version_soname/
is_default rules without restructuring anything. Confirmed to fail
against the pre-fix code, using the exact foo@V2-vs-foo@V1 example
from the review comment.
An eighth finding closed the last gap the fourth/fifth findings'
is_variadic/contract_attributes sufficiency checks opened without
noticing (Codex review, fresh evidence). _CONFIRMED_SIGNATURE_
CHANGE_KINDS still only covered FUNC_PARAMS_CHANGED/FUNC_RETURN_
CHANGED/VAR_TYPE_CHANGED -- so a symbol with a real, diff-confirmed
FUNC_VARIADIC_ADDED/FUNC_VARIADIC_REMOVED/CALLING_CONVENTION_
CHANGED that also happened to carry an unrelated unresolved field
still produced a redundant, contradictory "cannot be confirmed or
denied" finding alongside the already-proven break. Fixed by adding all
three kinds to the set. bundle._detect_intra_dep_signature_changed's
own relevant_kinds does not (yet) include these two either -- noted
as a pre-existing, narrower gap in that sibling function's own
docstring update, not something this fix needed to wait on. Confirmed
to fail against the pre-fix code, parametrized over all three kinds.
A ninth finding closed a version-blindness gap in the old-side
retained-export check itself (Codex review, fresh evidence, filed once
this Phase 4 detector had a real caller on compare --release and was
exercised against realistic versioned-symbol scenarios for the first
time). _symbol_was_exported(symbol, old_snap) reads only
AbiSnapshot.function_map/variable_map -- both keyed by bare symbol
name, with no per-GNU-version distinction (the same limitation this
repo's own root AGENTS.md already documents for ElfMetadata.
symbol_map's "last-entry-wins" collapse of versioned aliases). So when a
provider previously exported only foo@V1 and the new release adds
foo@V2 for a consumer requiring exactly V2, the old-side check answered
"yes, foo was exported" purely from the unrelated foo@V1 entry, and
the detector reported the brand-new foo@V2 as a retained-signature risk
even though V2 has no old-side counterpart to be uncertain about at all.
Fixed by adding _provider_entry_retained_from_old(), which checks
old.resolution.provides[symbol] -- the bundle-resolution layer, built
from real per-symbol GNU version data, unlike the AbiSnapshot-layer
check -- for a same-library, same-ProviderEntry.version old-side
provider before treating the new one as retained. Two regression tests:
one confirming the false positive is gone for a genuinely fresh version,
one confirming the finding still fires when the version genuinely was
retained from the old side. Confirmed the positive-control test fails
against the pre-fix code.
A tenth finding, from the same Codex review round as the ninth, widened
the confirmed-kinds allowlist rather than continuing to add one kind at a
time. _CONFIRMED_SIGNATURE_CHANGE_KINDS (the eighth finding's own set)
still omitted every Function-level fact diff_symbols.py can confirm
independently of the four fields _symbol_evidence_sufficient itself
inspects (return_type/params/is_variadic/contract_attributes) --
concretely, a real, diff-confirmed FUNC_NOEXCEPT_ADDED on a symbol that
also carried an unrelated unresolved field still produced a redundant,
contradictory "cannot be confirmed or denied" finding alongside the
already-proven break, the identical shape the eighth finding fixed for
FUNC_VARIADIC_ADDED/FUNC_VARIADIC_REMOVED/CALLING_CONVENTION_
CHANGED. Rather than adding one more kind reactively (what the eighth
finding's own review round was, by the reviewer's own framing, at risk of
becoming), the fix was widened to every kind in that same shape:
FUNC_NOEXCEPT_ADDED/FUNC_NOEXCEPT_REMOVED (is_noexcept, a plain
bool, always confidently comparable), FUNC_EXCEPTION_SPEC_CHANGED/
CTOR_EXPLICIT_ADDED/CTOR_EXPLICIT_REMOVED (exception_spec/
is_explicit, tri-state fields whose own diff_symbols checks already
skip on None the identical way is_variadic/contract_attributes do),
FUNC_REF_QUAL_CHANGED (ref_qualifier, a plain string field), and
FUNC_VIRTUAL_ADDED/FUNC_VIRTUAL_REMOVED (is_virtual, a plain bool).
Deliberately excluded, with the reasoning recorded in the module's own
docstring: FUNC_LANGUAGE_LINKAGE_CHANGED (an extern "C" transition
changes the mangled name itself, so old and new sides can't share the
symbol key this module matches on in the first place) and the
vtable-slot/inline-transition kinds (facts about virtual-dispatch layout
and definition placement, not the calling-signature-agreement question
this module exists to answer). Nine regression tests (parametrized over
all eight added kinds), each confirmed to fail against the pre-fix
six-kind set.
An eleventh finding, from the same review round, closed a deeper
version-blindness gap one layer past the ninth finding's own fix. Even
a symbol the ninth finding correctly identifies as retained from the old
side can have multiple co-existing GNU versions live on one or both sides
(foo@V1 and foo@@V2 both still exported -- an entirely ordinary shape
for a provider that has never broken ABI compatibility across a versioned
release) -- and AbiSnapshot.function_map/variable_map keep exactly one
bare-name-keyed Function/Variable entry regardless of how many real
GNU-versioned definitions exist. _symbol_evidence_sufficient() evaluates
that single entry without any way to know which version it actually
reflects, so a consumer requiring specifically V1 could be told evidence
was fully sufficient purely because the collapsed entry happened to look
complete -- even though no V1-specific signature was ever actually
captured; the "sufficient evidence" answer could silently be borrowed from
an entirely different version. Fixed by adding
_bare_name_version_collapsed(), which detects the collapse using the
bundle-resolution layer's own per-version ProviderEntry list for that
(provider_lib, symbol) pair (a signal AbiSnapshot itself does not
carry) and forces both sides' sufficiency to False when detected, so the
"unverified" finding correctly fires (fail-closed) rather than trusting
ambiguous evidence. Two regression tests: one confirming the finding fires
when the old/new bare-name entry is version-collapsed (confirmed to fail
against the pre-fix code), one confirming a genuinely single-version
provider (even one flagged is_default=True) is unaffected.
A twelfth finding, from the same review round as the tenth and
eleventh, found the eleventh finding's own fix was reachable only some of
the time -- the confirmed-change precedence check one step earlier in the
same loop could suppress a provider entry before the version-collapse
guard ever ran. find_unverified_signature_findings()'s main loop
checks (provider_lib, symbol) in confirmed and continues past the
entire rest of the per-provider-entry body -- including the eleventh
finding's own _bare_name_version_collapsed() check -- the moment ANY
diff-confirmed change exists for that bare-name pair. But confirmed is
itself built from DiffResult.changes, which are computed against the
identical version-blind, bare-name-keyed AbiSnapshot entries the
eleventh finding's fix already distrusts for evidence-sufficiency
purposes -- so a diff-confirmed FUNC_PARAMS_CHANGED on a collapsed
bare name foo (retaining both foo@V1 and foo@@V2) is itself only
ever describing whichever version the model happened to keep, not
necessarily both. Pre-fix, this silently suppressed the unverified
finding for every consumer of that bare name, version-blind, even
though only one version's evidence was ever actually confirmed. Fixed by
computing version_collapsed once per provider_entry before the
confirmed-precedence check, and gating that check on not
version_collapsed -- confirmed-change precedence still applies normally
to the overwhelming majority (non-collapsed) case, and only yields to the
fail-closed "unverified" treatment when the bare name is genuinely
ambiguous. Two regression tests: two consumers pinned to each of two
collapsed versions with one confirmed change on the shared bare name
(confirmed to fail against the pre-fix code -- zero findings for either
consumer, when two were expected), and a non-collapsed sibling control
confirming precedence is otherwise unaffected.
A thirteenth finding, from the same review round, closed a gap in
_type_spelling_is_unresolved() unrelated to symbol versioning.
dwarf_snapshot.py's _compute_type_name fallback branch -- reached for
any DWARF type-DIE tag with no dedicated handling (e.g.
DW_TAG_ptr_to_member_type) -- returns name or tag or "unknown". When
the DIE carries no DW_AT_name (the common case for an obscure,
unhandled tag), this leaks either the bare literal "unknown" or the raw,
unresolved DWARF tag spelling itself into Function.return_type/
Param.type/Variable.type as though it were a genuine type name --
neither is one, and the previous sentinel set ("?", the recursion-cap
"...", "fn(...)") recognized none of them. Since both leaked forms
pass through the identical _resolve_inner_info/_resolve_inner_name
wrapping layer the recursion-cap sentinel already accounts for, they can
also appear composited with pointer/reference/array suffixes and
qualifier prefixes ("unknown *", "DW_TAG_ptr_to_member_type[]").
Fixed by widening the existing recursion-cap regex -- renamed
_UNRESOLVED_WRAPPED_SENTINEL_RE to reflect its now-broader scope -- to
alternate over \.\.\.|unknown|DW_TAG_\w+ as the wrapped base, rather
than only \.\.\.. Four new parametrized regression cases (the two bare
forms and one wrapped instance of each), confirmed to fail against the
pre-fix regex.
A fourteenth finding, from a further Codex review round, showed
retention is not actually a uniform, per-ProviderEntry fact -- it can
vary by which consumer is asking. _provider_entry_retained_from_old
(the ninth finding) matches purely on ProviderEntry.version, which is
correct for "does this exact version have any old-side counterpart at
all" but says nothing about whether a specific consumer could actually
have reached that old-side counterpart. Concretely: a provider previously
exporting only foo@V1 (is_default=False) that marks the identical
foo@@V1 default in the new release presents a genuinely new capability
to an unversioned consumer (which binds only to a default definition,
per _consumer_matches_provider's own rule) -- that consumer could not
have resolved foo from this provider before at all, so there is no
old-side signature for it specifically to be "unverified" against, even
though the bare version string "V1" checks out. A version-specific
consumer requiring foo@V1 explicitly is unaffected either way, since
its own match rule never inspects is_default. Fixed by adding
_consumer_retained_from_old(), folded into the consumer_libs filter
alongside _consumer_matches_provider rather than into the existing
per-provider-entry check -- deliberately additive, not a replacement:
removing the original check in favor of only the per-consumer one would
reopen the ninth finding's own bug, since an unversioned consumer's match
rule ignores symbol version entirely and would treat any old default
entry (of a completely different version) as satisfying retention for a
provider entry whose version never existed in old at all. Both checks
answer genuinely different questions and both must hold. Regression test
with two sibling consumers (unversioned, version-specific) against the
identical default-binding-flip scenario, confirmed to fail against the
pre-fix code (the unversioned consumer's library appeared in the findings
when it should not have).
A fifteenth finding, from the same review round, was investigated and
deliberately declined rather than fixed reactively. _compare_one_
library's collect_diff_results gate (widened earlier in this same PR
to also trigger whenever bundle analysis is enabled -- the default) means
every ordinary, non-JUnit release comparison now retains both full
AbiSnapshot objects for every library until the whole release finishes
and bundle reporting completes -- changing peak memory from roughly the
active worker set to the sum of every old/new snapshot pair, a real
concern for a release with many header-backed libraries. This was already
called out as an accepted tradeoff in this PR's own first changelog entry
(see this fragment's "Added" section), and investigating a fix confirmed
why it isn't a same-pass patch: entry["_old_snapshot"] is shared with a
pre-existing JUnit rendering path in cli_compare_release.py (predating
this PR, per PR #798) that requires a real AbiSnapshot
(isinstance(old_snap, AbiSnapshot)-gated) -- it cannot simply be
replaced with the compact function_map/variable_map/elf_only_mode
structure find_unverified_signature_findings actually needs. A safe fix
needs the stashing code in _compare_one_library to know why
collect_diff_results was triggered for a given run (JUnit vs.
bundle-analysis-only vs. both) and store a full snapshot only when JUnit
genuinely needs one -- a real control-flow change to already-reviewed,
working code, not something to attempt under continued review pressure
in this pass. Left as a known, accepted gap per this file's own "known
gaps over risky reactive patches" convention.
bundle_intra_dep_signature_changed already fires correctly when a
provider's DWARF/header evidence shows a real signature change. This phase
adds the missing negative case: a new, dedicated ChangeKind,
bundle_intra_dep_signature_unverified — required, not a
field-vs-kind choice left open for implementation time. A bare field on
BundleFinding cannot be independently suppressed, promoted, or exit-coded
by a --policy file the way a ChangeKind can (policy_file.py's
overrides: block, checker_policy.py's severity/verdict registry, and
severity.py's exit-code mapping all key off ChangeKind, not an
ad-hoc finding field) — so only the dedicated-kind form actually satisfies
this plan's own acceptance criterion 5 ("a distinct finding... so a
--policy file can suppress or promote it independently"). Registered in
change_registry.py exactly like every other bundle_* kind, with its own
report/JSON representation, registry entry, and acceptance test pinning its
default RISK verdict plus a policy-override case — the same bar every
other new ChangeKind in this codebase clears (see the root AGENTS.md's
"Adding a new ChangeKind" checklist).
The kind fires per consumer/provider symbol pair, evaluated with symbol-level evidence, not a report-level signal:
- the consumer's undefined symbol resolves by name to a provider's export
(the C-linkage match
compare_bundle()already establishes), and - that specific symbol's evidence — on the provider side, whether its
Function/Variableentry in the provider'sAbiSnapshotcarries real DWARF/header-derived type information at all, vs. being visible only as a bare ELF export with no corroborating declaration (an L0-only bundle member, or a provider whose header crosscheck never matched this symbol) — is insufficient to confirm or deny a signature change on either side.evidence_status_for_result's existingARTIFACT_PROVEN/UNATTRIBUTEDdistinction is report-level (see AGENTS.md's own "Evidence-provider model" known-gap entry) and is explicitly not reused here for exactly that reason — a report-level signal would make this finding's outcome depend on unrelated symbols elsewhere in the same comparison, which defeats the point of a per-symbol gate. CheckingFunction.params/Function.return_type/Variable.typefor non-Nonedoes not actually work as this gate's check (caught in review —Function.return_type/Variable.typeare requiredstrfields, neverNone, andFunction.paramsdefaults to an empty list, so an L0-only symbol has the same shape as a real, evidenced zero-argument function:dumper_elf_fallback.py's ELF-only path fills the unknown type in explicitly as the literal sentinel string"?"with an empty parameter list, and stampsvisibility=Visibility.ELF_ONLYon the entry rather than leaving any field empty or absent). The real, symbol-scoped check this phase needs is therefore: does the resolved provider (and consumer, where applicable)Function/Variablefor this specific symbol havevisibility != Visibility.ELF_ONLY, a return/variable type other than the"?"sentinel, and every parameter's own type also other than"?", on both old and new. That last clause is not redundant with the return-type check — a second review round caught a DWARF-specific gap in an earlier draft that checked only the return/variable type: a function can have full DWARF coverage overall (so it is neverELF_ONLY) while one parameter's ownDW_AT_typeis absent —dwarf_snapshot.py's_process_paramexplicitly emitsParam(type="?")for exactly this case — so a signature with a known return type but an unresolved parameter type would otherwise read as fully verified when it is only partially so. The check must walk every parameter, not just the return/variable type, i.e. recognize the actual unknown-evidence marker this codebase already uses everywhere it can appear, not merely at the top level of a signature. This is deliberately much narrower than the full per-finding evidence-provider model AGENTS.md's known-gap entry describes out of scope — it answers only "does this symbol have a corroborated signature," not "which tier produced every finding in the report."
Default verdict: RISK, distinct from both "no change" and the confirmed
BREAKING bundle_intra_dep_signature_changed — this is the "binary-name
compatible, signature unverified" language the review's finding-taxonomy
table asks for.
Files & surfaces¶
| File | Change |
|---|---|
abicheck/bundle_facts.py |
Shipped (Phase 2). New leaf module (not bundle_models.py — see the implementation-status note above): BundleFacts, capture_bundle_facts(), bundle_snapshot_from_facts(), compare_bundle_from_facts() (a thin wrapper delegating to bundle.compare_bundle() unchanged, so the parity test holds two calls to one implementation equal) |
abicheck/bundle_manifest.py |
Shipped (Phase 2). manifest_to_dict/manifest_from_dict/manifest_entry_to_dict/manifest_entry_from_dict — round-trip serialization for InstantiationManifest, reusing _parse_manifest_entry's existing validation rather than a second parser |
abicheck/bundle_multibuild.py |
Shipped (Phase 3, pairing primitive only). variant_fingerprint, pair_variants, VariantOutcome, VariantComparison, coverage_regression_findings |
abicheck/bundle_signature_evidence.py |
Shipped (Phase 4, detector only). find_unverified_signature_findings — standalone leaf module, not wired into bundle.compare_bundle() (see the Phase 4 implementation-status note above) |
abicheck/serialization.py |
Shipped (Phase 2). save_bundle_facts/load_bundle_facts plus bundle_facts_to_dict/bundle_facts_from_dict (the latter two live here, not in bundle_facts.py, to avoid the import cycle noted above) |
abicheck/comparability.py |
Bundle-level fingerprint-mismatch refusal, mirroring the existing single-snapshot ScopeMismatchError (Phase 3, once variant_fingerprint carries real per-variant identity — Phase 2's field is always "default"); no change to single-snapshot behavior |
abicheck/checker_policy.py / abicheck/change_registry_buildsource.py |
Shipped (Phase 3): bundle_variant_coverage_regressed registry entry. Shipped (Phase 4): bundle_intra_dep_signature_unverified registry entry, in change_registry_buildsource.py alongside its Phase 3 sibling |
abicheck/cli_options.py / abicheck/cli_compare_release*.py |
Shipped (producer half only). --bundle-facts-out <path> on the existing compare release fan-out (release_options() in cli_options.py, threaded through run_compare()/_dispatch_release_compare/compare_release_cmd, written via cli_compare_release_helpers.write_bundle_facts_out()) — an additive output flag, not a new root command. Not shipped: compare --against <bundle facts> consumer wiring (deferred — see the implementation-status note above) and whichever multibuild CLI surface Phase 3 needs |
abicheck/reporter.py / abicheck/report_summary.py |
Render the two new finding shapes; extend bundle.json/bundle.md (Phases 3-4) |
docs/reference/change-kinds.md |
Phase 1 taxonomy note; new-kind entries for Phases 3-4 |
docs/contribute/adr/023-bundle-aware-multi-binary-analysis.md |
Amendment block linking to this plan (see below) |
tests/canonical_identity_contract.py |
Shipped. Both new kinds (bundle_variant_coverage_regressed, bundle_intra_dep_signature_unverified) classified into UNVERIFIED — required by the root AGENTS.md's "Adding a new ChangeKind" step 5; tests/test_canonical_finding_id_completeness.py passes for both |
Tests¶
- Shipped:
tests/test_bundle_facts.py(new file, not an extension oftest_bundle.py) —BundleFactsround-trip (save_bundle_facts→load_bundle_facts→ identicalcompare_bundle_from_factsoutput, covering the manifest's three entry shapes and both compressed/ uncompressed storage), and the mandatory dump/live parity test from Phase 2's acceptance criterion 6 (a graph-native finding, a diff-derived finding, a negative/no-change control, and manifest override precedence —compare_bundle_from_facts()'s findings/verdict compared field-for-field against a livecompare_bundle()call on the identical underlying facts). - Shipped:
bundle_variant_coverage_regressed's positive/negative cases (Phase 3) — seetests/test_bundle_multibuild.pybelow. - Shipped:
bundle_intra_dep_signature_unverified's positive/negative cases (Phase 4) — seetests/test_bundle_signature_evidence.py, listed in the Phase 4 implementation-status note above. - Shipped:
tests/test_bundle_multibuild.py—variant_fingerprintdeterminism and sensitivity: two builds differing only in an ABI-irrelevant flag, or only in-std=/build-derived defines, fingerprint identically (Phase 3's own point — that drift is corroborated-comparable build state, not variant identity, so it must pair and letcxx_standard_floor_raised/abi_relevant_build_flag_changedclassify it); two builds differing in a logical-identity coordinate (target triple, or a feature toggle likeONEDAL_DATA_PARALLEL) fingerprint differently.pair_variants' never-union guarantee as a Hypothesis property (mirroring this repo's existing "Primitive-level property tests" convention for a reusable merge/pairing primitive — see AGENTS.md's own guidance on why a primitive this shape needs a property-test class, not only hand-picked examples), and the missing- variant-produces-its-own-finding case. - Extend the FP-rate/tier-accuracy corpora with one oneDAL-shaped case per
new
ChangeKind(mirroring howbundle_soname_skewalready hasexamples/case84_bundle_soname_skew/).
Example fixtures¶
examples/case<N>_bundle_dump_comparability/— a real, small multi-.so bundle (three tiny libraries, one importing from another) with a storedBundleFactsbaseline compared against a live "new" directory reproducing the same internal-symbol-removed break the review's oneDAL repro used, at a fixture scale this repo's example corpus can actually check in.examples/case<N>_bundle_multibuild_coverage_gap/— two variants old side, one variant new side, pinningbundle_variant_coverage_regressed.examples/case<N>_bundle_c_boundary_unverified/— an L0-only bundle member (stripped, no DWARF) whose C-linkage import still resolves by name, pinning the unverified-signature finding.
Stabilization phases (post-Phase-4 external review)¶
An external review of the state after Phase 4 landed (PR #842/#844, main at
c370aed/42da2d2) found Phases 1–4 to be good foundational work that is
not yet a complete, safe-by-default product feature: Phase 2's stored
facts have no CLI consumer, Phase 3's pairing primitive has no production
caller, and Phase 4 — now wired into the live compare --release path —
carries a real correctness bug, a policy/severity inconsistency, a
stored-baseline parity gap, and a memory regression. That review is the
origin of the phases below; each restates one of its numbered findings as
a scoped, independently-landable change rather than one large PR. Numbering
continues from Phase 4 rather than restarting, since these are direct
continuations of the same initiative, not a new one.
Phase 5 — Confirmed vs. promotable boundary-break kind sets (shipped)¶
Finding: bundle._detect_intra_dep_signature_changed's promoted-kinds
set (3 kinds: params/return/var-type) and
bundle_signature_evidence's suppression set (12 kinds) were two
independently maintained lists that had already drifted apart — a confirmed
CALLING_CONVENTION_CHANGED (and eight other kinds) correctly suppressed
BUNDLE_INTRA_DEP_SIGNATURE_UNVERIFIED but was never promoted to a
consumer-attributed BUNDLE_INTRA_DEP_SIGNATURE_CHANGED, silently losing
cross-library causality for exactly the kinds Phase 4 added confirmed-change
detection for.
First fix attempt, reverted the same PR after a Codex review round (fresh
evidence): sharing one 12-entry set for both purposes is itself wrong.
"Confirmed, so don't claim total ignorance about this symbol" (suppression)
and "confirmed severely enough to fabricate a consumer-attributed BREAKING
bundle finding" (promotion) are two different bars — change_registry.py's
own entries prove it: FUNC_NOEXCEPT_ADDED has default_verdict=COMPATIBLE,
and FUNC_NOEXCEPT_REMOVED/FUNC_EXCEPTION_SPEC_CHANGED are
COMPATIBLE_WITH_RISK with an explicit "not a binary break" rationale in
their own registry comments. A single shared 12-entry set would have made
_detect_intra_dep_signature_changed promote any of those three to a
BREAKING BUNDLE_INTRA_DEP_SIGNATURE_CHANGED — fabricating a
release-blocking cross-library finding out of a change the tool's own policy
layer says is not a binary break at all. FUNC_VIRTUAL_ADDED/
FUNC_VIRTUAL_REMOVED are genuinely BREAKING but describe vtable-slot
layout, not a direct calling-boundary mismatch — a different failure mode
than BUNDLE_INTRA_DEP_SIGNATURE_CHANGED's own "calling convention is now
mismatched" description.
Fix (shipped): two sets in bundle_models.py, one a strict subset of
the other (asserted at import time and pinned by
tests/test_bundle.py::TestIntraDepSignatureChanged::
test_promotable_kinds_are_a_strict_subset_of_confirmed_kinds, which also
asserts every promotable kind's own default_verdict is BREAKING via
change_registry.REGISTRY):
CONFIRMED_C_BOUNDARY_SIGNATURE_BREAK_KINDS(broad, 12 kinds) — used only bybundle_signature_evidence.pyfor suppression, unchanged from the first attempt.PROMOTABLE_C_BOUNDARY_SIGNATURE_BREAK_KINDS(narrow, 6 kinds: the original params/return/var-type plusFUNC_VARIADIC_ADDED/FUNC_VARIADIC_REMOVED/CALLING_CONVENTION_CHANGED) — used only bybundle._detect_intra_dep_signature_changedfor promotion. This is the actual fix for the finding above: it closes the real gap (CALLING_CONVENTION_CHANGEDand the two variadic kinds now promote) without also promoting the six kinds that are correctly suppression-only evidence.
Regression coverage: the subset/verdict test above, plus
test_does_not_promote_noexcept_added_to_a_breaking_finding (an end-to-end
case pinning the exact fabrication the first attempt would have shipped)
and test_promotes_calling_convention_change_to_consumer (the genuine gap
this phase closes).
Lesson for future phases in this sequence: "these two consumers read the same underlying fact, so they should share one constant" is not sufficient justification on its own — check whether the two consumers are actually answering the same question at the same confidence bar before unifying. Phase 10 (bundle policy/severity threading) will face a structurally similar temptation (one policy object feeding multiple decision points) and should verify each consumer's bar independently rather than assuming a single resolved object is automatically correct for all of them.
Two more findings from the same review round, both fixed (shipped):
- Provider-key normalization.
diff_by_library's keys were built fromPath(result.library).name— the real, possibly SONAME-versioned on-disk filename (DiffResult.libraryis always set this way at every ELF/PE/Mach-O dump site) — whileBundleSnapshot.resolutionkeys every provider/consumer by the version-stripped bundle-canonical name. CodeRabbit traced a concrete repro throughcli_compare_release.py's own_bundle_key/DiffResult.librarysplit: the wiredcompare --releasepath already stores the canonical key separately and leavesDiffResult.libraryas the real filename, so a promoted finding could carryprovider_library="libcore.so.1.2.3"while the resolution graph knows the same provider as"libcore.so"— silently defeating theconsumer.library != provider_libcomparisons the detector depends on.bundle_signature_evidence.py's own_confirmed_provider_symbolshad needed and shipped the identical fix once already (a prior, independent Codex review). Both consumers now share onebundle_models.basename_to_bundle_key()function instead of one already having the fix and the other not — the same "one shared leaf-owned primitive, not two independently drifting copies" pattern as the boundary-break kind sets above. Regression:test_promotes_using_canonical_provider_key_for_a_versioned_basename. plugin_abipolicy preservation during promotion. Widening the promotable set to includeCALLING_CONVENTION_CHANGED(this phase's own fix) reached a kind with a realpolicy_overridesdemotion —change_registry.pyclassifies itCOMPATIBLEunderplugin_abi(a plugin and its host rebuilt together from the same toolchain) — but the promotedBUNDLE_INTRA_DEP_SIGNATURE_CHANGEDbundle finding had no policy sensitivity at all, alwaysBREAKINGregardless of the caller's selected policy._detect_intra_dep_signature_changednow takes the samepolicystringcompare_bundle()already receives and skips promoting a change whose effective category under that policy (via the samepolicy_kind_sets/effective_categoryprimitivescompute_verdictitself uses) is notBREAKING. Regression:test_plugin_abi_policy_suppresses_calling_convention_promotion(paired with astrict_abicontrol confirming this isn't a blanket regression of the fix above).
Known residual gap, not fixed in this phase (Codex review, fresh
evidence, filed rather than rushed):
_detect_intra_dep_signature_changed's consumer lookup
(new.resolution.consumers_of(change.symbol)) is a bare, name-only match
with no reachability or version/default-binding filtering — unlike
bundle_signature_evidence.find_unverified_signature_findings, which
already gates on reachable_intra_libraries() (the consumer must actually
be able to load the provider through a real DT_NEEDED path) and
_consumer_matches_provider() (a GNU symbol-version match, not just a bare
name match) before attributing a finding. This predates Phase 5 — widening
relevant_kinds from 3 to 6 kinds increases how often the pre-existing
imprecision is reached, but does not introduce the imprecision itself. Not
fixed here: bundle.py is at the AI-readiness 2000-line hard cap (1997/2000
after this phase's own fixes), so borrowing bundle_signature_evidence.py's
reachability/version-matching logic needs either a shared leaf-module
extraction (the two private helpers would need to become public, tested
primitives with their own home) or an equal-or-greater removal elsewhere in
bundle.py first — a real, separately-scoped change, not a follow-up edit
to the same function under review pressure. Until then, a
compare --release bundle report can attribute a promoted finding to a
provider a consumer cannot actually reach, or across a version mismatch,
for the six promotable kinds — the same class of imprecision the
pre-existing three kinds already carried, now reachable by twice as many
kinds.
Real-world validation: napetrov/abicheck-bazel-lab, real oneDAL¶
Sequenced immediately after Phase 5, ahead of Phases 9–13 below: this validation pass is real, at-scale evidence rather than a synthetic fixture, and Phases 6–8 it produced are concrete, already-measured gaps — prioritized ahead of the more speculative (if still real) Phases 9–13 that follow.
An external contributor ran a full real-oneDAL validation pass (daal,
oneapi::dal, and dpc scans; three dump baselines) against a pin bump
from 7cf8adf83 to c370aed07a (101 commits). This is the first evidence
in this plan sourced from a real, at-scale binary rather than a synthetic
fixture, and it both confirms Phase 4 works correctly in production and
surfaces real gaps this section turns into phases. Headline results:
- Cost-neutral pin bump. All three scans and three dumps landed within
noise of the old pin (22m40s → 22m44s total scan time across the three
libraries; snapshot sizes unchanged). All three scans exit 0,
COMPATIBLE_WITH_RISK, with identical risk scores (78/27/47) to the old pin — the bump changed correctness, not behavior, for this codebase. - A real dumper correctness fix, already shipped, with an operational
lesson for this plan. The bump also picked up a fix for an enumerator-
initializer value getting lost when folded on an intermediate clang AST
wrapper node (a positional auto-increment silently replaced the real
value — e.g.
csrArray = 1 << 4recorded as3instead of16). This is a general dumper bug, not specific to bundle analysis, and is not part of G38 — but re-running the same source tree against stale, pre-fix baselines produced 280 falseenum_member_value_changedBREAKINGfindings (155daal+ 125dpc) withschema_version=25on both sides and nothing in the report naming which baseline caused it. Re-dumping the baselines at the new pin took all 280 to zero. The operational lesson — "schema_versionunchanged does not imply a baseline is still valid across a core pin bump; any pin bump needs baselines re-dispatched" — belongs in the repo's release/CI process documentation, not this plan, but is recorded here since it was found by exercising this plan's own Phase 2 stored-facts model at scale. A structured coverage block (Phase 11) naming which baseline a mismatch traces to would have made this diagnosable without a bisection. - Phase 4 confirmed correct at production scale. The cheap, headerless
bundle path (34s / 240MB for the full oneDAL bundle) correctly produced
bundle_verdict=COMPATIBLE_WITH_RISKwith 156 advisorybundle_intra_dep_signature_unverifiedfindings — not spuriousBREAKINGfindings — validating both the C-boundary signature-evidence gate's design and (retroactively) the Phase 5 promotion/suppression split above: a headerless scan has essentially no DWARF/header evidence for its stripped internal C symbols, so every one of the 156 correctly landed as the advisory "unverified" finding rather than a fabricated break. - Bundle blockers: three of four now understood, two already fixed.
Independently confirms
#831's two landed fixes (header analysis in directory-scoped bundle compare; SONAME-stem matching eliminating 379 falsebundle_intra_dep_removedfindings on external providers, → 0) and identifies two more, detailed as Phases 6–8 below.
Phase 6 — Headerless-bundle public-surface scoping¶
Finding: a headerless directory/package bundle compare has no header
evidence to scope by, so FilterNonPublicSurface (or its bundle-analysis
equivalent) has nothing to restrict findings to the library's actual public
API — every ELF-visible symbol, including ones with no public header at
all, is treated as in-scope. Measured on real oneDAL libraries: 1414/237/272
unscoped BREAKING findings across the three headerless scans, an order of
magnitude more than a header-scoped compare of the same libraries would
report. An earlier upstream attempt to scope this from ELF visibility alone
(no headers) was reverted — ELF visibility (GLOBAL/hidden) answers "is
this symbol exported," not "is this symbol part of the documented public
API," and the two diverge for exactly the internal-but-exported C symbols
this whole initiative's own oneDAL repro (ADR-023's own origin story) is
built around.
Not yet designed. A correct fix needs a public-surface signal that
does not depend on parsing headers — candidates worth evaluating rather
than assuming one is right: (a) a version-script/export-map-derived
allowlist, when the library's build already produces one (oneDAL's own
CMake does, for several of its libraries); (b) --public-header-dir/an
explicit -H/include-scoping flag threaded through the headerless bundle
path specifically so a cheap partial header set (just the public
umbrella headers, not the full transitive include graph a header-scoped
compare pays for) can scope without paying Phase 8's full cost; (c) a
documented, opt-in "no public-surface scoping available" flag on the
finding set itself (mirroring Phase 11's structured coverage idea) so a
headerless bundle report is honest about running unscoped rather than
silently over-reporting. Given the ELF-visibility attempt's own revert,
whichever design is chosen needs validation against the same real oneDAL
corpus before landing, not just synthetic fixtures.
Phase 7 — Audit-mode (scan --artifact-set) system-provider coverage and friction¶
Finding, part 1 (additive, low-risk): scan --artifact-set's cheap
audit path (10.8s / 383MB for the full six-library oneDAL set, no baseline
required) is blocked in practice by bundle_models.DEFAULT_SYSTEM_PROVIDERS
having no entries for the Intel oneAPI/TBB/MKL runtime libraries oneDAL
links against — 862 audit findings collapsed to 58 once the missing
sonames were supplied via the existing --bundle-system-providers escape
hatch. Not fixed in this pass: the exact SONAMEs (libtbbmalloc.so.2,
libiomp5.so, the MKL runtime family, the oneAPI Level Zero/Unified
Runtime loaders, ...) were not independently re-verified against a real
Intel oneAPI install in this environment, and this codebase's own
"known gaps over risky reactive patches" convention argues against
committing unverified SONAME strings to a shared, curated default list —
a wrong entry here silently under-reports, which is worse than the
current, honest "you must name it yourself" default. The actionable next
step is for whoever ran this validation to supply the exact SONAME list
(from ldd/readelf output against the real libraries) so it can be
added to DEFAULT_SYSTEM_PROVIDERS with real provenance, mirroring how the
existing entries (libtbb.so.12, libsycl.so, ...) already cover the
same product family.
Re-confirmed, still not fixed (2026-08-24, pin c370aed07a5 re-scan):
a follow-up validation pass against a bumped abicheck pin (see this
section's own scan-cost table below) reproduced the identical 862 → 58
split for the same six-library oneDAL set at effectively zero added cost
(scan --artifact-set still 10.8s/383MB), and re-identified the missing
family in the same shape as before — TBB malloc, MKL, and the Intel
runtime loaders — without supplying the literal SONAME strings this
section's "actionable next step" still asks for. The corpus and the
family are consistent across two independent runs; only the exact,
ldd/readelf-sourced spellings remain the missing input to actually
land the DEFAULT_SYSTEM_PROVIDERS entries.
Finding, part 2 (a documented design tradeoff, re-examined, not
reversed): even after naming every system provider, 58 residual audit
findings remained, traced to _detect_unresolved_intra_dependency's own
docstring — it deliberately has no _looks_system_symbol name-shape
fallback, unlike its diff-driven sibling _detect_intra_dep_removed,
specifically so a legitimate, non-system-shaped custom export (the
docstring's own example: vendor_init) is never silently swallowed by a
shape heuristic. That design choice is sound in the abstract and is not
reversed here — but real usage shows it creates real friction: a fully-
correct --bundle-system-providers list still needs to separately name
every Intel-runtime-internal mangled symbol the loader resolves, since
DEFAULT_SYSTEM_SYMBOLS/_looks_system_symbol are consulted only by the
diff-driven detector, not the audit one. Worth a scoped follow-up (not
attempted here, since it needs the same real Intel runtime evidence Part 1
does): let --bundle-system-providers optionally accept a symbol-name
pattern, not only a SONAME, so a real distinguishing signal for exactly
the Intel-runtime-internal symbols this audit path can't otherwise resolve
is available without reopening the vendor_init false-negative risk the
original design decision correctly avoided.
Phase 8 — Cross-pair header/source-context cache for the bundle layer¶
Finding: the fourth, most expensive blocker — a header-scoped directory/package bundle compare independently re-parses the shared header tree for every library pair, rather than once per unique compile context — measured at 2.5+ hours / 38.3GB peak RSS for a 12-union-header-parse oneDAL bundle compare (6 libraries × old+new), a cost that makes the header-scoped path effectively unusable for a bundle this size in CI. This is the same "original multi-binary performance problem" this plan's own Phases 9–13 section already declares out of scope for G38 proper — this finding does not change that scoping decision, it sharpens it with a real, measured number rather than a hypothetical one, and confirms (independently of this plan) that the streaming JSON pruner AGENTS.md documents as a negative result (~1.2% peak-RSS reduction, ~13% slower) is not a viable point fix for this cost. The real fix — a shared, content-addressed header/AST cache keyed by compile context rather than by library — remains its own, separately-scoped initiative per the existing "Out of scope" text above, not additional G38 phase surface; this entry exists so that initiative inherits a concrete, real-world acceptance target (12 union header parses → however many unique compile contexts the bundle actually has, which for a single-source-tree product like oneDAL should be closer to 1–2 than 12) instead of starting from nothing.
Deferred CLI/API surface asks (not yet filed as phases)¶
Three smaller, concrete asks came out of the same validation pass that don't yet have a phase of their own, each blocking a specific workflow rather than correctness:
dump --public-header-dirand per-library header roots on the CLI — both needed for Phase 6's option (b) above (a cheap, partial header set for headerless-bundle scoping) and for a cleaner Phase 13 stored-baseline producer invocation against a multi-library release whose libraries don't all share one umbrella header directory.--bundle-facts-out's consumer half — already tracked as Phase 13 above; this validation pass is independent confirmation that it's the single highest-value remaining ask, since it's the one gap keepingscan --artifact-set's otherwise-working 10.8s/383MB cheap audit path from producing a real baseline-comparable exit code instead of an audit-only one.
Phase 9 — Compact per-library signature evidence (memory regression fix, shipped)¶
Finding: wiring Phase 4 into the live compare --release/bundle-
analysis path (the "Phase 4" changelog entry above) made
collect_diff_results=True the default for every directory/package
comparison, not only when --bundle-facts-out/JUnit was requested — so
every completed library's full old+new AbiSnapshot (functions, types,
layouts, source graph, build-source evidence, everything) was retained
until the whole release finished and bundle analysis ran.
_collect_bundle_result() then built complete old/new snapshot maps from
those retained objects. For an N-library release, peak memory approached
the sum of every completed library's full snapshot pair plus whatever
active parallel workers were still extracting — a real regression relative
to the pre-Phase-4 default, where only JUnit/--bundle-facts-out paid that
cost.
Fix (shipped): a new, frozen bundle_models.BundleSignatureEvidence
projection carrying only the three fields
find_unverified_signature_findings actually reads
(function_map/variable_map/elf_only_mode — confirmed by reading
every attribute access that function's own helpers make on a snapshot),
built immediately in _compare_one_library right after each per-library
comparison finishes, holding references to the same Function/
Variable objects rather than deep-copying — the rest of the snapshot
(types, source graph, build-source evidence, everything not referenced
from the compact projection) becomes eligible for garbage collection the
moment the caller drops its own reference to the full AbiSnapshot,
rather than staying alive until _collect_bundle_result runs. The single
collect_diff_results flag split into two: collect_diff_results (stash
something for the bundle layer) and a new need_full_snapshots
(JUnit/--bundle-facts-out — the two reasons that genuinely need the
real AbiSnapshot), so a default compare --release with bundle
analysis on but neither of those stashes only the compact projection
under "_old_bundle_evidence"/"_new_bundle_evidence" instead of
"_old_snapshot"/"_new_snapshot". _collect_bundle_result/
_run_bundle_analysis and find_unverified_signature_findings itself
accept either type interchangeably (duck-type compatible — both expose
the same three fields), so neither JUnit/--bundle-facts-out's full-
snapshot path nor the detector's own logic needed to change.
Regression coverage: tests/test_compare_release_contract_coverage.py::
test_compare_one_library_stashes_old_snapshot_only_when_requested
(pins that collect_diff_results=True alone stashes the compact
projection referencing the same function_map/variable_map objects,
never a full AbiSnapshot, and that need_full_snapshots=True restores
the old full-snapshot behavior) and
tests/test_cli_compare_release_bundle_signature_wiring.py::
TestCollectBundleResultAcceptsCompactBundleEvidence (the compact and
full-snapshot paths reach find_unverified_signature_findings and
produce identical finding kinds end to end). The acceptance criterion
this phase was filed against — "a default compare --release retains
zero full AbiSnapshot objects once each library's own comparison
completes" — now holds by construction: nothing downstream of
_compare_one_library in the default (no-JUnit, no---bundle-facts-out)
path ever receives a full AbiSnapshot reference at all.
Phase 10 — Bundle-finding policy/severity/exit-code consistency¶
Finding: BundleDiffResult.bundle_verdict is computed from a bare
policy-profile string (checker_policy.compute_verdict), so a built-in
profile name (strict_abi/sdk_vendor/plugin_abi) reaches bundle
findings but a custom --policy-file, a kind: policy pack override, or
direct suppression of a bundle_* kind does not — and the severity
exit-code fold converts bundle findings to Changes and calls
compute_exit_code() without threading the resolved policy/severity
config through at all. The displayed verdict and the process exit code can
therefore disagree for any non-default policy/severity combination.
Fix, partial (shipped): the severity exit-code fold
(_fold_release_global_severity) omitted policy= entirely when scoring
bundle findings — unlike the sibling matrix_result branch two lines
below it, which already threads policy/kind_sets/policy_file
through correctly. Confirmed the exact disagreement this caused: a
plugin_abi-demoted CALLING_CONVENTION_CHANGED-derived bundle finding
already read COMPATIBLE in bundle_verdict (the displayed verdict,
which does read BundleDiffResult.policy) but still forced a nonzero
severity-aware exit code, since the fold scored it under an implicit
policy=None. Fixed by passing policy=bundle_result.policy — the same
resolved policy name bundle_verdict already uses — so the two agree for
every built-in policy profile name (strict_abi/sdk_vendor/
plugin_abi). Regression:
tests/test_config_review.py::TestReleaseSeverityPolicyAndGlobal::
test_fold_bundle_honors_the_bundle_result_own_policy (the identical
bundle finding scores exit 4 under strict_abi, exit 0 under
plugin_abi).
Still open, deliberately not attempted in the same fix: BundleDiffResult
has no policy_file/pack-override/suppression fields at all today (only the
bare policy: str), so a custom --policy-file, a kind: policy pack
override, or direct suppression of a bundle_* kind still don't reach
bundle findings anywhere — not the verdict, not the exit code, not
rendering. Closing that needs the full ResolvedBundlePolicy design this
phase originally proposed (profile, PolicyFile | None, per-kind pack
overrides, suppression, severity config, threaded through classification/
verdict/rendering/exit-code uniformly) — a real, separately-scoped
feature addition to BundleDiffResult's own data model, not a follow-up
to the one-line policy= fix above.
Phase 11 — Structured bundle-analysis coverage/degradation¶
Finding: bundle snapshot construction failures, find_unverified_
signature_findings exceptions, and a provider missing from either
snapshot map are all caught and reported as stderr warnings only — a
report's "bundle_findings": [] cannot be distinguished from "analysis
ran cleanly and found nothing" versus "analysis partially failed."
Fix, partial (shipped, P0-D). BundleDiffResult gained
analysis_errors: list[str]. Two of the three degradation points named
above now record into it instead of only echoing to stderr:
compare_bundle()raising inside_run_bundle_analysis— the returned stubBundleDiffResultnow carriesanalysis_errors=["bundle analysis raised: <exc>"]instead of losing the detail (previously only the per-library report survived;bundle_findingsdegraded silently to empty).find_unverified_signature_findings()raising — appended to the already-populated result'sanalysis_errorsthe same way, additive to whatevercompare_bundle()already found.
analysis_errors is surfaced to both report formats: _format_release_
json's summary["bundle_analysis_errors"] (present only when non-empty,
matching this file's established "present only when active" convention for
optional summary keys), and a new "⚠️ Bundle Analysis Warnings" Markdown
section, rendered even when bundle_findings is empty — an empty finding
list after a raised exception means "nothing was checked," not "nothing was
found," and a reader must not conflate the two.
Still open: the third degradation point — build_bundle_snapshot()
raising inside _run_bundle_analysis, before any BundleDiffResult exists
to attach errors to — still returns bare None with only a stderr echo, so
a caller distinguishing "bundle analysis was never attempted" from "bundle
analysis ran and found nothing" still can't do so from the JSON/Markdown
report alone for that one failure mode. Closing it needs
_run_bundle_analysis's return type to widen (e.g. always return a
BundleDiffResult, with a dedicated "snapshot construction failed" status
rather than None), which changes every caller's is not None check — a
real, if narrow, follow-up, not bundled into this fix to keep it additive
and low-risk. The richer, contract_coverage_ledger-style structured
coverage block (per-sub-analysis complete/partial/not_requested
status, a strict policy able to escalate incomplete bundle coverage to
NOT_COMPARABLE) also remains not implemented — analysis_errors is a
flat, additive error list, not a coverage ledger with its own gating
semantics.
Phase 12 — Live/stored Phase-4 parity (one bundle-analysis orchestrator)¶
Finding: compare_bundle_from_facts() (the stored-baseline path)
delegates only to compare_bundle(); find_unverified_signature_findings()
is a separate companion the live compare --release CLI path calls
directly (Phase 4's own wiring). A stored-facts comparison therefore never
runs the C-boundary signature-evidence gate at all, so "live vs. live" and
"stored old vs. live new" bundle analysis can disagree on findings for the
identical underlying evidence — the parity Phase 2's own design section
promised does not (yet) extend to Phase 4.
Planned fix: one analyze_bundle() orchestrator both the live release
path and compare_bundle_from_facts() call, taking optional per-library
signature-evidence maps (Phase 9's compact projection) so a stored side
with no retained AbiSnapshot can still participate. compare_bundle()
stays the core graph-native/diff-derived detector implementation; it is no
longer presented as the complete bundle-analysis surface.
Implementation status (shipped). A new leaf module,
abicheck/bundle_analysis.py, provides analyze_bundle(): it runs
compare_bundle(), then -- when old_signature_evidence and
new_signature_evidence are both given and non-empty -- runs
find_unverified_signature_findings() and folds its output into the same
bundle_findings list, with either stage's own exception recorded
additively in BundleDiffResult.analysis_errors (Phase 11's contract)
rather than discarding the other stage's results. Both real callers were
migrated onto it:
cli_compare_release_helpers._run_bundle_analysis(the livecompare --releasepath) now builds the two liveBundleSnapshots, loads an explicit--manifest, callsanalyze_bundle()once, and re-surfaces itsanalysis_errorsas the sameclick.echo(..., err=True)warnings it always emitted --analyze_bundle()itself is a pure/leaf function with no CLI-echoing concerns, since it's shared with the stored-facts path, which has noclickcontext to echo into.bundle_facts.compare_bundle_from_facts()now callsanalyze_bundle()instead ofcompare_bundle()directly, passingold_facts.per_library_snapshots(always a real, mandatorydict[str, AbiSnapshot]-- seeBundleFacts's own docstring) asold_signature_evidence. It gained a new optionalnew_signature_evidenceparameter for the NEW side's counterpart map; omitted (every pre-existing caller's shape), the Phase 4 gate simply does not run, identical to every caller's behavior before this phase.
Confirmed the duck-type-compatibility claim Phase 9 made (AbiSnapshot and
BundleSignatureEvidence both accepted anywhere
find_unverified_signature_findings takes an evidence mapping) by reading
that function's own signature and docstring directly -- it already declares
Mapping[str, AbiSnapshot | BundleSignatureEvidence] for both sides, so no
blocking gap existed here; analyze_bundle()'s own parameters are typed the
same way.
One design note worth recording: analyze_bundle() imports
compare_bundle/find_unverified_signature_findings inside its own
function body (a lazy, per-call import) rather than at module scope, even
though this module is a genuine leaf with no cycle to avoid. This is
deliberate, not an oversight -- the pre-existing bundle-analysis tests
(tests/test_cli_compare_release_bundle_signature_wiring.py) monkeypatch
abicheck.bundle.compare_bundle/abicheck.bundle_signature_evidence.
find_unverified_signature_findings as module attributes, the way this
codebase's bundle tests already do throughout; a module-scope from .bundle
import compare_bundle would bind a name once at import time that a later
monkeypatch.setattr(bundle_mod, "compare_bundle", ...) could no longer
reach, silently breaking every one of those pre-existing tests' patch
targets without a single one raising an error (they'd just observe the
real function running instead of the fake). The lazy import mirrors
exactly what the two pre-Phase-12 call sites already did for the same
reason.
Regression coverage: tests/test_bundle_analysis.py (analyze_bundle()
tested directly, per this repo's "primitive-level property tests"
convention -- both stages succeeding, only one side of evidence given
(the gate correctly does not run), policy threading through the new
orchestrator (Phase 10), full-vs-compact-vs-mixed evidence shape
interchangeability, and each stage's failure recorded additively without
losing the other stage's findings, including both stages failing at once);
tests/test_bundle_facts.py's new TestCompareBundleFromFactsPhase4Parity
(the mandatory acceptance test extended to Phase 4: a stored-old-vs-
live-new comparison given both sides' signature evidence produces the
identical BUNDLE_INTRA_DEP_SIGNATURE_UNVERIFIED finding a live
analyze_bundle() call over the same evidence does, plus a negative
control confirming the gate stays silent when new_signature_evidence is
omitted); and the pre-existing
tests/test_cli_compare_release_bundle_signature_wiring.py suite, which
exercises _run_bundle_analysis's migrated implementation unchanged and
passed without modification, confirming the live path's observable
behavior (finding kinds, analysis_errors, JSON/Markdown surfacing) is
unaffected by routing through the shared orchestrator.
Known gap, deliberately not closed here: Phase 13 (the stored-facts
CLI consumer) still doesn't exist, so new_signature_evidence has no real
producer yet -- compare_bundle_from_facts()'s Phase 4 parity is verified
today only by a Python-API-level test passing that map by hand, not by any
end-to-end CLI invocation. This is the correct, minimal scope for Phase 12
on its own (Phase 13's own plan section already says it is deliberately
sequenced after this phase, precisely so the future CLI surface inherits
parity rather than needing to re-establish it) -- but it means Phase 12's
parity guarantee has no live CLI path exercising it until Phase 13 lands.
Phase 13 — Stored-facts CLI consumer and multibuild wiring¶
Finding: Phase 2's BundleFacts are producible (--bundle-facts-out)
but not consumable — there is no compare old.bundlefacts.json new-release/
CLI path, only a documented programmatic API. Phase 3's pair_variants()
has no CLI/config caller and no producer populates a real (non-"default")
variant fingerprint, so two same-side captures collide as identical
identity today.
Planned fix: a BundleSideInput abstraction (LiveBundleInput |
StoredBundleFactsInput) resolving into one ResolvedBundleSide, so live/
live, stored/live, and stored/stored share one comparison pipeline instead
of a second hand-written loop; a declarative bundle_variants: config
block naming each variant's identity coordinates explicitly (target,
compiler family, feature toggles) rather than inferring it; and a
required: true/false distinction so a missing required variant can gate
a release rather than only demoting to COMPATIBLE_WITH_RISK.
Implementation status: the Python-API resolution/pairing layer is shipped
and inherits Phase 9/12's discipline in full; the literal CLI surface
(abicheck compare ... --old-bundle-facts <path> / .abicheck.yml's
bundle_variants: block actually being read) is deliberately not
attempted, for one concrete, measured reason — see "Known gap" below.
abicheck/bundle_side_input.py—LiveBundleInput/StoredBundleFactsInput(theBundleSideInputunion),ResolvedBundleSide, andresolve_bundle_side(): the shared resolution step this section asked for, unifying whatcli_compare_release_helpers._run_bundle_analysis(live) andbundle_facts.compare_bundle_from_facts(stored) each already computed independently into one(BundleSnapshot, {canonical_name: AbiSnapshot | BundleSignatureEvidence}, InstantiationManifest | None)shape.compare_bundle_sides()is the one comparison entry point built on top of it — the first in this codebase able to express every pairing (live/live, stored/live, live/stored, stored/stored), all four routed throughbundle_analysis.analyze_bundle()so none of the four can independently drift on which detectors ran (Phase 12's own guarantee, extended here to the two pairings — live/stored and stored/stored — that didn't exist as callable shapes before this phase).compare_release_against_bundle_facts()is the concrete unblocking: given a stored OLD-sideBundleFactspath and a live NEW-side directory, it discovers the NEW side's.sofiles, dumps and diffs each matched library through the Tier-2service.resolve_input/service. compare_snapshotschokepoints, builds the NEW side's compactBundleSignatureEvidenceprojection (Phase 9's memory discipline — never a full retained snapshot map beyond one in-flight comparison), and callscompare_bundle_from_facts()with a realnew_signature_evidencepopulated — closing Phase 12's own "Known gap" note verbatim ("no end-to-end CLI invocation ... exercising the Phase 4 parity guarantee") at the Python-API level, with a real@pytest.mark.integrationtest (tests/test_bundle_side_input.py::TestCompareReleaseAgainstBundleFacts, realgcc-compiled.sofiles) as the exercise.abicheck/bundle_variants_config.py—parse_bundle_variants_config()(eager, hard-error validation of a rawbundle_variants:mapping intoBundleVariantSpecobjects:target_triple/compiler_family/feature_toggles/required, mirroringvariant_fingerprint()'s own explicit-coordinate shape exactly, per this section's own design) andrun_bundle_variant_pairing()— the first real caller ofbundle_multibuild.pair_variants()anywhere in this codebase outside its own test suite (confirmed by grep before and after this change). Therequired: true/falsedistinction reuses the existing ADR-027 D3.2BundleFinding.effective_verdict/modulation_reason/modulation_ruleoverride mechanism — a missing required variant's ownBUNDLE_VARIANT_COVERAGE_REGRESSEDfinding is escalated toVerdict.BREAKINGin place, rather than a second, parallel gating path being invented alongside the one every other bundle-level override (policy, suppression) already flows through.
Known gap, deliberately not closed here — the literal CLI/config
surface. Neither compare_release_against_bundle_facts() nor
run_bundle_variant_pairing() is reachable from abicheck compare ... or
from a real .abicheck.yml, and this is not an oversight: every file that
would have to host the new dispatch is, as measured by wc -l immediately
before this phase's own code was written, within two lines of the
AI-readiness 2000-line hard cap —
| File | Lines / cap |
|---|---|
cli_compare_release.py (the release fan-out's own Click entry point) |
1998 / 2000 |
cli_compare_helpers.py (directory/package operand dispatch) |
1998 / 2000 |
cli_helpers_compare.py (discover_project_config/_build_match_map) |
1278 / 2000 (room, but not the dispatch site) |
cli.py (_dispatch_release_compare) |
1959 / 2000 |
cli_options.py (release_options — the shared flag-decorator family) |
1977 / 2000 |
buildsource/inline.py (BuildConfig — where a new .abicheck.yml top-level block is parsed) |
2000 / 2000 (already at the cap) |
bundle.py |
2000 / 2000 (already at the cap) |
A new Click option plus its dispatch branch, or a new BuildConfig block,
cannot land in any of these without first splitting one of them — a
separate, larger refactor of its own (this codebase's several cli_*.py/
diff_*.py module splits are the established precedent for how that's
normally done, each its own dedicated pass), not a follow-up edit
attempted reactively under this phase's own time budget. Forcing either
change into an already-at-cap file would either blow the hard cap outright
(an AI-readiness ERROR, not a WARN) or require a same-session, unreviewed
trim of unrelated content to make room — exactly the "known gaps over
risky reactive patches" tradeoff this repository's own root AGENTS.md
names explicitly. abicheck/bundle_side_input.py's and abicheck/
bundle_variants_config.py's own module docstrings record this same table
(re-measured at the time each was written) so a future contributor who
splits one of these files has a concrete, checkable pointer to what should
consume the room it frees, rather than rediscovering this constraint from
scratch.
Update (2026-09-01): both halves of this gap are now accounted for,
neither by squeezing into the at-cap files this table measured.
compare_release_against_bundle_facts()'s own CLI surface shipped in
Phase 17 below (compare --old-bundle-facts), via a new, split-out
frontends/cli/commands/compare_bundle_facts.py dispatch module
(ADR-061's migrated-package pattern) rather than a new option squeezed
into one of the legacy files this table measured — sidestepping the
constraint described above rather than resolving it in place.
run_bundle_variant_pairing()'s own .abicheck.yml bundle_variants:
wiring, by contrast, turned out not to need building at all: Phase 15's
own correction (below) found that the declarative CI pipeline that would
have been its caller (check-project.yml, via G30 P1.4/P1.5's
bundles:/profiles: schema) always resolves its baseline live, in-job,
so it never needs a mechanism for pairing two already-captured
BundleFacts documents in the first place — see Phase 15's own "Why
bundle_variants_config.py/pair_variants/BundleVariantSpec stay
unwired" note for the full reasoning. Neither half remains blocked on
file-size room the way this section originally framed it; the table above
is kept as the historical record of why this phase itself didn't attempt
either at the time.
Fixed (Phase 13 follow-up, second pass): bundle_variants_config.py's
own narrower, non-CLI-blocked gap — that it never verified a captured
BundleFacts.variant_fingerprint against what a declared spec's own
.fingerprint() would compute for the same name — is closed.
run_bundle_variant_pairing() gained an opt-in verify_fingerprints: bool
= False parameter: when True, a name present in both specs and one of
the facts maps whose captured, non-default variant_fingerprint
disagrees with specs[name].fingerprint() raises
BundleVariantsConfigError (the wrong file assigned to the wrong declared
variant name), while a facts file still carrying the
DEFAULT_VARIANT_FINGERPRINT sentinel — what every --bundle-facts-out
capture produces today, since no real capture pipeline can be told a
variant name yet — is never flagged, since it was never captured against
any declared coordinates to verify against. Default False so every
pre-existing caller (this module's own test suite included, which pairs
specs against arbitrary sentinel fingerprints unrelated to any real
coordinates) is unaffected. This does not need the CLI/BuildConfig
wiring above — it is a pure addition to the already-shipped Python-API
run_bundle_variant_pairing() function — so it was safe to close
independently of the CLI-surface gap above (itself now resolved, per the
Update note above, rather than still open). See
tests/test_bundle_variants_config.py::TestRunBundleVariantPairingVerifyFingerprints.
The original multi-binary performance problem (repeated header/AST extraction across sibling DSOs sharing one source tree) is explicitly out of scope for all of Phases 9–13 above — Phase 9 stops a new regression Phase 4's wiring introduced, it does not address the pre-existing per-binary extraction cost. That remains its own, separately-scoped initiative (shared/content-addressed evidence storage, memory-aware scan scheduling), not additional G38 phase surface.
Phase 13 follow-up — real-world assessment of the driver, all three gaps closed¶
A follow-up assessment, exercising compare_release_against_bundle_facts()
against a real, mixed-toolchain oneDAL-shaped release (a -fsycl/icpx
dpc library alongside plain-C++ daal/oneapi::dal libraries sharing one
umbrella header tree), reported three gaps. All three are now small and
precisely specified rather than architectural — all three are fixed, the
first later than the other two:
- Fixed, later — Phase 17. At the time this follow-up was written,
adoption still needed a committed Python step calling
compare_release_against_bundle_facts(...)directly, notuses: abicheck/abicheck@shawith a bare CLI flag, for the same file-size-cap reason the "Known gap" note above gives. Phase 17 below closed this: a realcompare --old-bundle-factsCLI flag, reachable from a plainabicheck compare old.bundlefacts.json new-release/ --old-bundle-factsinvocation with no committed driver script — see Phase 17's own "Shipped implementation" list for exactly how it landed without squeezing into any of the at-cap files this note used to name. - Fixed. The driver's
service.resolve_input()call never forwardedheader_backend/compile, so a header-scoped NEW side always resolved under the library's ownheader_backend="auto"default — absent a real castxml on the host,resolve_inputstill picks castxml first and dies in seconds on a clang/icpx-only host rather than falling back. The assessment's own monkeypatch injectingCompileContext(gcc_path="icpx", gcc_option_tokens=("-fsycl", "-DONEDAL_DATA_PARALLEL", "-std=c++17"), frontend="clang")directly intoservice.resolve_inputis what produced its one successful (35-minute, 10.2 GB peak RSS) header-scoped run — proof the fix works, not a supported way to reach it. Both kwargs are now real, forwarded parameters oncompare_release_against_bundle_facts()itself; no monkeypatch needed. - Fixed, additively.
headers/includes/compileapplied uniformly to every matched library — correct only when every library in the bundle shares one header tree and one compile configuration, which does not hold for oneDAL's own mix (plain C++daal/oneapi::dalalongside-fsycl/icpxdpc): the assessment's header-scoped run in practice parsed thedaallibrary's headers under thedpclibrary's own SYCL/DPC++ flags, since there was no way to say otherwise. That run is therefore correctly characterized as a cost proof (the driver completes end to end in bounded time/memory against a real multi-library release) rather than a correctness proof (every library's headers parsed under its own real compile configuration) — the two are different claims, and only the former was actually demonstrated. New optionalper_library_headers/per_library_includes/per_library_compile{canonical_name: ...}maps are now consulted before the uniform fallback per matched library, so a caller can givedpcits own SYCL flags whiledaal/oneapi::dalfall back to the plain-C++ uniform default (or vice versa) — a library absent from a given override map still falls back to that map's own uniform sibling, so only the libraries that actually differ need naming. The function's own docstring states the cost-proof-vs-correctness-proof distinction explicitly, so a future caller running with only the uniform fallback against a mixed-toolchain bundle cannot mistake the resulting exit code for a per-library-correct comparison.
Regression coverage for both fixes, in
tests/test_bundle_side_input.py::TestCompareReleaseAgainstBundleFactsResolutionUnit:
test_header_backend_and_compile_are_forwarded (pins that both kwargs reach
service.resolve_input unchanged, replacing the need for the assessment's
own monkeypatch) and test_per_library_overrides_win_over_the_uniform_fallback
(a two-library fixture confirming a per_library_* entry for one library
doesn't leak onto a library absent from that same map, which still receives
the uniform headers/compile default).
Phase 14 — Decouple diff-derived bundle detectors from public-surface scoping (SHIPPED)¶
Origin: external upstream-only review (base commit 327df7b5616bcf
aea8c330aad418b796c17f3970, PRs #860/#883 merged), items 7 and 8 of its
P1 list. Read alongside docs/use/multi-binary.md's own "Diff-derived
detectors inherit scoping indirectly, through starvation" section, which
already documents the mechanism this phase exists to fix — that section
stays accurate as a description of today's behavior; this phase is what
makes it stop being the correct behavior for the bundle-internal case.
Finding: the bundle layer has two detector families, and only one of
them is safe against public-header scoping. Graph-native detectors
(bundle_intra_dep_removed, bundle_library_removed/_added,
version-drift, manifest enforcement — see the "Graph-native detectors
ignore public-surface scoping entirely" section of multi-binary.md) work
directly from the bundle's own ELF resolution graph and are unaffected.
Diff-derived detectors (bundle_intra_dep_signature_changed,
bundle_intra_type_changed, bundle_provider_changed) are computed by
scanning each library's already public-surface-scoped DiffResult.
changes for the specific kinds they promote — so when --scope-public-
headers removes the underlying provider-side change because the changed
symbol isn't part of that library's own public API, the bundle detector
never sees it and never promotes it, even though the symbol is very much
part of the bundle's internal linkage contract between two sibling DSOs.
This is unsafe specifically for an internal C ABI between siblings:
libcore.so exports an internal C function with no public header at all;
libmath.so imports it via DT_NEEDED; the function's signature changes
incompatibly. The external SDK report may correctly classify the symbol as
non-public (that classification is correct for the "did the public API
change" question) — but the shipped bundle still breaks at load/call time,
and today's diff-derived detectors are starved of the evidence needed to
say so.
Each of the three detectors has its own, already-shipped reachability
mechanism, and none of them should be replaced with a uniform "actual
sibling DT_NEEDED import" gate — verified against all three functions
directly, correcting an earlier draft of this section that got one of the
three wrong.
_detect_intra_dep_signature_changed()genuinely does gate on an import- resolution edge already: it callsnew.resolution.consumers_of(change. symbol)and_consumer_resolves_via_provider(), i.e. a real "does this sibling actually import and resolve this exact symbol against this provider" check — this part of the earlier description was correct._detect_intra_type_changed()is not gated on an import edge at all, and must not become so. Verified directly: its reachability computation (consumer_reach) is a name-embedding match against every other library's own symbol table — "doesstripped(type_name)appear as a substring in some sibling's exported (public_hit) or internal (internal_hit) symbol name" — with no call toresolution.consumers_ofor any other import-graph primitive anywhere in the function. Its own docstring documents this explicitly as a "conservative heuristic," not an import-based check, and states the reason: a type layout change affects every mangled symbol that embeds the type's name in its template/signature encoding, regardless of whether the consumer's own build happens to import a specific symbol from the provider — requiring an actualDT_NEEDED-resolved import edge here would be a new, strictly narrower gate than the detector's shipped semantics, dropping a case its own reachability rule is designed to catch: a sibling that publicly re-exposes the provider's type in its own exported signature (embedding the type name in its own mangled symbols) without necessarily having an import-resolution edge to the specific changed symbol the provider-side diff names._detect_provider_changed()(bundle.py) is a third, distinct shape again: it emitsbundle_provider_changedwhenever a mangled symbol is removed from one library and added, under the same name, to a different library in the same release — unconditionally, with no reachability check of any kind today — because a provider move is exactly as breaking for an external consumer statically/dynamically linked against the old provider's DSO as it is for a bundle sibling; ADR-023 and the current implementation both treat the finding as protecting that external-consumer case, not only an intra-bundle one. Adding any reachability requirement here — import-edge or otherwise — before promotingbundle_provider_changedwould silently drop that existing protection for the (arguably more common) external-consumer case whenever no bundle sibling happens to reach the moved symbol — a real regression relative to today's behavior, not a refinement of it.
Planned fix: maintain two separate views rather than one scoped
DiffResult feeding both questions:
- the external public-contract view — today's already-scoped
DiffResult, unchanged, answering "did the public API change" for the standalone per-library report; - a bundle-internal linkage-contract view — either the unscoped raw
per-library changes, or raw old/new signature and type evidence computed
independently of
--scope-public-headers— that all three diff-derived bundle detectors consume instead of the scoped view. Public scoping continues to determine the standalone library's own verdict; it must never again be the mechanism that silently erases evidence needed to prove a sibling DSO no longer works. - on top of the unscoped view, each detector keeps its own, already-shipped reachability rule unchanged — none is replaced with a uniform import-edge gate:
bundle_intra_dep_signature_changedcontinues requiring the sameresolution.consumers_of()/_consumer_resolves_via_provider()import-resolution check it already has, just evaluated against unscoped rather than scoped evidence.bundle_intra_type_changedcontinues requiring the same name-embedding symbol-table match (consumer_reach,public_hit/internal_hit) it already has — not an import-resolution edge — also evaluated against unscoped evidence. Its existing internal-vs-public demotion (Verdict.COMPATIBLE_WITH_RISKwhen the match is only against a sibling's internal symbols) is unaffected.bundle_provider_changedkeeps its current, unconditional promotion rule unchanged (unscoped evidence only, no reachability requirement of any kind added) — the fix for this detector is purely "stop losing the underlying change to public-header scoping," not a new gate.
The reachability requirement matters as much as the unscoping, for the two
detectors that have one: an internal, headerless change with no
sibling reaching it under that detector's own existing rule must not
become a bundle_intra_dep_signature_changed (no resolved import edge) or
bundle_intra_type_changed (no name-embedding match, public or internal)
finding just because scoping no longer filters it. bundle_provider_changed
is not subject to any reachability requirement, per the previous
paragraph.
Acceptance tests: (1) an internal, headerless C export consumed by a
sibling changes from int(int) to long(long). The standalone external
API report may demote/filter it (unaffected, by design). The bundle report
must emit a consumer-attributed bundle_intra_dep_signature_changed
breaking finding. The identical change with no sibling consumer must not
become a bundle break. (2) an internal, headerless C export with no
public header moves from libcore.so to libmath.so between releases,
with no sibling DSO importing it at all. The standalone external report
may demote/filter the per-library removal (unaffected, by design). The
bundle report must still emit bundle_provider_changed for the move —
confirming the fix does not regress the existing external-consumer
protection by requiring a sibling import that this detector never
required before. (3) an internal, headerless type changes layout in
libcore.so, and a sibling libmath.so publicly re-exports the type by
embedding its name in one of libmath.so's own exported (mangled) symbols
— with no DT_NEEDED import-resolution edge from libmath.so to the
specific changed symbol in libcore.so (e.g. the type reaches libmath.so
only via a shared header, not via a call to a provider symbol). The
standalone external report may demote/filter the per-library change
(unaffected, by design). The bundle report must still emit
bundle_intra_type_changed for this case — confirming the unscoping fix
does not regress _detect_intra_type_changed()'s existing name-embedding
reachability rule by wrongly requiring an import edge this detector never
required before.
Files & surfaces — routed through ADR-061's canonical package owners, not
grown in the frozen legacy modules that currently host this logic
(bundle.py/bundle_side_input.py are both listed in architecture/
modules.yaml's legacy_root_modules no-growth inventory, and
cli_compare_release.py is a frozen_root_families["cli_"] entry — new
behavior belongs in the target layer, with only a thin call added to the
existing legacy entry point):
abicheck/compare/— the new raw, unscoped signature/type-matching logic itself (acompare/-owned sibling to today's_detect_intra_dep_signature_changed/_detect_intra_type_changed/_detect_provider_changed, since this is "match old/new entities or identify a raw change" per ADR-061's routing table) plus each sibling's own, already-shipped reachability rule, unchanged in kind: an import-resolution check (resolution.consumers_of()/_consumer_resolves_via_provider()) for_detect_intra_dep_signature_ changed, a name-embedding symbol-table match (consumer_reach) for_detect_intra_type_changed— not the same mechanism as its sibling, despite both being "gated" in some sense — and no reachability check at all for_detect_provider_changed, which consumes the unscoped view unconditionally (see the "Finding"/"Planned fix" sections above).abicheck/workflows/— coordination that decides when to invoke the newcompare/matcher (alongside the existing graph-native detectors) and folds its output intoBundleDiffResult, rather than this decision living inline inbundle.py/bundle_side_input.pydirectly.abicheck/frontends/— the CLI-level plumbing that supplies the unscoped evidence toworkflows/for thecompare-releasefan-out (today'scli_compare_release.py/cli_compare_release_helpers.pycall sites gain only the minimal forwarding needed, not new detector logic).bundle.py/bundle_side_input.py/cli_compare_release.pykeep their existing call shape (compare_bundle()'s own signature,analyze_bundle()), extended with a second, parallelunscoped_results/raw-evidence parameter that is threaded straight through to the newcompare//workflows/code — likely as a second, parallel argument rather than re-running the per-library compare a second time with scoping disabled (that would double the extraction cost this initiative's own Phase 8/13-follow-up work is careful to bound).
Effort: M — the reachability-gating logic already exists in spirit for
the graph-native detectors; the new work is threading a second, unscoped
evidence view to the three diff-derived detectors without doubling
per-library compare cost, plus updating docs/use/multi-binary.md's
"Diff-derived detectors inherit scoping indirectly" section once this
phase ships (it will no longer be an accurate description of the shipped
behavior).
Shipped, in a materially cheaper shape than this phase's own text above assumed, and with the ADR-061 routing explicitly not done -- both discovered only once implementation started, not designed for up front.
- The "second, unscoped evidence view" already existed, at zero extra
extraction cost.
DiffResult.out_of_surface_changes(checker_ types.py) already carries every changepost_processing. FilterNonPublicSurfacedemoted for being outside the public-header surface -- ADR-024 §D4/D5's "recorded, never silently dropped" ledger, wired since long before this phase. The "second raw evidence view... computed independently of--scope-public-headers" this phase's own "Planned fix" section above sketched as needing new extraction plumbing throughworkflows//frontends/turns out to already be sitting on the object every caller already has:diff.changes + diff.out_of_surface_ changes. No new compare pass, no doubled extraction cost, no new workflow/frontend surface -- the fix is a one-line change to what each detector iterates, at each of the three call sites. - ADR-061
compare/-package routing is not reachable today, and was not attempted, for the identical reason G38 Phase 16 already documented for its own resolver.architecture/debt.yamlnamescompareasbundle.py's own migration target -- butcompare/'smay_import(architecture/modules.yaml) is["model"]only, and every type this logic operates over (ChangeKind,BundleFinding,ElfSymbol) is an unclassifiedlegacy_root_module, not part ofmodel. Verified rather than assumed: attempting a sibling flat module (abicheck/bundle_diff_derived_detectors.py, following the establishedbundle_resolution_reachability.pyprecedent for a second bundle-level modulebundle.pyimports) was tried first and rejected outright bycheck_architecture.py's ownfrozen-root-family/root-modulechecks -- unlike the no-growth ledger (which gates existing file line counts), these two checks reject any file not already named inarchitecture/modules.yaml's closedfrozen_root_families/legacy_root_modulesenumeration, regardless of size. There is no flat-module escape valve at all for new production code; only a real classified-package home or an in-place edit to an already-listed file is accepted. Implemented as the latter instead: the fix is entirely in-place insidebundle.pyitself (also pinned at an exact 2000-line no-growth baseline), each of the three detector functions' ownfor change in diff.changes:line changed to iteratediff.changes + diff.out_of_surface_changesinstead -- a content edit to an existing line, not a new one -- with each function's docstring extended to document why, offset by compacting a few genuinely collapsible pre-existing multi-line conditionals elsewhere in the same file (no logic change) so the file lands one line under its pinned baseline rather than over it. - A narrower, previously-undocumented gap found while writing this
entry: suppression asymmetry between the two change sources now being
combined.
post_processing.py's own step ordering runsFilterNonPublicSurfacebeforeApplySuppression, so a change already demoted toout_of_surface_changesnever reachesApplySuppressionat all -- it was never checked against a--suppressrule, whereas a change that stayed indiff.changeswas. Combining the two sources means a--suppressrule targeting an internal-only symbol has no effect on the newly-visible out-of-surface half of the combined view, even though it already suppressed the in-surface half before this phase. Documented as a known, deliberately-undosed gap inbundle.py's own docstrings and indocs/use/multi-binary.md's updated suppression section, rather than solved here: re-running suppression against the out-of-surface ledger specifically is a real, separate behavior change to what--suppressreaches (needing its own verification), not a silent side effect of this fix. docs/use/multi-binary.md's "Diff-derived detectors inherit scoping indirectly, through starvation" section is rewritten to describe the shipped behavior (scoping no longer starves these three detectors; suppression still does, for the in-surface half only).
Regression coverage: tests/test_bundle_diff_derived_scoping.py (a new
file -- tests/test_bundle.py carries the identical no-growth pin
bundle.py does, so a genuinely new test class needs a new file),
reproducing this phase's own three acceptance scenarios directly against
a DiffResult whose relevant Change lives only in
out_of_surface_changes: (1) an internal signature break with a
resolving sibling consumer promotes, the identical break with no
resolving consumer does not (unchanged reachability rule); (2) an
internal provider move between two libraries with no bundle sibling
importing it at all still promotes (confirming no reachability
requirement was added); (3) an internal type-layout change reachable
only via a sibling's own exported (mangled) symbol name -- no DT_NEEDED
edge at all -- still promotes (confirming the name-embedding rule was
not replaced with an import-edge requirement). All three positive cases
confirmed to fail against the pre-fix bundle.py (git stash on that
one file); the negative-reachability control passes on both.
Phase 15 — Declarative-pipeline wiring: check-project.yml/Action/CLI for BundleFacts and variants (SHIPPED — via G30, not via this phase's own original design)¶
Origin: same external review, item 8. Phase 13/13-follow-up above
shipped the Python-API half of item 8 — BundleSideInput/
resolve_bundle_side()/compare_bundle_sides() (live/live, stored/live,
live/stored, stored/stored all through one analyze_bundle()
orchestrator), plus bundle_variants_config.parse_bundle_variants_config()/
run_bundle_variant_pairing(), implementing the exact bundle_variants:
shape (target_triple/feature_toggles/required) the review's own
sketch proposes.
This section previously described the CI-declarative half as blocked on
a real, unsolved "cross-job assembly problem": no mechanism for one
check-project.yml matrix cell's snapshot to reach a later "bundle
dispatch" job, and no DiffResult.to_dict()/from_dict() to reconstruct a
per-member comparison result across a job boundary. That framing was
itself stale — the third time this document's own prose has drifted from
shipped reality (after Phase 17's operand-kind-detection sketch and this
same phase's own file-split-blocker text, both corrected earlier). A
separate initiative, G30 P1.4/P1.5 (abicheck/buildsource/run_plan.py,
abicheck/buildsource/project_targets.py) plus ADR-047's declarative
targets:/bundles:/profiles:/baseline: .abicheck.yml schema, wired
into check-project.yml, landed after this phase's text was written and
was never cross-referenced here. It already satisfies this phase's own
acceptance bar — via a fundamentally different design than the one this
section used to assume, which is why the "assembly problem" it worried
about turns out not to apply.
Why no cross-job assembly is needed. The blocked design this section
used to describe assumed one job must capture a snapshot for a later job
to reassemble. The shipped design instead makes each (bundle, profile)
pair its own independent check-project.yml matrix cell —
run_plan.py's generate_run_plan() loops for check in bundle.checks:
for profile_id in profile_ids: ... (run_plan.py:825-911), emitting one
RunPlanCheck per cell, each carrying exactly one profile_id — and each
cell resolves its own baseline live, in-job, via resolve-baseline
(actions/check-target/action.yml:604: old-library: ... inputs.kind ==
'bundle' && steps.resolve.outputs.binaries-dir). Baseline resolution
never crosses a job boundary in this design, so "cell A's snapshot must
reach cell B" — the problem DiffResult serialization/recompute would
have solved — never arises for it. This is not a smaller version of the
same problem; it is a different architecture that doesn't have the
problem.
The acceptance bar, checked against what this shipped design actually proves:
- "Old CPU pairs only with new CPU, old DPC pairs only with new DPC,
never unioned" — true by construction: a generated
RunPlanCheckcell carries exactly oneprofile_id, with its own independent baseline/candidate resolution, so no code path can union two profiles' facts even accidentally. Proven for a bundle spanning two sibling required ELF profiles bytests/test_run_plan_bundle_multi_profile.py::TestBundleAcrossTwoRequiredVariantProfiles::test_two_required_variant_profiles_produce_two_independent_bundle_checks(added alongside this correction — the pre-existingtest_run_plan.pybundle tests only exercised one ELF profile plus a non-participating Windows profile, not two profiles both required). - "Missing required DPC is a coverage regression" — a hard,
run-plan-generation-time error, provided the bundle's check declares
an explicit
checks[].profiles:selector naming every mandatory profile (e.g.profiles: [cpu, dpc]). Proven bytests/test_run_plan.py::TestBundleChecks::test_bundle_check_errors_when_a_member_is_missing_and_profile_is_explicitand::test_bundle_check_missing_build_output_for_an_explicit_profile_is_an_error(single-profile shape, pre-existing) plustest_run_plan_bundle_multi_profile.py'stest_missing_required_variant_profile_is_a_hard_run_plan_error/test_required_variant_profile_entirely_absent_is_a_hard_run_plan_error(two-required-profile shape, added alongside this correction). The one real authoring requirement this phase's own investigation surfaced: omittingprofiles:(the "implicit sweep") instead treats a non-participating profile as a silent, valid skip — correct for "this profile legitimately doesn't build this bundle" (a Windows-only profile skipping a Linux-only bundle), wrong for "this variant is mandatory and went missing." A project author declaring a genuinely mandatory multi-variant bundle must use the explicit selector, not the implicit sweep — a config-authoring convention to document (docs/reference/ project-targets-schema.md), not a missing capability.generate_run_plan()'s failure is also partial, not atomic worth noting precisely: a still-valid profile's cell (e.g.cpu) stays inplan.checkseven when a sibling required profile (dpc) fails — butreport.okisFalseeither way, andcli_project.py'sproject plancommand doessys.exit(0 if report.ok else 1)regardless ofplan.checks's contents, so theplanjob (and, viacheck-project.yml's ownneeds:chain, the whole run) still fails loudly. - "Facts from variants are never unioned" — same structural guarantee as the first bullet; nothing to add.
- "Live/live and stored/live runs produce equivalent normalized findings" — Phase 12's own guarantee, extended by Phase 13, independent of this phase's CI-wiring question.
Why bundle_variants_config.py/pair_variants/BundleVariantSpec
(Phase 13) stayed unwired into .abicheck.yml — a deliberate design fact,
not a lingering gap. (Historical note, 2026-09-06: the module itself
was deleted outright in ADR-065 S1 rather than given a consumer —
bundle_variants:'s member-selection need was served instead by
model/release_selection.py's ReleaseSelection, a different axis than
this module's variant-identity pairing; see storage-format-v2.md's A1.6
entry for the current status. The reasoning below for why this specific
mechanism was never wired into .abicheck.yml remains accurate as a
historical record, but the module it describes no longer exists to wire.)
They solved a genuinely different problem than the
one check-project.yml's declarative pipeline has: pairing two
already-captured, already-serialized BundleFacts documents for a
caller with no live access to the old binaries — the Python-API/
stored-facts consumer G38 Phase 17's compare --old-bundle-facts surface
serves. The CI declarative pipeline always has live baseline access (via
resolve-baseline), so it never needs that pairing mechanism; wiring
bundle_variants: into .abicheck.yml on top of the already-sufficient
profiles:/bundles: mechanism would be a second, redundant way to
express the same declaration, not a missing one.
Do not implement depth: source for bundles by simply passing headers
and sources to a directory operand (the review's own explicit caution,
still holds) — that reintroduces exactly the per-binary extraction-cost
regression Phase 9 was written to close and the mixed-toolchain
per-library-compile-context gap Phase 13-follow-up's fix #3 closed. Route
depth: source bundle checks through the already-shipped
compare_release_against_bundle_facts()/per-library override maps
instead.
Out of scope, unchanged: the measurement-harness and SARIF-output items the original review also raised for this phase remain genuinely separate, unstarted work — nothing in this correction touches them.
Effort: none remaining — every acceptance-bar invariant is proven by an
existing or newly-added test against already-shipped code (G30 P1.4/P1.5 +
ADR-047), with no new production code needed. The only artifact this
phase's finalization produced is the missing test coverage for the
two-required-profile shape
(tests/test_run_plan_bundle_multi_profile.py) and this section's own
correction.
Phase 16 — Thread a resolved PolicyFile into the release fan-out's own bundle analysis (SHIPPED)¶
Origin: Codex review on the PR that documented Phase 14/15 above,
verified against current source, not assumed. compare_bundle()/
analyze_bundle() both accept an optional policy_file: PolicyFile |
None (see this plan's own docstring excerpt for compare_bundle's
policy_file parameter above), and the stored-BundleFacts Python-API
driver (bundle_facts.compare_bundle_from_facts(),
bundle_side_input.compare_bundle_sides()/
compare_release_against_bundle_facts()) already resolves and forwards a
real one. The CLI's directory/package compare-release fan-out does
not: cli_compare_release_helpers._run_bundle_analysis() calls
analyze_bundle(..., policy=policy, ...) with only the bare
policy-profile-name string, and its caller,
_collect_bundle_result(), has no policy_file parameter at all —
confirmed by reading both functions and their one caller in
cli_compare_release.py. So a --policy custom.yaml document's
overrides: entry for a bundle_* kind still has no effect on the
release fan-out's own aggregate bundle_verdict today, even though the
capability to honor one now exists two calls away.
Fix: thread the release fan-out's already-resolved PolicyFile (the
same one _load_suppression_and_policy/policy_file_with_packs already
build for per-library scoring in this same module — see
_load_probe_matrix_changes's sibling handling a few functions over) into
_collect_bundle_result()'s and _run_bundle_analysis()'s signatures and
onward into analyze_bundle(..., policy_file=pf, ...), mirroring exactly
what the stored-facts driver already does. This is a narrow, mechanical
change — the capability, its Python-API plumbing, and its stored-facts
caller are all already shipped; only this one live-comparison caller is
missing the thread-through.
Acceptance test: compare-release two directories with a --policy
custom.yaml document overriding bundle_intra_dep_removed to
compatible; the release's aggregate bundle_verdict must reflect the
override (previously: unaffected, always scored under the bare policy
name's coarse three-way switch).
Effort: S — the blocking file-size-cap constraint documented in Phase 13's table applies to adding a new CLI surface (a flag, a config block); this phase adds no new flag, only forwards an already-resolved local variable one call deeper, so it was never blocked by that constraint the way Phase 13's own literal CLI/config surface once was — see Phase 13's own "Update" note and Phase 15's corrected section for how that later resolved (shipped via Phase 17's split-out module, in one case; found to be permanently unnecessary, in the other) rather than by relieving the file-size room this table measured.
Shipped, in a materially different shape than the first pass, once
architecture/debt.yaml's no-growth pin on cli_compare_release.py
and cli_compare_release_helpers.py (ADR-061 -- both are frozen at
their exact adoption-time line count, not merely the AI-readiness
2000-line hard cap) turned out to also gate this phase, caught by Codex
review after the first pass landed a new resolver function inside
cli_compare_release_helpers.py and grew both frozen files. Two changes
from that finding:
- The resolver lives in
abicheck/pack_application.py(resolve_bundle_policy_file()), not incli_compare_release_helpers.py-- the same_load_suppression_and_policy()then, when a--packwas resolved,policy_file_with_packs()pattern_collect_matrix_result()already uses a few functions over incli_compare_release.py, just homed in a module with no no-growth pin (pack_application.pyisn't inarchitecture/debt.yamlat all) rather than one that is. _run_bundle_analysis()itself is untouched -- nopolicy_fileparameter, no forwarding toanalyze_bundle().BundleDiffResult. policy_fileis a plain mutable dataclass field (not frozen) andbundle_verdictis a lazily-computed@propertythat reads it at access time, not construction time -- so_collect_bundle_result()(which does still gain apolicy_fileparameter, since it's the one place both the resolved value and theBundleDiffResultit must land on are both in scope) simply doesbundle_result.policy_file = policy_fileright after_run_bundle_analysis()returns, before readingbundle_result.bundle_verdictto fold into the release'sworst_verdicta few lines later. This reaches the identical outcome as threading a new parameter through_run_bundle_analysis()/analyze_bundle()-- confirmed by readingBundleDiffResult.bundle_verdict's own implementation, which is the only placepolicy_fileis ever consulted anywhere in the bundle-analysis pipeline -- while touching one frozen file's line count instead of two, and touching it for only a parameter-line and a one-line mutation (offset by compacting two pre-existing multi-lineif-conditions incli_compare_release.py/cli_compare_release_ helpers.pydown to one line each, a legitimate, behavior-preserving trim of code these files already contained, so both files land at or under their exact pinned baseline rather than merely under the separate 2000-line hard cap).
docs/use/multi-binary.md's "release fan-out doesn't forward policy
files" section was updated to describe the shipped behavior. Regression
coverage: tests/test_cli_compare_release_bundle_signature_wiring.py's
TestBundleAnalysisForwardsPolicyFile (a policy_file override actually
demoting BundleDiffResult.bundle_verdict through _collect_bundle_
result, with a negative control, plus a direct plumbing check pinning
that _collect_bundle_result sets policy_file on the result before
reading bundle_verdict) and TestResolveBundlePolicyFile (the resolver
itself: no-op with nothing given, a real policy document, a resolved pack
application) -- the latter deliberately lives alongside the former rather
than in test_pack_application.py, since that test module carries its
own architecture/debt.yaml no-growth pin too.
A second, sibling gap found by the same review round (Codex, fresh
evidence), fixed alongside the above: _fold_release_global_severity()
(cli_compare_release_helpers.py) folds bundle findings into the
severity-aware process exit code via compute_exit_code(bundle_changes,
config, policy=bundle_result.policy) -- forwarding .policy (the fix a
prior Phase 10 entry already made) but not .policy_file. Once
BundleDiffResult.policy_file started being genuinely set by this phase's
own fix, that gap became live: a bundle_intra_dep_removed: ignore
override already changed the displayed bundle_verdict (a .policy_
file-aware property) while the severity-aware exit code still scored the
unmodified bare-policy classification -- the identical displayed-verdict-
vs-exit-code disagreement Phase 10 already fixed for .policy alone,
just for the field this phase added. Fixed by forwarding policy_file=
bundle_result.policy_file at that one call site (a single-line edit, no
line growth in the pinned file). Regression coverage:
tests/test_config_review.py::TestReleaseSeverityPolicyAndGlobal::
test_fold_bundle_honors_the_bundle_result_own_policy_file (confirmed to
fail against the pre-fix code -- asserted exit 0 under the override,
observed exit 4).
Phase 17 — Stored-facts/per-library CLI surface (SHIPPED)¶
Origin: uxlfoundation/oneDAL#3693
— a second, fully independent real-world driver (bundle_gate.py plus
onedal_libraries.py) hitting the identical "No CLI surface" known gap
Phase 13's own table and Phase 13 follow-up's item 1 already named, against
the same 6-library, 3-toolchain-lane oneDAL shape Phase 8's 2.5h/38.3GB
measurement used. This was confirmation, by two unrelated callers, that the
gap had outgrown "known gap" footnote status.
Revision note (2026-09-01): this section originally drafted a design
where compare's existing positional OLD_INPUT/NEW_INPUT operands would
gain a new auto-detected operand kind (cli_resolve.classify_compare_
operand() returning "bundle_facts" from a bounded, compression-aware
content-shape probe), through several Codex review rounds refining that
probe's exact discriminators. That design was not what shipped, and this
section is rewritten to describe the actual implementation instead of the
superseded plan. What shipped is simpler: a --old-bundle-facts boolean
flag on OLD_INPUT (compare old.bundlefacts.json new-release/
--old-bundle-facts), landed in c562b63 ("feat(compare): expose
--old-bundle-facts and wire public_headers into run-plan") and hardened
through roughly twenty follow-up fix: commits closing gaps found in
review — package-operand extraction, --devel-pkg/OLD-side header
rejection, --lang/OSError handling, --depth binary clearing headers,
temp-dir cleanup, and more. The prior design's operand-kind-detection
machinery (the two-discriminator plain/gzip/zstd-JSON vs. G40-archive probe,
the {"directory", "package"} membership-test sweep across cli_resolve.py/
cli_compare_helpers.py/cli_options.py) was never built and should not be
read as a still-intended target — the flag-based dispatch below reaches the
same acceptance bar without it.
Shipped implementation:
abicheck/frontends/cli/commands/compare.py:465-487—--old-bundle-facts(boolean flag) and--max-json-object-nodes(the decode-budget override), declared inline oncompare_cmdalongside its other single-command options.abicheck/frontends/cli/commands/compare.py:744-796— insidecompare_cmd's body,if kwargs.pop("old_bundle_facts", False):short-circuits beforerun_compare/_dispatch_release_compareever run, dispatching tocompare_bundle_facts.dispatch().abicheck/frontends/cli/commands/compare_bundle_facts.py(a new leaf module underfrontends/cli/commands/, not a flatcli_*.pyroot sibling — thecli_root-prefix family is frozen perarchitecture/modules.yaml'sfrozen-root-familygate) — the real dispatcher: resolves the small option subsetcompare_release_against_ bundle_facts()actually needs fromcompare_cmd's already-parsed, already-normalize_sided_options-processed kwargs, extracts a package NEW-side operand via the same_extract_if_package()primitive the live release fan-out uses (--devel-pkg new=...honored the same way), callscompare_release_against_bundle_facts(), and renders the result as its ownmode: "bundle_facts"JSON/markdown envelope.abicheck/frontends/cli/commands/compare_bundle_facts_rejections.py— a sibling module (split out purely to keepcompare_bundle_facts.pyunder the architecture no-growth 800-line production cap as this guard list grew round over round) that raisesclick.UsageErrorfor every flagdispatch()doesn't explicitly wire through, rather than silently ignoring it — the same "reject rather than silently diverge from the request" rule--dry-run/--contractalready set as precedent elsewhere in this CLI.bundle_side_input.pyis classifiedworkflowsinarchitecture/modules.yaml, socompare_bundle_facts.py(afrontendsmodule;may_import: [model, workflows, report]) reaching it is a legal edge, not an architecture-boundary workaround — the concern this section originally raised at length aboutbundle_side_input.pybeing architecturally unclassified no longer applies.docs/use/multi-binary.md's "## Comparing against a stored bundle baseline" section documents--old-bundle-factsas shipped, superseding the "deliberately scoped to the producer half" disclaimer this section's own text used to quote.
Two things this section originally called "must reach parity" were
instead deliberately, permanently rejected by the shipped code, with the
reasoning recorded in compare_bundle_facts.py's own module docstring —
these are closed design decisions, not open gaps:
--fail-on-removed-librarysupport. Rejected outright (compare_bundle_facts_rejections.py). Per the module docstring: "computing it would mean re-scanningold_facts_patha second time only to read backper_library_snapshots.keys(), defeating the entire point of a caller handing in an already-loaded, potentially huge (SYCL/DPC++- scale) facts document just to avoid re-parsing it." The exit-8 gating parity this section's "systemic requirement" text called for is therefore explicitly out of scope, not deferred.- The full release-summary rendering shape (exit-decision object,
severity/contract blocks,
_format_release_summary()'s own input shape). Per the same docstring: "deliberately not the full release-summary shape... since this is a narrower, newly-exposed surface, not a drop-in replacement for it."--contract/--severity-preset/--packare all explicitly rejected too (compare_bundle_facts_rejections.py). Plain--policy/--suppressare already forwarded and honored (_load_suppression_and_policy(), consumed bycompare_release_against_bundle_facts()'s ownpolicy/policy_file/suppressparameters) — only the coarser severity-preset/pack/contract knobs and the release-summary rendering shape stay out of scope.
This codebase's earlier "reject explicitly, never silently drop" principle
(this section's own prior text) is exactly what was applied: every
flag without a real channel into compare_release_against_bundle_facts()
is a UsageError, not a silent no-op — but the target design decided
several of them should stay rejected rather than be built out, once their
cost (re-scanning a large facts document, or maintaining a second
release-summary shape for a narrower surface) was weighed against the
actual need.
The one genuinely open gap this section identified — now closed in this
same change: per-library headers/includes/compile overrides for a
mixed-toolchain bundle (oneDAL's own CPU/DPC++ shape — the whole reason
this phase exists, per its own "Origin" table). compare_release_against_
bundle_facts() already accepted per_library_headers/per_library_
includes/per_library_compile: dict[str, CompileContext] since Phase 13
follow-up, but nothing on the CLI path forwarded them. Closed via:
abicheck/workflows/bundle_facts_library_overrides.py—parse_bundle_ facts_library_overrides(), an eager, strict-unknown-key-rejecting parser for a{library_name: {headers: [...], includes: [...], gcc_path: ..., gcc_options: [...], ...}}YAML/JSON manifest. Physically underabicheck/workflows/from the start (unlikebundle_variants_config.py's own pre-ADR-061 flat-root placement — a real Codex-review finding: adding a new module to the frozenbundle_root family instead of routing it to its real ADR-061 owner "defeats the migration gate," root AGENTS.md's own "route new behavior to the target owner rather than extending a flat root prefix family"), reusing that module's validation-style precedent (a plaindictinput rather than a new.abicheck.ymlblock, for the identical reason that module's own docstring already gives:BuildConfighas a fixed, declared schema and adding a new top-level block there needs real schema/precedence work a CLI-only manifest flag avoids entirely).abicheck/frontends/cli/options/bundle_facts.py—@bundle_facts_manifest_options, declaring--bundle-facts-library-manifest PATHas a small, reusable decorator (not an inline@click.optiononcompare_cmd, which was already at the 800-line production cap with no headroom) applied tocompare_cmdalongside--old-bundle-facts.compare_bundle_facts.dispatch()parses the manifest and forwards its three maps into the existingper_library_headers=/per_library_ includes=/per_library_compile=parameters unchanged;compare_bundle_facts_rejections.pyrejects the flag outright when--old-bundle-factsisn't set, mirroring_reject_bundle_facts_out_for_ single_pair's existing precedent for the producer-side flag.
Explicitly out of scope, unchanged from the original review:
- A driver's own summary-JSON/Markdown rendering, but not blanket SARIF.
compare_bundle_facts.py's ownmode: "bundle_facts"JSON/markdown envelope covers the summary/Markdown half; SARIF for a directory/package (bundle/release) comparison remains unavailable at any granularity (_RELEASE_FORMATSis exactly{"json", "markdown", "junit"}, andsarif.to_sarif()consumes a singleDiffResult, not aBundleDiffResult) — a real, separate, currently-undocumented gap, not reimplemented by this phase. - A caller's own measurement harness. Wrapper scripts reproducing
wall-clock/peak-RSS/exit-code numbers under a pinned container (e.g.
oneDAL#3693's
mkvenv909.sh/bg909.sh/bg909b.sh) carry zero ABI content by design and are not a candidate for upstreaming under any phase of this plan.
Acceptance criteria (all met): a directory/package compare
--old-bundle-facts invocation can (a) consume a stored OLD-side
BundleFacts baseline instead of reopening OLD .so files — shipped; (b)
give each library its own header root and compile context via
--bundle-facts-library-manifest, entirely from abicheck compare ...
flags with no committed driver script standing in for it — shipped, this
change; (c) --fail-on-removed-library — explicitly rejected rather than
supported, a deliberate scope decision (see above), not an unmet criterion.
Tests: tests/test_cli_compare_bundle_facts.py,
tests/test_cli_compare_bundle_facts_rejections.py,
tests/test_cli_compare_bundle_facts_rejections_more.py (the flag surface
and its rejection matrix); tests/test_bundle_facts_library_overrides.py
(the manifest parser, table-driven over valid/unknown-key/wrong-type/
unknown-library/empty-manifest cases, mirroring
tests/test_bundle_variants_config.py's style); tests/
test_bundle_side_input.py (the per-library override forwarding, at the
Python-API layer compare_release_against_bundle_facts() already covers).
Out of scope¶
Restated from the originating review, explicitly deferred rather than silently dropped:
- Reverse impact analysis against an external consumer application —
stays in
appcompat/stack-check, unchanged by ADR-023 and unchanged by this plan. dlopen/dlsymplugin-style dynamic dependency edges — invisible from DT_NEEDED; needs source/manifest evidence this plan does not add (ADR-008's own follow-up work already tracks this, and G5'splugin-checkcovers a related but distinct host↔plugin contract).- Mach-O/PE bundle equivalents — Phase 2's
BundleFactsschema is written to be platform-neutral in shape (artifact identity + resolution graph + evidence), but populatingBundleArtifactFactsfrom a.dylib/.dllset, and the loader-graph specifics (load commands vs. import tables vs. DT_NEEDED), is real per-platform work left for a follow-up once the ELF path is proven out. - A general-purpose, content-addressed bundle archive format (the review's §9 sketch) — see Phase 2's own "deliberately not attempted" note above.
- Full per-finding evidence-provider model (which extractor/tier
produced each individual finding, not just each
AbiSnapshot) — already tracked as its own, larger, cross-cutting gap in the rootAGENTS.md's "Evidence-provider model" known-gap entry; Phase 4 above deliberately does not reuse the existing report-levelevidence_status_for_resultsignal (see Phase 4's own design section for why a report-level signal is wrong for a per-symbol gate) and instead adds one narrow, symbol-scoped check, rather than attempting that larger per-finding project as a side effect of this plan. - A genuine toolchain-identity probe validating a resolved compiler
binding's real family/version against a declared multibuild-variant
constraint —
bundle_multibuild.variant_fingerprintrecords the declared logical-identity toolchain facts (target triple, compiler family/version string); it deliberately excludes standard/flags (see Phase 3's own design section for why) and does not independently verify the resolved binary atcompile.bindingactually matches even the fields it does record. That verification is already tracked as its own gap (rootAGENTS.md's "Toolchain-profile compiler- family rendering" entry, and G34 Phase A's toolchain-binding probe) and is not duplicated here.