ADR-065: Comparison Scope, Member Selection, and Input Completeness¶
Amendment (2026-09-06, ADR-068). This ADR's model is depended on, not changed:
unselected,expected but not produced,failedanddeliberately retiredstay four states, a partial local build still never reads as a removal, and a run that completed zero comparisons still never reads as a pass. Two additions follow from retiringscan:
- One new acquisition state,
declared_absent— the OLD side of anabicheck compare --no-baseline NEWrun, which replacesscan's audit-only mode. It is explicitly notnot_supplied: the user declared there is no prior surface, so the evolution axis is reportednot_evaluatedand contributes no compatibility verdict, rather than the scope being reported incomplete.scan --artifact-set's multi-library members become this ADR's members. ADR-056 is superseded, so its parallel set model does not survive alongside the acquisition and selection model here; S3's package component inventories are the prerequisite for that fold.Sequenced as Phase 1 of
plans/one-comparison-product.md.
Date: 2026-09-05 Status: Proposed — S1, S2, S3 and S4 implemented.
S2 (2026-09-05): the acquisition record
(abicheck/model/scope_acquisition.py), the completeness axis on
RunOutcome/ExitDecision (abicheck/policy/outcome.py,
abicheck/policy/exit_decision.py, abicheck/policy/scope_completeness.py),
the release fan-out's per-member record and no comparison completed
outcome (abicheck/workflows/release_scope.py, abicheck/cli_compare_release.py),
the degraded stranded-library marker (BundleFacts.degraded_members), and
the comparison_scope report section (abicheck/report/comparison_scope.py),
with --on-incomplete-scope warn|block as the D6 setting; exit 8 now
requires D2's completeness proof (migration note in
docs/reference/exit-codes.md). S1 also implemented (2026-09-06, landed
after S2): abicheck/model/release_selection.py's ReleaseSelection,
--select/--select-required on compare's directory/package fan-out,
and the --dry-run "Comparison plan" preview
(abicheck/workflows/release_plan.py,
abicheck/frontends/cli/release_dry_run.py) — identity/coordinate member
selection with a plan preview, through the typed API and CLI.
bundle_variants_config.py was deleted outright in this slice rather than
given a consumer (see the plan's own A1.6 note).
S3 (2026-09-06): package component inventories and support-promise
findings. abicheck/model/package_inventory.py is the declared component
set with its own completeness flag; abicheck/package.py's
package_component_inventory builds one from an archive the extractor
unpacked in full (ExtractResult.container_complete), which makes a live
package operand a D2 completeness proof for the first time — a directory
operand still proves nothing. EXPECTED_NOT_PRODUCED, reserved by S2 and
unproduced until now, gets its producer (a declared component the tree
cannot reach). abicheck/policy/support_promise.py is D1's fifth concept
under the --support-promise off|declared contract-policy field, emitting
support_promise_component_retired/_introduced from proven inventory
changes only; abicheck/workflows/release_support_promise.py wires it into
the release fan-out's own results.
S4 (2026-09-06): the deletion gate. _match_release_keys's
old-minus-new / new-minus-old set difference is gone
(abicheck/cli_compare_release_helpers.py) — its last two consumers, the
JSON unmatched_old/unmatched_new keys and the fan-out's stderr notices,
read the acquisition record instead
(abicheck/report/comparison_scope.py's release_scope_warnings). The
Action/aggregate parity this slice also names landed with S2 (the
SCOPE_INCOMPLETE verdict tier in action/run.sh, aggregate report schema
1.8's scope_completeness axis). Scalar-versus-bundle operand
convergence stays with plans/cli-cleanup-phase-two.md's open PR I rather
than being done twice — see that plan's own row. The silent canonical
fallback in compare_product_directories (D3's ambiguous diagnostic)
is also left for a later slice, since turning it into a refusal-to-compare
is a behaviour change with its own migration note.
S0 (executable scenario table) remains open. Design record for
the vision's "partial matrices" and "scope-sensitive analysis" decisions
(vision.md). Implementation is sequenced in
plans/vision-api-abi-evolution.md
(workstream "Comparison scope and completeness"), which also carries the
existing-versus-missing assessment this ADR's Context summarizes.
Decision maker: maintainer (product decision recorded in vision.md);
technical sign-off pending review of this document.
Context¶
abicheck already compares one artifact against another, a release directory against another, a package against another, a candidate against a multi-profile baseline set, and folds a CI matrix into one gate. Each of those paths grew its own answer to three questions this ADR separates:
- What is the analysis boundary? One artifact, a package's component set, a declared release matrix.
- Which members were expected, which were actually supplied, and which were compared? The difference between unselected, expected but not produced, failed, out of scope, and deliberately retired.
- Did the project's support promise change? A retired platform or a component confirmed absent from a complete inventory is a contract change; an artifact a partial run never supplied is not.
The current code answers these unevenly (file references are from the 2026-09-05 assessment; see the plan for the full table):
- The release fan-out pairs libraries by filename stem and defines
removed = old_keys - new_keys(abicheck/cli_compare_release_helpers.py,_match_release_keys), then reports that set under the JSON keyunmatched_oldand feeds it to--fail-on-removed-library's exit8. A name-normalization miss, a SONAME bump, a failed extraction, and a genuine deletion are one state. (Closed: S2 took exit8, the verdict bump and the Markdown/PR-comment sections off it; S4 deleted the set difference itself, so the JSON key and the stderr notices read the acquisition record.) compare_product_directories(abicheck/product_baseline.py) has richer pairing (exact path, then an ambiguity-guarded SONAME/case-folded fallback), but an ambiguous group is silently left unpaired and then surfaces asBUNDLE_LIBRARY_REMOVEDplusBUNDLE_LIBRARY_ADDED— an ambiguity reported as a removal.- A release run with zero matched pairs appends a warning and keeps
worst_verdict = "NO_CHANGE", exit0(cli_compare_release_pairwise.py). - Multibuild variant pairing is exact-fingerprint-only and deliberately
never a union (
abicheck/bundle_multibuild.py, G38 Phase 3), with same-side collisions detected — but every capture path stamps the default fingerprint,bundle_variants:(abicheck/bundle_variants_config.py) has no production caller, and a declared-but-never-capturedrequiredvariant is invisible. - The aggregate workflow is the one place the model this ADR wants already
exists end to end:
ExpectedTargetswith required/optional members, synthesizedTargetReports for expected-but-missing cells,OnMissingRequired/OnUnexpectedTargetgates, and afinding_matrixwith an explicit undetermined third state (abicheck/workflows/aggregate/). It is aggregate-only; no per-library or per-variant scope consumes it. - The Action's baseline resolution has a typed outcome vocabulary
(
resolved / not_found / ambiguous / wrong_profile / stale_schema / incompatible_evidence / wrong_project_ref / stale_generation / new_target,abicheck/buildsource/baseline_set.py) that stops at the Action boundary: anew_targetor dry-run "baseline unavailable" run is a green check with no comparison performed. - No typed request (
CompareRequest,BundleCompareRequest,ScanRequest,AnalysisPlan) carries an expected inventory or a member/variant selection; noExitReason/RunOutcomeaxis expresses "the inventory I was asked to cover was not fully covered". - Package extraction (
abicheck/package.py) returns directories, never a declared component inventory, so a component the extractor failed to unpack and a component the package no longer ships are indistinguishable. (Closed by S3:package_component_inventoryreturns a declared component set whosecompleteflag is the extractor's own "this container was unpacked in full" statement, and a declared component the tree cannot reach isEXPECTED_NOT_PRODUCEDrather than absent.) - A stranded old-side library in the release fan-out degrades to an
ELF-only
AbiSnapshotwritten into the baseline with only a stderr line (cli_compare_release.py,_resolve_stranded_library) — a degraded capture persisted as if complete.
The consequence the vision names directly: a developer who builds one Linux/GCC variant locally and compares it against a twelve-variant baseline can be told eleven platforms were removed; a CI matrix whose macOS job failed can be read as a retired macOS promise; and a release whose one library failed to extract can pass.
Decision¶
D1 — Four concepts, four representations¶
The following are distinct and are never collapsed onto one field:
| Concept | Meaning | Where it lives |
|---|---|---|
| Analysis boundary | What the user asked to analyze: one artifact, a package/component set, or a declared release matrix | The typed request (existing CompareRequest/bundle/scan request types, extended — never a new request family) |
| Selection | Which members/variants the user or the resolved plan chose for this run, and any explicitly authorized cross-profile pairing | The resolved plan (AnalysisPlan/run plan), with the selector and its provenance recorded |
| Expected inventory | Which members this run should have produced/consumed, with provenance (a resolved project plan, a package's own manifest, a trusted complete inventory) | The resolved plan; absent for a bare artifact comparison |
| Acquisition state | Per expected member: available, expected_not_produced, failed, not_supplied, unsupported, out_of_scope |
The typed result, per member, separate from any policy verdict |
A support-promise change (a retired platform, a component confirmed absent from a complete inventory) is a fifth thing: a contract change with evidence, emitted as a finding under a configurable policy, never inferred from acquisition state alone.
D2 — Unmatched is not removed¶
A member present on one side and absent on the other is unmatched with a
recorded reason. It becomes a removal finding only when the selected
domain's completeness is proven on the side that lacks it: the new side's
inventory is complete for the boundary (a full package inventory, a matrix
whose expected members all resolved, or an explicit user statement that
the new side is complete). The rule is symmetric: a new-only member becomes
an addition finding only when the old side's inventory is proven
complete, since a partial old input cannot prove the member was absent from
the old release. out_of_scope is reserved for members the run did not
select: in a partial or selected run an unselected baseline member is
out_of_scope and contributes nothing to the verdict, whereas a selected,
expected member whose counterpart was not produced or whose extraction
failed keeps its expected_not_produced or failed acquisition state and
flows into D6's incompleteness outcome or D8's operational error — it is
never demoted to out_of_scope, and never promoted to a removal. The
existing BUNDLE_LIBRARY_REMOVED/_ADDED kinds and
--fail-on-removed-library keep their meaning and become consumers of the
proven-complete state of the relevant side rather than of a raw set
difference.
Clarification (2026-09): completeness evidence is a concrete receipt, not
an implicit claim. A component-removal finding produced under D2's
completeness rule should carry the receipt that justified it, not merely a
boolean "completeness was proven" — otherwise a reviewer (or a later
re-evaluation, ADR-066) has no way to check what established completeness
without re-deriving it from the raw inputs. The receipt names, per finding:
the side the completeness claim is about (old or new — D2's rule is
symmetric, but each finding's own completeness evidence is one-sided); the
selected boundary the claim covers (the analysis boundary from D1 —
completeness for "this package" is not completeness for "this release
matrix" containing it); the inventory source (a package manifest, a
resolved project plan's expected-member list, or an explicit user
completeness assertion — D1's three provenance kinds); the inventory
identity (a stable identifier/digest for the specific inventory document
consulted, so two runs claiming completeness from two different, silently
inconsistent manifests are distinguishable); the acquisition coverage
(which members that inventory named were actually resolved to an
available acquisition state, versus expected_not_produced/failed —
completeness is never proven while a named member's own acquisition status
is unknown); and, when applicable, the explicit user completeness
assertion itself (a caller can assert "this side's package is complete"
without a machine-readable manifest — D1's third inventory-source kind —
and that assertion is itself part of the receipt, not indistinguishable
from a manifest-derived proof). No schema is fixed by this clarification;
the shape above states what a future implementation's per-finding
completeness data must be able to answer, not a JSON field list.
D3 — Ambiguity is a diagnostic, never a guess¶
When a candidate could pair with more than one baseline member (or vice
versa), the run reports an actionable ambiguous selection diagnostic
naming the candidates and the coordinates that would disambiguate, and does
not compare. Neither "first", nor "latest", nor an arbitrary canonical
fallback is chosen silently. This generalizes the aggregate/baseline-set
ambiguous outcomes and the multibuild same-side-collision error to every
pairing site, including compare_product_directories' canonical fallback.
D4 — Pairing is by identity and coordinates¶
Members pair on a stable target identity plus variant coordinates (the
existing VariantRef declared/captured coordinates, variant_fingerprint,
profile fingerprint, and the baseline-set channel × target × profile
tuple), not on filenames alone. Filename-based matching remains a
last-resort tier that is reported as such. A cross-profile pairing
(old GCC, new Clang; old x86-64, new AArch64) is allowed only when
explicitly requested, and is then analyzed dimension by dimension per the
comparability contract (ADR-050, extended by the evidence-adequacy
workstream) — never merged into one profile to manufacture coverage.
D5 — Many baseline members, one candidate selects¶
"Many baseline members, one candidate" means select the matching member. It is not a Cartesian product and not a union. An explicitly requested check of one candidate against several supported baselines (a support window) is a different task that produces independently attributed comparisons, each with its own selection record, never merged evidence.
D6 — Completeness is a run outcome with a default of warn¶
Every selected, expected member that did not reach a completed
comparison is an incompleteness signal on the run, whatever the
reason: expected_not_produced, not_supplied, unsupported (an
artifact this build cannot analyze), failed (which is also an
operational error, D8), or an ambiguous selection (D3). The signal is a
warning by default, configurable to block through the existing
outcome/exit machinery (a new axis on RunOutcome/ExitDecision beside
compatibility, assurance, operational, and coverage — ADR-064's
precedence, extended, not a second gate scheme), and it is raised
independently of how many other selected members compared cleanly: a
matrix with one clean pair and one unsupported or ambiguous selected
member is an incompletely checked scope, never a clean pass. It never
becomes an ABI finding.
The two settings are defined exactly like ADR-049 Phase 7's coverage axis
(contract_coverage_exit.py), so there is one fold shape, not two:
| Setting | RunOutcome |
ExitDecision contribution |
Exit status | Report |
|---|---|---|---|---|
warn (default) |
The scope axis reads incomplete (members and reasons listed); the outcome is not "scope fully checked" |
0 |
Unchanged: 0 when the compared members are clean, the gate's own 2/4 otherwise |
The compact and full views state the unchecked members and that the scope was incompletely checked; the top-level decision wording never says "compatible" for the whole scope, only for the compared members |
block |
Same axis value | 1, folded with max |
A clean 0 becomes 1; a 2/4 is never lowered |
Same wording plus the reason the gate failed |
warn therefore permits a successful exit for the compared members while
the outcome and the report remain honest about the scope; it never
silently upgrades an incomplete scope into a clean one, and the axis is
persisted in the typed result so a downstream consumer can gate on it
even when the process exit was 0. A retired support promise is
configured separately (a contract-policy field), so a project can say
"macOS is no longer supported" and have that evaluated as a contract
change, while a missing macOS job stays an incomplete run.
D7 — Zero completed comparisons is never success¶
A run whose selected scope produced no valid comparison reports
no comparison completed as its operational outcome, whatever the
completeness policy says. A permissive completeness setting can downgrade
missing members to a warning; it cannot turn nothing compared into
"compatibility checked". This applies to the release fan-out, the Action's
new_target/dry-run baseline-unavailable paths, and any future selected
comparison alike.
Clarification (2026-09): "no comparison completed" is scoped to the
requested operation, not to "zero binary-pair diffs ran" specifically — a
documentation review found the acceptance-test wording below ("a run with
zero valid comparisons reports no comparison completed") reads, out of
context, as if it could apply to a case D2 already resolves the other way.
Consider: an old package has three libraries, the new package intentionally
has zero — every one of the three is unmatched, and D2's symmetric rule
resolves each to a genuine removal finding once the new side's inventory
is proven complete (an empty package is a completely-inventoried package,
same as a full one). Zero binary pairs ran, but a real, evidence-backed
package-inventory comparison completed and established three removals —
that is success at the requested question ("did this package's component
set stay the same"), never "no comparison completed". D7's own trigger
condition is narrower and orthogonal to this: it fires when the run could
not establish any answer at all for the requested scope — no complete
inventory on either side to reason from, every selected member ambiguous
or failed, or (the dry-run/baseline-unavailable case) no baseline to
compare against in the first place. State each of the three outcomes'
reporting independently, since none is a variant of another:
- Successful planning — a
--dry-runplan view enumerated the expected set. It never claims compatibility, and it never reportsno comparison completedeither, because no comparison was attempted at all — D7's trigger condition presupposes an attempted comparison, and a pure planning view is not one. - Baseline initialization — a
new_target, first-ever-baseline run. This does reportno comparison completed, exactly as D7's base text already requires (unchanged by this clarification): it is an honest, expected first-run state, not a failure, but it is not exempted from that label — there is genuinely no prior baseline to compare against. - Successful compatibility analysis with zero binary-level diffs — the
three-removals case above, or a matrix where every selected member
matched with no findings. This is a real, complete answer, so it must
not report
no comparison completed.
The acceptance test above should be read with this distinction already
applied: "zero valid comparisons" there means zero established answers of
any kind for the requested scope, not merely zero binary pairs — it does
not relax D7's existing new_target/dry-run reporting requirement in any
way.
D8 — Failed extraction is a failed member, persisted as such¶
A member whose extraction failed is failed in the acquisition record and
an operational error in the outcome. It is never written into a baseline or
BundleFacts document as a degraded snapshot without an in-band marker;
the storage document carries the member's status (storage v2's explicit
fact availability, ADR-062) so the next run reads missing data, not an
impoverished old side.
D9 — Scope inference is conservative¶
The OLD input being a project snapshot or baseline set does not by itself
imply a full-release task. The run infers a narrow current-artifact task
when the selection is unambiguous (one candidate, one matching member) and
requires a minimal selector when it is not. Every run exposes a plan view
(the existing --dry-run shape, ADR-043/054) listing each expected member,
its match or exclusion, and the reason.
Consequences¶
- Scalar comparisons are untouched: with no inventory and no selector, the
expected set is the one pair, acquisition is
available, and no new field is populated. The one-member package path and the scalar path must produce the same applicable findings (an executable invariant). - The release fan-out's
unmatched_oldJSON key becomes what its name says; the removal finding and exit8require D2's completeness proof. This is a behavior change for a partial release directory that today exits8; it is a correction, sequenced with a migration note. bundle_variants:'srequired:field, currently unread in production, gets its consumer through D6, or is deleted — the plan decides which, and the deletion gate is explicit.- Report projections (ADR-036/061) gain a scope/selection section and a per-member acquisition table; the compact and one-line views must carry the incompleteness and no-comparison notices (the reporting workstream's rule).
- The Action's typed baseline outcomes propagate to the run outcome instead of ending at the composite step.
Relationship to existing decisions¶
Extends, does not replace: ADR-002/006/023 (release/package/bundle
comparison), ADR-047/054 (project lifecycle and project plan), ADR-050
(comparability), ADR-055 (typed requests), ADR-062 (storage v2's fact
availability), ADR-063 (AnalysisPlan, RunOutcome), ADR-064 (exit
precedence). G30/G34/G38 own the profile/variant machinery this reuses.
Nothing here revives the retired baseline registry (ADR-022/043 D4) or a
settable exit-code scheme (ADR-064).
Implementation slices¶
See the plan for status. S0: this ADR plus an executable scenario table.
S1 (landed): identity-and-coordinates member selection with a plan preview,
through the typed API and CLI. S2 (landed): expected/observed inventory,
acquisition states, and the completeness axis on RunOutcome/ExitDecision.
S3 (landed): package component inventories and support-promise findings under
contract policy. S4 (landed): Action/project/aggregate parity and deletion of
the replaced set-difference path; scalar-versus-bundle operand convergence
stays a slice of plans/cli-cleanup-phase-two.md's PR I, and
compare_product_directories' canonical fallback (D3) is not yet an
ambiguous diagnostic — both are recorded as open in the plan rather than
claimed here.
Acceptance tests (contract)¶
- Exact member partitions per run (
available,expected_not_produced,failed,not_supplied,unsupported,out_of_scope) are pairwise disjoint and sum to the expected set. - One candidate against a twelve-variant baseline yields one comparison
and eleven
out_of_scopemembers, zero removals. - Adding an unrelated baseline variant cannot change a selected comparison's findings; input order cannot change pairing.
- Two plausible baseline members produce an
ambiguousdiagnostic and no comparison. - A complete old/new package with a library absent from the new inventory yields a component-removal finding with inventory evidence, without a sibling consumer.
- A partial old input against a complete new package yields no addition finding for a new-only member (old-side completeness unproven); the same pair with a complete old inventory does.
- A declared matrix with a failed expected member yields incompleteness (warn by default, block when configured) and no invented API deletion.
- A mixed matrix — one selected member compared cleanly, another selected
member
unsupported(or ambiguously matched) — reports the clean pair's findings and an incomplete-scope outcome. Underwarnthe exit status is0, theRunOutcomescope axis readsincomplete, and every view names the unchecked member; underblockthe same run exits1, and a run whose compared members already exit4still exits4under either setting. - A run with zero valid comparisons reports
no comparison completedunder every completeness policy. - Replacing unavailable evidence with empty evidence fails a test.
- Binary/binary, binary/snapshot, snapshot/binary, snapshot/snapshot, and the package counterparts all go through the public paths (CLI, typed API, Action) and agree.