Skip to content

ADR-063: One Semantic Pipeline — Unifying Application, Fact, Identity, and Outcome Models

Date: 2026-08-27 Status: Proposed — roadmap ADR, partially implemented.

Per-phase implementation status used to live here as a consolidated, hand-maintained bullet list. It was removed 2026-09-02 (per an external review's "PR 0" recommendation, accepted): a second, independently-updated summary of the same mutable state is exactly the "one concept, two representations" pattern this ADR's own Governing Invariant forbids, and it had already drifted from the primary source twice in one day (PR #1019 caught and fixed two stale paragraphs, then a third factual error, before this removal). This section below is now the stable target-architecture text only — Context, Governing Invariant, Decision Drivers, and D1-D10 — which does not change as phases land. For current, per-phase and per-concept status, use:

  • the implementation plan's own per-phase "Landed"/"Still not landed" notes — the primary, most detailed source, updated in the same PR that lands a slice;
  • docs/_meta/one-semantic-pipeline-status.yaml — a machine-readable, per-concept summary (primitive/producers/consumers/authority/lifecycle/ removal_gate, plus an optional investigated_declined list), structurally validated but hand-maintained content (no generator derives it from the plan or the codebase — update it in the same PR that changes a concept's status, exactly like the plan and this pointer), for a quick "who's authoritative today" check without reading the whole plan;
  • docs/contribute/adr/index.md's own ADR-063 row — a paraphrase for readers browsing the ADR index (this file's own established convention for an actively-evolving ADR is a detailed row, not a one-liner — see ADR-061's/058's own rows — so this one stays that detailed too), kept in sync with the plan and ledger above rather than independently re-deriving a status claim.

See the implementation plan for the full phase-by-phase state, including every slice's own "Landed"/"What this slice deliberately does not attempt" notes and review-found corrections. Several of the still-unimplemented phases' decisions are already partially satisfied by work this ADR consolidates rather than replaces (see "Relationship to existing ADRs" below). Decision maker: abicheck maintainers Relates to / builds on: ADR-024, ADR-031, ADR-036, ADR-037, ADR-042, ADR-043, ADR-044, ADR-045, ADR-046, ADR-048, ADR-049, ADR-050, ADR-053, ADR-054, ADR-055, ADR-057, ADR-061, ADR-062 Implementation plan: One Semantic Pipeline

Context

A second pass over abicheck's merged-PR history — not just fix: commits, but feature/refactor/perf/hardening PRs and, in particular, chains of the shape feature → follow-up → corrective fix → generalized fix — shows a recurring root cause behind the codebase's most expensive defects. It is not localized algorithm bugs. It is disagreement between multiple representations of the same concept that are each updated independently, by different PRs, at different times:

Concept Representations that can disagree today
Input resolution cli.py/cli_dump_helpers.py, service.py, scan_engine.py, the composite Action, bundle/project paths — each independently resolving headers, includes, compile context
Configuration Click options, .abicheck.yml, Action inputs, CompareRequest/DumpRequest kwargs, environment variables
Identity dict key, name, qualified_name, mangled, castxml synthetic ctor/dtor key, finding_identity's resolved identity, source-graph node id
Availability None, [], a boolean reliability flag, a coverage row, a provenance map entry
Public surface header provenance, exported-symbol reachability, path-prefix rules, source-graph reachability
Semantic result findings (Change), Verdict, severity-scheme exit code, AnalysisAssurance, the raw process exit code
Report DiffResult, JSON construction, Markdown, SARIF, JUnit, the PR comment
Fact support a model field, a parser, a serializer, a detector, a provenance entry, a capability-matrix row
Persistence per-library snapshot, baseline set, BundleFacts, BuildSourcePack
Compiler context CLI flags, a compile database, a captured evidence pack, L4 replay argv, the resolved CompileContext
Evidence scope requested --depth, enforce_requested_depth's resolved-rank check, a resolved snapshot's actually-embedded facts, analysis_assurance.requested_depth

AGENTS.md's own "Known gaps" section is the best evidence for this claim, not an assertion made here: dozens of numbered findings there are the same shape — a fact is folded into one of these representations but not a sibling, or two independently-maintained implementations of the same decision quietly diverge. Two of those findings are cited below by name; the other two examples are independently verifiable in the repository's own merged-commit history (git log --grep) rather than in AGENTS.md — stated that way deliberately, so a reader checking a citation against the wrong source doesn't read it as unsupported. All four are real, not illustrative:

  • ELF binding (commit e5fabd403 / PR #734, feat(model): expose ELF symbol binding as a Function/Variable field + suppression selector): adding one symbol-binding fact required coordinated edits to model.py, dumper_elf_symbols.py, serialization.py, Change in checker_types.py, diff_symbols.py, suppression.py, scripts/backend_capabilities.py and its generated docs, a suppression doc, and two regenerated example fixtures — nine files for one additive field. (AGENTS.md separately documents a different, later defect on this same field — elf_binding/elf_visibility collapsing mixed bindings across symbol-versioned aliases — which is not this example's point and is not the source of this citation.)
  • L3→L2 compile-context fold (the AGENTS.md "Known gaps" entry by that name): eighteen-plus numbered follow-on findings, each a different place the same resolved compile context needed to be threaded but wasn't — perform_elf_dump, handle_non_elf_dump, scan_engine. _build_new_snapshot, the header-graph second pass, three independent AST cache keys, and the legacy -p/--compile-db auto-match overlapping the new fold instead of composing with it.
  • scan severity folding (commit 73b5576c5 / PR #700, titled feat: honor severity/exit-code-scheme in scan --against): teaching scan --against to respect a configured severity scheme required widening the exit-code space, which broke aggregate.py's own assumption that a gated target reading non-blocking under the old scheme meant it was safe to drop from blocking_targets — fixed in the same PR, but only because the regression was caught before merge, not because the dependency was made impossible. The exit code was being used inside the system as semantic data, not only as an external contract.
  • --depth as a floor for live extraction vs. a ceiling for a pre-built snapshot (the docs/contribute/known-gaps.md entry by that name, surfaced by a Codex review round on PR #1016): enforce_requested_depth fails a run when resolved evidence falls short of an explicit --depth, but nothing projects a requested depth back down onto an already-serialized JSON snapshot carrying richer embedded evidence — resolve_input's fmt == "json" branch returns load_snapshot(path) verbatim regardless of what was asked for. Real on both operand shapes a request can name (a single pre-built snapshot pair, or a directory/ package of them), not introduced by the PR that happened to surface it: extending --depth binary's acceptance to a second operand shape merely made an existing, single-pair-only instance of the disagreement reachable from a second code path.

None of these are bugs in the PR that shipped the fix. They are evidence that the integration surface for one new fact, one new config value, or one new policy decision is too large, because the same concept exists in more than one place and nothing makes those places agree by construction.

abicheck's current main already contains the correct building blocks for fixing this the right way: typed CompareRequest/CompareResult (ADR-055), a shared resolve/classify compare pipeline (service_compare_pipeline.py), AnalysisAssurance as an axis independent of Verdict (introduced alongside ADR-049/055-era work), the compatibility_decision/gate separation (ADR-042), canonical entity identity and graph reconciliation (ADR-046/048), the responsibility-package migration (ADR-061), and the storage-v2 foundation — FactStatus/ FactAvailability, occurrence-preserving identity, canonical encoding, separated version axes (ADR-062, Phase 0 already implemented in abicheck/storage/).

This ADR does not propose a rewrite. It proposes finishing the consolidation these ADRs already started, generalizing three of their primitives (typed request/result, fact availability, canonical identity) from the specific subsystem each first applied to (the compare path, the storage layer, source-graph matching) to the whole analysis model, and then removing the legacy parallel paths each of those ADRs left in place rather than letting a fourth, fifth, or sixth representation accumulate next to them.

Governing invariant

One concept, one representation, everywhere it is used. Never two.

This is the one rule every decision and every phase below exists to enforce, and it is deliberately stated as absolute, not as a preference to be weighed against convenience:

  • A second implementation of an already-consolidated concept is a defect, full stop — not an acceptable transitional state, not a pragmatic shortcut, and not something a later cleanup gets to find "eventually." If a phase's own PR leaves the representation it was meant to replace still reachable by any caller, that PR is incomplete, regardless of how much of the new representation it built. This is why every phase in the implementation plan carries its own explicit deletion step and acceptance criterion, and why the plan's final phase (delete the superseded representations) is not optional cleanup — it is the point at which each earlier phase's consolidation is actually true rather than merely begun.
  • "Generalize and finish," never "add a parallel design." Every decision in this ADR explicitly names the existing, narrower primitive it generalizes (ADR-042/046/048/049/055/061/062's own work) rather than proposing a new one from scratch. A future contributor extending this architecture inherits the same rule: before introducing a new type or module for a concept this ADR already names (availability, identity, public surface, outcome, configuration, fact), check whether it already has a representation here first. It does not get a second one.
  • This is enforced mechanically, not by convention alone. Each phase that consolidates a representation adds (or extends) an AI-readiness or architecture-gate check that makes the old representation's reappearance a build failure, not a matter of code review catching it — the Fact[...] truthiness/availability-handling check in Phase 0, the identity ambiguity-tracker deletion in Phase 2, the no-inline-gate-computation check in Phase 7, and so on. A rule that only holds "as long as reviewers remember to enforce it" is not the rule this ADR commits to.
  • Reviewers and future ADRs should read a proposal that adds a second way to represent something this ADR already covers as a rejection trigger, not as a variation to reconcile later. The corrective action is to extend the existing representation (or file a narrowly-scoped amendment to this ADR explaining why it cannot be extended), not to let two stand side by side.

Decision drivers

  • A large share of abicheck's worst-documented incidents are cross-layer disagreement, not single-function logic errors — fixing the class means changing where disagreement is possible, not adding another guard.
  • Several ADRs (042, 046, 048, 049, 055, 061, 062) already introduce the right primitive for one subsystem. The fix is to generalize and finish wiring those, not to invent a seventh parallel design.
  • Migration must be incremental and behavior-preserving per phase — ADR-061 and ADR-062 already establish this discipline (vertical slices, explicit "nothing consumes this yet" states) and this ADR keeps it.
  • Every phase that consolidates a representation must end by deleting the representation(s) it replaces. A consolidation that leaves the old path "for compatibility" creates a third implementation, which is the exact failure mode this ADR exists to stop (AGENTS.md's own closing principle: "After each migration phase, delete the old. This is critical.").
  • Generalized regression tests (AGENTS.md's "bug-class regression testing" convention, and tests/regressions/manifest.py) become smaller and stronger once an invariant is enforced by the type/module structure instead of by a test that has to re-check it at N call sites.
  • Nothing here should weaken the AI-readiness gates, the architecture gate (scripts/check_architecture.py, ADR-061), or the FP-rate/tier-accuracy/ mutation-score gates — each new primitive below must ship with the verification those gates already require for a behavior change of this size.

Decision

Adopt ten decisions (D1–D10), each generalizing an existing, narrower primitive into a repository-wide one, executed as the sequence in the implementation plan. Collectively they are referred to as One Semantic Pipeline.

D1 — One application pipeline; adapters never call the engine directly

All entry points (cli.py/cli_*.py, the Python API, the composite Action, cli_project.py, bundle/release fan-out) construct a typed request and hand it to one application-layer pipeline — resolve → plan → execute → classify → outcome — instead of each independently orchestrating dumper.dump/checker.compare/policy loading/evidence collection.

This generalizes ADR-055's D1 (CLI and the typed API already share one input-resolution path for compare) and ADR-061's workflows ring to every operation, not only compare. Concretely: dump's ELF/PE/Mach-O execution paths (perform_elf_dump/handle_non_elf_dump) were meant to converge on the resolve_dump_request/execute_dump_request split already added to service_dump_pipeline.py, and ran their own legacy route when this was written. Both have since converged and then been deleted: Phase 1 routed dump_cmd's real run for either binary format through execute_dump_request (via frontends/cli/dump_execute.py), and Track 1 of docs/contribute/plans/duplication-and-convergence-assessment.md removed the two now-callerless functions, cli_dump_non_elf.py and cli_dump_protocols.py (2026-09-05). scan's candidate resolution (scan_engine._build_new_snapshot) already converges on the shared workflows.artifact.execute. _resolve_side_snapshot_impl primitive (service_input_resolution is only a delegating facade over this same module; import the real owner in new code, per that facade's own docstring) — the same one execute_dump_request itself calls internally (resolve_dump_request does not; it only validates evidence and builds a ResolvedDumpRequest) — not on resolve_dump_request/execute_dump_request verbatim, since scan has no DumpRequest-shaped input for that pair's own signature to accept (per AGENTS.md's own record, this half is already landed; only dump's real execution path remains on the legacy route, still needing to be migrated onto execute_dump_request itself, not merely onto the shared primitive it wraps). See AGENTS.md's "PR C" note for the concrete, already-identified blockers to finishing the dump half, and the Action/cli_project.py/ bundle fan-out stop doing their own policy interpretation or compare setup. The cli-contract/engine-cli-boundary AI-readiness gates are widened to check this directly rather than only the currently-allowlisted legacy exceptions.

No new root entry point is introduced. ADR-043/054's CLI surface is unchanged; this decision is about what happens behind each existing entry point.

One dump shape is explicitly excluded from this convergence, by name, rather than silently implied as covered — an earlier draft of this decision left it unstated, and a repository-wide search of the implementation plan found no phase that ever migrates or retires it. The binary-less dump --sources/--build-info path (no SO_PATH), which executes through cli_buildsource.dump_source_only(), stays a third, independent assembler after this plan's Phase 1 ships — that phase's own text names it as a tracked residual (the executor has no concept of a snapshot with no binary-derived L0-L2 facts at all, a real, separate design question), but tracking it inside one phase's own prose does not satisfy this decision's "all entry points" wording, and no later phase picks it back up. This decision's own scope is therefore: every dump execution path that produces a binary-derived snapshot converges on resolve_dump_request/execute_dump_request; the source-only path is a named, intentionally out-of-scope exception until a future, separately- justified phase gives the executor a real answer for a binary-less snapshot — not a gap this decision claims to close.

A second, narrower exception, found the same way (checked against real code, not assumed covered by "bundle/release fan-out" above): two branches inside cli_compare_release.py bypassed the pipeline. _collect_matrix_result() (the --probe-matrix-* release-global build-configuration feature) calls service.compare_snapshots() directly over a pair of empty snapshots with extra_changes — the sanctioned Tier-2 chokepoint, not the disallowed Tier-1 checker.compare() core, so this does not itself trip the cli-contract gate — but it still never constructs a request or a plan, which is what this decision's own convergence is about. This one remains unmigrated, and deliberately so: an AnalysisPlan pre-flight check has nothing to check here — there is no requested-vs-resolved evidence input to validate feasibility for, only two already-empty synthetic snapshots and an already-computed extra_changes list — so forcing this branch through AnalysisPlanner.resolve() would mean inventing a vacuous plan for a request shape the checker was never designed to answer, not closing a real gap.

_resolve_stranded_library() (the --bundle-facts-out path's fallback for a library missing from the normal per-pair comparison) is migrated (ADR-063 Phase 8 follow-up): it used to call cli_resolve._resolve_input() directly — the same Tier-2 resolution resolve_compare_request itself calls, but reached independently, with its own hand-rolled depth=binary header-clearing special-case and no AnalysisPlan pre-flight check at all. Unlike the matrix branch above, this one genuinely is "the same shape of check" once looked at correctly: a stranded library is exactly one dump-shaped input (a path, headers, includes, version, language, an optional depth), so it now builds a real DumpRequest and runs it through resolve_dump_request/execute_dump_request — the identical pipeline dump/scan already converge on — gaining a real AnalysisPlanner. resolve() pre-flight check and dropping the hand-copied depth-clearing rule (the shared evidence-resolution machinery a matched pair's own CompareRequest side already uses does the identical clearing consistently). Its "deliberately-degrading stranded-library fallback" nature — the reason this was originally judged not-the-same-shape-of-check as an ordinary comparison — is preserved exactly, not designed away: a PlanningError/ValidationError/SnapshotError from either the resolve or execute step still degrades to an ELF-only entry with a warning rather than aborting the release. The release fan-out's main per-pair path (_run_compare_pair) already routed through service.run_compare, so "bundle/release fan-out" above is now unconverged in exactly one narrower branch (_collect_matrix_result()), not two.

D2 — Fact[T]: one representation of "do we know this, and how"

Generalize ADR-062 Phase 0's FactStatus/FactAvailability (currently storage-scoped, in abicheck/storage/) into the in-memory domain representation used by the comparison/detector layer, not only the on-disk one. A fact-bearing model field becomes a tagged value distinguishing:

  • "we never attempted to collect this" (NotCollected) from
  • "this backend cannot produce this fact" (Unsupported) from
  • "collection ran and failed" (Failed[Reason]) from
  • "the fact does not apply to this kind of entity" (NotApplicable) from
  • a genuine present value, including a confirmed absence (Present[T], T legitimately None/empty; optionally Partial[T]) — FactStatus.PRESENT's own existing definition already covers "the producer ran... including establishing that a collection is legitimately empty," so confirmed absence is not a seventh status; it is Present carrying an empty payload, and Fact[T] does not introduce a status FactStatus itself does not have.

This replaces the current overloaded use of None/False/[] to mean several of the above simultaneously — documented repeatedly in AGENTS.md's "Known gaps" (the type_vtable_changed/type_base_changed evidence-gap entries, the Param.is_va_list reliability-flag entry, the per-finding evidence-provider gap) as a root cause of fabricated or suppressed findings. A detector cannot write if old.default != new.default without first unwrapping availability — Fact.__bool__ is defined to raise rather than merely left undefined (an object with no __bool__ is still truthy in Python), so if fact: itself is a type error, not an untested branch. Fact.__eq__/__ne__ are deliberately not given the same raising treatment — an earlier draft of this decision proposed exactly that, and review correctly rejected it. Fact[T] is a field on RecordType/Function/every other fact-bearing model dataclass, and a raising __eq__ on a field poisons the containing dataclass's own generated __eq__ the moment it reaches that field — comparing two otherwise-identical RecordType instances (ordinary test assertions, list/snapshot comparisons, SemanticIR's own CanonicalEntity equality) would raise TypeError too, which is a far more disruptive failure than the one this decision is trying to prevent. Fact.__eq__ instead keeps the plain dataclass-generated structural comparison (status, value, and diagnostics together) — correct and unsurprising for a containing object's own equality, and exactly what a detector comparing old.default != new.default would get instead of the raise this decision's own earlier text claimed. Guarding that specific misuse — comparing two Fact[...] values directly inside detector logic, rather than unwrapping first — is enforced the same way the .value_or() misuse already is: a static AST check (the implementation plan's check_ai_readiness.py rule) flags a bare Fact[...]-typed field on either side of ==/!= inside a detector module, the identical mechanism and the identical file scope as the existing bare-attribute-read rule, rather than a second runtime mechanism layered underneath the first. abicheck/storage/availability.py's existing FactAvailability/FactStatus vocabulary is reused verbatim as the wire encoding Fact[T] serializes to — but because ADR-061 fixes the dependency direction as storage -> model, the vocabulary itself (FactStatus/Confidence, not the storage-specific FactAvailability ledger record) relocates to abicheck/model/ as part of this generalization, with storage/ re-exporting it rather than model/ importing from storage/; this decision does not introduce a second vocabulary, and the implementation plan states the exact relocation.

D3 — EntityId/OccurrenceId as the one identity primitive

Generalize ADR-062's occurrence-preserving identity model and ADR-046/048's canonical-entity/source-graph identity into the identity primitive used everywhere an entity needs to be referenced: diff matching (diff_helpers. TypeMap, finding_identity.py), the source graph (graph_facts.py), and persisted snapshots.

Suppression/policy selectors are deliberately not on this list — an earlier draft of this decision included them, and implementation-plan review correctly found no phase actually migrates them (Phase 2 never touches suppression.py/reclassify.py, and Phase 9's own selector- consolidation is a string-grammar merge between those two modules, not an EntityId projection). Unlike the other listed consumers, a selector's symbol/namespace/entity_namespace/cause_namespace fields are regex/glob patterns a user writes into a public YAML policy file, not an internal reference to one specific entity — there is no single EntityId a pattern like namespace: "ns::.*" could be rewritten to point at, and changing what these fields accept would be a breaking change to the public suppression-file schema, a different and much larger problem than the identity-consumer migration this decision is about. Collapsing a pattern-matching surface into a point-identity primitive is not the same kind of generalization as the other three consumers, so it is named here as explicitly out of scope rather than silently implied by "everywhere an entity needs to be referenced."

This does leave a narrower, real residual the suppression/reclassify grammar still carries, named rather than fixed here: a namespace/ entity_namespace/cause_namespace pattern matches against a rendered qualified-name string, the same flattening documented further below in this section as lossy — two entities whose typed ScopePaths differ only in segment kind can render to the identical string, so a namespace pattern could match (or fail to match) both identically even though a ScopePath-aware match would tell them apart. This is an accepted limitation of the string-pattern selector surface as it exists today, not introduced or worsened by this decision; closing it would mean designing a ScopePath-aware pattern-matching semantics for a public, user-authored config format, which is its own scoped follow-up, not a consequence of migrating internal identity consumers in Phases 2/9. A structural ScopePath (a sequence of typed segments — namespace, record, inline-namespace, anonymous/local markers) replaces string-concatenated qualified names wherever identity, not display, is being computed, which closes the family of bare-name/qualified-name collision bugs AGENTS.md's "Known gaps" documents repeatedly for opaque-type suppression, typedef dedup, and type_reachability.py's stdlib-reference detection — each currently patched locally, each sharing the identical root cause.

Backend-internal implementation keys (castxml's synthetic ctor/dtor key being the canonical example — PR #582 introduced the key format, and the follow-up fix, PR #761 (titled fix: reconcile castxml synthetic ctor/dtor key format drift (PR #582)), is the real incident: the key-generation algorithm changed and a persisted snapshot's old-format key stopped matching its own unchanged constructor) are explicitly not persisted identity; EntityId is produced once, downstream of backend-specific extraction, not re-derived from a backend-specific string at comparison time.

D4 — AnalysisPlan resolved before any extraction runs

Before a single collector or backend is invoked, an immutable AnalysisPlan states: the requested operation, per-side evidence requirements, and the requested toolchain/compile-context inputs — each evidence requirement as a requested/resolved/unsupported/ambiguous tuple. A request that cannot be satisfied (AGENTS.md's recorded --build-target + pre-captured aquery silent no-op, or a -H flag accepted by a collect mode that cannot use it) is rejected by the planner, before execution, instead of discovered mid-run or not at all.

The toolchain/compile-context field is the requested inputs, never the resolved output of the P0.3 L3→L2 compile-context fold — an earlier draft of this decision said "resolved," and implementation-plan review correctly found that contradicts an already-landed design decision. service_dump_pipeline.py's own ResolvedDumpRequest (landed ahead of this ADR) is explicit that the fold cannot be determined without invoking it, and the fold can raise HeaderCompileContextAmbiguousError on genuinely ambiguous build evidence — which is exactly why that object deliberately excludes the fold's result and the fold stays inside execute_dump_request, never the side-effect-free resolve step: running it there would change --dry-run's existing never-raises-but-a-usage-error contract, not merely extend it. AnalysisPlan is bound by the identical constraint, being built at the same resolve-time point. It therefore carries the same requested inputs ResolvedDumpRequest itself carries (explicit --gcc-path/--ast-frontend/language, whatever --build-info/ --sources path was given) — not the fold's resolved compile context, and not a path for HeaderCompileContextAmbiguousError to surface as a PlanningError. This costs nothing this decision's own named scenarios need: both are about build-info/depth/collect-mode compatibility, resolvable from the request's own inputs before any compile-unit matching runs.

AnalysisPlan deliberately excludes resolved policy and the public-surface contract — an earlier draft of this decision included both, and implementation-plan review found neither belongs here. This decision's own scope is extraction-feasibility pre-flight; policy/pack resolution and contract-mode selection answer a later question (how an already-extracted comparison is classified and scored), and for the native compare/scan CLIs specifically that question is not yet answerable at the point a plan would be built — cli_compare_receipt. resolve_and_apply() (ADR-049 Phase 5) is a separate, Click-dependent step that runs strictly after snapshot resolution, not before it, since it depends on CLI-specific inputs (--policy/--pack/--exit-code-scheme/a discovered .abicheck.yml) a plan built from a bare request has no seam for. A plan that carried "resolved policy" as of its own construction would therefore be stale or incomplete for exactly that front end — worse than not recording the field at all. ADR-049's D7 precedence resolver keeps its own existing timing, wherever each front end's configuration seam for it already sits; this decision does not move that earlier or claim a canonical pre-extraction point for it that does not exist for every front end.

This generalizes ADR-050's comparability contract (a profile_fingerprint/ scope_fingerprint computed and checked, today, mostly after both sides are extracted) into a pre-flight step for evidence/extraction satisfiability specifically — not for policy, which stays exactly where it already ran.

D5 — Public surface as a query over one evidence graph

Superseded in part by the "Amendment" note near the end of this section (2026-09-02, accepted) — read that note first. The paragraph immediately below states this decision's original text, kept verbatim for the historical record (the package split, the shared-primitive reasoning, and the twelve-L5-call-site node-id-collision gap it describes all remain accurate and still apply). Its one specific sentence that does not hold as shipped is "compute_public_surface() becomes a traversal... through this graph": the Amendment note explains why, after three review rounds, that literal mandate was replaced with a deterministic SemanticReferenceIndex that never reads the graph for this decision's motivating case. Treat that sentence as historical intent, not current instruction.

Generalize the public-surface computation (surface.py, dumper_scoping.py, type_reachability.py, ADR-024/044) into a query over one authoritative graph with typed nodes (Header, TranslationUnit, Declaration, Type, Symbol, Target) and typed edges (Includes, Declares, References, Instantiates, Exports, OwnedByTarget, ...). compute_public_surface() becomes a traversal from explicit public roots through this graph rather than a second, independently-maintained reconstruction of the same relationships from the flat snapshot — closing the class of bugs AGENTS.md documents under the namespace-collision and partial-qualification findings in type_reachability.py.

This graph is not a new primitive. GraphNode/GraphEdge/ merge_graph_facts (ADR-031 D2, ADR-046 D1/D2) — relocated by an unrelated ADR-061 Phase 5 migration to model.graph_facts/graph_identity/ graph_vocabulary, with buildsource/graph_facts.py now a back-compat re-export shim — already is exactly the producer-agnostic node/edge/evidence-merge primitive this decision needs, today used to build the optional L5 source/build-evidence graph and, independently, impact/consumer_graph.py's/impact/use_cases.py's own consumer-impact graph (CodeRabbit review, PR #958). D5 reuses that already-relocated primitive directly (ADR-061's own task-routing table already names model/ as owning "an ABI entity/value shared across stages") and builds the public-surface graph as a second set of node/edge kinds over the same primitive, available unconditionally rather than gated on L3-L5 evidence — not a second dataclass hierarchy. A first draft of this decision proposed exactly such a second hierarchy in compare/; that draft was rejected during review for violating this ADR's own Governing Invariant, and the corrected design is what's stated here.

Building the graph and deciding relevance from it are two different responsibilities under ADR-061's own task-routing table ("match... a raw change" vs. "decide relevance"): the public-surface graph builder — the code that walks a snapshot and populates node/edge instances of the shared model.graph_facts/graph_identity/graph_vocabulary primitive D5 reuses above — lands in compare/ (a reconciliation of raw facts, not a policy decision; not the shared GraphNode/GraphEdge/ merge_graph_facts primitive itself, which stays under model.graph_facts per the relocation two paragraphs above — a first draft of this sentence said "the graph substrate lands in compare/," contradicting that same relocation within this decision's own text), and the relevance query itself — what compute_public_surface() actually decides — lands in policy/, which may import from compare/ under ADR-061's fixed dependency direction. D5 does not move a relevance decision into compare/; see the implementation plan's Phase 3 for the exact package split.

Sharing one node identity (Phase 2's EntityId) alone does not merge two independently-built graph objects — merge_graph_facts only folds the facts already attached to one node within one SourceGraphSummary instance; it is not itself what combines two builders' separate outputs. What actually closes ADR-053's TU→link-unit→DSO attribution and ADR-057's consumer graph risk of permanently disagreeing with the public-surface graph about the same declaration is a real assembly step: when L3-L5 evidence is present, both the public-surface builder and the L5 source- graph builder are handed the same SourceGraphSummary instance — by the orchestrating workflow code, since this is exactly where two independent builder packages can be made to share state without either importing the other — and both call its real add_node/add_edge, which is what invokes the merge. Migrating ADR-053/057's own query logic onto this shared graph directly is still explicitly not part of this decision's first implementation phase — see the implementation plan's Phase 3 for the exact assembly mechanism, the full file list, and why sharing one instance is sufficient to close the disagreement risk even before those consumers' own query logic moves.

"One authoritative graph" is this decision's target state, not a claim this decision's own first implementation phase fully achieves — a review round correctly asked where that gap is, and the honest answer is narrower than "closed." The public-surface builder and the pre-existing L5 builder sharing one SourceGraphSummary instance only reconciles a declaration/type node when both builders mint the same node id for it. Implementation-plan review found, and this decision's own first implementation phase does not close, a real case where they don't: the twelve pre-existing L5 producer call sites mint ids from a flattened, already-normalized identity string with no ScopePath segment-kind information behind it, while the collision-free id this decision requires (Phase 2's EntityId/OccurrenceId identity) is deliberately not injective on a flattened string — two EntityIds whose ScopePaths differ only in segment kind can render to the same string, which is exactly the collision this decision's own graph-key design avoids by keying on the segments' own identity fields instead. Migrating those twelve L5 call sites to carry real structured identity through is a genuine, cross-cutting rewrite of eight already-complex modules, not a same-phase fix — so for a snapshot with L3-L5 evidence where a real segment-kind collision occurs, the public-surface node and the L5 node for that one declaration remain two separate, unreconciled nodes even after this decision's first implementation phase ships, an accepted, named limitation rather than a silently-assumed-closed one. See the implementation plan's Phase 3 for the full accounting and what a real fix would require.

Amendment (2026-09-02, accepted): the shipped design does not read this graph for the public-surface closure walk, and this decision's own text is amended to match rather than left describing a design that was tried and rejected. Three independent review rounds on the traversal migration (see the implementation plan's own Phase 3 section; full account in docs/contribute/known-gaps.md) each found reading AbiSnapshot. surface_graph for compute_public_surface() unsafe — stale/unenriched node attrs on the ordinary dump path, a measured 30-100%+ performance regression once enrichment was added to fix that, and a confidence-merge precedence hazard that let a stale or adversarial persisted fact win over a freshly-derived one even with enrichment in place. The shipped fix removes the graph from this one computation entirely: compare/surface_graph.py::referenced_identifiers_by_node() is a pure function of the snapshot's own current declarations, computed before any GraphNode is built, and is what policy/public_surface_closure.py/ export_surface.py call directly. This decision's "one authoritative graph, queried by traversal" premise is therefore split into two concepts going forward, formally amending D5's own text: a SemanticReferenceIndex (deterministic, sourced from SemanticIR/snapshot declarations alone, authoritative for the public-surface closure walk — what referenced_identifiers_by_node() already computes today, not yet re-sourced from SemanticIR specifically) is the query this decision's motivating case actually needs, and the shared evidence graph (GraphNode/GraphEdge/ merge_graph_facts, multi-producer, provenance/confidence-bearing) remains for explanation and L5 analysis — never for a decision with exactly one legitimate source, which is what a mergeable, multi-producer structure structurally cannot safely be. Re-sourcing referenced_identifiers_by_node() from SemanticIR instead of raw declarations is tracked under Phase 6B (SemanticIR checker cutover) in the implementation plan, not as a residual of this decision. The twelve-L5-call-site node-id-collision gap above is unaffected by this amendment — it was never about this closure walk.

D6 — RunOutcome as independent axes; no exit_code inside the domain

Generalize ADR-042's CompatibilityDecision/gate-decision split and the existing AnalysisAssurance axis into one RunOutcome with independent, non-encoding fields:

RunOutcome(
    compatibility: CompatibilityVerdict | None,
    assurance: AnalysisAssurance,
    gate: PolicyGateDecision,
    operational: OperationalStatus,
    lifecycle: TargetLifecycle,
)

compatibility is optional, not required, and a review round correctly found the first draft's bare CompatibilityVerdict couldn't represent every report this ADR itself names. The implementation plan's Phase 7 is explicit that buildsource/check_report.py's build_operational_ error_report()/build_bootstrap_report()/build_new_target_report() each build a report for exactly the non-EXISTING cases operational/ lifecycle exist to represent — a pipeline failure before any comparison ran, a target with no baseline to compare against yet, a target new to this baseline-set — none of which ever computed a real compatibility verdict. A required field leaves an implementer with no honest value to put there: inventing a verdict (COMPATIBLE as a default, say) would silently claim a comparison happened when none did, exactly the kind of encoded-meaning collapse this decision exists to get away from. None means "no compatibility axis was computed for this report," distinct from any real CompatibilityVerdict member — not a sixth verdict value folded into the enum (which would force every existing compatibility-axis consumer to handle a case that isn't actually a verdict), but the absence of the axis itself, the same shape Fact[T]'s NotApplicable uses one layer down for a fact that doesn't apply to a given entity. Every ordinary single-pair compare, scan --against, and release/bundle comparison populates a real, non-None value; only the three synthetic report builders above, and any future report genuinely representing "no comparison ran," leave it None.

TargetLifecycle is attributed to ADR-053 in an earlier draft of this decision without checking — ADR-053 is TU-to-link-unit-to-DSO attribution and defines no lifecycle vocabulary at all, and no TargetLifecycle type exists anywhere in the repository today. Defined here instead, grounded in vocabulary this codebase's aggregate domain already distinguishes rather than invented from nothing: workflows/aggregate/contracts.py's own _BOOTSTRAP_VERDICT/_NEW_TARGET_VERDICT sentinels separate exactly the cases a target's own lifecycle state needs to distinguish — TargetLifecycle = EXISTING | BOOTSTRAP | NEW_TARGET, where EXISTING is the ordinary case (a target this baseline-set already knows, with a real prior baseline to compare against), BOOTSTRAP is load.py's own "no baseline published yet" synthesis (raw_verdict == _BOOTSTRAP_VERDICT), and NEW_TARGET is its "target new to this baseline-set" synthesis (raw_verdict == _NEW_TARGET_VERDICT). This axis is meaningful only where a "target" exists at all — a single-pair compare invocation, with no baseline-set or expected-target concept, always reports EXISTING, the same fixed default every non-aggregate RunOutcome construction uses; aggregate's own _load_report_file is where the real derivation (reading raw_verdict against the two sentinels above — both genuinely part of a report's own persisted content) happens, not a new mechanism this decision invents.

TargetReport.unexpected is deliberately not folded into this axis — an earlier draft of this section included it as a fourth member, and review correctly traced why that doesn't work. Reading the real code (workflows/aggregate/execute.py), unexpected is not part of a report's own content the way verdict is — it is assigned by the aggregator, per invocation, from comparing the set of reports actually found against a specific expected-target manifest (ExpectedTargets) that invocation was given. The identical report file, aggregated against two different manifests, can be unexpected under one and not the other — so it cannot be a property _load_report_file derives once and bakes into a per-report RunOutcome, the way EXISTING/BOOTSTRAP/NEW_TARGET genuinely can be. It is also not mutually exclusive with the other three members the way a single enum requires: a target can be both NEW_TARGET (or mid-bootstrap) and absent from a given manifest's expected set at the same time, two orthogonal facts a four-way enum has no way to represent together. unexpected therefore stays exactly where it already correctly lives — a separate, aggregation-context-only field (TargetReport.unexpected, set by execute.py at aggregation time, after RunOutcome.lifecycle has already been read off the loaded report) — and this decision does not move it, fold it into RunOutcome, or ask any report writer to emit it. PolicyGateDecision is a new, code-free type this decision defines — not a reuse of severity.GateDecision, the existing type that already has this name's natural meaning in the codebase today. The existing severity.GateDecision dataclass carries exactly what D6 exists to keep out of domain/workflow data: an exit_code: int, a blocking: bool derived from it, and blocking_categories, all scheme-dependent (legacy vs. severity-aware) and correct for what that type is — the CLI's and aggregate's own exit-code encoder output, not something RunOutcome should hold. PolicyGateDecision is instead an ordered, exit-code-free enum/dataclass (e.g. NONE < ADDITION_QUALITY < POTENTIAL_BREAKING < ABI_BREAKING, or the equivalent IssueCategory-shaped ordering compute_exit_code's own severity scheme already uses) carrying enough to derive an exit code, never one itself. PolicyGateDecision alone is not the whole gate, though — it only orders compatibility categories, and scan's own legacy exit codes (5 for budget overflow, 6 for not-comparable) are real, independent blocking conditions neither category covers. RunOutcome.operational carries exactly this.

OperationalStatus itself is named throughout this decision with no defined members, ordering, or front-end mapping anywhere — an earlier draft used it as if it were already specified, and review correctly found nothing backing that. Defined here, grounded in the real, already-distinct conditions this codebase's report writers/readers already encode rather than invented from nothing: OperationalStatus = NONE | BUDGET_OVERFLOW | NOT_COMPARABLE | EVIDENCE_CONTRACT_ERROR | EXTRACTION_ERROR, ordered NONE < BUDGET_OVERFLOW = NOT_COMPARABLE = EVIDENCE_CONTRACT_ERROR = EXTRACTION_ERROR for fold purposes — the four non-NONE members are equally blocking and mutually exclusive per report (a scan run that hit budget overflow didn't also fail extraction), so there is no further internal ordering among them to state, only "blocking vs. not." BUDGET_OVERFLOW/NOT_COMPARABLE are scan's own legacy exit 5/6 (gate.py::from_scan_report's existing raw-code branch, read the same way for a fresh, structured report). EVIDENCE_CONTRACT_ERROR is a fourth, genuinely distinct member, not a renaming of EXTRACTION_ERROR — an earlier draft of this definition conflated the two, and review correctly found service_scan.py returning a real, separate ScanResult(verdict="EVIDENCE_CONTRACT_ERROR", exit_code=1) at more than one call site (ADR-037 D5's evidence-contract check), never mapping to EXTRACTION_ERROR's own exit 4. EXTRACTION_ERROR is compare-release's own verdict: "ERROR" sentinel (load.py's _OPERATIONAL_ERROR_VERDICT — "a library failed to dump/extract/compare," ranked above BREAKING and floored to exit 4 today). Each front end maps its own real failure modes onto this set at the point it already computes them (ScanOutcome/ScanResult/ ScanSetResult's own to_dict() for scan's three members, the release fan-out for the fourth) — this decision does not invent a new front-end computation for it, only the one shared vocabulary the existing writers already need to agree on. A front end with no operational failure of its own to report (a plain single-pair compare, a scan invocation that completed cleanly) always reports NONE.

Which layer folds the two axes together — gate.py's own per-target readers, or fold.py's cross-target aggregation — is stated once here, and an earlier revision of this section said it twice, inconsistently: "fold.py's aggregation orders and max()s PolicyGateDecision values directly, with no integer in the comparison" in this paragraph, while the implementation plan's own corrected Phase 7 design (the one actually built and verified) has gate.py's readers fold both axes into the one GateInfo they already return, leaving fold.py itself unchanged. The plan's design is the normative one, restated here rather than contradicted: gate.py's GateInfo.from_report_data/from_scan_report fold PolicyGateDecision's ordering and OperationalStatus's blocking set together, by max() over the shared exit-code scheme both already share, into the single GateInfo each reader returns per target — two independent axes, neither masking the other, the same orthogonal-fold shape ADR-049 Phase 7's contract-coverage axis already uses elsewhere in this codebase, resolved once per target rather than threaded as two separate values for fold.py to remember to fold itself. fold.py's own cross-target aggregation (max(t.gate.exit_code for t in gated...)) is unchanged by this decision — it was already, and stays, a max() over each target's own GateInfo.exit_code, which by the time it reaches fold.py is the output of gate.py's semantic fold, not a raw integer read back off a persisted report a second time; "no integer in the comparison" describes gate.py's own fold of the two typed axes, not fold.py's aggregation across targets, which has always been (and remains) an integer max() by design. Converting a PolicyGateDecision to severity.GateDecision. exit_code is confined to the same boundary encoders D6 already names — a first draft of this list omitted gate.py itself, contradicting the fold this same decision just described two sentences earlier: folding PolicyGateDecision and OperationalStatus together "by max() over the shared exit-code scheme" is exactly this conversion, performed inside gate.py, not deferred past it. The real, complete list is four encoders, not three: gate.py (folding both typed axes into one GateInfo.exit_code, once per target, the read-time boundary), the CLI's _exit_with_severity_or_verdict, the Action's encoder, and aggregate's own exit_code() method (each converting an already-folded GateInfo/ RunOutcome into the final process exit code or JSON integer, the write-time boundary) — every one of them already exists today and already owns exactly this conversion for the legacy-scheme case, so this is a new input type for an existing function, not a new encoder. The two boundaries stay genuinely distinct rather than one encoder duplicating the other's work: gate.py is the only place a RunOutcome axis is decoded from raw fields at all (structured-first, with the legacy exit_code decode as the named fallback); the other three only ever consume gate.py's already -folded GateInfo/fold.py's own cross-target aggregation of it, never re-deriving the same fold from RunOutcome a second time.

No domain or workflow code computes a new semantic decision by branching on an integer exit code. The one stated exception is a compatibility adapter, not a gap in this rule: gate.py's decode of a legacy report's raw exit_code — a report that predates this decision and carries no structured RunOutcome fields at all — is reading a persisted external wire value back into the semantics this decision defines, the same "read once, decode for legacy, never for fresh" backfill shape D0's Fact[T] bridge already establishes against the pre-existing reliability flags. It is scoped narrowly (named fallback path only, never reached for a report carrying the new fields) and is the mechanism that keeps every already-published report decodable rather than orphaned by this decision. Exactly one function per front end (the CLI's _exit_with_severity_or_ verdict, the Action's own encoder, and aggregate's own fold.py:: exit_code() — restated here explicitly rather than only in the paragraph above, so this sentence's own list doesn't read as narrower than it is) maps RunOutcome to that front end's exit-code scheme. "The Action's own encoder" names the role this decision assigns to action/run.sh, not a claim that Phase 7 of the implementation plan migrates it there — implementation-plan review found that phase's Files list never touches the script, so its several hundred lines of case $ABICHECK_EXIT in ...) branching stay exactly as unmigrated after that phase as before it, pending a future, separately-scoped rewrite; see that plan's own Phase 7 text for the full accounting. For the aggregate path specifically: gate.py reads a report's structured RunOutcome.gate/.operational fields and folds both into the one GateInfo it returns, by max() over the shared exit-code scheme — PolicyGateDecision's own ordering for the compatibility contribution, OperationalStatus's blocking set for the operational one — falling back to legacy exit_code decoding only for a report that predates this change; fold.py's own aggregation (unchanged by this decision) max()s the resulting per-target GateInfo.exit_code values the way it already does today, and fold.py::exit_code() is the one place that final aggregated value is converted back to the integer aggregate's own JSON output and process exit code need — two steps (structured read-and-fold at gate.py, one final integer encode at fold.py::exit_code()), not three, since folding both axes together at the read boundary is what lets fold.py's own pre-existing aggregation stay exactly as it is. This directly targets the PR #700 failure mode (a downstream consumer decoding semantic meaning from an exit-code integer) and finishes what ADR-042 started: mcp_server.py's removal already deleted one of the remaining inline exit-code computations AGENTS.md flagged; workflows/aggregate/gate.py/fold.py's own max()-over-raw-integer aggregation is folded into this decision's scope too — it was missed by an earlier draft of this ADR's own implementation plan, caught in review, and is exactly the shape of gap D6 exists to close: decoding and aggregating exit-code semantics inside aggregate's own workflow code, not only at its final encoder.

RunOutcome is a report-level aggregate; it is not a substitute for a per-finding decision, and this decision does not ask it to be one, nor does it touch junit_report.py's own per-test-case pass/fail — _is_failure already decides that per-render, from (Change, SeverityConfig, relevant_ids), and an earlier draft of this decision tried repeatedly to move that answer onto a stored Change field (first reusing compatibility_decision, deliberately None/ NOT_EVALUATED by design and therefore the wrong signal; then a new, always-resolved field under several candidate stamping layers). Review correctly rejected the whole direction, not just each individual layer: the identical DiffResult can legitimately be rendered twice under two different SeverityConfigs/relevant_ids, requiring opposite _is_failure answers for the same Change — a single field stamped once on a shared object cannot represent that, regardless of which layer stamps it. D6 therefore touches no per-finding field at all; _is_failure stays the unchanged per-render function it already is. See the implementation plan's Phase 7 for the full correction and for exactly what this decision does and does not change.

D7 — A declarative fact/capability registry

Every persisted, detected, or reported fact is declared once — value type, producing backends, persistence, identity-relevance, comparability, suppressibility — in a registry (abicheck/model/ fact_registry.py or equivalent), generalizing change_registry.py's existing ChangeKindMeta pattern (already required for every ChangeKind, per AGENTS.md's "Adding a new ChangeKind" steps) from change kinds to facts. Serialization, the backend capability matrix (docs/reference/*capability*), coverage computation, and the AI-readiness changekind-* checks are generated or validated from this registry instead of hand-maintained in parallel, closing the "add a field, touch nine files" cost the ELF-binding incident (PR #734) is the canonical example of. Detector logic stays hand-written — this only removes the mechanical plumbing, mirroring the distinction AGENTS.md's own decision already draws between "the registry entry" and "implement detection in the appropriate diff module."

This decision's initial realization is narrower than "every fact," and a review round correctly found the implementation plan's own Phase 5 scope (availability-ambiguous fields guarded by a backend-reliability flag — the ELF-binding incident's own shape) does not cover the full population this paragraph names, without that gap being stated anywhere. An ordinary, always-present fact with no availability ambiguity at all — an entity's name, a type's size, a symbol's binding, a report-only derived value — has no "unavailable vs. genuinely absent" question for Fact[T]/a reliability flag to resolve, so registering it carries none of D7's stated benefit (closing exactly that ambiguity) and would, at the scale of this codebase's model, mean an inventory entry for essentially every dataclass field that exists — a different, much larger project than the one Phase 5 actually ships. D7's own scope is therefore the availability-bearing subset Phase 5 implements; registering the remaining, unambiguous fact population is a real, legitimate directional extension of this same registry, not ruled out by this ADR, but it is an explicit, separately-justified future amendment — per this ADR's own "don't attempt a change with no real caller" discipline (D1) — not a claim this decision already covers.

A fact also carries an explicit lifecycle state (MODELLED → PRODUCED → NORMALIZED → PERSISTED → CONSUMED → REPORTED → PUBLIC). A capability is documented or exposed as a CLI option only once it reaches PUBLIC — closing the repeated "shape shipped, wiring followed later" pattern AGENTS.md records for the L3→L2 fold (compare's implicit-dump path wired first, dump's CLI path following later, scan's candidate resolution later still).

D8 — Finish storage v2 as domain/wire separation, nothing else

ADR-062 already states the correct target for persistence: content- addressed sections, explicit fact availability, occurrence-preserving identity, separated version axes, semantic-vs-operational payload separation. This ADR adds one explicit rule to that target: no phase of storage v2 serializes a runtime/domain object directly (no asdict( AbiSnapshot), no single large mirror deserializer the shape serialization.py's snapshot_from_dict has today — PR #696, refactor: cut CodeFactor complexity across the five reporting entry points, already had to de-duplicate part of that same function's structure once, which is evidence the shape recurs rather than evidence it was fixed). Each layer is Domain SemanticIR → DTO vN → canonical wire encoding, and back, with a migration adapter per DTO version — so an internal refactor (a synthetic key rename, a reordered field) is never, by construction, a persisted-schema change. This decision does not change ADR-062's phasing; it is an added constraint on Phase 1/2's implementation.

D9 — A canonical semantic IR between raw backend output and the checker

CastXML, direct-clang, DWARF, PDB, and BTF/CTF (the Linux kernel debug formats — each has its own type representation, BtfType/CtfType, and its own _TypeResolver, genuinely exposed to the identical class of scope/spelling-normalization disagreement the header-AST/DWARF backends already have, even though no specific AGENTS.md incident has been filed against them yet) each produce RawXFacts; a single SemanticNormalizer (not duplicated per backend) turns each into one SemanticIR that the comparison layer consumes. Canonicalization of type spelling, scope, template arguments, anonymous/lambda naming, CV-qualification, and identity happens exactly once, downstream of the backend, rather than once per backend — closing the repeated-fix pattern AGENTS.md documents across more than a dozen numbered findings in the type_reachability.py/ dumper_clang.py/dumper_castxml.py entries, each an instance of one backend normalizing a construct the other backend handles differently (or not at all). A backend adapter's contract narrows to "what did the tool actually say" — it no longer decides how abicheck identifies a C++ entity.

ELF/PE/Mach-O binary-symbol extraction is deliberately excluded from this normalizer, not an oversight of scope. A first draft of this decision named it alongside the type-declaration-producing backends above; review correctly pointed out the implementation plan never actually migrated it, and on inspection there is nothing there to migrate in the same sense: elf_metadata.py/pe_metadata.py/macho_metadata.py extract symbol-table facts (a name string, a binding, a section/offset) with no AST-level type declaration, template argument, or anonymous/lambda-naming concern of the kind SemanticNormalizer exists to canonicalize — there is no cross-backend type-spelling disagreement to close for a binary symbol table, because there is no type spelling there at all. Scoped out explicitly here rather than left as a silently-unmigrated item the implementation plan's own completeness check would otherwise have to explain away.

"The comparison layer consumes" above names the target architecture, not this decision's own phase's delivery bar — a review round correctly asked where the checker actually migrates onto SemanticIR, and the honest answer is: not in the phase that builds it. The implementation plan's Phase 6 assembles a real SemanticIR alongside every existing backend (populating AbiSnapshot.semantic_ir, verified end to end against every assembly call site) and makes it available to a future detector, but every detector that ships with that phase still reads the legacy functions/types/... projection, exactly as before — checker.compare() needs no change there, and is given none. That is a deliberate, named sequencing rather than an oversight: migrating the checker's own detectors onto SemanticIR is a separate, much larger change (every diff_*.py module, not one normalizer), and attempting it inside the phase that first makes SemanticIR exist would mean validating both "is the IR correct" and "does every detector still behave identically once reading from it" in one unreviewable pass. The two representations can therefore disagree after construction (Phase 6's own text says so), and neither is retired by this ADR — that happens in whichever future, separately-scoped phase first has a real SemanticIR-only detector population large enough that the legacy projection has no remaining reader. This decision's own commitment is narrower than "the checker consumes SemanticIR" read in isolation would suggest: it is that a canonical IR exists, is assembled once per backend instead of per detector, and is reachable — not that this ADR's own phases complete the checker migration onto it.

D10 — Selector/identity/availability as dependency-free leaf packages

selectors/ (glob/regex/match grammar shared by suppression, reclassification, and policy filtering), the Fact[T]/identity primitives from D2/D3, and the fact registry from D7 are leaf packages with zero dependency on checker_types, reporter, or any CLI module — generalizing the fix direction PR #733 already took for one local import cycle (reclassify.py's importlib.import_module workaround to avoid importing suppression.py) into a standing rule enforced by scripts/ check_architecture.py's import-direction gate (ADR-061), not solved ad hoc per occurrence. See the implementation plan's Phase 9 for the selector half specifically: extracting the shared matching grammar into policy/selectors.py is what actually lets reclassify.py drop its importlib.import_module workaround, rather than this decision stating the generalization as an aspiration with no phase that closes it.

Relationship to existing ADRs

This ADR does not supersede any of the ADRs it builds on. It states how their already-accepted decisions generalize and finish converging:

  • ADR-055 (typed request/result): D1 extends its CLI/API convergence from compare to every operation; D7's registry extends its schema-version-registry idea from wire formats to facts generally.
  • ADR-061 (responsibility packages): D1/D9/D10 are instances of the extract/model/compare/policy/workflows/storage/report/frontends ring structure it already defines; this ADR does not add new packages beyond what ADR-061 already names, and the model package is D2/D3/D7's home.
  • ADR-062 (storage v2): D8 is an added constraint on its existing phasing, and D2/D3 generalize its Phase 0 primitives (FactAvailability, occurrence-preserving identity) from storage-only to domain-wide. Nothing here changes ADR-062's own phase boundaries.
  • ADR-042 (compatibility/gate separation) and the AnalysisAssurance axis: D6 is their completion, not a new design — it closes the two remaining inline exit-code computations ADR-042 already flagged as unfinished.
  • ADR-046/048 (canonical entity identity, source-graph identity v2): D3 generalizes their identity model from source-graph matching to every identity-bearing subsystem (diff matching, graph identity, persistence) — not suppression selectors, which D3 itself now explicitly excludes (a first draft of this summary still listed them, contradicting that correction; fixed here to match).
  • ADR-049 (contract relevance/compatibility configuration): D4's AnalysisPlan deliberately does not relocate its D7 precedence resolver's own call site — per D4's own corrected scope, policy/pack resolution stays exactly where each front end's configuration seam for it already sits, since AnalysisPlan carries no policy field to give it a second one. D6's RunOutcome is where its compatibility_decision axis already lives and stays.
  • ADR-050 (comparability contract): D4 promotes its fingerprint checks from a post-extraction gate to a pre-extraction planning input where possible; the fingerprint mechanism itself is unchanged.
  • ADR-036 (report view-model): D6's RunOutcome is exactly the upstream object ADR-036's ReportModel should render — this ADR extends ADR-036's "renderer does not compute, only displays" rule to cover the gate/assurance/lifecycle axes ADR-036 predates.

Consequences

Benefits

  • A large, recurring class of defect (cross-representation disagreement) becomes structurally harder to introduce, not merely better-tested.
  • New facts, new config fields, and new policy decisions have one integration point instead of N, shrinking both the review surface and the AGENTS.md "Known gaps" entries this exact pattern keeps producing.
  • Generalized regression tests shrink and strengthen: a test proves one shared primitive's contract once, rather than re-proving "this config value reached every consumer" at each consumer.
  • Performance improves as a side effect of correctness (D9's single normalization pass, a run-scoped semantic index replacing repeated re-derivation — see the CastXML XML-root-scanned-nine-times finding in AGENTS.md for a concrete case this already reproduces).

Costs / risks

  • This is a multi-quarter, many-PR effort touching nearly every package. Each phase must be independently shippable and behavior-preserving, or the project accumulates exactly the kind of half-migrated parallel path (ADR-061's own "Phase 5 begun... the rest remains incremental" note is the honest precedent) this ADR is meant to prevent.
  • Several of the backing ADRs (049, 050, 055, 061, 062) are themselves partially implemented. Sequencing matters — D1 depends on finishing specific, already-identified blockers in service_dump_pipeline.py (see AGENTS.md's "PR C" entry) before it can close; D8 depends on ADR-062 Phase 1 landing an actual writer/reader, not only the Phase 0 primitives that exist today.
  • A registry-generated capability matrix (D7) can mask a real gap as "modelled" if lifecycle-state discipline isn't enforced in code review, not only in the registry's own schema — this is the same vigilance AGENTS.md's "change the kind of tests that get written" section already asks for ChangeKind registration.
  • D6 does not remove or change junit_report.py's own inline _is_failure computation — its answer is a per-render function of each call's SeverityConfig/relevant_ids, not a property a finding carries, so there is nothing to migrate away from it, and an earlier draft of this section overstated D6's scope to include it. The behavior-risk- bearing change D6 actually makes is the aggregate report reader/encoder migration (workflows/aggregate/gate.py's readers folding structured RunOutcome.gate/.operational fields, fold.py::exit_code() staying the unchanged external encoder) — that is what needs its own parity tests before and after, per the repository's "toolchain/wire format changes need a round-trip test at production scale" convention, run against both a legacy-exit_code-only fixture and a freshly-regenerated structured-field fixture.

Explicitly not done by this ADR

  • No new CLI root command, no new Python public API class is promised by this decision alone — RunOutcome, AnalysisPlan, Fact[T], EntityId are internal domain types unless and until a specific phase's plan explicitly promotes one to public API (and updates ADR-055's typed surface accordingly).
  • No existing persisted schema (snapshot v25, BundleFacts v1, baseline set manifests) changes as a result of accepting this ADR alone — before any of its phases land, nothing about today's schemas is different. Once the implementation plan's phases do land, several schema migrations are explicit and intentional parts of specific phases, stated there and nowhere hidden: Phase 0 bumps serialization.SCHEMA_VERSION for the new Fact[...] fields (the same counter every prior reliability-flag addition already bumped), Phase 7 adds structured RunOutcome fields to the report JSON alongside the unchanged exit_code, and Phase 8 is ADR-062's own ProjectSnapshot/DTO schema migration, on ADR-062's own phasing, unchanged by this ADR. This bullet is about the decision to adopt ADR-063, not a blanket freeze on every schema touched by its own implementation.
  • No existing exit code, JSON field, or CLI flag changes meaning as a result of accepting this ADR alone; D6/D7's changes are internal until a phase's own plan states an external contract change and the usual docs/test/changelog-fragment discipline applies.

See the implementation plan for phasing, sequencing against the blockers named above, file-level targets, and acceptance criteria per phase.