Skip to content

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:

  1. compare_bundle() only ever reopens live .so files. BundleSnapshot is built directly from filesystem paths inside abicheck/bundle.py; there is no serialized BundleFacts object a dump-produced snapshot set carries, and no compare --against <stored bundle> path exists at all. analyze(extract(live)) and analyze(load(dump)) are not the same function today because the second one doesn't exist. This means a stored baseline — the normal scan --against/CI workflow for every other surface this tool supports — cannot get a bundle-level verdict; only a live-directory-vs-live-directory compare invocation can.
  2. 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.py already models a build matrix for compare --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).
  3. A C-linkage symbol match is treated as proof of signature compatibility, not just proof of binary-name compatibility. bundle_intra_dep_signature_changed is keyed off a provider's own per-library func_params_changed/func_return_changed finding — 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."
  4. The rationale for separate public-ABI and bundle-integrity findings is undocumented. bundle_library_removed/ bundle_intra_dep_removed etc. already exist as their own kinds (not reusing BREAKING_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 be BREAKING at the bundle level, which is a recurring point of confusion when a --policy profile scoped to "public API only" doesn't suppress a bundle_intra_dep_removed finding 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:

  1. 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.
  2. The same finding reproduces from a stored bundle dump, not only from two live directories. — Phase 2.
  3. An unused private export removal does not produce the same severity as (1). Already truebundle_library_removed/ bundle_intra_dep_removed only fire when a sibling actually consumes the symbol; an unconsumed export removal falls through to the existing per-library func_removed, whose severity is governed by the ordinary public-surface/suppression rules, unaffected by this plan.
  4. 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.
  5. 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.
  6. 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:

  1. No BundleArtifactFacts/persisted ResolutionGraph. The sketch below assumed the resolution graph needed its own serialized form. It doesn't: abicheck/bundle.py already has build_bundle_snapshot_from_metadata() — a pre-existing primitive (built for a different, still-unshipped snapshot-first product-baseline use case) that reconstructs a fully-functional BundleSnapshot (cross-DSO DT_NEEDED/version-table resolution included) from bare ElfMetadata alone, with no binaries read. Since AbiSnapshot.elf already is that ElfMetadata for every ELF dump, BundleFacts.per_library_snapshots alone is sufficient to reconstruct everything BundleArtifactFacts/ ResolutionGraph would have stored — persisting them separately would only add a second, redundant representation that could drift from _compute_resolution_graph()'s real behavior. Implemented in abicheck/bundle_facts.py, not abicheck/bundle_models.py (that file stays a leaf with respect to abicheck.bundle; bundle_facts.py is its own leaf-with-respect-to-bundle.py module, importing it only lazily inside function bodies to avoid a real bundle_facts <-> serialization import cycle the first draft of this module hit — see that module's own comment for why the to_dict/ from_dict pair had to move to serialization.py instead of living next to the dataclass).
  2. 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 a BundleFacts file for the old side would mean a second, parallel per-library comparison loop (service.compare_snapshots() against each stored AbiSnapshot instead of service.run_compare() against a live path), which is a genuine, separate feature with its own option surface (most of compare'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.
  3. Three review-driven fixes closed after initial implementation (Codex review, same day). write_bundle_facts_out()'s producer only ever captured diff_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 via parse_elf_metadata(), matching what a live build_bundle_snapshot() does for the identical case. capture_bundle_facts() gained a library_paths parameter that probes and persists real filesystem soname aliases (symlink target basenames, hard-linked siblings) at capture time, and build_bundle_snapshot_from_metadata()/ _compute_resolution_graph() gained a matching extra_aliases parameter to replay them at reconstruction time with no filesystem access — closing a gap where a provider without a usable DT_SONAME could resolve differently from a stored baseline than from a live comparison. bundle_facts_from_dict() now rejects a schema_version newer than this reader supports, mirroring snapshot_from_dict()'s existing hard rejection for AbiSnapshot. The alias-probing helpers live in the pre-existing abicheck.bundle_soname leaf module (not bundle.py itself, 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.py gains save_bundle_facts/load_bundle_facts, mirroring save_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 BundleFacts producer to — a real gap in an earlier draft of this plan, caught in review. dump (cli.py's dump_cmd) produces exactly one snapshot from one binary; its --dump-manifest option 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 in compare'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 existing compare release fan-out, not a new root command — a bare abicheck dump-bundle would need to separately clear the root's own admission bar (root AGENTS.md's "Adding a new top-level command" criteria; a directory-of-libraries operand a user already thinks of as a compare-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's AbiSnapshot in 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_snapshots plus the resolution graph) into one BundleFacts file 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.bundlefacts is 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 the compare-release-shaped equivalent, per whatever cli_compare_release.py looks 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 a BundleFacts file for the old side. The new side may still be live (the common scan-style workflow: compare a stored baseline bundle against what's on disk today).
  • checker_policy/comparability.py: a BundleFacts.variant_fingerprint mismatch between old and new is refused the same way a single-snapshot scope_fingerprint mismatch 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.py implements variant_fingerprint, VariantOutcome, VariantComparison, pair_variants, and coverage_regression_findings per this section's design below, with two real deviations. First: variant_fingerprint takes explicit, named coordinates (target_triple, compiler_family, feature_toggles) rather than a raw BuildEvidence | None / EnvironmentMatrix | None pair. Telling a genuine logical-identity feature toggle (ONEDAL_DATA_PARALLEL) apart from build state that legitimately drifts release to release (an ABI-relevant -D define, a raised -std=) cannot be done reliably from raw build evidence alone — both can appear as an indistinguishable BuildOption/CompileUnit entry — 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_version is 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 make pair_variants read 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 spurious bundle_variant_coverage_regressed finding. target_triple/ compiler_family stay 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 in checker_policy.py / change_registry_buildsource.py (not change_registry.py, which is at the AI-readiness 2000-line hard cap — default verdict RISK, per this section's design), classified in tests/canonical_identity_contract.py's UNVERIFIED bucket (matching every other pre-existing bundle_* kind — none of them have had their construction call sites individually verified against the TYPE_BEARING/VALUE_INSENSITIVE criteria yet), and covered by tests/test_bundle_multibuild.py (determinism/sensitivity cases, the never-union Hypothesis property, the missing-variant case, and coverage_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 a ChangeKind — nothing renders it into the reporter's bundle.json/bundle.md yet (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, extracts BundleFacts per variant, and calls pair_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-captured BundleFacts documents) — and comparability.py's bundle-level fingerprint-mismatch refusal is likewise not yet wired to this module's variant_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.py implements find_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 into bundle.compare_bundle() itself, because abicheck/bundle.py is exactly at the AI-readiness 2000-line hard cap (confirmed via wc -l) and cannot accept new code without an offsetting removal. A caller invokes this function separately, alongside compare_bundle(), and merges the two list[BundleFinding] results — the same standalone-companion shape bundle_multibuild.py established for Phase 3's coverage_regression_findings. Deliberately does not import abicheck.bundle (a small, 3-member _CONFIRMED_SIGNATURE_CHANGE_KINDS frozenset is duplicated locally rather than imported, to stay a strict leaf module bundle.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_map has 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-library AbiSnapshot construction 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" in checker_policy.py / change_registry_buildsource.py (not change_registry.py, at the same 2000-line cap Phase 3's kind avoided — default verdict RISK, per this section's design), classified in tests/canonical_identity_contract.py's UNVERIFIED bucket (matching every other bundle_* kind), tiered L0 in scripts/evidence_tiers.py (the detectable-at signal is the same C-linkage resolution match every other bundle_* 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 by tests/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 ).nameDiffResult.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_unverifiedrequired, 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:

  1. the consumer's undefined symbol resolves by name to a provider's export (the C-linkage match compare_bundle() already establishes), and
  2. that specific symbol's evidence — on the provider side, whether its Function/Variable entry in the provider's AbiSnapshot carries 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 existing ARTIFACT_PROVEN/UNATTRIBUTED distinction 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. Checking Function.params/Function.return_type/ Variable.type for non-None does not actually work as this gate's check (caught in review — Function.return_type/Variable.type are required str fields, never None, and Function.params defaults 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 stamps visibility=Visibility.ELF_ONLY on 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/Variable for this specific symbol have visibility != 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 never ELF_ONLY) while one parameter's own DW_AT_type is absent — dwarf_snapshot.py's _process_param explicitly emits Param(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 of test_bundle.py) — BundleFacts round-trip (save_bundle_factsload_bundle_facts → identical compare_bundle_from_facts output, 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 live compare_bundle() call on the identical underlying facts).
  • Shipped: bundle_variant_coverage_regressed's positive/negative cases (Phase 3) — see tests/test_bundle_multibuild.py below.
  • Shipped: bundle_intra_dep_signature_unverified's positive/negative cases (Phase 4) — see tests/test_bundle_signature_evidence.py, listed in the Phase 4 implementation-status note above.
  • Shipped: tests/test_bundle_multibuild.pyvariant_fingerprint determinism 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 let cxx_standard_floor_raised/ abi_relevant_build_flag_changed classify it); two builds differing in a logical-identity coordinate (target triple, or a feature toggle like ONEDAL_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 how bundle_soname_skew already has examples/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 stored BundleFacts baseline 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, pinning bundle_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 by bundle_signature_evidence.py for suppression, unchanged from the first attempt.
  • PROMOTABLE_C_BOUNDARY_SIGNATURE_BREAK_KINDS (narrow, 6 kinds: the original params/return/var-type plus FUNC_VARIADIC_ADDED/ FUNC_VARIADIC_REMOVED/CALLING_CONVENTION_CHANGED) — used only by bundle._detect_intra_dep_signature_changed for promotion. This is the actual fix for the finding above: it closes the real gap (CALLING_CONVENTION_CHANGED and 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):

  1. Provider-key normalization. diff_by_library's keys were built from Path(result.library).name — the real, possibly SONAME-versioned on-disk filename (DiffResult.library is always set this way at every ELF/PE/Mach-O dump site) — while BundleSnapshot.resolution keys every provider/consumer by the version-stripped bundle-canonical name. CodeRabbit traced a concrete repro through cli_compare_release.py's own _bundle_key/DiffResult.library split: the wired compare --release path already stores the canonical key separately and leaves DiffResult.library as the real filename, so a promoted finding could carry provider_library="libcore.so.1.2.3" while the resolution graph knows the same provider as "libcore.so" — silently defeating the consumer.library != provider_lib comparisons the detector depends on. bundle_signature_evidence.py's own _confirmed_provider_symbols had needed and shipped the identical fix once already (a prior, independent Codex review). Both consumers now share one bundle_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.
  2. plugin_abi policy preservation during promotion. Widening the promotable set to include CALLING_CONVENTION_CHANGED (this phase's own fix) reached a kind with a real policy_overrides demotion — change_registry.py classifies it COMPATIBLE under plugin_abi (a plugin and its host rebuilt together from the same toolchain) — but the promoted BUNDLE_INTRA_DEP_SIGNATURE_CHANGED bundle finding had no policy sensitivity at all, always BREAKING regardless of the caller's selected policy. _detect_intra_dep_signature_changed now takes the same policy string compare_bundle() already receives and skips promoting a change whose effective category under that policy (via the same policy_kind_sets/effective_category primitives compute_verdict itself uses) is not BREAKING. Regression: test_plugin_abi_policy_suppresses_calling_convention_promotion (paired with a strict_abi control 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 << 4 recorded as 3 instead of 16). 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 false enum_member_value_changed BREAKING findings (155 daal + 125 dpc) with schema_version=25 on 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_version unchanged 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_RISK with 156 advisory bundle_intra_dep_signature_unverified findings — not spurious BREAKING findings — 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 false bundle_intra_dep_removed findings 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-dir and 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 keeping scan --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 stub BundleDiffResult now carries analysis_errors=["bundle analysis raised: <exc>"] instead of losing the detail (previously only the per-library report survived; bundle_findings degraded silently to empty).
  • find_unverified_signature_findings() raising — appended to the already-populated result's analysis_errors the same way, additive to whatever compare_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 implementedanalysis_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 live compare --release path) now builds the two live BundleSnapshots, loads an explicit --manifest, calls analyze_bundle() once, and re-surfaces its analysis_errors as the same click.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 no click context to echo into.
  • bundle_facts.compare_bundle_from_facts() now calls analyze_bundle() instead of compare_bundle() directly, passing old_facts.per_library_snapshots (always a real, mandatory dict[str, AbiSnapshot] -- see BundleFacts's own docstring) as old_signature_evidence. It gained a new optional new_signature_evidence parameter 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.pyLiveBundleInput/StoredBundleFactsInput (the BundleSideInput union), ResolvedBundleSide, and resolve_bundle_side(): the shared resolution step this section asked for, unifying what cli_compare_release_helpers._run_bundle_analysis (live) and bundle_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 through bundle_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-side BundleFacts path and a live NEW-side directory, it discovers the NEW side's .so files, dumps and diffs each matched library through the Tier-2 service.resolve_input/service. compare_snapshots chokepoints, builds the NEW side's compact BundleSignatureEvidence projection (Phase 9's memory discipline — never a full retained snapshot map beyond one in-flight comparison), and calls compare_bundle_from_facts() with a real new_signature_evidence populated — 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.integration test (tests/test_bundle_side_input.py::TestCompareReleaseAgainstBundleFacts, real gcc-compiled .so files) as the exercise.
  • abicheck/bundle_variants_config.pyparse_bundle_variants_config() (eager, hard-error validation of a raw bundle_variants: mapping into BundleVariantSpec objects: target_triple/compiler_family/ feature_toggles/required, mirroring variant_fingerprint()'s own explicit-coordinate shape exactly, per this section's own design) and run_bundle_variant_pairing() — the first real caller of bundle_multibuild.pair_variants() anywhere in this codebase outside its own test suite (confirmed by grep before and after this change). The required: true/false distinction reuses the existing ADR-027 D3.2 BundleFinding.effective_verdict/modulation_reason/modulation_rule override mechanism — a missing required variant's own BUNDLE_VARIANT_COVERAGE_REGRESSED finding is escalated to Verdict.BREAKING in 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:

  1. 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, not uses: abicheck/abicheck@sha with a bare CLI flag, for the same file-size-cap reason the "Known gap" note above gives. Phase 17 below closed this: a real compare --old-bundle-facts CLI flag, reachable from a plain abicheck compare old.bundlefacts.json new-release/ --old-bundle-facts invocation 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.
  2. Fixed. The driver's service.resolve_input() call never forwarded header_backend/compile, so a header-scoped NEW side always resolved under the library's own header_backend="auto" default — absent a real castxml on the host, resolve_input still picks castxml first and dies in seconds on a clang/icpx-only host rather than falling back. The assessment's own monkeypatch injecting CompileContext(gcc_path="icpx", gcc_option_tokens=("-fsycl", "-DONEDAL_DATA_PARALLEL", "-std=c++17"), frontend="clang") directly into service.resolve_input is 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 on compare_release_against_bundle_facts() itself; no monkeypatch needed.
  3. Fixed, additively. headers/includes/compile applied 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::dal alongside -fsycl/icpx dpc): the assessment's header-scoped run in practice parsed the daal library's headers under the dpc library'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 optional per_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 give dpc its own SYCL flags while daal/oneapi::dal fall 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 calls new.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 — "does stripped(type_name) appear as a substring in some sibling's exported (public_hit) or internal (internal_hit) symbol name" — with no call to resolution.consumers_of or 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 actual DT_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 emits bundle_provider_changed whenever 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 promoting bundle_provider_changed would 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_changed continues requiring the same resolution.consumers_of()/_consumer_resolves_via_provider() import-resolution check it already has, just evaluated against unscoped rather than scoped evidence.
  • bundle_intra_type_changed continues 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_RISK when the match is only against a sibling's internal symbols) is unaffected.
  • bundle_provider_changed keeps 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 (a compare/-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_changednot 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 new compare/ matcher (alongside the existing graph-native detectors) and folds its output into BundleDiffResult, rather than this decision living inline in bundle.py/bundle_side_input.py directly.
  • abicheck/frontends/ — the CLI-level plumbing that supplies the unscoped evidence to workflows/ for the compare-release fan-out (today's cli_compare_release.py/cli_compare_release_helpers.py call sites gain only the minimal forwarding needed, not new detector logic).
  • bundle.py/bundle_side_input.py/cli_compare_release.py keep their existing call shape (compare_bundle()'s own signature, analyze_bundle()), extended with a second, parallel unscoped_results/raw-evidence parameter that is threaded straight through to the new compare// 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 change post_processing. FilterNonPublicSurface demoted 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 through workflows//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.yaml names compare as bundle.py's own migration target -- but compare/'s may_import (architecture/modules.yaml) is ["model"] only, and every type this logic operates over (ChangeKind, BundleFinding, ElfSymbol) is an unclassified legacy_root_module, not part of model. Verified rather than assumed: attempting a sibling flat module (abicheck/bundle_diff_derived_detectors.py, following the established bundle_resolution_reachability.py precedent for a second bundle-level module bundle.py imports) was tried first and rejected outright by check_architecture.py's own frozen-root-family/root-module checks -- unlike the no-growth ledger (which gates existing file line counts), these two checks reject any file not already named in architecture/modules.yaml's closed frozen_root_families/legacy_root_modules enumeration, 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 inside bundle.py itself (also pinned at an exact 2000-line no-growth baseline), each of the three detector functions' own for change in diff.changes: line changed to iterate diff.changes + diff.out_of_surface_changes instead -- 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 runs FilterNonPublicSurface before ApplySuppression, so a change already demoted to out_of_surface_changes never reaches ApplySuppression at all -- it was never checked against a --suppress rule, whereas a change that stayed in diff.changes was. Combining the two sources means a --suppress rule 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 in bundle.py's own docstrings and in docs/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 --suppress reaches (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 RunPlanCheck cell carries exactly one profile_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 by tests/test_run_plan_bundle_multi_profile.py::TestBundleAcrossTwoRequiredVariantProfiles::test_two_required_variant_profiles_produce_two_independent_bundle_checks (added alongside this correction — the pre-existing test_run_plan.py bundle 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 by tests/test_run_plan.py::TestBundleChecks::test_bundle_check_errors_when_a_member_is_missing_and_profile_is_explicit and ::test_bundle_check_missing_build_output_for_an_explicit_profile_is_an_error (single-profile shape, pre-existing) plus test_run_plan_bundle_multi_profile.py's test_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: omitting profiles: (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 in plan.checks even when a sibling required profile (dpc) fails — but report.ok is False either way, and cli_project.py's project plan command does sys.exit(0 if report.ok else 1) regardless of plan.checks's contents, so the plan job (and, via check-project.yml's own needs: 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 in cli_compare_release_helpers.py -- the same _load_suppression_and_policy() then, when a --pack was resolved, policy_file_with_packs() pattern _collect_matrix_result() already uses a few functions over in cli_compare_release.py, just homed in a module with no no-growth pin (pack_application.py isn't in architecture/debt.yaml at all) rather than one that is.
  • _run_bundle_analysis() itself is untouched -- no policy_file parameter, no forwarding to analyze_bundle(). BundleDiffResult. policy_file is a plain mutable dataclass field (not frozen) and bundle_verdict is a lazily-computed @property that reads it at access time, not construction time -- so _collect_bundle_result() (which does still gain a policy_file parameter, since it's the one place both the resolved value and the BundleDiffResult it must land on are both in scope) simply does bundle_result.policy_file = policy_file right after _run_bundle_analysis() returns, before reading bundle_result.bundle_verdict to fold into the release's worst_verdict a few lines later. This reaches the identical outcome as threading a new parameter through _run_bundle_analysis()/analyze_bundle() -- confirmed by reading BundleDiffResult.bundle_verdict's own implementation, which is the only place policy_file is 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-line if-conditions in cli_compare_release.py/cli_compare_release_ helpers.py down 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 on compare_cmd alongside its other single-command options.
  • abicheck/frontends/cli/commands/compare.py:744-796 — inside compare_cmd's body, if kwargs.pop("old_bundle_facts", False): short-circuits before run_compare/_dispatch_release_compare ever run, dispatching to compare_bundle_facts.dispatch().
  • abicheck/frontends/cli/commands/compare_bundle_facts.py (a new leaf module under frontends/cli/commands/, not a flat cli_*.py root sibling — the cli_ root-prefix family is frozen per architecture/modules.yaml's frozen-root-family gate) — the real dispatcher: resolves the small option subset compare_release_against_ bundle_facts() actually needs from compare_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), calls compare_release_against_bundle_facts(), and renders the result as its own mode: "bundle_facts" JSON/markdown envelope.
  • abicheck/frontends/cli/commands/compare_bundle_facts_rejections.py — a sibling module (split out purely to keep compare_bundle_facts.py under the architecture no-growth 800-line production cap as this guard list grew round over round) that raises click.UsageError for every flag dispatch() doesn't explicitly wire through, rather than silently ignoring it — the same "reject rather than silently diverge from the request" rule --dry-run/--contract already set as precedent elsewhere in this CLI.
  • bundle_side_input.py is classified workflows in architecture/modules.yaml, so compare_bundle_facts.py (a frontends module; may_import: [model, workflows, report]) reaching it is a legal edge, not an architecture-boundary workaround — the concern this section originally raised at length about bundle_side_input.py being architecturally unclassified no longer applies.
  • docs/use/multi-binary.md's "## Comparing against a stored bundle baseline" section documents --old-bundle-facts as 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:

  1. --fail-on-removed-library support. Rejected outright (compare_bundle_facts_rejections.py). Per the module docstring: "computing it would mean re-scanning old_facts_path a second time only to read back per_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.
  2. 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/--pack are all explicitly rejected too (compare_bundle_facts_rejections.py). Plain --policy/--suppress are already forwarded and honored (_load_suppression_and_policy(), consumed by compare_release_against_bundle_facts()'s own policy/policy_file/suppress parameters) — 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.pyparse_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 under abicheck/workflows/ from the start (unlike bundle_variants_config.py's own pre-ADR-061 flat-root placement — a real Codex-review finding: adding a new module to the frozen bundle_ 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 plain dict input rather than a new .abicheck.yml block, for the identical reason that module's own docstring already gives: BuildConfig has 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 PATH as a small, reusable decorator (not an inline @click.option on compare_cmd, which was already at the 800-line production cap with no headroom) applied to compare_cmd alongside --old-bundle-facts.
  • compare_bundle_facts.dispatch() parses the manifest and forwards its three maps into the existing per_library_headers=/per_library_ includes=/per_library_compile= parameters unchanged; compare_bundle_facts_rejections.py rejects the flag outright when --old-bundle-facts isn'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 own mode: "bundle_facts" JSON/markdown envelope covers the summary/Markdown half; SARIF for a directory/package (bundle/release) comparison remains unavailable at any granularity (_RELEASE_FORMATS is exactly {"json", "markdown", "junit"}, and sarif.to_sarif() consumes a single DiffResult, not a BundleDiffResult) — 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/dlsym plugin-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's plugin-check covers a related but distinct host↔plugin contract).
  • Mach-O/PE bundle equivalents — Phase 2's BundleFacts schema is written to be platform-neutral in shape (artifact identity + resolution graph + evidence), but populating BundleArtifactFacts from a .dylib/.dll set, 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 root AGENTS.md's "Evidence-provider model" known-gap entry; Phase 4 above deliberately does not reuse the existing report-level evidence_status_for_result signal (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 constraintbundle_multibuild.variant_fingerprint records 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 at compile.binding actually matches even the fields it does record. That verification is already tracked as its own gap (root AGENTS.md's "Toolchain-profile compiler- family rendering" entry, and G34 Phase A's toolchain-binding probe) and is not duplicated here.