Project-wide duplication assessment and convergence plan¶
Type: Architectural assessment plus a phased convergence plan. Not a
gap-closure plan against a usecase-registry.yaml entry — this is a
cross-cutting initiative plan, alongside
CLI cleanup, phase two,
G33 (typed API convergence), and
G32 (comparability contract),
all three of which this plan builds directly on and names throughout.
Related: ADR-037 (CLI interface
contract), ADR-049
(contract-relevance/compatibility configuration), ADR-050
(comparability contract), ADR-054
(root-surface admission), ADR-055
(typed request/result completeness), ADR-056
(scan --artifact-set), ADR-061
(ReportDocument — this plan's ReportEnvelope in "Phase 4 — Introduce the
canonical report model" below is the generalization of the same target. The
three gaps that ADR's Phase 2 originally recorded as "not met yet" — the
Markdown/HTML prose rewrite, the per-finding verdict consolidation, and
post-render mutation — have all since closed there; what remains is its
gap C, one document per completed evaluation rather than one per format,
which is what this plan's Phase 4 now owns. The two documents converged on
the same diagnosis independently and should be read together rather than as
competing plans; ADR-061 also owns the ownership/dependency half of this
plan's P0 configuration and P1 dependency-direction hotspots — see
"Relationship to in-flight work" below)
Effort: XL (six phases, each independently landable; Phase 1 alone spans
several PRs, and Phase 6 decomposes into ten parallel tracks) · Risk:
high for Phase 1 (touches every extraction call site — dump, both
compare operands, scan candidate and baseline, release fan-out); lower
for Phases 2-5, which mostly consume what Phase 1 and Phase 2 produce;
medium for Phase 6, whose risk is concentrated in deletions rather than new
behavior.
Why this document exists¶
This assesses abicheck as of main at commit e674d3127634176dc7a41566bd1943bf6f481176
(2026-08-20) for semantic duplication, re-assessed against main at the
merge of PR #1062 (2026-09-04, the --exit-code-scheme removal) — see
"2026-09-05 re-assessment" below, which supersedes this section's headline
framing where the two differ. Semantic duplication: places where the same
responsibility — a decision, a normalization, a workflow — exists in more
than one codepath, expressed as different code that is intended (and mostly
succeeds, at real correctness cost recorded in AGENTS.md's "Known gaps")
to produce equivalent results. That is a materially more dangerous class of
duplication for this codebase than literal copy-paste, because two
independently-evolving implementations of "resolve this input" or "compute
this exit code" drift silently, one bug fix at a time, until a real-world
report surfaces the divergence (see AGENTS.md's L3→L2-fold and
include_sequence entries for concrete incidents of exactly this).
The verdict: the project is midway through convergence, not badly
designed. The compare pipeline (service_compare_pipeline.py +
service_input_resolution.py) already demonstrates the right architecture —
parse frontend input, build a typed request, resolve once, execute once,
classify once, project into different outputs. The problem is that this
pattern is not yet the mandatory architecture for dump, scan, release,
aggregate, and compat. This plan's job is to make it mandatory.
Scope summary¶
| Area | Current state | Duplication risk |
|---|---|---|
| Binary/debug/header parsers | Mostly appropriately separated by backend | Low |
| Diff detectors and policy classification | Generally shared and registry-driven | Low–medium |
compare resolution |
Properly converged around typed service APIs | Low |
Artifact extraction for dump, scan, and compat |
Several partially equivalent paths | High |
| Configuration and pack application | One resolver, several runtime representations | High |
| Exit and gate calculation | Partially unified, with operation-specific parallel folds | High |
| Report construction | Significant post-render mutation and format-specific repair | High |
| Release and matrix aggregation | Reinterprets outputs rather than consuming a common result model | Medium–high |
| CLI/service dependency direction | CLI concepts still leak into engine/service layers | High |
What is already right — and why it's the model¶
The compare pipeline. service_compare_pipeline.py splits comparison
into resolve_compare_request() and classify_compare_pair(). The CLI, the
typed Python API, and the former MCP path (see ADR-021, retired) share
resolution and classification without forcing CLI-specific configuration
work into the service layer — cli_compare_receipt.py's ADR-049 resolution
step runs between the two phases, exactly because they're split. This is
the template: parse frontend input → build typed request → resolve once →
execute once → classify once → project into different outputs.
Per-side resolution. service_input_resolution.py exists specifically
so a comparison pair and a standalone dump don't each carry their own copy
of "resolve one input into a snapshot" — it owns resolve_side_snapshot,
embed_side_build_source, enforce_requested_depth, and
reject_hybrid_source_frontend. That is the correct abstraction boundary;
the gap (Phase 1 below) is that not every caller uses it yet.
Working single-source-of-truth efforts worth preserving as patterns:
snapshot_io.py (canonical snapshot storage envelope), the ChangeKind
registry deriving BREAKING_KINDS/API_BREAK_KINDS/COMPATIBLE_KINDS/
RISK_KINDS rather than hand-maintaining four sets, shared CLI option
decorators (cli_options.py) for cross-command concepts, and the stored
SONAME mapping in bundle resolution that prevents a later graph traversal
from independently reconstructing a subtly different one.
2026-09-05 re-assessment — the remaining gap is authority transfer, not construction¶
An external re-assessment of main (the merge of PR #1062, which removed
--exit-code-scheme) was reviewed and accepted on 2026-09-05. It
supersedes this document's own headline framing without invalidating any
landed slice: the primitives this plan and
ADR-063/
one-semantic-pipeline.md call for are largely
built; what is not done is making them the sole authority and
deleting what they replaced.
What has genuinely closed since the original audit¶
| Area | Now implemented | What that does not yet establish |
|---|---|---|
| Gate configuration | policy/release_gate_options.py's GateOptions; the manual --exit-code-scheme selector deleted (PR #1062, ADR-064) |
That effective-policy application has one implementation — see apply_release_gate_pack below |
| Semantic consumers | The typedef and constant detector families read SemanticIRIndex (compare/typedefs.py, compare/constants.py), guarded by scripts/semantic_ir_cutover.py |
That the IR is their sole data source |
| Fact semantics | compare_facts()/FactComparison used by real detectors; base-class comparison refuses incomplete evidence rather than treating PARTIAL as a complete list |
That every fact-dependent decision handles availability and scope consistently |
| Execution context | resolve_compare_request/execute_dump_request both build and enrich a ResolvedExecutionContext |
That execution and classification consume it as their sole authority |
| Evidence depth | policy/depth_projection.project_pair_to_depth() is used in compare classification |
That projection is provenance-driven rather than a manual field-clearing inventory |
| Storage | Typed section codecs; multi-artifact import/export paths; bundle_facts_store.py now delegates to one import_bundle_facts/export_bundle_facts implementation instead of keeping two physical layouts |
That standard single-/multi-artifact workflows share one logical flow |
| Reporting | Every renderer family, including both alternate Markdown modes, crosses ReportDocument |
That every report fact is computed once before format-specific construction |
Bundle storage is the model to copy. Two tracks had implemented different physical layouts for the same bundle-composition facts; one now delegates to the other and the surviving entry points are adapters. That is what every remaining item below should look like when it is done.
The completion rule this plan was missing¶
The review identified a real loophole in how items here and in one-semantic-pipeline.md have been closed: several migrations were investigated and declined because no currently-demonstrable finding justified them, and that disposition was then treated as equivalent to "the removal gate is closed." Those are different things. Separate the two decisions:
- Behavioral change — changing matching precedence, suppressing a
finding, altering evidence requirements, or changing a published finding
ID. Needs a demonstrated benefit and correctness evidence. Declining one
for lack of a demonstrated benefit is correct, and this plan's existing
"attempted twice, reverted twice" discipline (AGENTS.md,
known-gaps.md) stays in force. - Implementation consolidation — moving existing, unchanged semantics behind one owner and deleting the duplicate. This does not need a new bug to justify it. Its benefit is fewer implementations, fewer configuration-forwarding paths, and fewer independently mutable representations of the same fact.
"No current bug found" can justify preserving behavior. It cannot, by itself, justify preserving a second implementation of that behavior.
The governing prohibition, stated precisely enough to be applied without over-reaching:
No two independently maintained implementations of the same semantic decision, and no two independently writable current representations of the same semantic fact.
That is deliberately narrower than "no duplication": a pure import shim is not a duplicate implementation, a legacy decoder is not a duplicate current model, a different renderer is not a duplicate analysis engine, and a stack-loadability check is not the same question as library ABI compatibility. See "What should explicitly not be unified" below, which this rule refines rather than replaces.
The four-state status model¶
The authority ledger (docs/_meta/one-semantic-pipeline-status.yaml) and
this plan should both record a concept's position on one ladder:
with investigated_declined as a separate disposition, not a synonym for
retired. A declined behavioral change leaves the consolidation item
open; only an actual deletion (or reduction to delegation) closes it.
This matters most concretely for the vtable/TYPE_VTABLE_CHANGED cluster
(5B's "final closure"): the investigation is genuinely complete, but the
PDB-driven fabricated-finding path it documents is still reachable and the
authority transition still has not happened. Recording that as
investigated_declined with an open removal gate is accurate; recording it
as closed is not.
Landed (track T2, 2026-09-05). Both halves are now schema, not
convention: the ledger is at schema_version: 2, every concept carries a
lifecycle rung, and scripts/pipeline_status_ledger.py enforces that
lifecycle agrees with authority (which it refines — authority cannot
express either end of the ladder, because two of its three values are
ambiguous across two rungs each: authority: legacy covers both
introduced and wired, and authority: self covers both authoritative
and retired; only mixed picks out exactly one rung, wired, which
wired does not imply in return), that retired requires every status
field complete, and that a
concept carrying any investigated_declined entry cannot sit at retired
— the loophole above, closed mechanically rather than restated. Each entry
names item, decided, leaves_open (what the decline does not close)
and tracked_as (the narrative owner holding the full reasoning);
leaves_open is required precisely because an entry that says nothing
about what stays open is the loophole in structured form.
The re-audit that shipped with the schema found no concept at retired,
and only the three authority: self concepts (public_surface,
report_document, l5_source_graph_identity) at authoritative; the other
six are wired. Three carry a declined disposition: facts (the
vtable/TYPE_VTABLE_CHANGED FactStatus gating above), identity (the
entity: alias-tier promotion), and semantic_ir (the record/function
detector cohort). That distribution is this section's own headline —
primitives built, authority not transferred — recorded as data rather than
prose.
Corrections to current status prose¶
Two claims elsewhere in this family of documents are now stale and are corrected here (drift of exactly the kind the ledger exists to prevent):
- Dump/compare execution context is populated, not absent.
execute_dump_requestcallswith_assurance()and carries per-sidecompile_contexts;resolve_compare_requestattaches a context andclassify_compare_pairreadsrequested_depthoff it. The real gap is that these objects are largely unread, and thatevaluation_configis stillNoneon every realResolvedComparePair/ResolvedDumpRequest— not that the wiring is missing. action/run.shis partially migrated, not unmigrated._report_compat_verdict()and_severity_gate_exit()already preferrun_outcome's structured fields. What remains is the residual raw-process-exit/stderr-text interpretation, which PR #1062 extended again for evidence-contract errors.
What "done" looks like for the next milestone¶
Measurable as fewer independent execution paths, no dual current-data fallback for the migrated cohorts, and named legacy implementations deleted — not "more fields populated," "another phase investigated," or "all existing tests still pass."
The explicit retirement table¶
Every consolidation item below needs a named replacement and a named
deletion. Each row was verified against the implementation on
main at the time of this update.
| Existing path/representation | Canonical owner to use | Required cleanup |
|---|---|---|
~~cli_dump_helpers.perform_elf_dump() / cli_dump_non_elf.handle_non_elf_dump()~~ — DONE (T1, 2026-09-05): both deleted, along with abicheck/cli_dump_non_elf.py and abicheck/cli_dump_protocols.py |
Typed dump execution (service_dump_pipeline.execute_dump_request via frontends/cli/dump_execute.py) |
Complete. The unique assertions were rehomed to tests/test_dump_cli_execution_behaviors.py (against the real dump CLI, since header_roots and the public-root forwarding are computed on either side of the executor); everything else those tests asserted was already owned at the shared pipeline's own seams, named individually in that module's docstring. CLI_CONTRACT_ALLOWLIST lost its cli_dump_helpers.py:…:dumper.dump entry — the first line to leave that list, and by deletion rather than rerouting. See the T1 row below for the two facts the retirement surfaced. |
cli_buildsource.dump_source_only — execute_dump_request explicitly refuses a binary-less request and redirects here |
A source-only execution variant inside the shared workflow | Keep CLI parsing/presentation only; one semantic assembler, persistence path, and error contract for both variants |
appcompat.check_appcompat()'s two direct dumper.dump() calls |
Shared extraction + comparison workflow | Delete the independent per-side header/include resolution; keep only application-specific requirement/impact evaluation |
stack_checker._run_abi_diff()'s direct dumper.dump()×2 + checker.compare() |
Shared per-library comparison operation | Remove the bypass and the generic-failure-to-None collapse; carry typed operational outcomes into stack analysis; keep dependency resolution/loadability as stack-specific |
compare/typedefs.typedef_index_pair() / compare/constants.constant_index_pair() runtime fidelity selectors — both build IR-backed and legacy-backed indexes on every comparison and let the legacy projection adjudicate |
Canonical family data + a historical-input adapter at the load boundary | Stop rebuilding a legacy index for a current-format snapshot; a disagreement becomes an explicit consistency failure, not a silent fallback |
Writable legacy value + *_fact sibling pairs; current-runtime reliability flags |
One canonical fact payload carrying observation-vs-inference, producer, scope, and positive-observation-vs-completeness | Remove dual writes and synchronization machinery once consumers migrate; historical decoding stays permanently in the import adapter |
~~policy/release_gate_options.apply_release_gate_pack() — folds pack severity into a 5-tuple of raw strings, mirroring pack_application.apply_to_compare_config's fold over an already-resolved config~~ (the mirror is gone, T6, 2026-09-05) |
The fold rule itself now lives once in policy/gate_pack_fold.py's fold_gate_pack_severity — a leaf inward of both callers, which is what let both call it without policy importing the flat-root pack_application (the _GatePackApplication Protocol stays for the same reason). The two callers' remaining difference is their fold target, not the rule |
Done for the rule. Still open: the two runtime shapes themselves (four raw optional strings vs. an already-resolved SeverityConfig), which is this plan's EffectiveGate/EffectiveEvaluationConfig target — tests/test_release_gate_pack_fold_parity.py now guards those two shapes, and tests/test_gate_pack_fold.py states the shared primitive's own contract |
action/run.sh's residual raw-exit-code/stderr-text verdict reconstruction |
Structured run_outcome/exit reader |
Retain only a small transport-level fallback for "no valid result because invocation failed"; fail-on-* stays explicit step policy and must not rewrite the reported verdict |
Four sibling _exported_symbol_names()-shaped implementations (policy/depth_projection.py, buildsource/cross_source_checks_base.py, buildsource/snapshot_exports.py, post_manifest.py, plus diff_unnamed_types's own) |
One canonical raw export index with explicitly named projections | Remove the equivalent local implementations — while preserving the real distinctions (versioned ELF exports, default versions, Mach-O spelling normalization, named PE exports, ordinal imports, missing versus confirmed-empty tables) as named views, not one universal set-of-strings helper |
Report semantics recomputed per format (alternate Markdown builders still consult DiffResult, filtering, policy, and recommendation helpers, reaching them through render_markdown_document._reporter_markdown()'s runtime importlib load of ..reporter_markdown to dodge a cycle) |
Shared report preparation producing evaluated facts once | Keep only view construction and rendering in format-specific code; layout/grouping/presentation filters stay legitimately per-format. report/scoped_gate.py's own runtime load of ..reporter is a separate dependency — scoped JSON construction, not Markdown — and has its own row below |
report/scoped_gate.apply_scoped_gate() — folds the scoped (--used-by/--required-symbol) gate into an already-built JSON payload as a plain dict mutation, and reaches reporter through a runtime importlib load because reporter → reporter_contract_blocks → scoped_gate would otherwise close a real cycle |
The same shared report preparation as the row above: evaluate the scoped and full-library views through the shared machinery and construct each, rather than building one payload and mutating it into the other | Delete the post-hoc dict fold and, with it, the ..reporter runtime load — the cycle exists only because the fold runs after construction. Same root cause as scope_diff_to_app()'s late enrichment below, so the two are one fix at two sites, not two independent items |
Two adjacent findings that are not retirements but belong with them:
- ~~
GateOptions.exit_code_schemeis documented as purely derived but remains an independently constructible dataclass field besideseverity.~~ Closed (T6, 2026-09-05): it is a derived property now, and so isResolvedCompareConfig.exit_code_schemebeside its ownseverity_active— the same defect, one object over. The concern was that the model permitted disagreement even though the resolver never produced one, and the concern was justified: two unit-test helpers (tests/test_config_review.py,tests/test_cov95_cli.py) were constructing aGateOptionscarryingexit_code_scheme=Nonebeside a realSeverityConfig. Every site that re-spelled the"severity" if ... else "legacy"derivation now calls onegate_pack_fold.gate_exit_code_scheme. DumpResult's effective include paths can point into an already-deleted inferred-build directory. Returning more paths is not a substitute for owning their lifetime: the shared extraction session must own those resources across every consumer that needs to read them (parsing, enrichment, graph construction, persistence), which is the resource-lifetime half of Phase 1 item 1 that is still open.scope_diff_to_app()synthesizes findings and mutates shared changes after the original comparison, forcing extra suppression and impact-cache handling. A cleaner finalization boundary evaluates those findings through the shared machinery and keeps full-library versus consumer-scoped views explicit rather than mutating one into the other (this is the same finding as P1 "Reporting composes too late" below, reached from the consumer-scoping side).
Hotspots, in priority order¶
P0 — Artifact extraction and evidence resolution¶
The largest duplication and correctness risk. At least ten
partially-equivalent paths exist today, in three groups: seven user-facing
operations (1–7); one internal, backend-level exception (8, the probe
harness, called out below as not itself a user-facing operation, since
nothing outside its own module calls it); and two internal, supplementary
extraction call sites invoked by the user-facing compare/scan
operations above, not separate operations a user invokes directly (9–10,
each its own service.resolve_input(..., symbols_only=True) call distinct
from either side's primary resolution):
- Typed dump:
DumpRequest → resolve_dump_request() → execute_dump_request()(service_dump_pipeline.py) - Native ELF CLI dump:
dump_cmd → perform_elf_dump()(cli_dump_helpers.py) - Native PE/Mach-O dump: a separate non-ELF path (
handle_non_elf_dump) - Scan candidate/baseline:
scan_engine._build_new_snapshot(), which callsservice.resolve_input()directly rather than routing throughservice_input_resolution - Dump dry-run:
render_dump_dry_run(), a second, independent approximation of resolution — its own docstring calls this "Cheap, read-only resolution only... never runs castxml/clang" - Standalone application-compatibility:
appcompat.check_appcompat()callsdumper.dump()directly for both sides (its own docstring: "Dumps and compares the two libraries itself"), bypassing every one of the five paths above — a caller who reachescheck_appcompat()directly (rather than throughcompare's own app-usage scoping, which callsscope_diff_to_app()against an already-resolved diff instead) gets none of whatResolvedArtifactPlanwould eventually centralize: resource lifetime, the L3→L2 compile-context fold, cache-relevant paths, or post-processing hooks. deps compare:stack_checker._run_abi_diff()callsdumper.dump()for both sides andchecker.compare()directly (abicheck/stack_checker.py), andcli_stack.py'sdeps_compare_cmdindependently computes its own process exit code fromresult.loadability/result.abi_riskrather than through any shared exit model — Phase 3 covers this with two new axes and aDepsCompareExitPolicy(see that section).- The probe harness:
probe_harness._snapshot_object_file()(used byrun_probe_matrix(..., snapshot=True), the header-only-library compile-and-snapshot driver behind G25/G26-family evidence-tier work) also callsdumper.dump()directly on each compiled probe object. Deliberately called out separately from the seven user-facing operations above (paths 1–7), since it isn't one — see "backend-level exception" below. - L0 export-delta re-extraction (an internal supplementary call site, not
a separate user-facing operation):
l0_export_delta. collect_l0_export_delta()— invoked by both nativecompareand scan baseline reconciliation — independently callsservice.resolve_input()twice withsymbols_only=True, a supplementary extraction distinct from either side's primary resolution. Missing this from the migration would letcompare/scan's primary-side equivalence tests pass while this secondary path still misses the centralized lifetime, fingerprint, and post-processing behavior. - Scan's POI (point-of-interest) export prepass (also an internal
supplementary call site, not a separate user-facing operation):
scan_engine._load_exports_for_poi()— a separate, best-effortservice.resolve_input(..., symbols_only=True)callscan_engine.pymakes for both baseline and candidate ahead of the primary extraction, when export-delta POI tracking is needed — is a third, independentresolve_input()call site alongside path 9's L0 delta and the primary-side resolution scan candidate/baseline routing already covers. Missing it the same way path 9 would be missed: routing_build_new_snapshot()and the native baseline says nothing about this prepass, since it resolves on its own, ahead of either.
appcompat.check_plugin_host_contract() — the plugin-host counterpart to
check_appcompat() (path 6 above) — is deliberately not listed here:
unlike check_appcompat(), it takes two already-built AbiSnapshot
objects as its own parameters and does no extraction of its own — it
calls compare_snapshots() directly and then scopes the result, nothing
more. It belongs to this plan's comparison-path duplication instead (see
Phase 2 item 5 and the "Comparison equivalence" acceptance test below,
both of which already name its own pre-scope compare_snapshots() call),
not the artifact-extraction list this section enumerates.
service_dump_pipeline.py documents directly that native dump behavior
historically lived around resolve_input() in CLI code, forcing non-CLI
consumers to reimplement or omit those steps. AGENTS.md's "Known gaps"
section records the concrete cost of this divergence in detail: duplicate
inferred build queries contending on the same lock for up to 600 seconds
(fixed by seed_includes_and_fold_compile_context); the L3→L2 compile-
context fold reaching dump and compare's implicit-dump operand but not
scan's candidate or baseline resolution (three separate follow-up fixes,
numbered 8/12/13/15 in that entry); derived include directories
participating in some AST cache keys but not others (findings 2, 10, 17);
and a real, still-open include_sequence mismatch between a dump baseline
and a scan --against of it, confirmed against live Bazel/castxml CI
evidence and still not fully root-caused as of this writing.
Target shape:
ArtifactRequest
↓
resolve_artifact_request()
↓
ResolvedArtifactPlan
↓
execute_artifact_plan()
↓
ArtifactResult
ResolvedArtifactPlan carries: normalized input type and binary format;
requested and effective evidence depth; selected frontend and compiler;
headers and public-header scope; effective include search; effective
compile context; build/source collection plan; dependency scope;
cache-relevant paths; post-processing steps; provenance inputs.
ArtifactResult carries: the snapshot; achieved evidence depth; extraction
contract and fingerprints; effective compiler context; coverage and
degradation; executed stages and timings; advisories; post-processing
results. The same pipeline must serve dump, each side of compare, scan
candidate and native baseline extraction, release per-library extraction,
ABICC descriptor extraction, standalone appcompat's own dump-both-sides
path, and deps compare's per-dependency-pair extraction.
Lifetime problem. Some effective include paths can point into a
temporary inferred-build directory that is deleted once the resolving
function returns (the deferred-cleanup design AGENTS.md's L3→L2-fold entry
documents at length). Returning more paths from a resolve step is not
sufficient — and scoping the resource to execute_artifact_plan() alone is
also not sufficient, for two reasons the design has to account for
together: the directory can already be at risk of cleanup by the time
resolve_artifact_request() returns — before any execute_... call ever
starts — and the dry-run path below resolves a plan but deliberately never
executes it, so a scope that only opens at execute_artifact_plan() would
never close for dry-run at all. Ownership has to span resolution through
execution (or through dry-run's own inspection), not begin partway through:
with resolve_artifact_request(request) as plan:
# plan.session owns the inferred-build directory (if any) from here
if dry_run:
return render_plan(plan) # closes on context-manager exit
with execute_artifact_plan(plan) as result:
run_header_graph(result)
attach_build_context(result)
persist_snapshot(result)
# result borrows plan.session's resources; still open here
resolve_artifact_request() returns a context-managed
ResolvedArtifactPlan whose __exit__ releases whatever resources
resolution itself allocated (regardless of whether execution ever runs), and
execute_artifact_plan() borrows that same session rather than opening a
second one — so cleanup happens only after every extraction and
post-processing consumer, and dry-run's own inspection, has finished. This
removes the need for each call site to re-derive its own ordering and
deferred-cleanup rules — the exact class of bug the L3→L2-fold entry's fifth
finding (self-deadlocking duplicate inferred queries) had to be fixed for
one call site at a time.
One more failure mode this shape has to close, not just the two above:
Python fully evaluates resolve_artifact_request(request) — the function
body runs to completion or raises — before with ever calls __enter__
on whatever it returns. If resolution allocates the inferred-build
directory and only then fails a later validation step within its own
body, no ResolvedArtifactPlan is ever returned for a with block to call
__exit__ on — the directory and its lock leak regardless of how carefully
the caller-side with is written, since the caller never sees an object at
all. resolve_artifact_request() therefore can't be "allocate, then
return a context manager" — its own body needs a try/finally (or an
ExitStack it owns and only hands off to the returned plan on success) so
a failure partway through resolution tears down whatever it had already
allocated before the exception propagates, rather than leaving that
responsibility for a caller who received nothing.
Dry-run renders the plan, not a second prediction. resolve_dump_request
(added by the CLI-cleanup-phase-two "PR C" slice) already provides a real
"resolve without executing" mode — the missing piece is wiring
render_dump_dry_run() to build from it, through the same
context-managed ResolvedArtifactPlan described above, instead of
independently re-deriving depth/collect-mode/backend feasibility (and
instead of any bespoke cleanup of its own — dry-run closes the same session
resolution opened, on the same with exit, whether or not execution ever
runs). Dry-run should report one of: definitely valid; definitely invalid;
unresolved until execution; requires trusted build execution — never
maintain its own approximation of execution semantics.
P0 — Effective configuration and pack application¶
pack_application.py follows the right rule: it reads pack contributions
from the already-resolved CompatibilityEvaluationConfig and its
per-field ValueProvenance rather than reimplementing precedence or
conflict resolution (see AGENTS.md's own extensive documentation of D7/D8
in the module map). But the resolved answer still gets translated into
several different runtime shapes: single-pair compare uses a typed
resolved configuration; scan has its own receipt/configuration flow;
policy packs fold into PolicyFile; and the release fan-out now has its
own resolved gate object (policy.release_gate_options.GateOptions/
resolve_release_gate_options, ADR-064, landed 2026-09-02) rather than
raw scheme strings — closing this section's original "no equivalent typed
object at all" premise (Codex review, PR #1050, fresh evidence). What
remains: GateOptions is not itself EffectiveGate/
EffectiveEvaluationConfig-shaped. apply_release_gate_pack()'s manual
mirror of pack_application.apply_to_compare_config's fold logic closed
under T6 (2026-09-05): both call one shared
policy/gate_pack_fold.fold_gate_pack_severity. What that did not close
is the reason the mirror existed — the release fan-out still has no
ResolvedCompareConfig-shaped object of its own to fold packs onto, so the
two callers still fold onto different runtime shapes around the one shared
rule. That remains this section's target.
Target: one runtime object,
@dataclass(frozen=True)
class EffectiveGate:
exit_code_scheme: str # e.g. "legacy" or "severity"
severity: EffectiveSeverity
require_complete_analysis: bool
scope: ScopedGateSelection | None # ADR-043 --used-by/--required-symbol
@dataclass(frozen=True)
class EffectiveEvaluationConfig:
policy: EffectivePolicy
gate: EffectiveGate
contract: EffectiveContract
assurance: EffectiveAssurance
surface: EffectiveSurface
evidence: EffectiveEvidencePolicy
suppressions: EffectiveSuppressions
provenance: ConfigProvenance
digest: str
suppressions carries resolved rule identity, not just a policy summary —
the existing CompatibilityEvaluationConfig already models suppressions
as its own field, separate from policy, precisely because a suppression
rule directly changes which findings reach verdict and gate calculation.
Without it here, two runs given different --suppress inputs could share
an identical EffectiveEvaluationConfig and digest while producing
different findings and exit codes — defeating the digest's whole point as
a parity key.
gate carries the resolved exit_code_scheme alongside severity, not
severity alone — for a run combining --exit-code-scheme legacy with a
severity-only gate pack, severity by itself can't recover which scheme was
selected, so a consumer would otherwise have to keep an out-of-band raw
string (defeating the point of one runtime object) or re-derive the scheme
from severity, reintroducing the exact bug CLI-cleanup-phase-two's PR B
already found and fixed once (a re-derived scheme let a severity-only gate
pack silently override an explicit --exit-code-scheme legacy).
require_complete_analysis belongs in the same object for the identical
reason, not as a separate follow-on: two otherwise-identical runs differing
only by --require-complete-analysis exit successfully in one and fail in
the other on incomplete evidence, and the existing digest implementation
(effective_config_digest.py) already records this input as its own
gate.require_complete_analysis key — leaving it out of the sole runtime
object this section proposes would mean two runs with genuinely different
gate behavior could still land on the same EffectiveEvaluationConfig and
digest. scope belongs for the same reason again: two runs selecting
different --used-by consumers or --required-symbol entrypoints
(ADR-043's scoped-gate selection) can produce different scoped findings,
verdicts, and exit contributions, and the existing digest implementation
already records this input as its own gate.scope key
(effective_config_digest.py's _gate_scope_str(), encoding the
selection kind and its resolved targets) — omitting it here would recreate
the identical out-of-band-input problem require_complete_analysis above
was added to close, letting two differently-scoped runs share one digest.
All four gate fields feed the digest.
This object is consumed directly by compare, scan, the release
fan-out, and bundle/matrix findings alike, with the resolver remaining the
only place D7's precedence order (explicit_cli/api_request >
legacy_alias > run_recipe > run_profile > project_config >
built_in_default) is decided. The digest becomes a real parity key: same
normalized request + same effective-config digest ⇒ same policy/gate
interpretation, everywhere. (The reporter's existing effective-configuration
digest, landed for PR B, is a real, narrower precursor to this — see
"Relationship to in-flight work" below.)
"Bundle findings alike" is not aspirational here — it names a real,
already-documented gap this phase has to close, not just avoid repeating.
AGENTS.md's own "Known gaps" entry ("Bundle-level (cross-library) findings
on a directory/package compare never respect any policy override")
records that bundle.compare_bundle() computes BundleDiffResult.
bundle_verdict via checker_policy.compute_verdict(changes) with no
policy= argument at all — always the hardcoded strict_abi default —
while _run_bundle_analysis/_collect_bundle_result
(cli_compare_release_helpers.py) have no policy/PolicyFile parameter
either. So a release-wide --policy, --policy-file, or a kind: policy
pack overriding a BUNDLE_* kind reaches every per-library finding
correctly but silently leaves bundle-level findings (bundle_library_
removed, bundle_intra_dep_removed, ...) governed by the built-in policy —
a bundle finding can keep a release's worst-of verdict at BREAKING even
after the same kind was demoted or ignored everywhere else. Phase 2 closes
this specifically by having compare_bundle/_run_bundle_analysis/
_collect_bundle_result accept and classify against the same
EffectiveEvaluationConfig every per-library comparison already uses,
rather than a bundle-specific policy parameter threaded through in
isolation — with test coverage asserting policy parity between a release's
per-library and bundle-level verdicts for the same run, which is the gap
the AGENTS.md entry names as still open.
P0 — Exit and gate decisions¶
ExitDecision (from CLI-cleanup-phase-two's "PR G1") models compatibility/
scoped-gate contribution, contract-coverage contribution, analysis-assurance
contribution, and promoted scan-crosscheck contribution — but explicitly not
yet NOT_COMPARABLE, scan budget overflow, release removed-library policy,
release operational errors, or aggregate's missing/unexpected-target
policies. Release therefore still owns its own precedence chain (not
comparable first, then removed-library exit 8, then operational-error
floor, then severity/verdict, then contract coverage folded separately), and
aggregate has to reverse-engineer upstream results — e.g. distinguishing
whether a scan's published exit 1 means contract coverage or a genuine
scan failure, because scan's exit code is already an opaque fold of
several axes by the time aggregate sees it.
Do not simply extend the current implementation with max() over more
numeric codes — exit-code integers are not a reliable cross-operation
priority ordering (a compare 4 and a scan 4 do not always mean "the
same severity of thing is wrong"). Instead, make contributions and their
precedence explicit:
@dataclass(frozen=True)
class ExitContribution:
axis: ExitAxis
active: bool
code: int
priority: int
details: Mapping[str, object]
@dataclass(frozen=True)
class ExitDecision:
code: int
primary_reason: ExitAxis # CLEAN when every contribution is inactive
contributing_reasons: tuple[ExitAxis, ...]
contributions: tuple[ExitContribution, ...]
with axes covering clean (mirroring the existing ExitDecision's own
ExitReason.CLEAN — a successful run still needs a real, non-optional
primary_reason rather than one implementation fabricating a failure axis
and another making the field optional), compatibility_gate,
scoped_gate, contract_coverage, analysis_assurance,
crosscheck_promotion, not_comparable, budget_overflow,
evidence_contract_error (scan's own EVIDENCE_CONTRACT_ERROR verdict —
service_scan.run_scan() returns it, with exit_code=1, when an explicitly
pinned --depth can't collect its required evidence; documented separately
from not_comparable/budget_overflow in run_scan_core() today, and
without its own axis it can only collapse into a generic
operational_error or stay the opaque exit-1 heuristic this phase exists
to remove), bundle_incomplete (scan --artifact-set's own
BUNDLE_INCOMPLETE verdict — service_scan.run_scan_set() returns it,
also exit_code=1, when the member scans complete but not every snapshot
needed for the cross-library audit could be built; a second, distinct
scan-only incompleteness signal from evidence_contract_error, not a
duplicate of it), operational_error, removed_required_artifact,
missing_required_target, unexpected_target, and two axes for deps
compare's own dependency-loadability result — dependency_load_failure
and dependency_abi_risk — since cli_stack.py's deps_compare_cmd
today distinguishes three outcomes (loadability/ABI-break failure → 4,
ABI risk or loadability warning → 1) that don't map onto any axis above;
and per-operation policies (NativeCompareExitPolicy, ScanExitPolicy —
now also covering evidence_contract_error and bundle_incomplete —
ReleaseExitPolicy, AggregateExitPolicy, AbiccExitPolicy, and
DepsCompareExitPolicy for the two new dependency axes plus the existing
not_comparable) that read the same evaluated result but keep each
operation's own external exit-code contract — compat's 0/1/2/...
mapping in particular should
be derived through AbiccExitPolicy, not bypass the shared model.
P1 — Reporting composes too late¶
Several renderers currently render a string, then reparse and patch it:
service_render._render_json_output() serializes via to_json(), parses
the JSON back, inserts dependency information, and re-serializes; dump
provenance is folded into already-rendered JSON text
(fold_dump_provenance_into_dict/_into_json); scoped-gate handling parses
rendered JSON, swaps full/scoped verdicts, adds findings not present in the
original changes array, recomputes summaries, and carries separate repair
logic per format (one-line, Markdown, review, SARIF, JUnit). Each of these
is well-commented precisely because each documents an incident where one
format disagreed with another because business semantics were applied
during or after formatting rather than before it.
Target: a canonical report intermediate representation, computed before any serialization:
@dataclass(frozen=True)
class ReportEnvelope:
operation: OperationKind
schema_version: str
operational_state: OperationalState # SUCCESS / NOT_COMPARABLE / ERROR / UNAVAILABLE
inputs: InputReport
resolution: ResolutionReport
effective_config: EffectiveConfigReport
evidence: EvidenceReport
findings: tuple[ReportFinding, ...]
full_evaluation: EvaluationSummary | None
effective_evaluation: EvaluationSummary | None
exit_decision: ExitDecision
dependencies: DependencyReport | None
timings: StageTimings
advisories: tuple[Advisory, ...]
with full_evaluation (the whole-library result) and effective_evaluation
(post-scoping, e.g. --used-by/--required-symbol) kept distinct, and
every renderer (JSON, Markdown, SARIF, JUnit, HTML, one-line) as a pure
projection — none of them modifying verdicts, inventing findings,
recomputing gate status, or parsing another renderer's output.
operational_state is its own field, not folded into exit_decision or
either EvaluationSummary — the "Smaller, concrete duplication" section
below states the rule this field exists to satisfy: OperationalState
(SUCCESS/NOT_COMPARABLE/ERROR/UNAVAILABLE) must stay a distinct
axis from CompatibilityVerdict ordering, never spliced into it. Encoding
it only as an ExitDecision contribution would still leave aggregate and
renderers reconstructing operational status from exit-code semantics — the
exact drift this envelope exists to end; placing it inside either
EvaluationSummary would conflate a compatibility result with whether a
comparison could be evaluated at all, which is precisely what
aggregate.py's existing, correct separation of these concerns already
gets right and this envelope must not regress.
full_evaluation/effective_evaluation are | None for the identical
reason the existing compare_report.schema.json's own verdict property
is nullable and documents three operational sentinel values alongside the
five real ones: a library that failed during extraction or comparison in
a release fan-out, or a pair the comparability gate rejected before any
diff ran, never produces a DiffResult — an ERROR/UNAVAILABLE/
NOT_COMPARABLE operational_state report carries whatever partial
context (inputs, resolution, advisories) is actually available, not
a fabricated evaluation. Only operational_state == SUCCESS obligates
both evaluation fields to be present.
P1 — Aggregate consumes representations, not decisions¶
aggregate.py correctly keeps compatibility, gate, target availability,
contract coverage, and analysis assurance conceptually separate, and
correctly refuses to treat a missing required report as a compatibility
verdict. But because upstream report shapes differ, it maintains
format-specific extraction rules for native-compare severity blocks, scan's
nested diff decisions, scan's top-level exit mapping, contract-coverage and
analysis-assurance contribution recovery, legacy-verdict fallbacks, and
release's operational-error sentinel. Once the ReportEnvelope/
ExitDecision work above lands, aggregate should read one uniform
evaluation/exit shape and stop inferring meaning from an integer based
on which command produced the document.
P1 — ABICC compatibility is a parallel frontend and engine path¶
abicheck compat intentionally keeps a distinct user-facing contract — that
part is correct and should stay. But its implementation calls
dumper.dump and checker.compare directly rather than through the typed
dump/compare pipelines, then applies its own post-comparison
transformations, report dispatch, and exit mapping — so it structurally
cannot receive fixes that live only in the typed orchestration layer (evidence
resolution, contract/assurance processing, canonical exit decisions). Target:
an ABICC adapter that builds DumpRequest/CompareRequest/
EffectiveEvaluationConfig and hands off to the shared pipelines, keeping
only genuinely ABICC-specific concerns (flag aliasing, XML descriptor
parsing, suppression translation, report shape, exit-code mapping) in the
adapter itself. Post-comparison transformations (strict mode, source-only
filtering, warn-on-new-symbol) should become declared evaluation-policy
inputs rather than DiffResult mutations applied after classification.
P1 — Compiler invocation handling needs one typed model¶
Recent fixes (referenced in this repository's git history and in AGENTS.md's compiler-invocation and toolchain-profile entries) centralized launcher stripping, environment-prefix handling, driver recognition, split-operand decoding, and canonical encoding across build adapters, header-compile-context derivation, L2 replay, L4 source replay, and include graph collection — because equivalent compiler commands were being interpreted differently by different consumers. The next step is moving from shared helper functions to a shared parsed object:
class CompileAction(Enum):
OBJECT = "object" # -c: source -> object file, compile stops here
ASSEMBLE = "assemble" # -S: source -> assembly text file
PREPROCESS = "preprocess" # -E: source -> preprocessed text (file or stdout)
LINK = "link" # no stop flag: compile straight through and
# link -- the source's own intermediate object is an internal,
# unnamed temporary the compiler manages itself, never a file this
# model names; the invocation's own `output` field is the real result
@dataclass(frozen=True)
class SourceOperand:
path: str
language: Language # effective language for THIS operand specifically
action: CompileAction # what this source's own compile stops at
effective_output: Path | None # this source's own resolved output; None
# when the action streams to stdout (bare -E, no -o, no per-source
# default target the way -c/-S have)
@dataclass(frozen=True)
class CompilerInvocation:
original_argv: tuple[str, ...]
recorded_directory: Path
effective_directory: Path
environment: EnvironmentOverlay
launchers: tuple[str, ...]
driver_token: str
resolved_driver: Path | str
driver_mode: DriverMode
standard: str | None
target: TargetConfig
defines: tuple[DefineOp, ...]
include_search: tuple[IncludeSearchEntry, ...]
forced_includes: tuple[Path, ...]
abi_flags: tuple[AbiFlag, ...]
sources: tuple[SourceOperand, ...]
link_inputs: tuple[str, ...] # positional .o/.a/.so/... operands, in
# argv order, for a link-shaped invocation (empty for a compile-only one)
output: Path | None
opaque_flags: tuple[str, ...]
sources — every positional argv token naming a translation unit, in
argv order, mirroring the existing sources_from_argv()'s own "return
every one" contract (gcc -c a.c b.c compiles two TUs in one
invocation, and a caller that wants all of them must not stop at the
first). opaque_flags cannot stand in for this: it is scoped to
unrecognized flag tokens, not positional operands, and nothing about a
bare filename argument marks it as source rather than some other kind of
operand. Without a dedicated field, an L2/L4 replay or build-attribution
consumer would have no way to recover which TU(s) an invocation actually
compiled without rescanning original_argv itself — exactly the
"parse once" contract this model exists to establish, and the omission
this field closes. Each entry carries its own resolved language, not
just a path: a single invocation can force a different -x <language>
between sources (gcc -c -x c first -x c++ second validly compiles
first as C and second as C++ — confirmed against real gcc --help,
which documents -x as applying to the input files that follow it), so
the top-level language field alone can't describe every entry. This is
a genuine improvement over the existing effective_language() helper,
not merely a restatement of it: that helper is documented as correct only
"for a single-source TU" — it returns the last forcing token seen
anywhere in argv, not the one that was actually in effect at a given
source's position, so a naive per-invocation reuse of it already gets a
multi-source case like this one wrong today. SourceOperand.language
must be resolved positionally (tracking -x state as it walks argv,
the way sources_from_argv()'s own forced-language tracking already
does) rather than by calling effective_language() once per source.
CompilerInvocation itself deliberately carries no invocation-wide
language field alongside this: a top-level field left undefined for a
mixed-language invocation like the one above, or defined only as some
other value (the driver's initial default, say), would give consumers two
sources of language truth that can legitimately disagree — and since this
model's whole premise is that a consumer must never rescan original_argv
to resolve ambiguity, a second, lower-fidelity authority would just invite
some consumer to read it instead of sources[i].language and reproduce
the exact divergence this parsed model exists to eliminate. Any caller
that only cares about a single-source TU's language reads
sources[0].language.
output — the resolved -o <file> operand for a link-shaped invocation
(or None when absent). A link invocation produces exactly one artifact
(an executable or shared library from .o/.a inputs), so a single field
is correct there, and real build attribution already needs and stores this
today (CompileUnit.output/LinkUnit.output, both populated by
_output_from_argv() in every build adapter and consumed by the source
graph and link_attribution.py) — omitting it from the parsed model would
mean output-to-source/link attribution has no way to recover it except by
rescanning original_argv, the same parse-once violation sources was
added to avoid.
link_inputs — every positional .o/.a/.so/.lib/... operand of a
link-shaped invocation (g++ a.o libb.a -o app), in argv order. sources
alone cannot carry these: a link invocation's positional operands are
already-compiled objects/archives, not translation units, so they belong
in neither sources (which names only source files with a resolved
language) nor output (a single field for the one produced artifact).
Without this field the existing LinkUnit.inputs this model is meant to
replace — consumed by link_attribution.py to match a link line's inputs
against the CompileUnit.outputs that produced them — would have no way
to recover them except rescanning original_argv, exactly the parse-once
violation this model exists to end. Empty for a compile-only invocation.
For a compile invocation, output alone cannot describe the result:
gcc -c a.c b.c (multi-source, compile-only, no explicit -o — and GCC
itself rejects combining an explicit -o with -c/-S/-E across more
than one source file, so this is the only legal shape for a multi-source
compile-only invocation) implicitly produces a.o and b.o, one per
source, from each source's own basename — a single Path | None cannot
hold both. SourceOperand.effective_output carries this per-source
instead: the resolved output path this specific source compiles to,
whether that's the invocation's single explicit -o (only ever legal
paired with exactly one source) or this source's own default naming when
no explicit -o applies to it. The existing CompileUnit.output/
_output_from_argv() this model is meant to replace already assumes one
source per compile unit in practice (every build adapter's own
compile-line parser extracts one source/output pair per recognized
line, matching how real build-system-generated compile databases and
recipe lines are emitted — a build system splitting its own multi-source
invocations before recording them, not this model inventing new
semantics), so effective_output generalizes that existing, per-source
assumption rather than contradicting it.
effective_output's naming and even its kind depend on
SourceOperand.action, not just -c: gcc -c foo.c defaults to
foo.o/foo.obj, but gcc -S foo.c (confirmed against real gcc) instead
produces foo.s (assembly text, CompileAction.ASSEMBLE), and gcc -E
foo.c (also confirmed) writes preprocessed text to stdout when no
-o is given at all — CompileAction.PREPROCESS with effective_output
= None in that shape, since there is no default file target the way
-c/-S have one; an explicit gcc -E foo.c -o foo.i still resolves to
foo.i. gcc foo.c -o app — no -c/-S/-E at all — is a fourth,
distinct shape: GCC's own -c documentation is explicit that it means
"compile and assemble, but do not link", so its absence compiles
straight through the assembler and linker in one invocation, producing
the linked app directly, never a persisted foo.o a build system could
name. CompileAction.LINK models this: the source's own intermediate
object is the compiler's internal, unnamed temporary (its exact path is
implementation-defined and not stable across compiler versions), so
effective_output is None here too — the real result lives on
CompilerInvocation.output (the invocation-level artifact output
already models for a link-shaped command). A model that always assigned
an .o-shaped default output regardless of -S/-E/no-stop-flag would
silently mismatch what the invocation actually produces.
recorded_directory is the compile-database entry's own directory field
(or the equivalent for a live build-adapter query) — not optional, since a
relative source or include operand's meaning depends on it. Two
otherwise-identical argv values executed from different directories can
resolve entirely different headers, so recording original_argv alone
would force replay and include normalization to either fall back to
abicheck's own process cwd (silently wrong whenever that differs from the
compile unit's own recorded directory) or keep an out-of-band value next to
the parsed object — exactly the "parse once" contract this model exists to
establish. It participates in the invocation's own identity, the same way
this field already has to for the L3→L2 fold's cache-key/relative-path
handling described in AGENTS.md's own entry for that work. recorded_directory
alone is not sufficient, though — the two fields split for a real reason
the existing _argv.py/clang.py parsing machinery already had to solve:
a launcher prefix can itself change the effective directory (env -C build
clang ..., GNU env's documented -C/--chdir) independently of any
recorded_directory compose (env -C a env -C b ... → a/b), so
effective_directory — mirroring the existing effective_directory()
helper — is what replay must actually resolve relative operands against,
including a compile-database entry's own @response-file expansion:
build_context.py's existing parser currently expands response files
against the recorded directory before stripping/applying launcher
prefixes, which is the identical class of bug this field exists to
close — response-file expansion belongs after launcher-prefix resolution,
against effective_directory, exactly like every other relative operand.
recorded_directory stays the raw, unmodified compile-database value for
identity/provenance. environment is a small structured type, not a bare
mapping (or a mapping-vs-cleared-sentinel union — a first pass at this
model tried exactly that and it still couldn't represent GNU env's real
grammar, env [OPTIONS] [NAME=VALUE]... COMMAND, which lets -i/-u and
inline assignments compose in one invocation: env -i CPATH=/sdk clang
... clears the inherited environment and then sets one variable on top;
env -u CPATH clang ... removes a single named variable, independent of
any clearing; either shape can carry any number of ordinary NAME=VALUE
assignments alongside it):
@dataclass(frozen=True)
class EnvironmentOverlay:
clear_inherited: bool # a clear occurred somewhere in the prefix chain
unset: frozenset[str] # names removed AND NOT later reset — final state
assignments: tuple[tuple[str, str], ...] # NAME=VALUE surviving to the driver, in argv order
A plain {} (or a single cleared-vs-not sentinel) can't carry this: it
can't distinguish "no override recorded" from a real env -i clear, can't
hold an assignment applied on top of a clear in the same invocation, and
can't name which variable(s) an -u removed — and the distinction matters
concretely, since CPATH/C_INCLUDE_PATH/CPLUS_INCLUDE_PATH directly
alter compiler include search, not merely driver lookup the way PATH
does. Without this, replay would have to rescan original_argv itself
(defeating the parse-once contract again) or risk resolving a different
include search than the real build used. This generalizes, rather than
replaces, _argv.py's existing _EnvPathCleared sentinel and
path_cleared state — but the equivalence is narrower than "OR the two
flags": "PATH" in unset alone is always correct (an explicit unset always
means "no PATH"), while clear_inherited alone is not — a clear
followed by a later PATH=... assignment in the same folded chain (env -i
PATH=/sdk clang ...) leaves PATH genuinely set, not cleared, since the
fold (per the ordering note above) already applied the assignment on top
of the clear. The correct equivalence is "PATH" in unset or
(clear_inherited and "PATH" not in {name for name, _ in assignments}) —
"cleared and never reassigned." The parsed model should carry what a real
env invocation can express, not a simplified projection of it.
unset and assignments are not independently-collected sets that a
consumer merges later — nested launchers can make identical raw field
contents describe opposite final environments depending on order (env
FOO=x env -u FOO clang ... ends with FOO absent; env -u FOO env FOO=x
clang ... ends with FOO=x; both are two real, distinct, legal GNU env
invocations). The single parse must fold the whole launcher-prefix chain
in argv order — the same left-to-right traversal _argv.py's
_traverse_env_and_launcher_prefix() already performs — into one
normalized final state before populating this dataclass: assignments
holds only the value each name has after every later operation in the
chain, unset holds only a name removed and never subsequently
reassigned, and a later -i clears whatever assignments/unset state
the fold had accumulated so far, not merely the ones this dataclass would
otherwise expose. Once folded, the two fields are safe to read
independently — the ordering risk lives entirely in how they are
produced, not in what they represent once parsing is done.
Raw compiler-command parsing happens once; replay, ambiguity detection, build-option drift, and reporting all consume the structured fields instead of re-scanning argv.
P1 — Dependency direction and CLI leakage¶
scan_engine.py — documented as the shared engine for both CLI and typed
API — still imports click, raises click.ClickException, prints via
click.echo, and imports helpers from cli_scan_baseline/
cli_scan_helpers. service_input_resolution.py imports
_is_inputs_pack_dir from a CLI helper module. service.py uses a dynamic
importlib import specifically to stay invisible to the static
import-cycle checker rather than resolve a real cycle. Target architectural
rule, to become a real check_ai_readiness.py-style gate (Phase 0 below):
models / leaf utilities
↑
domain primitives
↑
artifact / compare / scan engines
↑
service/application operations
↑
CLI / Python facade / Action / compat adapters
Engine modules may not import click; engine/service modules may not
import cli_*; CLI modules may not call dumper.dump, checker.compare,
or service.resolve_input directly (the cli-contract gate now enforces
all three, allowlist-and-shrink over today's pre-existing call sites — see
Phase 0 item 2 below); frontends only build requests, call
application operations, render results, and translate exceptions;
pack detection belongs under buildsource, not a CLI helper module;
progress notification uses callbacks/events, not click.echo.
Smaller, concrete duplication¶
Verdict ordering is independently re-derived in BundleDiffResult,
the release summary rollup, and the aggregate rollup (aggregate additionally
carries its own legacy exit map). Separate CompatibilityVerdict ordering
(NO_CHANGE < COMPATIBLE < COMPATIBLE_WITH_RISK < API_BREAK < BREAKING)
from OperationalState (SUCCESS / NOT_COMPARABLE / ERROR / UNAVAILABLE) —
an operational state should never be spliced into a string-keyed
compatibility ordering; let rollup policy decide how one dominates or
coexists with the other.
Bundle findings are lowered too early. BundleFinding mirrors Change
and then flattens bundle attribution (consumer/provider) into the
description string purely to reuse existing reporters. A FindingLike
protocol that keeps subject/attribution as structured fields would let
bundle-specific data stay data instead of becoming a formatted prefix.
What should explicitly not be unified¶
This plan is about collapsing decisions that are duplicated, not about erasing legitimate domain differences. Keep separate:
- ELF, PE, Mach-O, DWARF, PDB, BTF, and CTF parsers
- CastXML and Clang extraction backends
- pair-only decisions (old/new extraction concurrency, pair-wide
language-standard reconciliation) —
service_compare_pipeline.py's own module docstring already explains why these stayed out of the per-input primitives inservice_input_resolution.py - bundle symbol-resolution analysis versus per-library ABI detection
- ABICC's report shape and external exit-code compatibility contract
- JSON, Markdown, SARIF, JUnit, and HTML serializers
- identity rules that genuinely differ by entity type (functions,
variables, types — see
finding_identity.py's own tiered design)
The boundary: different backends may collect facts differently, but must return the same typed domain models, and must never independently decide configuration, evidence depth, verdicts, gates, or report semantics.
Target architecture¶
CLI / Python API / ABICC / GitHub Action
│
▼
Frontend adapter
parse syntax; no decisions
│
▼
OperationRequest
│
▼
resolve_operation()
│
▼
ResolvedOperation
┌────────────┼────────────┐
│ │ │
artifact plan effective set/matrix
config plan
└────────────┼────────────┘
▼
execute_operation()
│
▼
RunResult
snapshots + raw findings
│
▼
evaluate_result()
│
▼
EvaluatedResult
verdicts + scope + coverage
+ ExitDecision
│
▼
build_report_model()
│
▼
ReportEnvelope
┌──────┬──────┬──────┬──────┐
▼ ▼ ▼ ▼ ▼
JSON MD SARIF JUnit HTML
One producer, many projections — not several producers kept equivalent through ongoing parity fixes.
Implementation sequence¶
Phase 0 — Architectural guardrails first¶
Add tests establishing the desired ownership before moving more code,
mirroring how scripts/check_ai_readiness.py's import-cycle-growth and
cli-contract checks already work (baseline-and-shrink, not
block-everything-immediately):
- No
scan_engine,service*.py(including bareservice.py),artifact_*, orbuildsourceengine module importsclickorcli_*. Implemented:scripts/ check_ai_readiness.py'sengine-cli-boundarycheck (allowlist-and-shrink,ENGINE_CLI_BOUNDARY_ALLOWLIST;tests/test_engine_cli_boundary.py). Known residual gap, not yet closed: the check is AST-based and can only see a realimport/fromstatement, so it cannot catchservice.py's ownimportlib.import_module()escape hatch — used specifically because that dynamic form is invisible toimport-cycle- growth's AST walk too (see AGENTS.md's own note on why). Closing that needs either a literal-string-argument scan forimportlib.import_modulecalls naming acli_*/clicktarget (a real but narrower AST pattern than the static-import case) or removing the dynamic import once Phase 1 resolves the cycle it was working around — left as a follow-up rather than attempted as a drive-by widening of this check's first version. - No CLI or
compatmodule callschecker.compare,dumper.dump, orservice.resolve_inputdirectly (extends the existingcli-contractgate, which previously only coveredchecker.compare). Implemented:scripts/check_ai_readiness.py'scheck_cli_contractnow walks a generic_TIER1_TARGETStable (checker.compare,dumper.dump,service.resolve_input) over everycli*.py,appcompat.py, andcompat/cli.pymodule (the last was previously outside this check's own_iter_cli_contract_sources()scan entirely, despite being exactly the "nested front end" this item names), allowlist-and-shrink (CLI_CONTRACT_ALLOWLIST, pre-populated with the seven pre-existing, already-documented call sites this same plan's P0/P1 sections name:cli_dump_helpers.perform_elf_dump,appcompat.check_appcompat's two dump calls,cli_scan_baseline's baseline resolution, andcompat/cli.py's three direct calls).cli_resolve.py's own_resolve_input()— the CLI's designated, framework-aware wrapper overservice.resolve_input(see its module docstring) — is the one exemption from theservice.resolve_inputrule, the same roleservice.pyitself already plays forchecker.compare.tests/test_cli_contract.pymirrors both the generalized detection (an aliased-import and an aliased-module-call case per new target, plus a not-flagged case for the sanctioned wrapper) and atest_cli_contract_allowlist_entries_are_real_violationsfreshness check so a fixed call site can't leave a stale allowlist entry behind unnoticed. - Every artifact extraction call site for the seven user-facing
operations and the two internal supplementary call sites named in
Phase 1 routes through the future artifact application service.
Deliberately excludes
probe_harness. _snapshot_object_file(): it has no CLI/API entry point today (nothing outsideprobe_harness.pyand its own tests callsrun_probe_matrix()), so it is a backend-level exception recorded here explicitly, not a call site this guardrail can silently forget — if a real user-facing command starts callingrun_probe_matrix(), that command's routing becomes a Phase 1 item at the same time, not a drive-by addition to this guardrail's allowlist. - Every completed-operation exit of a modeled compatibility-analysis
command — one of the operations
ExitDecision's axes actually cover (compare,scan, release, aggregate,compat check,deps compare) — derives from anExitDecision(Phase 3). Named ascompat checkspecifically, not barecompat: the group has a second subcommand,compat dump, which only creates an ABI snapshot and has no evaluated compatibility result — the same fabricated-state problem the next sentence already rules out for nativedump, so it gets the identical treatment rather than being silently swept in under the group's name. Standaloneappcompatis deliberately excluded from this command-exit list, not merely unmentioned:appcompat.check_appcompat()has no registered CLI command or process exit of its own to derive one from (cli_options_contract.py'sVERDICT_EMITTING_COMMANDSrecords that it was folded intocompare --used-byper ADR-043, andcheck_appcompat()is a Python helper returningAppCompatResult, not an exit-code-bearing command) — a caller reaching it throughcompare --used-byis already covered bycompare's ownExitDecision, and a direct Python-API caller ofcheck_appcompat()wants a result-shape guarantee (does the comparisoncheck_appcompat()runs agree with every other comparison path), not a process-exit guarantee this guardrail models. That is a distinct, API-level requirement, already tracked separately below under "Comparison equivalence" (which namesappcompat.check_appcompat()'s andcheck_plugin_host_contract()'s own pre-scope comparisons explicitly) — not folded into this command-exit list.dump(native) is deliberately excluded from this list, not merely unmentioned: a plaindumpperforms no compatibility evaluation at all — its own target pipeline (P0's artifact-resolution section above) ends atArtifactResult, never at an evaluated compatibility result, and Phase 3's per-operation policy list has noDumpExitPolicyfor exactly that reason. Requiringdump's exit to derive fromExitDecisionwould force fabricating compatibility-evaluation state a bare extraction command never has. Scoped deliberately in two further directions: (a) a bad invocation or an aborted run has no evaluated result to derive a decision from, andcli.py's_AbicheckGroup.mainalready, correctly, maps those before any operation runs (ClickUsageError→_EXIT_USAGE_ERROR/64,click.exceptions.Abort→ 1); (b) aproject validate/project validate-build/project plan-family command (cli_project.py) builds its own evaluated report and exits0 if report.ok else 1on a questionExitDecision's axes don't model at all (config/build-manifest validity, not ABI compatibility) — this guardrail must not force any of these three shapes intooperational_error, or demand a permanent, unreviewable exception for any of them. - Every persisted compatibility-analysis report (the same modeled
operations as item 4) is built from a
ReportEnvelope(Phase 4). Scoped identically and for the identical reason:project validateandproject validate-build(cli_project.py) persist their own validation report via--outputtoo, but that report has no ABI findings, no full/effective evaluation, and no compatibilityExitDecisionto carry — it answers a config/build-manifest validity question, not this guardrail's question. Forcing it throughReportEnvelopewould mean fabricating compatibility semantics a config-validity report doesn't have; a project-config report is either out of scope for this check or, if it should eventually gain its own generic envelope, that is its own design question left to aproject-specific follow-up, not solved by stretchingReportEnvelopeto cover it. - Every effective evaluation carries a digest (Phase 2). True for its
natural scope (
compare,scan,release,aggregate) as of this audit:compare(service_render.py→reporter.to_json/to_stat_json→add_effective_config_digest,reporter_contract_blocks.py),scan(cli_scan_baseline.pycalls the same helper directly on its own summary dict), andrelease(the per-library fan-out's own persisted reports go through the samereporter.to_json) already carried the digest before this audit.aggregatedid not —AggregateResult.to_dict()'s per-target roll-up (_LoadedReport→TargetReport) silently dropped each target's own already-computed digest rather than carrying it through. Closed: both dataclasses gained aneffective_config_digest: str | None = Nonefield (declared last, matching this file's own established positional-construction-safety convention forTargetReport/_LoadedReport), read straight off the loaded per-target report JSON in_load_report_file— never recomputed, sinceaggregate.pyholds none of theDiffResult/SeverityConfigevidenceeffective_config_fields/add_effective_config_digestare typed against. Regression coverage:tests/test_aggregate_effective_config_digest.py(a sibling oftest_aggregate_analysis_assurance.py's own split, for the identical file-size-cap reason —test_aggregate.pywas already at 1982 lines). Two operations are deliberately excluded from this item's scope, not silently unmentioned (mirroring item 4'sdump/appcompatexclusions above):compat checkdoesn't get the digest by design, not oversight —compat/cli.py's_generate_compat_reportcallsto_json(r, include_exit_decision=False)deliberately, since the existing digest'sgate.exit_code_scheme/gate.severity.*fields describe onlycompare's legacy/severity scheme and say nothing aboutcompat's own-strict/-source/-binary/-warn-newsymtransform options — emitting the same digest for two behaviorally-differentcompatruns would be actively misleading, not merely incomplete.deps comparehas no digest at all: it builds its report from aStackCheckResult(abicheck/stack_checker.py/stack_report.py), not aDiffResult—effective_config_fields/add_effective_config_digestare typed against the latter, so closing this gap needs either generalizing that machinery to acceptdeps compare's own policy/severity-equivalent knobs or a new stack-specific digest function mirroring the existing one's shape, a moderate-to-large design task left as a documented residual rather than attempted here.
Each check starts with a reviewed allowlist of acknowledged pre-existing
violations, the same pattern IMPORT_CYCLE_ALLOWLIST already uses — the
list must only shrink, and a new entry requires the same sign-off bar
AGENTS.md already sets for that allowlist (an ADR or explicit maintainer
sign-off, not a routine PR).
Phase 1 — Finish artifact-resolution convergence¶
- Started (Milestone A).
abicheck/artifact_plan.pyintroducesResolvedArtifactPlan— a real, independently-tested context-managed session (tests/test_artifact_plan.py) owning thelist[Callable[[], None]]cleanup accumulator every resolution call site used to thread and drain by hand. Wired into exactly one already-isolated, already-well-tested call site as proof:cli_dump_helpers. perform_elf_dump()'s_l2_pending_cleanupsaccumulator is now aResolvedArtifactPlaninstance, with identical cleanup thunks (seed_includes_and_fold_compile_context(..., pending_cleanups= _artifact_plan.pending_cleanups)) and identical timing (drained viarun_cleanups()at the exact two points the old code called_run_cleanups()— immediately on a failed header parse, or after the whole post-dump pipeline completes) — a behavior-preserving refactor, not a new resolve/execute split. Existingperform_elf_dumpcoverage (tests/test_cli_dump_helpers_coverage.py, includingtest_perform_elf_dump_wraps_dump_errors_still_cleans_up_seeded_dirs) passed unmodified against the migration. Milestone A follow-up.handle_non_elf_dump's identical_l2_pending_cleanupsaccumulator (the PE/Mach-O dump path) is now migrated the same way — its own, separateResolvedArtifactPlaninstance (the two functions are independent dump paths never invoked together, so nothing here makes them share a session), identical single-drain timing (the onefinallythis function's cleanup ever ran from). Existing coverage (tests/test_non_elf_dump_l2_seed.py,tests/test_cli_dump_helpers_coverage.py) passed unmodified. Milestone A completion.service_input_resolution._resolve_side_snapshot_impl's own hand-rolledcleanups: list[...] = [] - manual
if cleanups: _run_cleanups(cleanups)finally block — the third and, per this item's own earlier audit, last known call site with this exact pattern — is migrated the same way: its ownResolvedArtifactPlaninstance,_seeded_includes_and_compile_context's returned cleanups list extended onto_artifact_plan.pending_cleanups(that helper's own return contract is unchanged — only what the caller does with the returned list changes), drained viarun_cleanups()at the identical point the old code called_run_cleanups(). This is the shared primitivecompare's implicit-dump operand anddump's typedexecute_dump_requestboth already route through (resolve_side_snapshot), so this migration reaches both without touching either of their own call sites. Existing coverage (tests/test_header_compile_context.py,tests/test_bazel_root_targets_l2_seed.py,tests/test_bazel_root_targets.py,tests/test_scan_l2_cleanup_ordering.py,tests/test_typed_dump_request.py,tests/test_dump_cli_typed_api_parity.py,tests/test_header_compile_context_gcc_path.py,tests/test_header_compile_context_merge.py,tests/test_clang_public_roots_coverage.py) passed unmodified. All three call sites the plan's own earlier audit named now share this one primitive.
Milestone B (this slice): investigated item 1's "full shape" against
the real code before writing anything, and found a real conflict with an
already-documented, deliberate design decision — not an oversight to
fix. Item 1's own text (kept below) asks ResolvedArtifactPlan to own
any resource resolution allocates "from resolve_artifact_request()
onward," and to carry the resolved-fact fields the target-architecture
section lists. Two of those fields — effective include search and
effective compile context — are only known once the L3→L2
compile-context fold runs
(buildsource.l2_seed.seed_includes_and_fold_compile_context), and that fold is the one step
in this whole pipeline that can allocate the inferred-build temp
directory this session type exists to own. service_dump_pipeline.py's
own resolve_dump_request()/execute_dump_request() split (G33 Phase 5,
already landed, independent of this plan) already answers where that
fold runs today: deliberately inside execute_dump_request, never
resolve_dump_request — ResolvedDumpRequest's own docstring states why
verbatim: dump --dry-run's existing contract is to never raise on
anything but a usage error, and the fold can raise
HeaderCompileContextAmbiguousError on genuinely ambiguous build
evidence. Moving the fold into resolution to satisfy item 1's literal
text would mean either breaking that already-reasoned dry-run guarantee,
or redesigning it to tolerate a raise — a real behavior change to
already-shipped, already-reviewed code, not a clean generalization.
What was safe to land, and did: the fields item 1's target shape
names that genuinely are knowable without the fold — normalized
binary format, language, requested/effective header-AST backend,
requested depth, effective collect mode, public-header scope — are
exactly the facts resolve_dump_request() already computes for
ResolvedDumpRequest. abicheck/artifact_plan.py's ResolvedArtifactPlan
(Milestone A's cleanup-owning session type) now also carries these as
optional, keyword-only fields, all defaulting to None/() so the three
existing Milestone A call sites' bare ResolvedArtifactPlan()
construction is completely unaffected. resolve_dump_request() attaches
one, populated verbatim from the same values it already returns on
ResolvedDumpRequest — a new, additive artifact_plan field on that
dataclass (defaulted, so no existing caller breaks), built with an empty
pending_cleanups (resolution allocates nothing today, so that is an
honest report, not a placeholder). This is genuinely inert data today —
nothing yet reads resolved.artifact_plan — but it means a future
consumer of the general shape (the eventual render_dump_dry_run()
migration named in AGENTS.md's "PR C" entry, most concretely) has one
object to build from instead of ResolvedDumpRequest's own dump-specific
one, without this dataclass's field surface changing again when that
lands. Regression coverage: tests/test_artifact_plan.py (default
construction unaffected; resolved facts stored verbatim; a plan carrying
resolved facts is still a fully functional cleanup session) and
tests/test_typed_dump_request.py::TestResolveExecuteDumpRequestSplit::test_resolve_attaches_a_matching_artifact_plan
(the attached plan's
fields never independently drift from ResolvedDumpRequest's own —
confirmed to fail against the pre-change code).
Still not done, and now understood to be blocked on a real design
decision rather than merely unattempted: there is still no
resolve_artifact_request()/execute_artifact_plan() function pair
(only resolve_dump_request()/execute_dump_request(), dump-specific);
dump --dry-run still doesn't build from or render a
ResolvedArtifactPlan; and none of items 2–10 below have been
attempted. Closing the resource-lifetime half of item 1 for real needs
one of: (a) redesigning dry-run's own contract to tolerate the fold's
possible raise (a considered, documented behavior change, not a
generalization); or (b) accepting that the fold's resource lifetime
stays scoped to execution only, and treating item 1's own "from
resolve_artifact_request() onward" language as applying to the
resolved-fact fields (now done) rather than to every resource a
pipeline might ever allocate. Neither was decided here — recorded as an
open design question for whoever picks up item 1's remaining resource-
lifetime half, rather than guessed at under continued session pressure,
per this plan's own "known gaps over risky reactive patches" convention.
- (Full shape, not yet reached) Introduce
ResolvedArtifactPlanas a context-managed session that owns any resource resolution itself allocates (e.g. an inferred-build directory) fromresolve_artifact_request()onward — not scoped toexecute_artifact_plan()alone, since dry-run resolves without ever executing and must still close the same session. - Move
perform_elf_dump's remaining post-processing hooks (ADR-039 build-context collection, the header-graph second pass, the optional clang-layout-tool attach) into explicit post-processing stages against the new plan/result shape. - Route native
dumpthrough the typed artifact pipeline (closing the long-open "dumpdoesn't build aDumpRequest" gap named in G33 and CLI-cleanup-phase-two's PR C). - Route scan candidate and native baseline through the same pipeline.
- Route PE/Mach-O through the same orchestration while preserving backend-specific extraction.
- Route
appcompat.check_appcompat()'s standalone dump-both-sides path through the same pipeline too, so a direct caller of that function gets the same resource lifetime, compile-context fold, and cache-relevant pathscompare's own app-usage scoping already benefits from. - Route
deps compare's per-dependency_run_abi_diff()through the same pipeline, and fold its loadability/ABI-risk exit computation (cli_stack.py's ownsys.exitcalls) into Phase 3'sExitDecisionwork rather than leaving it as yet another independent exit-code path. - Route
l0_export_delta.collect_l0_export_delta()'ssymbols_only=Truesupplementary re-extraction, andscan_engine._load_exports_for_poi()'s ownsymbols_only=TruePOI prepass, through the same pipeline. Explicit, separate step rather than assumed-covered by steps 3/4:compare's andscan's own primary-side resolution moving ontoResolvedArtifactPlandoes nothing for either of these secondary calls, since both resolve independently, ahead of or after the primary sides are already settled. - Make dry-run render the resolved plan.
- Delete the now-redundant duplicated seed/fold/resolve paths this closes over (several are already named as follow-ups in AGENTS.md's L3→L2-fold entry).
Highest-value phase: it removes both correctness duplication (the
include_sequence/comparability-mismatch class of bug) and real
performance duplication (redundant inferred build queries).
Phase 2 — Make resolved configuration the runtime contract¶
- Started. Item 1's own target shape needed re-scoping before any code
could land — recorded below rather than guessed at, per this plan's own
"known gaps over risky reactive patches" convention (AGENTS.md), and the
first slice of the corrected scope has since landed.
Investigating item 1 (introduce
EffectiveEvaluationConfig) against the actual codebase — not just this document's own sketch — found the target shape overlaps far more with an already-existing object than this section's original text accounted for:abicheck/compatibility_evaluation_config.py'sCompatibilityEvaluationConfig(ADR-049 D7) already composespolicy(CompatibilityPolicyConfig),gate(GateConfig—exit_code_scheme,severity: SeverityConfig,preset,packs),contract(ContractConfig),assurance(AssuranceConfig),surface(SurfaceConfig),evidence(EvidenceConfig), andsuppressions(SuppressionConfig | None) — essentially every sub-object this section's ownEffectiveEvaluationConfigsketch names, under different but directly corresponding field names, plus real per-fieldValueProvenance(D7's precedence-tier record) this section's sketch only gestures at via a singleprovenance: ConfigProvenancefield. The "Relationship to in-flight work" section below already says as much at the plan level ("Public contract default (ADR-049) is the compatibility-configuration resolver Phase 2 makes the sole runtime contract... this plan does not change ADR-049's own D7/D8 precedence rules, only how uniformly the result of applying them reaches every operation") — but item 1's own code sketch, written before this closer look, reads as "introduce a new, separate dataclass," which would recreate exactly the kind of duplication this whole plan exists to eliminate, and directly contradicts the file it sits in a few lines below its own sketch.
What was genuinely missing from CompatibilityEvaluationConfig,
confirmed by grep rather than assumed: (a) GateConfig had no
require_complete_analysis field — that flag is threaded as a raw
bool through a long, independent chain of function signatures
(cli.py, cli_compare_helpers.py, cli_compare_options.py, ~15+ call
sites) with no typed home at all; (b) no scope
field for ADR-043's --used-by/--required-symbol scoped-gate
selection either, and there was no existing ScopedGateSelection-shaped
type anywhere in the codebase to reuse — representing that scope
correctly (a real union of "no scope" / "used-by consumer" /
"required-symbol entrypoint", each carrying its own resolved target
list) needed its own small design pass, not a one-line addition; (c) no
whole-object digest field or method — effective_config_digest.py
already computes one, but as an external function over a two-tier
input (DiffResult's own fields, only reading a
CompatibilityEvaluationConfig when one happens to be attached under
--contract/--pack), not a method on this object; and (d) — the
deepest gap, still open — CompatibilityEvaluationConfig is
deliberately opt-in today (built only when --contract/--pack
selected something, documented as an explicit ADR-049 Phase 1-6 design
choice in both that module's docstring and effective_config_digest.py's
own "two tiers" docstring), while this phase's whole point is a runtime
contract resolved for every comparison unconditionally.
First slice landed: (a) and (b), additively. GateConfig gained
require_complete_analysis: bool = False and
scope: ScopedGateSelection | None = None, both defaulting to "no
effect" so every existing zero/keyword-arg GateConfig(...) constructor
(compatibility_evaluation_frontend.py, contract_context.py,
contract_context_io.py) keeps working unchanged — confirmed by the
full existing test suite for this module passing unmodified (153 tests)
plus the broader compatibility-evaluation/digest/pack/contract-context
suites (1224 tests). ScopedGateSelection is a new frozen dataclass in
the same module (kind: str validated against
{"used_by", "required_symbol"}, targets: tuple[str, ...]), typed to
match the
encoding effective_config_digest._gate_scope_str had already
established informally (reading DiffResult.gate_scope/used_by/
required_symbols and JSON-encoding {"kind": ..., "targets": ...}) —
this new type doesn't invent a shape, it names one that already existed
as an untyped convention in one function. New test classes
(TestGateConfigRequireCompleteAnalysisAndScope,
TestScopedGateSelection) in
tests/test_compatibility_evaluation_config.py cover both fields'
defaults, validation, and (for ScopedGateSelection)
frozen/equality/order-preservation semantics.
Deliberately not yet done, in this slice: neither field is wired to
anything yet — no resolver reads the raw require_complete_analysis
bool or the raw used_by_apps/required_symbols tuples
(cli_compare_helpers._apply_scoped_gating) and constructs a
ScopedGateSelection/populates GateConfig.scope from them; the ~15+
raw-bool call sites are untouched. That wiring is real, separate work —
it's exactly what items 2-6 below need to do anyway (compare/scan
resolving this object, not merely consuming one that happens to
exist), so doing it here piecemeal, ahead of a real resolver, would mean
redoing it once that resolver lands. (c) (the digest computation) and
(d) (making resolution unconditional) remain fully open — the latter is
the deepest and largest remaining piece, described next.
A round of review on this slice's JSON round-trip (contract_context_io.py)
converged through four increasingly narrow findings before surfacing a
fifth that is real but not yet reachable — worth recording rather than
chasing with a fifth patch. The first four (both new GateConfig
fields omitted from the round-trip entirely; a present JSON null
silently defaulting instead of being rejected; a schema_version >= 2
payload silently defaulting an absent key instead of rejecting it;
the identical absence gap one level down in a present gate.scope
object's own targets key) were all fixed, each with regression tests
confirmed to fail pre-fix — see the four fix: commits on PR #817 for
the detail. The fifth: evaluation_context_to_dict() writes a block's
schema_version verbatim (by design — see the module's own "version
fields survive verbatim" docstring), so a hypothetical caller that
decoded a genuinely legacy (schema_version == 1) persisted context
and then attached real, non-default require_complete_analysis/scope
values to its resolved_config.gate before re-serializing would
produce a payload that mislabels itself — a real, genuinely-populated
v2 gate field under a v1 stamp, which a hypothetical old reader
(predating this PR) would silently ignore regardless of the label,
since it has no code path for the field's existence at all, not just
its absence. Confirmed unreachable by any current production code
path: the one call site that reconstructs a GateConfig post-decode
(with_resolved_gate, cli_compare_receipt.py) always operates on a
freshly-built PersistedContractContext from a live compare() run
(via build_persisted_context/with_resolved_config), never on one
decoded from an old persisted payload — and, per this same slice's own
"Deliberately not yet done" note two paragraphs up, nothing in the
current codebase constructs a non-default require_complete_analysis
or a real scope from real input yet, so the specific combination this
finding describes cannot occur today regardless of which context a
caller starts from. A correct fix also can't simply auto-upgrade the
label on write (silently re-stamping schema_version to 2 the moment a
v2 field is populated) without contradicting this module's own
documented "version fields survive verbatim" invariant one paragraph
above the evaluation_context_to_dict this finding is about — the
right fix is a write-side rejection of the mismatched combination,
which is a real design decision (where the check lives, what error type
it raises, whether it belongs in this leaf serializer at all versus a
caller-side invariant) rather than a mechanical extension of the
absent-key pattern the first four rounds established. Left for the
resolver work items 2-6 below to pick up once a real caller populates
these fields from real input — the point at which this combination
first becomes reachable and a concrete design has real call sites to be
verified against, rather than being designed against a hypothetical one
now.
The right further scoping for item 1 is additive to the existing
object rather than a parallel one: add a digest computation onto
CompatibilityEvaluationConfig itself (folding in
effective_config_digest.py's existing hashing logic rather than
duplicating it), and make resolving one unconditional for every
comparison — collapsing effective_config_digest.py's two-tier
"rich or baseline" split into the rich tier always, once every
comparison path actually populates the object it currently only
populates opportunistically. That last part is the real work items 2-6
below already describe (compare, scan, the release fan-out, and the
four comparison call sites item 5 names all need to resolve this
object, not just consume one that happens to exist) — so this
re-scoping doesn't shrink the phase, it corrects what object items 2-6
are migrating onto. docs/contribute/plans/public-contract-default.md
(ADR-049's own plan doc) is the right place to check for whether "make
CompatibilityEvaluationConfig universal" is already an open item
there — a keyword sweep of it (universal/opt-in/unconditional)
found no existing item phrased that way; its own phases (through
Phase 7's "default flip") are about whether contract evaluation
defaults on, a related but distinct axis from whether the
configuration object itself always resolves regardless of
--contract/--pack — so this looks like a genuinely new item for
that plan or this one to own, not a duplicate of an existing one, but
the sweep was keyword-based over a 4000+-line document, not a full
read, so treat that as a strong lead rather than a settled fact before
committing to it.
2. Move compare to consuming it directly.
3. Move scan to the same object.
4. Move the release fan-out off its six raw gate/severity strings.
5. Move appcompat.check_appcompat()'s and check_plugin_host_contract()'s
own direct compare_snapshots() calls, deps compare's
stack_checker._run_abi_diff()'s direct checker.compare() call, and
cli_compare_release._collect_matrix_result()'s own direct
compare_snapshots() call (release's probe-matrix build-configuration
comparison, which independently loads suppression/policy/pack state
rather than reusing the per-library release fan-out's already-resolved
configuration) onto EffectiveEvaluationConfig too — without this step,
the Comparison equivalence acceptance test's requirement that these four
paths produce identical configuration digests, contract/assurance
decisions, and exit contributions stays unimplementable even after every
other Phase 2 item lands, since item 4 (the release fan-out's six raw
gate/severity strings) covers only the per-library comparisons, not this
separate release-global one, and none of the other items touch it either.
6. Include the effective-config digest in every report and every
aggregate input (building on the reporter's existing digest work from
CLI-cleanup-phase-two's PR B).
7. Keep compatibility wrappers only at public API boundaries (the typed
Python API's existing dataclasses stay stable; only their internal
plumbing changes).
Phase 3 — Complete ExitDecision¶
- Model every exit axis named above.
- Separate priority from numeric code.
- Add the per-operation exit policies.
- Publish all contributions in reports.
- Remove aggregate's scan/report-type-specific heuristics.
- Keep ABICC's external exit-code mapping as
AbiccExitPolicy.
Phase 4 — Introduce the canonical report model¶
- Build findings, scope, verdict, coverage, assurance, dependencies, and exit before any rendering happens.
- Migrate JSON rendering first (it's the format every other renderer and
aggregateitself already treats as authoritative). - Migrate SARIF, JUnit, Markdown, review, and HTML.
- Remove the "render → parse → patch → render" functions this obsoletes.
- Make release and aggregate consume or embed the same envelope.
Status against ADR-061 Phase 2 (2026-09-07 re-assessment). Items 2, 3
and 4 have effectively landed on ADR-061's side, and item 1 has landed for
its two named halves — the gate decision
(policy/gate_decision.gate_decision_for_result) and the per-finding
verdict plus the IssueCategory this section's own review round demanded
(report/finding.py's ReportFinding, built once per Change with no
identity-keyed cache, exactly as the paragraphs below specify). Every
format now builds a ReportDocument and projects it purely, and the
scoped-gate render→parse→patch→render fold is gone
(report/scoped_gate.apply_scoped_gate).
What is left is precisely this phase's remaining reason to exist, and it
is now ADR-061's
gap C:
each format builds its own document from the DiffResult, and
service_render.render_output() still dispatches the result, snapshots,
severity configuration and presentation options down six independent paths.
Six separately-built projections wrapped in one immutable type do not prove
they cannot disagree. ReportEnvelope is that missing single document —
compatibility, assurance, scope, dispositions, consumer impact and the exit
decision finalized before format selection, with format builders arranging
presentation and deciding nothing. Build it on ADR-061's existing immutable
containers and projection tests rather than as a second design, and treat
its acceptance test as the ADR states it: render the same completed document
repeatedly and in different format orders, and require identical semantic
content with no renderer re-running extraction, policy evaluation, or gate
resolution. This is closure package 3 of that ADR's own sequence.
Landed (closure package 3). ReportEnvelope exists at
abicheck/report/envelope.py, built once per completed evaluation by
report/build.build_report_envelope and projected by the new
service_render.render_envelope(fmt, envelope); render_output is now
"build one envelope, project it". It carries the shared ReportDocument,
the severity GateDecision (resolved before the document build and handed
into it, so there is one resolution rather than two agreeing ones), one
ReportFinding per Change including scoped_only_changes, and the
presentation-only RenderOptions. Every renderer that previously re-derived
one of those (SARIF's and HTML's gate, HTML's and the review digest's
findings, JUnit's findings, Markdown's surface_changes) now reads the
envelope; SARIF's invocation exit-code fold moved to
report/sarif_invocation.py. The sketch above is the shape that landed with
two deliberate deviations recorded here rather than silently: the envelope's
operational_state/inputs/resolution/effective_config/evidence/
timings/advisories fields belong to this phase's other halves (P0's
ExitDecision model and the not-comparable/error paths) and are not
fabricated here — a single-pair render_output only ever holds a completed
DiffResult; and exit_decision stays where the report already publishes
it (inside the shared document), because the process-exit fold is
frontends' (cli._exit_with_severity_or_verdict), not a renderer's. The
acceptance test is executable in
tests/unit/report/test_build_report_document.py's
TestRendererOrderIndependence.
Item 1's per-finding verdict is not a fresh design question — ADR-061
Phase 2 already scoped it and flagged the one real hazard. Today,
junit_report.py/html_report.py/reporter_markdown.py each independently
resolve a per-change verdict via effective_verdict_for_change/
DiffResult._effective_verdict_for_change at their own call sites (some,
like junit_report.py, call it twice for the same Change from
_is_failure and _failure_type). ReportFinding (the sketch above) is the
right place to hold that verdict pre-resolved, exactly as this phase's item 1
already says — but the one implementation hazard the ADR names is real and
applies here unchanged: Change is a mutable dataclass and is not hashable
(its own __hash__ is None), so a naive "cache resolved verdicts in a
dict keyed by Change" design type-checks fine but raises TypeError the
first time a real Change instance is used as a key — mypy does not
enforce Hashable on dict's key type, so this is a runtime failure, not
a caught-at-review-time one. A ReportFinding built once per Change
during envelope construction (rather
than a separate cache keyed off identity) sidesteps the problem entirely —
build the tuple of ReportFinding by iterating DiffResult.changes once,
resolving each verdict inline, with no separate cache/index structure
needed. DiffResult's own public breaking/source_breaks/compatible/
risk properties are unaffected either way (ADR-061 already ruled out
touching them — a breaking change to the documented public Python API) and
should stay computed the way they are today; ReportFinding.verdict is a
new, additional field, not a replacement for them.
A bare verdict field is not enough by itself — a Codex review round on
this same PR caught that a pre-resolved Verdict alone still leaves
junit_report.py unable to become a pure projection. When a
SeverityConfig is active, _is_failure/_failure_type decide pass/fail
and the reported failure type from classify_effective_change's
IssueCategory — a distinct axis from Verdict (it separately
distinguishes, e.g., a compatible addition from a compatible quality issue,
and a demoted preset can make even a BREAKING/API_BREAK verdict pass) — not
from effective_verdict_for_change's Verdict alone. A ReportFinding
carrying only verdict would still force JUnit to call
classify_effective_change itself under a SeverityConfig, which is
exactly the per-renderer re-resolution this envelope exists to eliminate,
and risks the renderer's own category resolution silently disagreeing with
whatever the envelope's verdict implies. ReportFinding therefore needs a
second field alongside verdict — the resolved IssueCategory (or
equivalently, both branches' final failure classification) — computed the
same way, once per Change, in the same envelope-construction pass; every
renderer reads both pre-resolved fields instead of any one of them
re-deriving the other.
Progress update (2026-09-07, later same day): JSON's report_mode="full"
build is now the first format routed through one shared choke point.
report/build.py's build_report_document(result, ...) performs the
report_mode="full" JSON build exactly once (previously inlined in
reporter.to_json) and service_render._render_json_output calls it
directly, bypassing to_json's own independent pipeline for that mode.
tests/unit/report/test_build_report_document.py proves byte-identical
output against the pre-refactor path and that render_output("json", ...)
invokes the shared build exactly once. This is a real, if partial, step
toward ReportEnvelope/gap C — it establishes the intended shape (one
build function, called once, every consuming format projects its result)
using the existing ReportDocument container rather than a new envelope
type, per this ADR's own preference to build on existing immutable
containers first. Not yet done: Markdown, HTML, SARIF, and JUnit still
build their own documents independently; to_json's --stat/leaf/
root-cause modes are still their own separate builds too (a deliberate,
documented scope exclusion, not an oversight — see ADR-061's own gap C
status note, same date, for why). See that same note for the precise
JSON-full scope.
Progress update (2026-09-07, later same day): Markdown-full and review
now also route through the one shared build.
service_render.render_output()'s markdown branch (report_mode == "full"
only) and its review branch each call build_report_document(result,
...) once and thread the resulting ReportDocument into
to_markdown/to_review_digest -> report/render_markdown_document.py's
build_markdown_document/build_review_digest_document (both gained an
optional report_document parameter; omitting it keeps the prior,
independent-build behaviour for any existing direct caller). Those two
functions reuse the shared document's disposition_audit field instead of
independently calling compute_disposition_audit a second time. This closes
the structural half of the Markdown-full bullet below (one build call,
not two) but deliberately does not attempt the bullet's own harder
half — giving severity_groups' per-group oneline/note_lines shape a
home on the shared document so Markdown could read it instead of deriving
it. On inspection that remaining piece, and every other Markdown-full
section (headline, policy, confidence, etc.), was never actually a
second, independently decided value at risk of drifting from JSON's own:
every classification it depends on (categorize_changes,
gate_eligible_changes, apply_show_only,
_suppress_dangling_correlation_notes, impact_for) is already the
identical shared pure function JSON's build calls — calling the same
deterministic function of (result, ...) twice cannot disagree, only
costs a redundant call. So the genuinely available, byte-for-byte-safe
convergence in this slice was routing-plus-disposition_audit, not a
rewrite of every section to read a JSON-shaped field with no matching
presentation shape. review converges the same way (it already used
report_findings_for for its own impacted-symbol list, unchanged here).
Markdown's leaf/root-cause alternate views are untouched by this slice
and remain their own separate, legitimate documents, same reasoning as
before. Verified against the pre-change output via the markdown/review test
suites plus the full golden suite (byte-identical).
Assessment of the remaining formats (2026-09-07, same session; Markdown-full/
review partially closed later the same day -- see the progress update
above): each needs its own real, per-field convergence work, not a
mechanical swap.
Before stopping this slice of work, each remaining format's own build
function was read in full to judge whether reading from
build_report_document's JSON-shaped mapping would be a safe, close-to-
mechanical substitution or genuinely new engineering. All four were the
latter at the time of this assessment:
- Markdown-full (
report/render_markdown_document.py'sbuild_markdown_document) groups changes intoseverity_groups-- headed sections ("💥 Breaking Changes", etc.) with per-grouponeline/note_lines-- that JSON's flatchanges[]array plus summaryseverityblock does not carry. The underlying decision (each change's verdict/ category) already comes from the same shared primitive JSON's document uses (ReportFinding/report_findings_for, called viaReportModel.from_result) — so this is not a case of a competing decision, but the display grouping itself is a factbuild_report_documentwould need to gain (once, as a shared field) before Markdown could read it instead of deriving it; that is genuinely new shared-document design, not a substitution in the existing build. Update, later the same day: the structural half (one shared build call,disposition_auditreused from it) landed -- see the progress update above;severity_groupsitself still has no shared-document field and stays a Markdown-side derivation, for the reason just given. - HTML (
html_report.build_html_document) has the same shape of gap, one level further:removed/added/changedbuckets, per-section_build_sections_datarows,compat_html's ABICC 2-way verdict bucketing, and its ownnav_bar/summary_table/gate_card/scoped_verdictdataclasses are all HTML-specific arrangements of the same underlying findings, not present in JSON's document today. - SARIF (
sarif.to_sarif) computes onesarif_results[]entry per finding through_result_for(~150 lines) with SARIF-specific shape --ruleId/level/location/fixes/properties-- plus rule-catalog dedup (rules_seen) and its own root-cause/scoped-gate result properties. Several individual facts overlap with JSON's_change_to_dict(evidence status, contract fields, impact assessment), but the SARIF shape (level derivation, rule catalog, location parsing) does not exist in JSON's document and would need to be added there first. - JUnit (
junit_report.to_junit_xml) has noReportDocumentinvolvement at all today (unlike the other three) and derives pass/fail per test case fromReportFinding/SeverityConfigdirectly -- the least-converged of the four, and also the one needing the most net-new design (a JUnit whole-document build/render split has no existing precedent in this codebase to extend, unlike SARIF and HTML which at least already haverender_xml.py/render_html_document.pystructure to build from for other purposes).
None of the four is a same-shape drop-in read of build_report_document's
current fields — each would need (a) new fields added to the shared
document (never derived independently downstream, per this plan's own
rule), (b) that format's own build function rewritten to read those fields
instead of recomputing them from DiffResult, and (c) byte-exact golden
verification against its own pre-existing pinned output (per ADR-061 Phase
2's own durable lesson: "Every closure here was verified by capturing a
byte-exact golden against the pre-refactor path first"). That is real,
separate engineering work of comparable size to JSON's own slice, per
format -- not a mechanical follow-on once JSON's plumbing exists. It was
deliberately not attempted in this session beyond JSON: the risk of an
unverified, rushed rewrite of any of these four (1200+ line files, in
SARIF's/JUnit's case, with no golden-checked intermediate shape yet) regressing
already-pinned output was judged higher than the value of a partial,
unverified attempt at the time. Left for a following, similarly-scoped
session per format, in the order this codebase's own machinery makes least
risky: Markdown-full first (already had the closest-shaped compute_*/
render_* split and shares ReportFinding with JSON already) -- done for
its structural half, later the same day, see the progress update above --
then HTML -- done for its own structural half, in a following slice, see
the progress update immediately below -- then SARIF, then JUnit (still
open).
Progress update (2026-09-07, HTML slice): HTML's default view now also
routes through the one shared build, same structural depth as the
Markdown/review slice, not the full field-by-field convergence this
assessment described above as "real, separate engineering work."
service_render.render_output()'s html branch now calls
build_report_document(result, show_only=..., show_impact=...,
severity_config=...) once and threads the resulting ReportDocument into
html_report.generate_html_report/build_html_document (both gained an
optional report_document parameter, additive only). build_html_document
reuses the shared document's disposition_audit field (via
DispositionAudit.from_dict, the identical round-trip Markdown's own
report_document handling already established) at both of its two
independent compute_disposition_audit call sites — the compat_html
layout's own audit block, and compute_summary_table's audit argument.
Every other HTML fact this assessment's own bullet above named --
removed/added/changed bucketing, _build_sections_data rows,
compat_html's ABICC 2-way verdict bucketing and severity-band tables, and
the nav_bar/summary_table/gate_card/scoped_verdict dataclasses -- was
re-read in full during this slice and confirmed to still be exactly the gap
this assessment already recorded: none of it has a matching shape in
build_report_document's JSON-shaped fields today (JSON's flat changes[]
plus summary severity block does not carry HTML's own bucketing or
severity-band grouping), so genuinely converging it means adding new fields
to the shared document first, deliberately not attempted here -- same
reasoning, and same "not actually a second, independently decided value"
finding for disposition_audit specifically, as the Markdown slice already
established. Also closed in this slice: the ABICC-clone compat_html=True
layout had no golden test at all before it (an acceptance-criteria gap this
plan and ADR-061 both name); tests/golden/html_template/
main_report_compat.html now pins it byte-exact, alongside every
pre-existing HTML golden staying byte-identical. Verified via the HTML test
suite, the full golden suite, and the usual ruff/mypy/ai-readiness/
architecture gates.
Progress update (2026-09-07, SARIF slice): SARIF's default view now also
routes through the one shared build, same structural depth as the HTML/
Markdown slices above -- and, unlike JUnit, sarif.py already had SARIF's
own compute/render split in substance before this slice.
service_render.render_output()'s sarif branch now calls
build_report_document(result, show_only=show_only,
severity_config=severity_config) once, unconditionally (independent of
report_mode -- SARIF's own "root-cause" mode only adds extra per-result
properties on top of the same shape, unlike Markdown's genuinely separate
leaf/root-cause documents, so gating the shared build on report_mode
would have bought nothing), and forwards the resulting ReportDocument
into sarif.to_sarif/to_sarif_str (both gained an optional
report_document parameter, additive only). to_sarif reuses the shared
document's disposition_audit field -- read directly off
report_document.to_mapping(), no reconstruction step needed since it is
already JSON-shaped, unlike HTML's/Markdown's own DispositionAudit.
from_dict round-trip -- instead of an independent compute_disposition_
audit call for its properties.dispositionAudit block. This assessment's
own SARIF bullet above (rules_seen, per-result level/location
derivation, root-cause grouping, scopedGate/severityGate/coverage-
notification blocks) was re-read in full during this slice and confirmed to
still be exactly the gap already recorded: none of it has a matching field
in build_report_document's JSON-shaped structure today, so genuinely
converging it means adding new shared-document fields first, deliberately
not attempted here -- same reasoning as HTML's own remaining facts.
Verified via the SARIF test suite (220 tests), a new
TestSarifReusesSharedDocument class in tests/unit/report/
test_build_report_document.py (byte-identical to_sarif_str output with
and without a supplied report_document, equal dispositionAudit values,
and a build-called-exactly-once assertion for both report_mode="full" and
"root-cause"), the full golden suite, the HTML template golden (shared-
code regression tripwire), and the usual ruff/mypy/ai-readiness/
architecture gates, all clean.
Progress update (2026-09-07, JUnit slice -- the fifth and last of the
named formats): JUnit's default view now also routes through the one
shared build, same structural depth as SARIF's own slice.
service_render.render_output()'s junit branch now calls
build_report_document(result, show_only=show_only,
severity_config=severity_config) once, unconditionally (independent of
report_mode, same reasoning as SARIF's own branch: JUnit's
"root-cause" mode only adds rootCauseId/rootCause attributes to each
<failure> on top of the same shape), and forwards the resulting
ReportDocument into junit_report.to_junit_xml/_build_testsuite (both
gained an optional report_document parameter, additive only).
_add_disposition_audit_properties reuses the shared document's
disposition_audit field via disposition_audit_dict_reusing_document
(the identical helper SARIF's own slice introduced, this time round-tripped
through DispositionAudit.from_dict since JUnit needs the typed struct's
attributes to emit individual <property> elements, the same round-trip
HTML's/Markdown's own report_document handling already established)
instead of an independent compute_disposition_audit call.
This slice's own re-read of JUnit's remaining facts confirms this
assessment's own JUnit bullet above with one refinement: JUnit's per-finding
verdict/category resolution (_is_failure/_failure_type) was already
routed through report.finding's ReportFinding/build_report_findings
primitive by an earlier slice (ADR-061 Phase 2 item 4b, the fix this
assessment's own Phase 4 status section above specifically motivated) --
but as its own separate call, not a value read off the shared document,
because build_report_document's _add_changes_block does not itself
build a ReportFinding set (JSON's changes[] resolves each change's
verdict inline via effective_verdict_for_change, with no per-finding
IssueCategory anywhere in the JSON shape today). Threading that through
for real would mean either rewriting JSON's own _change_to_dict to also
compute and carry IssueCategory -- a change to a format this slice must
leave byte-identical -- or inventing a new, finding_id-keyed field on
ReportDocument for a fact only JUnit needs today; both are the
genuinely-new-shared-document-design category of work this assessment
already named for HTML's and SARIF's own remaining facts, deliberately not
attempted here. JUnit's symbol/testcase tree and root-cause grouping remain
its own computation, same reasoning.
Verified via the JUnit test suite (156 tests), a new
TestJunitReusesSharedDocument class in tests/unit/report/
test_build_report_document.py (byte-identical to_junit_xml output with
and without a supplied report_document, equal disposition-audit testsuite
properties, and build-called-exactly-once assertions for both
report_mode="full" and "root-cause"), the full golden suite, the HTML
template golden, and the usual ruff/mypy/ai-readiness/architecture gates,
all clean. Two acceptance tests named by the original gap-C task were added
in this slice (only meaningful now that all five formats cross the shared
boundary): TestRendererOrderIndependence (render one DiffResult through
json/html/sarif/junit/markdown in two orders; each format's own output is
byte-identical regardless of order, and build_report_document is called
exactly once per format render in either order, verified via
mock.patch(..., wraps=build_report_document) call-count) and
TestSarifAndJunitDecisionBoundary (the honest, narrower sibling of
test_render_html.test_render_html_imports_no_decision_making_module for
sarif.py/junit_report.py, which have no HTML-shaped compute/render
module split today -- see that test class's own docstring for the exact,
current boundary it asserts instead: the disposition-audit reuse path is
what's actually converged, verified by both a call-count guard and an AST
scan of the real call site rather than an import-list check that would be
either false or vacuous for these two files as structured today).
Gap C closure summary across all five named formats (2026-09-07, JUnit
slice). Converged, for real, across JSON, Markdown-full/review, HTML,
SARIF, and JUnit: the decision layer -- per-finding verdict/category, the
gate decision, and the disposition audit -- is resolved exactly once per
render via build_report_document and reused, never re-derived, by every
format for every fact it actually reuses today. Left open, precisely: each
format's own remaining presentation-shaped computation named in its own
progress update above (Markdown's severity_groups/leaf/root-cause; HTML's
bucketing/sections/compat-mode; SARIF's rule catalog/level-derivation/
root-cause/scoped-gate blocks; JUnit's per-finding verdict/category
resolution, testcase tree, and root-cause grouping) -- each would need a
genuinely new ReportDocument field designed and added first, which this
five-slice initiative deliberately did not attempt, matching the standard
this section's own prior updates already set for HTML and SARIF. "Converged"
in this ADR's sense means cannot disagree on a decision, not byte-
identical internal implementation -- most of what remains per format was,
on inspection, never a second independently-decided value at risk of
drifting, just presentation computed over already-agreed facts.
Phase 5 — Migrate compatibility and multi-artifact operations¶
- Make ABICC descriptors adapters into typed requests.
- Express
compat's strict/source-only/new-symbol behavior as evaluation configuration where the shape allows it. - Introduce shared
ArtifactSet,ArtifactPair, andSetComparisonResulttypes. - Share matching and rollup primitives between release,
scan --artifact-set(ADR-056), and bundle operations. - Keep distributed report aggregation a distinct operation, but have it consume the same envelope as everything else.
Phase 6 — Authority transfer and retirement (2026-09-05)¶
Phases 0-5 build owners; this phase makes them the owners and deletes what they replaced. Every item names both a canonical owner and a deletion — see "The explicit retirement table" above, which is this phase's work inventory. Ordered by dependency, not by size:
-
Remove already-obsolete implementations. ✅ Done (T1, 2026-09-05.)
perform_elf_dump()andhandle_non_elf_dump()had no production caller; only their own unit tests kept them alive. Both are deleted, withcli_dump_non_elf.pyandcli_dump_protocols.py. Two things worth carrying forward from doing it: -
Most of what those tests pinned was not unique. The large majority re-asserted, at a retired call site, behaviour the shared pipeline already owns and already tests at its own seam (the ADR-039 collector and the L2 seed/fold cleanup ordering in
test_typed_dump_request.py; theparsed_with_build_contextstamp and its unmatched-database negative intest_header_compile_context.py; the header-graph attach, its--dwarf-only/langnormalisation, the Python/NumPy attach and the AST-memoize scope intest_service_unit.py; the explicit--I-only provenance-widening rule intest_service_input_resolution.py; the legacy-ptoken precedence intest_legacy_compile_db_typed_threading.py). "Rehome the unique assertions" was mostly an exercise in establishing which ones those were — worth budgeting for on the remaining rows. - One assertion could not be rehomed, because the behaviour it pinned
changed with the migration.
perform_elf_dumprouted a-H <dir>operand intodumper.dump'sscope_header_dirs(extraction-contract scope only, ADR-015 provenance tagging deliberately off); the typed request splits-Hwithheader_utils.split_public_header_inputsand passes the directory as a realpublic_header_dirsentry, sodumpnow tags provenance for it — the same thingcomparehas always done with its own-Hlist. Nothing populatesscope_header_dirsfrom a typed request at all any more. That is a convergence, not a regression: the two commands agree where they previously did not, anddump's own--public-header-dirflag (removed earlier as a second way of saying the same thing) is what the behaviour now matches. It is recorded rather than reverted, andtest_dump_cli_execution_behaviors.pypins the current channel with the reasoning attached.
The roadmap loophole half of this item — a declined behavioral change
no longer closing a consolidation item (see "The completion rule this
plan was missing" above) — landed separately as track T2 on 2026-09-05,
enforced by the ledger's schema-2 validator rather than by convention.
2. Finish one complete data-authority cutover. Typedefs and constants
are closest. Establish one stored semantic state per migrated family
(reusing the existing typed Function/RecordType payloads inside the
canonical entity/occurrence model — not copying their fields into a
parallel generic representation), move legacy adaptation to the input
boundary, then delete the runtime dual-index selection. Acceptance is
concrete: typedef/constant comparison no longer builds a legacy index
for a current-format canonical snapshot, and no producer can update the
same family through two writable representations. Do not delete the
fidelity gate first — it currently protects anonymous-scope, identity,
missing-data, and ordering cases; preserve each in the canonical model
and the legacy adapter, then remove the double construction.
3. Finish execution/configuration ownership. Eliminate
execute_dump_request's out-of-band semantic kwargs
(build_config/build_query/build_compile_db/changed_paths/
allow_build_query/legacy_compile_db_tokens/
legacy_compile_db_matched/seed_collect_mode/
source_frontend_from_folded_context) by moving each into the typed
request/resolution model with its provenance; leave only operational
services (progress reporting, cancellation) as parameters. Keep the three
stages distinct — preflight (what is decidable without running
tools), executable plan (inputs, compile contexts, backend selection
and fallback policy as separate fields, scope, resources), observed
result (what ran, what evidence was obtained, what failed). Do not push
post-execution assurance into a pre-execution object, and do not make
dry-run secretly execute. Represent backend selection as
preferred-backend + fallback-policy rather than preserving "auto" as a
magic string carrying hidden control state (a prior premature
substitution to "castxml" changed behavior). Then route standalone
check_appcompat(), stack_checker._run_abi_diff(), and source-only
dump through the shared operations, and consolidate the release
gate-pack fold onto one effective-evaluation object.
4. Migrate fact semantics with explicit provenance and scope. A fact
must record what was observed versus inferred or supplied through a
legacy compatibility path, which producer and observation scope
support it, and whether the claim is a positive observation or a
completeness/absence claim — because status alone cannot separate
"I observed this virtual method" from "I established the complete set of
virtual methods." That is what the PDB vtable_fact=NOT_COLLECTED case
needs and what a bare FactStatus guard could not deliver (round 2
landed, round 3 reverted). Fix it at the model/import boundary, along
with the legacy-hybrid backfill blocker holding the seven
fact_provenance-gated fields, rather than adding more snapshot-level
booleans or reliability side channels. Give declined comparisons a route
into shared analysis accounting (observed changes / evaluated
requirements / unresolved requirements / unsupported requirements)
so a list[Change]-returning detector cannot silently discard the reason
it emitted nothing — a missing optional producer must not make the
whole scan incomplete; assurance is relative to the requested analysis
contract. Then retire the raw legacy fields and reliability flags. For
public Python constructors, choose and document the compatibility
behavior explicitly instead of leaving every detector to infer whether an
omitted argument meant "unknown" or "empty".
5. Expand the proven pattern. Remaining detector families, the export
index and its named views, shared report preparation, and standard
multi-artifact flows. Every cohort must reduce the number of live
legacy readers and writers; a cohort that ends with another populated
sidecar has not landed. The record and function families are the
concrete instance of that last clause and are named here explicitly so
they have an owner: SemanticIR already populates their occurrences on
every header-AST and DWARF producer while their detectors still read
AbiSnapshot.types/.functions. Migration was investigated on
2026-09-03 and declined — but the decline is of the behavioral change
(their matching already resolves through ADR-045's TypeMap and
resolve_function_identity's CANONICAL tier, so changing matching
precedence or published finding IDs would close no defect), not of the
consolidation, which needs no new bug to justify it. Track T3 does not
cover this: its scope is typedefs and constants.
Extend the existing architecture checks rather than adding a planning
system. scripts/semantic_ir_cutover.py's per-cohort registration is the
right shape, but a module-level "no legacy attribute read" rule is not
proof that a family has one authority — the typedef fidelity selector
satisfies that rule while the legacy projection still adjudicates. Extend
each cohort's guard to cover the actual dependency path (callers, adapters,
selectors, producers) and the legacy-writer retirement condition.
A phase item reaches retired only when all four hold:
- all applicable production callers use the owner;
- the replaced decision/data implementation is removed, or reduced to delegation;
- old persisted inputs still enter through an explicit compatibility adapter;
- an architecture check prevents reintroducing the old dependency/read path.
Parallel execution tracks¶
The Phase 6 items above are dependency-ordered, but they are not one serial queue. These tracks touch disjoint modules, have separate test surfaces, and can be executed concurrently by different contributors or sessions. Within a track, the steps are ordered.
| Track | Scope | Touches | Depends on |
|---|---|---|---|
| ~~T1 — Dead-implementation retirement~~ ✅ done (2026-09-05) | Rehomed perform_elf_dump/handle_non_elf_dump's unique assertions onto the live path; deleted both functions, cli_dump_non_elf.py and cli_dump_protocols.py |
cli_dump_helpers.py (-661 lines), cli_dump_non_elf.py + cli_dump_protocols.py (deleted), their tests, architecture/{modules,debt}.yaml, CLI_CONTRACT_ALLOWLIST |
nothing |
| ~~T2 — Ledger/status-model change~~ ✅ done (2026-09-05) | Added the introduced → wired → authoritative → retired ladder and a separate investigated_declined disposition to docs/_meta/one-semantic-pipeline-status.yaml + scripts/pipeline_status_ledger.py's field/enum validation; re-audited every concept row against it. Shipped as ledger schema_version: 2 with the cross-field rules and the re-audit described under "The four-state status model" above |
scripts/pipeline_status_ledger.py, the ledger, tests/ |
nothing |
| ~~T3 — Typedef/constant authority cutover~~ ✅ done (2026-09-05) | Deleted the runtime dual-index construction: typedef_index_pair/constant_index_pair now decide each side of a comparison independently, reading a side's real SemanticIR directly whenever it has one (never both-or-neither — a Codex review round found the first both-or-neither cut would starve an IR-carrying side of its own real evidence whenever the other side lacked one) and falling back to the legacy adapter's projection of that side's own flat collection only when it has none; the identity half of the old fidelity gate (a real IR disagreeing with its own typedef_entity_ids/constant_entity_ids sidecar) moved to the canonical model's load boundary (AbiSnapshot.__post_init__, and re-run explicitly after serialization.snapshot_from_dict decodes a stored IR — a second Codex finding, since that decode bypasses __post_init__), now a hard SemanticIrAuthorityError rather than a silent fallback. A related fix in the same PR: diff_constants was silently dropping a constant addition/removal whenever its value was Fact.unsupported(), since only now reachable with the dual-index gate gone (Codex finding). The old gate's name/value equality half against the legacy alias/value collections is deliberately not preserved anywhere — requiring it would make a populated legacy collection an accidental prerequisite of SemanticIR-only construction, the opposite of authority transfer |
compare/typedefs.py, compare/constants.py, model/semantic_ir_legacy_adapter.py, model/snapshot.py, errors.py, serialization.py, scripts/semantic_ir_cutover.py |
nothing (T2 records it) |
| ~~T4 — Dump request contract~~ ✅ done (2026-09-06) | Fold execute_dump_request's nine semantic kwargs into the typed request; split backend selection from fallback policy; give source-only dump an execution variant |
dumper.py, dumper_toolchain.py, extract/header_ast_backend.py, service_compare_evidence.py, service_dump_pipeline.py, workflows/artifact/dump_request.py, workflows/artifact/dump_execution_options.py, workflows/artifact/execute_source_only.py |
~~T1~~ — satisfied (T1 landed 2026-09-05) |
| ~~T5 — Direct-bypass migration~~ ✅ done (2026-09-05) | Routed appcompat.check_appcompat() and stack_checker._run_abi_diff() through service.run_dump/service.compare_snapshots instead of calling dumper.dump()/checker.compare() directly; removed appcompat.check_appcompat()'s two entries from CLI_CONTRACT_ALLOWLIST (stack_checker.py was never itself in that list -- it isn't one of the front-end modules the cli-contract gate scans). No cli_stack.py change was needed: _run_abi_diff()'s return type and failure semantics (DiffResult \| None, ProfileMismatchError/ScopeMismatchError still propagate) are unchanged |
appcompat.py, stack_checker.py, scripts/check_ai_readiness.py |
T4 for the dump half; the compare half is independent |
| T6 — Effective gate/policy convergence ✅ (landed 2026-09-05; the shared fold and the derived scheme are done, the two runtime shapes remain P0's own job) | Collapse apply_release_gate_pack's raw-string mirror of pack_application.apply_to_compare_config onto one shared fold without inverting the dependency direction — policy/release_gate_options.py deliberately consumes a _GatePackApplication Protocol rather than importing the flat-root pack_application, since policy may not import it (ADR-061; policy/AGENTS.md's "Permitted imports"), so the shared fold belongs in an inward module both may import, or an outer layer invokes both halves — never a policy → legacy root call. Also make GateOptions.exit_code_scheme derived rather than independently constructible |
policy/release_gate_options.py, pack_application.py, a new inward fold owner, tests/test_release_gate_pack_fold_parity.py |
nothing |
| T7 — Canonical export index | One raw export index plus named projections (versioned ELF / default versions / Mach-O normalization / named PE / ordinal imports / missing-vs-empty); delete the five sibling implementations | policy/depth_projection.py, buildsource/cross_source_checks_base.py, buildsource/snapshot_exports.py, post_manifest.py, diff_unnamed_types.py |
nothing |
| T8 — Action boundary | Remove the residual raw-exit/stderr verdict reconstruction; keep only a transport-level no-result fallback; keep fail-on-* as step policy that never rewrites the verdict |
action/run.sh, action/ tests |
nothing |
| T9 — Fact provenance and scope (first, second, and third slices landed 2026-09-05/2026-09-11 — see notes below the table; only the shared declined-comparison accounting remains) | Extend the fact model with observation-vs-inference, producer/scope, and positive-observation-vs-completeness; fix the PDB vtable and legacy-hybrid backfill blockers at the model/import boundary; add shared analysis accounting for declined comparisons |
model/fact*.py, diff_types_vtable.py, diff_cxx_rules.py, storage/fact_backfill.py, dwarf_snapshot.py, compare/vtable_evidence.py, the import adapter |
~~T2~~ — satisfied (T2 landed 2026-09-05, so the ladder and investigated_declined are available to record this work's status); otherwise independent |
| ~~T10 — Shared report preparation~~ ✅ done (2026-09-06) | Compute evaluated findings/outcomes once ahead of format-specific construction; remove both runtime cycle escape hatches, which are distinct sites with distinct fixes: render_markdown_document._reporter_markdown()'s ..reporter_markdown load (the Markdown cycle) and report/scoped_gate.py's ..reporter load (scoped-JSON construction, whose cycle exists only because apply_scoped_gate mutates an already-built payload); give consumer scoping an explicit finalization boundary instead of mutating shared changes |
report/render_markdown_document.py, report/render_markdown_alternate.py, report/scoped_gate.py, reporter_markdown.py, report/dispatch_markdown.py (new), appcompat.py's scope_diff_to_app |
~~T5's appcompat half for the scoping item~~ — satisfied (T5 landed 2026-09-05) |
T4 status note (2026-09-05, stated precisely rather than as a blanket
"done" so it can't be mistaken for closing the whole item — Codex review on
the PR that landed this slice, correctly, caught an earlier draft of this
row overclaiming): execute_dump_request's own nine keyword parameters
are folded into one typed service_dump_pipeline.DumpExecutionOptions,
passed as a single options= argument — that part of item 1 is done.
Follow-up (2026-09-05): item 1's remaining gap is now closed too.
ResolvedDumpRequest gained its own execution_options:
DumpExecutionOptions | None field, so a caller resolves and attaches
its DumpExecutionOptions onto the request itself rather than only ever
assembling one fresh at execute_dump_request's own call boundary;
execute_dump_request's own options=None now falls back to
resolved.execution_options before falling back to a bare
DumpExecutionOptions(). frontends/cli/dump_execute.py's
execute_dump_cli_run/execute_and_write_dump_cli_run no longer take the
nine values as separate parameters at all — the dump CLI
(frontends/cli/commands/dump.py) attaches a dry-run-safe preview
(frontends.cli.dump_build_context_preview.dry_run_build_context_preview, a new silent/
non-raising sibling of _resolve_build_context_flags that also returns
the derived legacy compile-db flags) onto its --dry-run resolution, and
the real (raise/echo-capable) values onto the request it actually
executes. dump --dry-run now renders an "Execution options" section
from the preview (cli_dump_helpers.render_dump_dry_run) — the gap this
note used to describe ("a caller inspecting a ResolvedDumpRequest has no
way to see what a real execution would pass") no longer holds.
T4 closed (2026-09-06): both of item 1's remaining clauses landed.
Backend selection split from fallback policy. dumper._resolve_single_
ast_backend/_resolve_header_backend/HEADER_BACKENDS moved to a new
extract/header_ast_backend.py module (a real ADR-061 extract
responsibility-package leaf, not a new flat dumper_*.py/service_*.py
sibling — architecture/modules.yaml's frozen_root_families closes both
of those flat namespaces to new members, and dumper.py/
dumper_toolchain.py were both already at their own no-growth debt-ledger
line-count baseline with zero room to add a function). A new function
there, _resolve_effective_ast_backend(requested_frontend, frontend_context),
is the one pure "selection" primitive: it validates via
_resolve_single_ast_backend (raising for a request no single parser can
satisfy — "hybrid", or an explicit/env-pinned castxml under a non-host
context) and then mirrors _header_ast_parser's own post-resolution
dispatch rule (if resolved == "clang" or frontend_context != "host":
return _run_clang()) exactly. dumper.py/dumper_toolchain.py re-export
every name from the new module unchanged, so no existing
from .dumper import _resolve_header_backend-shaped call site moved.
dumper._header_ast_parser's own dispatch now calls this same function
instead of re-deriving the rule inline, and
service_compare_evidence.effective_frontend_for_context (a new function,
alongside requested_frontend_override — the override-precedence half
effective_frontend already had inline, now factored out so it isn't
re-derived a second time either) calls it too for
service_dump_pipeline.resolve_dump_request's own effective_header_
backend reporting field — replacing that function's previous ~25-line
inline reimplementation of both rules with one shared-selector call.
Runtime fallback policy (dumper._castxml_fallback_reason — only
discoverable by actually attempting castxml and observing a specific
failure signature) is untouched and stays in dumper.py, where the
attempt itself happens; the two were never conflated by this change.
tests/test_ast_backend_selection.py states the "identical prediction
regardless of caller" contract directly, over a range of inputs, not just
the one case each pre-existing test already covered.
Source-only dump execution variant. service_dump_pipeline.
execute_dump_request no longer raises unconditionally for a binary-less
(InputSpec.path is None) ResolvedDumpRequest — it dispatches to a new
workflows/artifact/execute_source_only.execute_source_only_dump_request,
which builds an empty-library AbiSnapshot and embeds L3-L5 evidence via
workflows.extraction.embed_build_source (the engine primitive, not
cli_buildsource.embed_build_source's Click-error-translating wrapper),
then enforces the requested-depth floor exactly like the binary path does
— the same pipeline cli_buildsource.dump_source_only already ran,
reduced to its engine primitives so a non-CLI (typed API) caller can reach
it too. No idempotence guard was needed (unlike _write_snapshot_output's
own build_source_already_satisfies check): the new function's snapshot
is freshly constructed and embedded into exactly once, so there is no
earlier embed to be idempotent against — that guard remains relevant only
where a CLI write step might re-embed onto an already-populated snapshot
(the binary path's resolve-time embed), which this addition doesn't touch.
Lives in its own workflows/artifact/ module (a same-layer sibling of
service_dump_pipeline.py, which is itself layers.workflows-classified)
rather than growing service_dump_pipeline.py past its own 800-line
new-file architecture cap, and returns a plain SourceOnlyDumpOutcome
rather than a real DumpResult: service_dump_pipeline.py imports this
module to call the function, so returning DumpResult itself would need a
module-level import back into service_dump_pipeline.py, which the
AI-readiness import-cycle-growth gate's whole-file AST walk (it does not
special-case TYPE_CHECKING blocks or function-local imports) flags as a
new two-module cycle regardless of how that import were spelled; the
caller (already holding DumpResult in scope) builds the real object from
the three fields SourceOnlyDumpOutcome carries. ResolvedDumpRequest
itself also moved to a new workflows/artifact/dump_request.py leaf for
the identical reason — the new module's own function signature needs the
type, and importing it only from service_dump_pipeline.py would create
the same cycle; service_dump_pipeline.py re-exports it unchanged.
tests/test_dump_source_only_execution.py pins the general contract
(parity with dump_source_only for both the --sources and
--build-info-only shapes, the "no evidence" ValidationError, and the
unreached-requested-depth-floor ValidationError) rather than one
hand-picked example.
cli_buildsource.dump_source_only/the dump CLI's own source-only
dispatch (frontends/cli/commands/dump.py, so_path is None branch) are
deliberately left as they are — not a silent two-implementations gap, but
a scoped decision recorded here per this file's own "Record before
disposing" norm. A full migration of that CLI call site to build a
DumpRequest and call the new shared execute_dump_request path (the way
ADR-063 Phase 1 / CLI cleanup phase two PR C did for the ELF/PE/Mach-O
binary paths) would additionally need to preserve, byte-for-byte: provenance
stamping via _stamp_provenance; _write_snapshot_output's full
sectioned-document/compression/depth-check/--include-system-declarations
machinery; the G21.7 "missing requested evidence layer" warning; and the
CLI's own -H/--header-has-no-effect warning — all of which
service_dump_pipeline.py's own module docstring explicitly excludes from
this pipeline's scope ("the CLI's presentation and provenance layer").
Attempting that migration in the same pass that introduces the new
execution path itself risks exactly the kind of scope creep this plan's
own T4 status notes have twice already had to walk back an overclaiming
draft on (see the two notes above); it is better scoped as its own
follow-up slice once the new path has had a release cycle to prove itself,
mirroring how the ELF/PE/Mach-O migration itself was its own dedicated,
multi-PR effort (ADR-063 Phase 1). Nothing about leaving it as-is
regresses any existing behavior or test: dump_source_only's own pipeline
is unchanged, and the new execute_dump_request path is purely additive,
reachable only through the typed API today.
Recommended first wave (fully parallel, no shared files): ~~T1~~ (done), ~~T2~~ (done), T6 (landed with caveats — see its own row), T7, T8. Second wave: ~~T3~~ (done), ~~T4~~ (done), T9 (each large enough to be its own multi-PR effort). Third wave: ~~T5~~ (done), ~~T10~~ (done).
T9's first slice (2026-09-05): the PDB vtable fabrication is closed;
the rest of the item's scope is not. Fact[T] gained a producer: str |
None field (model/fact.py) — additive, defaulting to None, and
round-tripped through storage/fact_codec.py unversioned (a document
predating the field simply has no key, decoding to the same default every
pre-existing construction site already carries). pdb_model.py's
_record_from_layout now constructs every record's vtable_fact/
vptr_offset_bits_fact as an explicit Fact.unsupported(...,
producer="pdb") rather than omitting the fields — closing the exact gap
the 2026-09-04 5B closure diagnosed: PDB's own structural non-evidence and
a hand-built/typed-API RecordType's vtable= omission previously both
resolved to the identical NOT_COLLECTED status, which is why a blanket
FactStatus pre-check (round 2 of that closure) could not tell them apart
and had to be reverted. compare/vtable_evidence.
vtable_transition_is_evidenced now declines outright, before consulting
either fallback evidence stream, whenever either side's
vtable_fact.status is FactStatus.UNSUPPORTED — a status a typed-API
omission never produces, only an explicit incapability claim does — which
closes both fabrication paths the 2026-09-04 closure named (the size/base
fallback, and the owned-virtual-function fallback, since PE/PDB's own
Function.is_virtual also defaults False unobserved and that stream is
gated by the identical check). NOT_COLLECTED/FAILED handling is
untouched, so the leaf-class regression that closure's round 3 protects
stays exactly as it was.
What this slice does not close, left for the item's remaining scope:
the DWARF per-translation-unit completeness gap (Fact.present([]),
genuinely PRESENT, for a class whose virtuals live in a TU only the
other side's debug info covers) — a producer-capability signal like
producer/UNSUPPORTED cannot express this, since DWARF genuinely can
capture the family; the gap is per-TU scope, which is the
observed-vs-inferred / positive-observation-vs-completeness half of this
item's own stated scope, still unimplemented. Also untouched: the shared
analysis accounting for declined comparisons (observed changes /
evaluated requirements / unresolved requirements / unsupported
requirements) this item's own text calls for. Each remains real,
scoped, separately-actionable work — recorded here rather than implied
closed by the row above.
T9's second slice (2026-09-05): the legacy-hybrid backfill blocker is
closed too. storage/fact_backfill.py's CaseAFactRule gained a fifth,
optional field, fact_provenance_kind ("type"/"enum"/"field"/
"func"/"var") — additive, defaulting to None, so every pre-existing
rule and call site is unaffected. Set on the seven rules the "legacy-hybrid
backfill blocker" note above named (RecordType.deprecated,
EnumType.deprecated/is_scoped, Function.deprecated,
Variable.deprecated, TypeField.deprecated/default), it makes
apply_case_a_fact_backfill consult the document's own per-declaration
AbiSnapshot.fact_provenance map — passed through from serialization.py's
existing d.get("fact_provenance", {}) — whenever the document's
ast_producer is exactly "hybrid": a declaration with no recorded
provenance entry (probed namespace-qualified first, falling back to the
former bare key only when this document's own side has no other declaration
sharing that bare name — the same shape fact_provenance.
resolved_fact_producer already applies at compare time) downgrades the
claim to NOT_COLLECTED, following the module's own pre-existing
"downgrade the claim, never the value" rule (a real non-resting legacy
value is left untouched either way). This is exactly the correction the
5B investigation described as blocked: clang_deprecation_facts_reliable
reads True unconditionally for a hybrid producer, so only a real
per-declaration provenance lookup — not that flag — can tell "neither
backend's merge ever recorded looking here" apart from "confirmed,
genuinely resting-default" on a document predating this fact family's own
schema version. Verified against the full fast unit suite (no regressions,
including the exact end-to-end scenario the original investigation's
attempted fix broke,
test_dumper_hybrid.py::TestNamespaceQualifiedMerging::
test_legacy_bare_keyed_hybrid_baseline_still_detects_transition — which,
on inspection, never exercises this JSON-load-time code path at all, since
it constructs its snapshot in-memory rather than through
snapshot_from_dict) plus a new, dedicated test class,
tests/test_deprecation_family_facts.py::TestLegacyHybridProvenanceBackfill,
covering the qualified-key, bare-key-fallback (both unambiguous and
ambiguous), mangled-name-keyed, value-preserving, and non-hybrid-producer-
unaffected shapes directly. Still open, unchanged by this slice: the DWARF
per-TU completeness gap and the shared declined-comparison accounting
named above.
T9's third slice (2026-09-11): the DWARF per-translation-unit
completeness gap is closed. Unlike PDB's producer-wide UNSUPPORTED
claim, DWARF genuinely can capture bases/virtual_bases/vtable — the gap
is per-record scope, not per-producer capability, so it needed its own
signal rather than reusing producer. dwarf_snapshot.py's own "first
definition wins" ODR handling (_check_and_register_type_name) already
discards every non-retained CU's own copy of a record type; this slice
makes that discarded copy's own bases/virtual_bases/vtable membership
useful one last time before it's thrown away, purely as a comparison
witness: the new leaf module extract/dwarf_vtable_completeness.py's
note_duplicate_record_evidence (called from _process_record_type_named's
existing ODR-duplicate branch) compares each non-retained, non-declaration
duplicate DIE's own membership sets against the retained definition's, and
finalize_vtable_evidence_completeness (a new post-CU-walk pass, run
alongside the existing _finalize_vptr_offsets) downgrades exactly the
sibling fact(s) among bases_fact/virtual_bases_fact/vtable_fact that
actually disagreed to Fact.partial(...) — the existing FactStatus
member whose own docstring ("covered only part of the requested scope...
the uncovered part is unknown, not absent") already states exactly this
claim. A record where all three genuinely disagree still gets all three
downgraded; a record confined to one field (e.g. vtable only) leaves the
other two PRESENT — a Codex review round found the first landed version
of this slice downgraded all three as a blanket per-record decision
whenever any one disagreed, which could suppress a genuinely evidenced
TYPE_BASE_CHANGED/BASE_CLASS_VIRTUAL_CHANGED finding for a reason
unconnected to bases/virtual_bases evidence at all; builder._vtable_
evidence_conflicts is dict[str, set[str]] (qualified record name -> the
disagreeing field subset), not set[str], to track this. Both functions
take the builder itself
(duck-typed Any, no import of it) rather than several pieces of state
individually, since dwarf_snapshot.py is already at its own
architecture/debt.yaml no_growth line-count ceiling and needed its two
call sites to stay one line each (that ceiling was raised in two small,
rationale-carrying steps, 1994 -> 1998 for the genuinely irreducible
wiring -- one import, one per-instance conflict-tracking set, and the two
one-line call sites -- then 1998 -> 1999 for the set[str] ->
dict[str, set[str]] annotation-type change the per-field fix above
needed) — for every record where a disagreement was observed.
vptr_offset_bits_fact is deliberately untouched (see
compare/vtable_evidence.py's own "NOT consulted here" note: that field
carries a different, DWARF-partially-circular meaning already and
touching it here would be a drive-by extension of a status
diff_layout._check_vptr_introduced already relies on meaning something
else for the direct-clang backend).
compare/vtable_evidence.vtable_transition_is_evidenced's own decline
check — previously gated on FactStatus.UNSUPPORTED only, for
vtable_fact only — now also declines on FactStatus.PARTIAL, for
vtable_fact at its own top-level gate (the whole function declines when
either side's vtable_fact is PARTIAL/UNSUPPORTED) and for
virtual_bases_fact at that field's own point of use (the final
size/virtual-bases fallback branch, gated there rather than over the
whole function). bases_fact is not part of this function's decline
logic at all — this function never reads bases/bases_fact in the
first place (only diff_cxx_rules._transitive_bases does, via the next
sentence). A second Codex review round caught the first landed version of
this consumer-side fix still gating the whole function on
bases_fact/virtual_bases_fact alongside vtable_fact — reasoning
(true of the pre-per-field producer, no longer true after the fix above)
that a disagreement on any one sibling cast doubt on all three since the
producer downgraded them together; once the producer stopped doing that,
the consumer's own blanket gate started rejecting direct, non-empty
vtable evidence for a record whose only disagreement was on bases.
diff_cxx_rules._transitive_bases needed no code change: it already
reads bases_fact/virtual_bases_fact through _fact_str_list_confirmed,
which already treats any non-PRESENT status (PARTIAL included) as
"not confirmed complete" — producing PARTIAL is what activates a gate
that was already there.
Verified with a dedicated bug-class test suite (AGENTS.md's "regression
test targets the bug class" rule), not a single fixed reproducer:
tests/test_dwarf_vtable_completeness.py drives _DwarfSnapshotBuilder's
new methods directly with fake DIE/CU objects (agreement, disagreement on
each of the three sibling fields independently, order-independence,
declaration-only stubs never compared, a conflict never un-flagged by a
later agreeing CU, two unrelated types tracked independently), and
tests/test_vtable_evidence_guard.py::TestPartialProducerClosesTheDwarfPerTuGap
exhaustively parametrizes over (field × side × several independently-
shaped record pairs, each of which would otherwise evidence a transition
through a different internal branch of the guard) so a fix that only
closed one branch would still be caught. Re-verified against
scripts/check_fp_rate.py and scripts/check_tier_accuracy.py (both
still at their 0/0 and top-tier-correct baselines) before landing. Closes
this item's real, scoped remainder; still open, unchanged by this slice:
the shared declined-comparison accounting named above (a separate,
unrelated piece of this item's original stated scope).
T10 (2026-09-06): all three named items landed, scoped exactly to this track's own "Touches" column (no SARIF/JUnit/HTML change — those formats never shared the cycle or the escape hatch this track exists to close).
- Compute once:
--show-only-filtered/dangling-correlation-suppressed display changes are the one piece of "evaluated findings" preparation that was genuinely duplicated within this track's own files — full mode (render_markdown_document.build_markdown_document) and leaf/root-cause mode (render_markdown_alternate._view_preamble_mapping) each ran their own copy of the identicalapply_show_only->_suppress_dangling_ correlation_notespipeline and separately re-derived the same filter-note shape from it.render_markdown_document._resolve_displayed_changesis now the one place that runs, called by both. The headline table's unfiltered totals (DiffResult.breaking/.source_breaks/.risk/.compatible/.not_evaluated) and the display-filteredReportModelbuckets remain two intentionally different computations over two different change lists (full-run totals vs. what--show-onlyactually displays) — collapsing those would be a behavior change, not a deduplication, so this track leaves that distinction as-is. - Both cycle escape hatches removed:
reporter_markdown.py's Markdown dispatch (to_markdown/to_review_digest/_to_markdown_leaf/_to_markdown_root_cause/_markdown_alternate_rendering) moved to a newreport/dispatch_markdown.py— the only edge that madereporter_ markdown.pydepend onreport/render_markdown_document.py/render_markdown_alternate.py, so once it moved,render_markdown_ document.py's own_reporter_markdown()became a plain staticimport abicheck.reporter_markdowninstead of animportlibresolve (render_markdown_alternate.pyinherits the same fix via the helper it already imported from that module).reporter_markdown.pykeeps a lazy__getattr__shim for the moved names so every existingfrom abicheck. reporter_markdown import to_markdown-shaped call site (includingtests/) is unaffected — the same shim shapecli_buildsource.py's own tail already establishes for this exact situation.report/scoped_gate.pyno longer importsreporter/reporter_markdownat all, dynamically or otherwise: its six needed per-change helpers (_change_to_dict,_add_entries_to_root_causes,_finding_id,_root_cause_key_and_ display,root_cause_for_change,_resolve_scoped_gate_findings) are now passed in as aScopedGateChangeHelpersbundle (reporter.py's own_SCOPED_GATE_HELPERS, built once sincereporter.pyalready holds or re-exports every one of those six names), threaded throughreporter_contract_blocks.render_json_with_side_facts's newhelpersparameter — dependency inversion rather than relocating_change_to_dictitself, which stays exactly where it already was. - Consumer-scoping finalization boundary:
appcompat.scope_diff_to_app's two free-standing mutations of the already-returneddiff(thedisposition_ledgerattachment, previously assigned right before the overlay loop, and the_promote_scoped_contractcall, previously the last statement before buildingAppCompatResult) are now one named, called-once_finalize_consumer_scope_diff, whose own docstring states why both are instances ofpolicy.disposition_close. close_consumer_scope's own "a second producer joining after the close" problem and why their relative order (ledger attach, then promotion, then the verdict recompute promotion triggers) is fixed rather than left implicit across two call sites. Provably behavior-preserving: nothing between the ledger's resolution and this new call readsdiff.disposition_ledger(everyrecord_consumer_overlaycall already goes through the localoverlay_ledgerreference), so deferring the attachment changes nothing observable.
Verified via the full fast unit suite plus the Markdown/JSON golden suites
(tests/test_golden_output.py, tests/test_golden_root_cause.py,
tests/test_golden_review_digest.py), tests/test_appcompat.py,
mypy abicheck/, and scripts/check_architecture.py — zero output
changes, zero new cycles, zero new architecture-debt errors (the
appcompat.py no_growth baseline was raised 1887 -> 1928 for the new
_finalize_consumer_scope_diff function's own docstring and signature;
see that file's own entry in architecture/debt.yaml for the itemized
rationale).
Acceptance tests¶
The highest-value tests here are cross-path equivalence tests, not more example-specific regression tests — this is what a token/AST-based clone detector cannot catch, since the class of bug this plan targets is different code intentionally computing the same thing.
Artifact-resolution equivalence. Two tiers, since a symbols_only=True
call genuinely skips work a full resolution does (service.resolve_input()
guards its header-graph attach and debug-info resolution behind and not
symbols_only) — comparing both against the full field list would be
comparing a supplementary, header-free L0/L1 extraction against a
full-resolution result, which are not expected to agree on fields neither
one has any way to have populated identically.
Full-resolution paths — for one artifact and equivalent options, dump,
compare-side resolution, scan candidate resolution, ABICC descriptor
resolution, appcompat.check_appcompat()'s own per-side resolution, deps
compare's per-dependency-pair resolution, and release's own per-library
extraction — must produce identical: snapshot semantic fingerprint;
extraction-contract fingerprint; effective evidence depth; effective
compile-context digest; public-surface scope fingerprint; dependency
scope; build/source coverage; cache-relevant directory set.
symbols_only=True paths — l0_export_delta.collect_l0_export_delta()'s
supplementary re-extraction (invoked by both native compare and scan
baseline reconciliation, independent of either side's primary resolution)
and scan_engine._load_exports_for_poi()'s own prepass (a third
independent resolve_input() call site scan makes ahead of the primary
candidate/baseline extraction, when export-delta POI tracking applies) —
must produce identical exported-symbol projections against each other and
against the corresponding full resolution's own export set, plus the
subset of the full-resolution fields a symbols-only call actually
populates: effective evidence depth and resource lifetime/session
handling. Fields that require header/L2 evidence (extraction-contract
fingerprint, effective compile-context digest, public-surface scope
fingerprint) are not claimed for this tier, since a symbols_only=True
call never computes them. Nor is a configuration digest claimed here at
all: that's EffectiveEvaluationConfig.digest (Phase 2), a whole-run
object a CLI/API frontend resolves once before compare/scan executes
— resolve_input(..., symbols_only=True) itself accepts no configuration
parameter and returns no digest of any kind (confirmed by reading its
signature), so this artifact-resolution-tier test has nothing of that
shape to compare. This tier's own producer/consumer, by contrast, is
exactly resolve_input(..., symbols_only=True) itself — ArtifactResult
(Phase 1's target shape) is what would eventually carry the fields listed
above as its own resolution-level output, once this migration lands.
Release belongs here specifically because its adapter does real,
release-only work ahead of the shared resolution step:
cli_compare_release._run_compare_pair() follows GNU ld linker scripts and
applies its own per-library header/include mapping before ever calling
service.run_compare() — the target shape's promise to "serve... release
per-library extraction" is otherwise untested at the one place release's
extraction can still diverge even when ordinary compare-side resolution
passes cleanly. Standalone appcompat, deps compare, release, and the L0
re-extraction all belong in this matrix, not just in Phase 1's routing
list — a phase that satisfies every equivalence test here except one
direct caller's path would still leave that caller resolving depth,
compile context, cache paths, or resource lifetime differently from
everything else. Run this matrix once per binary backend (ELF,
PE/COFF, Mach-O), not once against a single ELF fixture: Phase 1
deliberately keeps PE/Mach-O's own backend-specific extraction path
(handle_non_elf_dump) rather than folding it into the ELF pipeline, so an
ELF-only fixture can satisfy every assertion above while a PE or Mach-O
caller still resolves depth, compile context, scope, or cache inputs
differently — exactly the class of divergence this gate exists to catch.
Not every one of the ten paths above has a PE/Mach-O caller today (e.g.
deps compare's stack_checker._run_abi_diff() and the L0/POI supplementary
re-extractions are ELF-specific in practice), so the backend axis applies
wherever a path is genuinely reachable on more than one platform — dump,
compare-side resolution, and scan candidate/baseline resolution all are.
Comparison equivalence. For one comparison, native compare, scan
--against's own nested baseline comparison, release per-library compare,
release's own global probe-matrix comparison (when compare-release
receives --probe-matrix-old/--probe-matrix-new,
cli_compare_release._collect_matrix_result() separately loads
suppression/policy/pack state and calls compare_snapshots() over
synthetic snapshots carrying extra_changes — a comparison distinct from
every per-library one this same command also runs), the Python API,
appcompat.check_appcompat()'s pre-scope compare_snapshots() call
(before its own scope_diff_to_app() step applies app-usage narrowing),
appcompat.check_plugin_host_contract()'s identical pre-scope comparison
(the plugin-host counterpart to check_appcompat()), each dependency
pair's _run_abi_diff() inside deps compare, and compat's ABICC
adapter's own comparison before its intentional
strict/source-only/new-symbol-warning transformations
(compat/_helpers.py's _apply_strict() and siblings) apply must produce
identical: canonical finding IDs (finding_identity.py); effective
verdicts; configuration digest; contract decisions; assurance decisions;
compatibility exit contribution. Naming appcompat, deps compare,
release's matrix comparison, and ABICC's pre-transformation comparison here
matters independently of Phase 1's own migration list — that phase, and
Phase 5's ABICC migration, only guarantee their extraction moves onto the
shared pipeline; without this equivalence test also covering their
comparison step, their finding IDs, contract decisions, or verdict
processing could still silently diverge even after extraction converges.
ABICC's own strict/source-only/new-symbol transformations are intentional,
documented ABICC-compatibility behavior (not divergence to eliminate) —
test them as before/after pairs against the shared pre-transformation
result, the same way scan's crosscheck promotion is tested below, rather
than requiring the post-transformation result to match compare's.
Scoped deliberately to scan's baseline comparison rather than its overall
result: scan --against --crosscheck KEY=error intentionally lets
scan_engine._crosscheck_severity_exit promote an otherwise-clean run to
API_BREAK (recorded as promoted_crosscheck) — a real, scan-only axis
Phase 3's crosscheck_promotion contribution deliberately preserves, not
a divergence to eliminate. Requiring scan's overall effective verdict
to match compare's would either fail on this correct behavior or invite
removing the promotion; test scan-specific contributions (crosscheck,
budget overflow) separately from this equivalence check.
Renderer equivalence. Every renderer, given the same ReportEnvelope,
must expose the same effective verdict, finding IDs, blocking findings,
exit code, and exit reasons.
A token/AST-based clone detector may still be worth adding as a secondary, advisory CI signal, but it should be understood as catching a different (much smaller) risk than the equivalence tests above.
Relationship to in-flight work¶
This plan does not compete with the existing initiative plans — it names where they converge and what remains once each is fully landed:
- CLI cleanup, phase two already names three
of the same prerequisites this plan generalizes (one typed
dumpresolution path — its "PR C"; one effective pack/gate configuration — its "PR B"; one canonical exit decision — its "PR G1", already merged). Phases 1–3 here are the full generalization of those three PRs across every operation, not justcompare/scan/release. - G33 built the schema registry
and
CompareRequest/CompareResultcompleteness this plan's Phase 1–2 extend todumpand the artifact-resolution surface generally; its own "Phase 6" note (a standing sequencing constraint on ADR-049's rollout) applies unchanged to this plan's Phase 2. - G32's
comparability contract (
ExtractionContract,scope_fingerprint) is exactly the fingerprint machinery Phase 1's acceptance tests reuse — this plan does not propose a second comparability mechanism. - Public contract default (ADR-049) is the compatibility-configuration resolver Phase 2 makes the sole runtime contract; this plan does not change ADR-049's own D7/D8 precedence rules, only how uniformly the result of applying them reaches every operation.
- ADR-061 owns implementation ownership and dependency boundaries for everything this plan converges. Its 2026-09-07 replan states six remaining acceptance gaps and a bounded closure sequence; three of them are this plan's own targets seen from the ownership side, and should be worked once, not twice:
- its gap C (one completed evaluation → one document → every format)
is this plan's Phase 4
ReportEnvelope; - its gap D (request/plan types carrying selection, inventory and
acquisition state; the release fan-out still folding onto a different
shape than
compare) is this plan's P0 "Effective configuration and pack application" and theEffectiveGate/EffectiveEvaluationConfigtarget track T6 deferred to P0; - its gap A (dynamic first-party imports hiding forbidden edges,
including the
workflows -> frontendsrendering back-edge) is the enforcement half of this plan's P1 "Dependency direction and CLI leakage" —ENGINE_CLI_BOUNDARY_ALLOWLISTshrinking does not measure an edge the checker cannot see. - One comparison product (ADR-068) decides
which command owns which analysis. Where this plan says "and
scan", read it as the capability, not the command: nothing here justifies building a permanent typed contract around a command scheduled for retirement.
Out of scope¶
- Rewriting binary/debug/header parsers, or merging CastXML and Clang backends into one implementation (see "What should explicitly not be unified" above).
- A general-purpose clone-detection tool; noted as an optional secondary signal, not a replacement for the equivalence tests this plan specifies.
- Any change to ABICC's or
compat's external, user-facing contract (flags, exit codes, report shape) — Phase 5 changes only how that contract is implemented internally. - Any change to the ChangeKind registry, detector registration pattern, or
identity-resolution tiers (
finding_identity.py) — these are cited throughout as examples of the target pattern already working correctly.