ADR-056: Multi-Artifact / Library-Set scan¶
Date: 2026-07-29
Status: Proposed — partially implemented (see
G35's "Implementation status" note
for what shipped ahead of formal sign-off vs. what remains deferred). The
shipped slice lives in abicheck/service_scan.py, abicheck/bundle.py, and
abicheck/cli_scan.py.
Verified: main@2e43d53 on 2026-08-04
Decision maker: (pending)
Context¶
ADR-023 already solved "a library ships as a bundle of several .sos with
cross-DSO relationships" — but only for compare. Its reference case is
literally Intel oneDAL: libonedal_core.so, libonedal_thread.so,
libonedal_dpc.so, ... behind one include/oneapi/dal/ header tree, with
DT_NEEDED edges between the algorithm libraries and core.
The gap this ADR addresses is narrower but real: scan/dump have no
equivalent. A user who wants to audit such a bundle in isolation (no old
snapshot to compare against — scan's own "one-build audit" mode, ADR-043
D5) or dump a full L2-L5 evidence snapshot for it cannot express "these N
.so files are one logical artifact" at all. scan/dump are hard
single-artifact:
dump:abicheck/cli.py'sso_pathargument has no plural form; there is noDumpRequesttype —service.run_dump(path: Path, ...)takes exactly one path in its signature. There is no dedicated rejection message; a directory falls throughresolve_input's format-sniffing chain to a generic "cannot detect format" error.scan:abicheck/cli_scan.py'sARTIFACTargument is a single positional, deliberately narrowed by ADR-043 D5 ("scanalways targets exactly one artifact") from an earlier repeated--binaryflag. The service layer enforces this explicitly:ScanRequest.binaries: list[Path](service_scan.py:226) is already plural-typed, and the cost estimator (_intrinsic_layer_estimates) already sums itsL0_binaryrow overlen(req.binaries)— butrun_scan(service_scan.py:881-883) hard-rejectslen(req.binaries) != 1. This is unfinished scaffolding, not a deliberate plural-then-narrowed design: no comment, test, or ADR explains why the field is plural while the guard is singular. Correction (checked against the live estimator, not assumed): only that oneL0_binaryrow islen(req.binaries)-aware —L1_debug/L2_header(_intrinsic_layer_estimates) and everyL3_build/L4_graph/L5_sourcerow (_source_layer_estimates) are computed once, independent oflen(req.binaries). The estimator is not "already plural-aware" as a whole; only its cheapest row is. See G35 Phase 1/3 for what this means forrun_scan_set's own cost accounting.- The GitHub Action's bash pre-flight validator (
action/validate-inputs.sh) independently enforces the same single-artifact contract forscan modeanddeps tree/deps compare, added specifically after a real user pointed a multi-library release directory atmode: scan.
Where compare's bundle layer (abicheck/bundle.py, ADR-023) does exist,
it is ELF-symbol-only: it parses ElfMetadata for each library and
builds a ResolutionGraph from DT_NEEDED edges and the dynamic symbol
table. It does not see header-AST (L2) or DWARF/build (L3-L5) evidence, so
it cannot resolve a cross-DSO type reference the way a single-library L2
scan already resolves types within one binary. AbiSnapshot itself
(model.py) has no multi-library representation — one snapshot is
irreducibly one library: str.
There is also a real doc/code drift discovered while investigating this:
ADR-023 states the bundle resolution graph "reuses resolver.py/
binder.py" (the stack-check engine, ADR-008). It does not — bundle.py
has its own independent, lighter ELF-only implementation. See ADR-023's
2026-07-29 amendment for the correction. This matters here because it means
there are already two independent dependency-graph engines in the
codebase before this ADR adds anything; a third, evidence-richer one should
not be added without deciding whether to consolidate first.
Decision¶
Two separable questions. They do not have to be answered the same way, and answering "no" to either one is a legitimate outcome of this ADR — the point of writing it is to make the choice explicit and recorded, not to presuppose an expansion.
D1. Does scan's operand shape change?¶
Per ADR-054's root-command admission bar (referenced from ADR-043's amendment trail), a change to what an existing command's operand accepts is a smaller bar than a new root verb, but D5's "exactly one artifact" is itself a recent (2026-07-16), deliberate, explicit decision — reopening it needs the same rigor as adding a command, not a routine flag addition.
Recommendation: yes, narrowly — scan gains an alternate operand shape
for a library set, not a change to its existing single-artifact form:
abicheck scan ARTIFACT # unchanged: exactly one artifact
abicheck scan --artifact-set DIR # new: every discoverable shared library in DIR
abicheck scan --artifact-set a.so,b.so,c.so # new: explicit comma-separated path form
scan's positional ARTIFACT is a required @click.argument in
abicheck/cli_scan.py (the actual module the scan command is registered
from — not cli.py, which was misidentified in earlier drafts of this ADR
after Context's own citations). Adding --artifact-set cannot simply sit
alongside that required positional: cli_scan.py must make ARTIFACT
optional and enforce, as a click.UsageError (exit 64, matching the
existing usage-error convention for mutually-exclusive compare scoping
flags per ADR-043 D2), that a scan invocation supplies exactly one of
ARTIFACT or --artifact-set — never both, never neither. This is a
required part of D1's implementation, called out explicitly here since it's
easy for an implementer to add the flag without touching the positional's
required= behavior and ship a command that can never actually reach the
new code path.
--against must also be rejected with --artifact-set. scan --against
OLD stores a single baseline path in ScanRequest.baseline
(abicheck/cli_scan.py); nothing about that shape extends to a set of
artifacts each needing their own, distinct old-side baseline — running
every member of --artifact-set against the same single --against value
would silently compare unrelated libraries against one shared file. D2
below scopes --artifact-set to audit-only (no old side, no
--against) specifically to avoid designing that set-to-set baseline
question here; cli_scan.py must therefore reject --artifact-set
--against together as a click.UsageError, the same way it rejects
ARTIFACT --artifact-set together. A future ADR can revisit a genuine
set-vs-set comparison form if there's real demand — not silently allowed
through by omission in this one.
Rationale against silently overloading the positional ARTIFACT the way
compare overloads its positional operands (auto-detecting file vs.
directory vs. package): compare's auto-detection is exactly the ambiguity
ADR-043's own Context section criticizes elsewhere (D5's rationale is
explicit-over-implicit: "--mode/--source-method are removed... there is
no longer a separate flag encoding what presence of --against already
tells you" — the same explicit-flag preference applies here). A one-artifact
scan somefile.so and an N-artifact scan --artifact-set somedir/ should
not be the same code path silently branching on Path.is_dir(); ADR-023's
own bundle layer is opt-out by default for compare because directory
input already implies "this is a release," but scan's positional
ARTIFACT has no equivalent precedent — it has only ever meant "one
binary." A new, explicit flag keeps that meaning intact and makes the N-ary
case opt-in and visible in the invocation.
--artifact-set reuses ScanRequest.binaries's existing plural typing —
closing the scaffolding gap described in Context — rather than introducing
a second field.
dump is explicitly not extended by this decision. dump's job is "one
persisted snapshot, one library" and every consumer of a dumped snapshot
(cache, --against, MCP abi_dump) assumes that 1:1 relationship;
overloading it to emit N snapshots (or one merged one — see D2, merging is
rejected) is a bigger, separate change this ADR does not authorize. A
library-set scan composes N ordinary single-library dumps internally
(see D2), so dump itself does not need to change.
D2. What evidence depth does cross-artifact resolution use?¶
Recommendation: reuse and generalize bundle.py's existing
ResolutionGraph, do not attempt L2-L5 cross-DSO type merging in this ADR.
A library-set scan produces list[AbiSnapshot] — one full snapshot per
artifact, at whatever --depth was requested, completely unchanged from
today's single-artifact scan pipeline run N times. On top of that list, it
builds the same BundleSnapshot/ResolutionGraph machinery compare's
directory path already uses (generalized to take a list[AbiSnapshot] +
paths instead of being reachable only from compare-release's
directory-matching code) for the current set alone.
This is deliberately a narrower finding set than compare's bundle layer,
not the same one. Re-reading ADR-023's own detection steps: 7 of its 9
bundle_* kinds are constructed by reading a per-library diff's changes
(func_removed, func_params_changed, type_*_changed, ...) against the
new-side resolution graph — bundle_intra_dep_removed,
bundle_intra_dep_signature_changed, bundle_intra_type_changed,
bundle_provider_changed, both manifest-instantiation kinds, and both
library-added/-removed kinds all require an old side to diff against. A
library-set scan audit has no old side by construction, so none of those
apply.
Correction (checked against the shipped code, not ADR-023's original
design table): ResolutionGraph (abicheck/bundle_models.py) has no
unresolved: list[UnresolvedImport] field — that field only ever existed
in ADR-023's proposed design; what shipped is provides/consumers/
intra_needed/extra_needed, and the "symbol nothing in the bundle
provides" computation lives inline in _detect_intra_dep_removed
(abicheck/bundle.py), not as a precomputed graph field. Generalizing that
detector to a no-old-side audit is not a safe drop-in reuse:
_detect_intra_dep_removed relies on _import_is_external to rule out a
legitimately external dependency before flagging a finding, and
_import_is_external returns False immediately for any unversioned
import (consumer.version == "", abicheck/bundle.py) — by design, since
in compare's diff-driven case an unversioned sibling import that used to
resolve and now doesn't is exactly the regression the detector exists to
catch. An audit-only --artifact-set has no "used to resolve" history to
lean on: a library in the set that legitimately imports an unversioned
symbol from a real dependency outside the declared set (any DSO not on
the --bundle-system-providers allow-list) would be indistinguishable from
a genuinely broken intra-set reference, and reusing the detector unmodified
would report it as BREAKING — a false positive, not a corner case.
The audit-scoped finding this ADR authorizes must therefore be more
conservative than a direct reuse of _detect_intra_dep_removed:
--artifact-setaudits under a declared closed-world assumption: the user is asserting the given set is the complete intra-set surface they care about, with any known external dependency named via a--bundle-system-providersequivalent.--bundle-system-providerstoday is declared only oncompare'srelease_options(abicheck/cli_options.py, consumed bycli_compare_release.py) — this ADR requires the same flag be added toscan's--artifact-setpath too (cli_scan.py), plus the matchingabi_scanMCP parameter and Action input, and threaded through torun_scan_set's audit-mode detector (D2's new check). Without it, ascan --artifact-setuser has no way to make the closed-world declaration this design assumes, and every legitimate external dependency produces an avoidable risk finding — that gap would defeat the point of downgrading the kind toCOMPATIBLE_WITH_RISKin the first place. This must be stated plainly in the command's--helptext and docs, not left implicit.- Even under that assumption, an unversioned import with no intra-set
provider is evidence of an unresolved reference, not proof of one —
the audit has no diff to confirm it ever worked. The new kind's
default_verdictis thereforeCOMPATIBLE_WITH_RISK, notBREAKING(mirroringbundle_provider_changed's own precedent in ADR-023's table for an indeterminate-until-confirmed case), and its description/evidence must say explicitly "no provider found in this artifact set" rather than implying removal. - Reported as a new, audit-scoped kind (e.g.
bundle_unresolved_intra_dependency, exact name TBD at implementation time) rather than reusingbundle_intra_dep_removed's name — that kind's own registry description is specifically "no longer provides" (implies a diff confirming a prior working state), which this finding cannot claim. bundle_intra_dep_resolved_to_different_versionis also diff-shaped (oldgnu.versionvs. new) and does not apply.
See G35 Phase 2 for the audit-mode entry point this implies, including the
requirement that it build its own conservative check rather than calling
_detect_intra_dep_removed directly.
Explicitly not in this ADR's scope:
- Merging N
AbiSnapshots' type tables into one cross-artifact type graph so a type defined via header in library A and used by value in library B resolves through the same mechanismTypeMap/surface.pyuse within one snapshot. This is the "harder, more valuable" case named in the original investigation (oneDAL'soneapi::dal::detail::data_collectionused by value across algorithm libraries) — ADR-023 case 3 already partially covers a version of this forcompare(atype_*_changedon a type reachable from another library's public symbol type closure), by reading the per-library diff's changes, not by building a merged type graph. A library-setscan(no old side to diff) has no equivalent "changes to read" — it would need direct, snapshot-only cross-library type reachability, which is new machinery, not a generalization of whatbundle.pydoes today. Left as explicit future work (see the ADR-056 plan's Phase 3+ in G35), not attempted here, for the same reasontype_reachability.py's own known-gaps list (AGENTS.md) treats each additional layer of cross-reference resolution as its own scoped, independently-verified follow-up rather than a drive-by extension. - Fixing the
resolver.py/binder.pyvs.bundle.pydoc/code drift (ADR-023 amendment). Recorded as a prerequisite worth doing before generalizingbundle.py's graph to a third caller (this ADR'sscan --artifact-set), since a third caller of an already-diverged-from-its-own-docs module makes the drift worse, not better — but the fix itself (either makebundle.pyactually callresolver.py/binder.py, or formally re-scope ADR-023's claim) is its own small, separately-reviewable change, not bundled into this ADR's implementation plan.
Non-goals (mirrors ADR-023's own Non-goals, extended)¶
- Merged
AbiSnapshots. A library-setscannever produces oneAbiSnapshotfor N binaries —AbiSnapshot.library: strstays singular; each artifact keeps its own identity in every report. Cross-artifact findings are always additive/attributed (provider_library/consumer_libraries), exactly like ADR-023'sBundleFindingshape. - Reverse impact analysis against an external application. Unchanged
from ADR-023 — stays in
appcompat/stack-check. - Dynamic
dlopen/dlsymplugin contracts. Unchanged from ADR-023. dumpgaining a multi-artifact form. See D1 above.- Non-ELF (PE/Mach-O) bundles.
bundle.py's resolution graph is ELF-only today (ADR-023's own scope); this ADR does not extend that.
Consequences¶
Positive
- Closes part of the audit-mode gap ADR-023 left open: a user with no "old"
snapshot (the common case for a first-time scan of a newly vendored
multi-.so dependency, e.g. oneDAL) gets at least the unresolved-import
subset of cross-DSO findings
comparealready gives a user who has two release directories — not the full diff-driven 9-kind set, which structurally needs an old side (see D2). - Finishes
ScanRequest.binaries's already-plural typing instead of leaving it as dead-end scaffolding — the estimator's existingL0_binarylen(req.binaries)-aware row becomes reachable (the other rows still need the fix G35 Phase 1/3 describes to scale correctly too). - Reuses
bundle.py's existing types (BundleSnapshot,ResolutionGraph,BundleFinding) rather than inventing parallel ones forscan; only one new, narrower audit-scopedChangeKindis added (D2), not a duplicate of the 9compare-side ones. - Keeps the harder cross-DSO type-merging problem explicitly deferred rather than attempted half-way, consistent with how the rest of this codebase's AGENTS.md "Known gaps" section treats similar cross-reference resolution work as its own scoped, individually-verified passes.
Negative / cost
- A new
--artifact-setflag and a new library-set code path inscanis real surface growth on a command ADR-043 D5 explicitly tried to keep minimal — justified here only because it's additive (existing single-artifactscan ARTIFACTis completely unchanged) and gated behind an explicit flag, not a behavior change to existing invocations. - Generalizing
bundle.py's resolution graph to a second caller (scan --artifact-set, not justcompare's directory path) means any future fix to that graph (e.g. resolving theresolver.py/binder.pydrift, per ADR-023's amendment) now affects two commands' output, not one — needs test coverage on both call sites, not justcompare's. --artifact-setaudit findings are opt-in-only-by-flag but, once triggered, follow ADR-023's "bundle analysis is default-on for the flag's scope" precedent — i.e. once a user passes--artifact-set, cross-DSO findings are always computed and reported, no separate--no-bundle-analysis-equivalent opt-out is introduced by this ADR (mirroring ADR-023's own default; add one only if usage feedback shows a real need, per that ADR's own escape-hatch precedent).
Migration
- None for existing users —
scan ARTIFACT(single positional) is byte-for-byte unchanged.--artifact-setis new, additive surface. - CLI/service/MCP parity (ADR-037 D1's tier discipline, ADR-043 D10):
service_scan.ScanRequestalready carries the needed field; the MCPabi_scantool needs the equivalentartifact_setparameter added in the same change that adds the CLI flag, not as a follow-up — per ADR-043 D10's rule that MCP tool surface changes track CLI changes together.
Implementation plan¶
Partially implemented (see the Status header above).
G35 is the single,
tracked source of truth for the phased implementation breakdown — module
list, task-by-task detail, and status per phase. Earlier drafts of this
ADR duplicated that breakdown inline here as a second numbered list; it
drifted out of sync with G35 more than once as review rounds corrected
details in one copy but not the other (most notably: whether run_scan
itself keeps rejecting a multi-item binaries list — it does, per D1/D2
above; only the new run_scan_set entry point accepts multiple binaries).
Per this file's own "one fact defined in exactly one place" rule
(docs/AGENTS.md), the phased plan now lives only in G35; this ADR records
the decision (D1/D2 above) and defers to G35 for how it gets built.
References¶
- ADR-002: Multi-binary / release compare UX
- ADR-006: Package-level comparison
- ADR-008: Full-stack dependency validation (
resolver.py,binder.py) - ADR-023: Bundle-Aware Multi-Binary ABI Analysis (the
compare-side precedent this ADR extends toscan; see its 2026-07-29 amendment for theresolver.py/binder.pydrift this ADR's D2 flags as a prerequisite) - ADR-037: CLI Interface Contract (D1 tier discipline, D10 MCP parity)
- ADR-043: Pre-1.0 CLI Surface Reset (D5, the decision this ADR narrowly amends; D1/D10 admission-bar precedent reused for D1 above)
- ADR-054: CLI Project-Integration Surface Consolidation (root-command admission bar referenced in D1)