G33 — Typed API convergence: schema registry, Request/Result completeness, MCP dedup¶
Historical note (2026-08-09). The MCP server this plan tracks convergence against has since been removed entirely (see
docs/contribute/adr/021-mcp-security-model.md, retired the same date). Every MCP-specific reference below (theabi_compare/abi_dump/abi_scantools,mcp_server.pyand its sibling modules, MCP↔CLI parity tests) is stale and describes a removed interface. The architectural conclusions that remain in force —CompareRequest/DumpRequest/ScanRequest/CompareResultas the shared typed request/result models, the schema registry, andresolve_compare_request/classify_compare_pairas the canonical Tier-2 resolution/classification split the CLI and the typed Python API both go through — are unaffected by the removal and are documented in current form indocs/use/python-api.mdanddocs/reference/compatibility-evaluation-config.md. This file is kept as a historical record of how that convergence was designed and phased, not as active guidance.
Status: Phases 0-5 done (ADR-055 D1-D4 all implemented, including D1's
deferred structural half — the CLI now shares one resolution with the typed
and MCP paths; Phase 5 gave dump the typed request compare had, and with
it the MCP parity it was blocking); Phase 6 is a standing constraint, not
work. One piece of follow-up is named and tracked rather than folded into a
closed phase: the native dump CLI still resolves its own inputs — see
Phase 5's "Deliberately not done here"
Normative decision: ADR-055
(Accepted — implemented)
Related: ADR-037 (G22, CLI consolidation — done), ADR-049 (contract relevance,
see public-contract-default.md for its own
rollout), ADR-043 (used_by/required_symbols app scoping), ADR-050
(comparability contract / frontend_context, see G32),
G30 (Actions tiers — this plan
defers to it rather than duplicating)
Scope: abicheck/service.py, abicheck/api_types.py,
abicheck/schemas/, abicheck/mcp_server.py's abi_compare
1. Where this came from¶
Two rounds of an external, Russian-language "API layers" review of abicheck
(CLI/Python service/MCP/Actions/schemas/producer-SPI/ADR-049) were checked
against main in the same session. The first round was largely stale —
G22/ADR-037 had already landed the CLI consolidation and a real MCP↔Tier-2
chokepoint the review flagged as missing — but verifying it line-by-line
surfaced two real, narrower gaps, written up as ADR-055 D1–D3 and addressed
partially in PR #646 (new
abi_deps/abi_aggregate/abi_project_validate/abi_project_plan MCP
tools, an Actions-tiers doc section, ADR-055's draft). A second, more
detailed round of the same review reasserted that abi_compare duplicates
Tier-2 business logic as its top-priority finding. Re-reading
mcp_server.py's abi_compare line-by-line (not just trusting either
review) confirmed the second round was right and ADR-055's own
first-draft Non-goal ("not touching the MCP server's tool surface... already
routes through compare_snapshots") was wrong — see ADR-055's "Gap 3"
section and its correction note for the specifics. This plan is the
implementation tracker for ADR-055's four decisions (D1–D4) plus the parts
of the review's remaining scope that don't already have a home elsewhere in
this repo's plan/ADR set.
What this plan deliberately does not re-litigate, because it already has an owner:
- Actions tiers / a second external CI-integration pilot — owned by
G30 (already tracks "P2 —
Deeper architecture" and the open "second complex pilot" gap). The
Recommended/Advanced/Legacy classification this plan's source review
proposed is already partially documented in
docs/integration/index.md's "Which Actions building block do I use?" table (landed in PR #646); a further narrowing of the rootaction.ymlitself is G30's to schedule, not duplicated here. - ADR-049 shadow-evaluator rollout — owned by public-contract-default.md. This plan's Phase 6 below only asserts the sequencing constraint (don't make ADR-049 authoritative until all frontends share one resolved config), it does not track ADR-049's own phase-by-phase status.
- Producer/extension SPI (
abicheck.evidence_providersentry points) — the source review itself judged this premature before producer identity/ version/digest stabilizes (ADR-049 already lays that groundwork). No phase below covers it; revisit only after ADR-049's evidence-provider model is live.
2. Target architecture¶
CLI / Python / MCP / Action
│
▼
Public Request Models
│
▼
Resolve + Validate + Provenance
│
▼
Resolved Operation Request
+ Evaluation Receipt
│
▼
Core Engine
│
▼
Typed Result Model
│
┌────────┼─────────┐
▼ ▼ ▼
CLI text MCP obj JSON artifacts
Invariant this plan exists to restore: no front end (CLI, MCP, or an
Action) resolves inputs, loads policy/suppression, or classifies a
comparison on its own — every front end builds the same typed request and
reads the same typed result for that classification step. abi_compare was
the one confirmed violation (Phase 4/D4 below) and no longer is; the other
phases closed gaps that made the invariant harder to reach even where it
wasn't yet violated. One qualification the invariant needs, now that it
holds: the CLI compare command builds the same request type, runs the
same resolution, and reads the same result. It carried its own richer
input resolution (cli_resolve._resolve_compare_snapshots) until Phase 2's
structural half landed; that helper now builds a CompareRequest and
delegates to the shared resolve_compare_request. See Phase 2's progress
note. This deliberately does not cover every
downstream presentation concern: used_by/required_symbols app-scoping
and severity/exit-code computation are explicitly kept as thin,
front-end-specific glue applied after a typed result exists (Phase 4
below) — they are not part of "recomputes scope/severity" in the sense this
invariant restricts, since there is no shared typed field for them to
recompute from a single source in the first place; folding them into the
request/result types would recreate the "one growing struct" problem this
plan's earlier phases exist to avoid.
3. Acceptance outcomes¶
abi_compare's implementation contains no local_resolve_inputcall and no directPolicyFile.load/SuppressionList.loadcalls — every one of those goes throughrun_compare_request/CompareRequest.- A
CompareRequestcan express every inputabicheck compareitself accepts (depth, sources, build_info, dump_manifest, per-sideCompileContext/frontend_context, public_header_dirs) — no caller needs to fall back to loose kwargs on a lower-level function to reach a feature the CLI already has. abicheck.schemas.current(name)returns the real, current version for every persisted artifact this repo emits (snapshot, compare, scan, aggregate, build-output, run-plan), and every doc that states a version number pulls it from there instead of a hand-copied literal.- A parity test proves
abi_compare's output for a fixed input matches the CLIcomparecommand's output for the equivalent flags, both before and after the D4 rewrite — the rewrite must not silently change behavior.
4. Work breakdown¶
Phase 0 — record the gap accurately (done)¶
- ADR-055 written (D1–D3), then corrected once Gap 3 was confirmed by
reading
mcp_server.pydirectly rather than trusting either review verbatim (D4 added). OutputSpecre-exported fromservice.__all__(was created inapi_types.pybut never exported — a smaller, already-fixed instance of the same "typed surface exists but isn't the real chokepoint" pattern).docs/use/python-api.md's staleschema_version 8claim fixed to link toreference/snapshot-format.mdinstead of a hand-copied number (the specific bug D3 exists to prevent recurring).
Gate: ADR-055 accurately describes current main, not a stale
snapshot of it. Progress: done, including the correction.
Phase 1 — schema-version registry (ADR-055 D3)¶
Add abicheck.schemas.current(name) (or an equivalent lookup), backed by
the existing constants: serialization.SCHEMA_VERSION,
abicheck.schemas.REPORT_SCHEMA_VERSION,
abicheck.schemas.SCAN_SCHEMA_VERSION,
aggregate.AGGREGATE_SCHEMA_VERSION,
buildsource.build_output.BUILD_OUTPUT_SCHEMA,
buildsource.run_plan.RUN_PLAN_SCHEMA. Read-only lookup facade — no new
versioning scheme, no compatibility metadata, no change to any constant's
current value or bump policy. (Deliberately not quoting each constant's
current value here — this page would go stale exactly the same way the
docs/use/python-api.md bug that motivated D3 did; call schemas.current()
or read the owning module's docstring for the live value.)
Gate: every persisted-artifact version number quoted in docs/ is
generated from this registry (or a page that itself reads from it), not a
hand-copied literal.
Progress: done. The registry itself —
abicheck.schemas.current(name) (abicheck/schemas/__init__.py) covers
all six artifact names above, backed by each artifact's existing constant
via a function-local import (a module-level import would create a real
cycle: run-plan needs buildsource.run_plan.RUN_PLAN_SCHEMA, but
buildsource/run_plan.py imports buildsource/check_report.py, which
already imports abicheck.schemas — confirmed by reading both modules
before choosing the deferred-import shape). Covered by
tests/test_schemas_registry.py.
The gate above ("every version number quoted in docs is generated from this
registry, not a hand-copied literal") is now met too, by a route this phase
did not originally anticipate: rather than converting pages to generated
ones, scripts/check_ai_readiness.py's existing doc-count-sync check
reads its expected snapshot and compare-report versions from
schemas.current() and pins them against the pages that quote them. These
numbers live inside hand-written prose and JSON examples, so a generator
would have had to own a whole page to own one number, while the check fails
the build on exactly the drift D3 was about — and caught two live instances
on its first run (docs/use/output-formats.md and
docs/reference/check-target.md both quoting long-superseded
report_schema_version values). Extending it to the remaining four
artifacts is mechanical: add an anchor when a doc page starts quoting one.
Phase 2 — extend InputSpec/CompareRequest (ADR-055 D1)¶
Add, as additive fields with defaults (api_types.py's own stated
convention): InputSpec.sources/build_info/dump_manifest/compile
(per-side CompileContext override)/public_header_dirs;
CompareRequest.depth/frontend_context. run_compare_request already
resolves every one of these concepts internally — this phase gives the
typed request a way to carry them in, not new resolution logic. Also add
the policy/suppression fields Phase 4 needs (CompareRequest has neither
today).
Gate: the two service.py comments ADR-055 quotes ("CompareRequest
has no explicit --gcc-options equivalent today"; "no lower-level
'parse only, don't classify' mode") are no longer true.
Progress: done, in PR #651
— whose title ("close dump/compare dependency-scoping asymmetry") is why this
note went on saying "not started" for several PRs afterwards. InputSpec
gained sources/build_info/dump_manifest/compile/public_header_dirs,
CompareRequest gained depth/frontend_context, and the resolution wiring
lives in the new abicheck/service_compare_evidence.py. The gate holds:
neither service.py comment quoted above still exists. Tests:
TestCompareRequestAdr055Evidence (tests/test_service_unit.py) and the
ADR-055 D1 blocks in tests/test_api_types.py.
The two-resolution-path question this note raised was first decided as
(b) — run_compare_request extended in parallel, the CLI still resolving
through cli_resolve._resolve_compare_snapshots, on the grounds that option
(a) would rewrite the CLI's most heavily-tested resolution path for no
user-observable gain. That decision has since been reversed to (a); see
"Structural half" at the end of this phase's note for what changed and why
the earlier reasoning missed the real obstacle. The two paragraphs below
describe the capability slice that landed while (b) still held.
A follow-up slice then closed the capability half properly. A first attempt
at naming what stayed CLI-only listed three things and was wrong on all
three: the per-side AST-frontend override already worked (InputSpec.compile.
frontend reaches that side's run_dump, verified by spying on its
arguments); source.method is expressible as CompareRequest.depth, and a
Tier-2 API deliberately does not read .abicheck.yml from the cwd; and the
set-input guard protects an input kind the typed path never accepts.
Diffing the two parameter lists instead of reasoning about them found the
real delta — dwarf_only, debug_format, ADR-050 D1's include_labels, and
--follow-deps — all four now on CompareRequest, with --follow-deps's
implementation moved to the new leaf module abicheck/dependency_info.py
that both layers depend on.
So the two paths differed in structure, not in what they could express.
Structural half — now done, and the decision flipped to (a). Doing the
migration showed why (b) had looked inevitable: run_compare_request was one
function that both resolved and classified, and the native compare CLI must
run its Click-dependent ADR-049 resolve_and_apply between those two steps —
it needs the Click context to answer "did the user type this?", and a --pack
it selects can move the policy file and severity levels the classification is
then scored under. With no seam, the CLI could reuse neither half.
So the change was not "call run_compare_request from the CLI" but splitting
it at its real joint into abicheck/service_compare_pipeline.py:
resolve_compare_request (validate → evidence → both snapshots →
--follow-deps → depth floor), classify_compare_pair (suppression/policy →
embedded build-source diff → compare_snapshots → metrics), and
run_compare_request as exactly their composition.
cli_resolve._resolve_compare_snapshots now builds a CompareRequest and
delegates; it resolves nothing itself. What stays CLI-specific is the
click.echo notifier, the ValidationError/SnapshotError → click
exception translation, and allow_parallel=False — the CLI's long-standing
sequential resolution, kept deliberately rather than flipped as a side effect
(see ADR-055 D1's "Structural half" note, which also records the
IMPORT_CYCLE_ALLOWLIST sign-off and the stale-guard defect the unification
surfaced on the typed path).
Phase 3 — CompareResult wrapper (ADR-055 D2)¶
Introduce CompareResult (diff/old_snapshot/new_snapshot, a pure
rename of the existing tuple shape) and a parallel typed entry point
returning it, while run_compare/run_compare_request's existing
tuple-returning signatures stay exactly as they are for every current
caller. (The parallel-entry-point half was superseded before this phase
closed — see the progress note.)
Gate: a new field (resolved depth, an EvaluationReceipt, a coverage
summary) has somewhere to land without a second tuple-shape break.
Progress: done, and then simplified past what the phase asked for.
CompareResult (diff/old_snapshot/new_snapshot) lives in
api_types.py beside CompareRequest. It first shipped behind a parallel
run_compare_request_v2 while run_compare_request kept its tuple; that
seam is now gone. run_compare_request returns CompareResult directly,
run_compare does too, and no _v2 function exists — the only reason to
carry two names was compatibility, which the project does not yet hold
pre-1.0. CompareResult.as_tuple() is the one-line migration for a
positional caller.
One departure from the decision as written: the struct carries a fourth
field, suppression, not just the tuple's three. Phase 4 is why — the
service resolves the suppression list internally, but appcompat.
scope_diff_to_app(..., suppression=...) needs the resolved object after
classification, and DiffResult carries the resolved policy file but not the
suppression list. Without it the MCP server would have kept a
SuppressionList.load call solely to re-derive what the service had already
loaded — the duplication Phase 4 removes, reintroduced by the shape chosen to
enable removing it. Tests: TestCompareResult (tests/test_api_types.py),
TestRunCompareRequestV2 (tests/test_service_unit.py).
Phase 4 — route abi_compare through run_compare_request (ADR-055 D4)¶
The review's stated top-priority item. Rewrite mcp_server.py's
abi_compare to build one CompareRequest and call run_compare_request
for resolve+classify, instead of its own _resolve_input +
compare_snapshots pair. used_by/required_symbols scoping and
severity_*/exit-code computation stay MCP-specific glue over the
returned CompareResult — they are cross-cutting concerns applied after
classification, not part of the typed request/result shape itself (folding
them in would recreate the "one growing struct" problem Phases 2–3 exist to
avoid).
Gate: abi_compare has no local _resolve_input call and no direct
PolicyFile.load/SuppressionList.load calls; a parity test confirms
identical output to the CLI compare command for equivalent flags, both
before and after the rewrite.
Progress: done. abi_compare builds one CompareRequest and calls
run_compare_request; mcp_server.py no longer imports
compare_snapshots at all. Both halves of the gate are executable in
tests/test_mcp_server_unit.py's TestAbiCompareCliParity: the source-level
absence check (comments stripped first — the function now documents what it
stopped calling), and CLI-parity assertions over a flag matrix (defaults,
policy profile, policy file, suppression file, --show-only,
--report-mode, severity-aware gating) covering the rendered report and the
exit code. Parity was additionally verified as a before/after diff of the
tool's own output across that matrix, so the rewrite is shown not to have
moved anything rather than only agreeing with the CLI.
Three findings worth carrying forward:
- One MCP guard had to become request surface:
_resolve_inputpinnedfollow_linker_scripts=Falsebecause the tool size-checks only the caller-supplied path, and a GNU ld script'sINPUT()target would bypass that.InputSpec.follow_linker_scripts(defaultTrue, matchingresolve_input's own default) carries it now. Path containment and file size stayed MCP-local, applied before the request is built — those constrain untrusted input, which really is the front end's job. - The report matches the CLI's exactly, except for two keys the CLI's
renderer adds and this tool has never emitted (
old_evidence_depth/new_evidence_depth). - One intentional behaviour change: an unsupported
languageis now a structured validation error instead of a value passed quietly down the resolver —CompareRequest.validate()doing what the CLI's--langchoice already did (ADR-037 D9's front-end parity), so it is recorded as intended rather than tolerated.
used_by/required_symbols scoping and severity/exit-code computation
stayed MCP glue over the returned CompareResult, exactly as scoped above.
Phase 5 — extend the other MCP tools to the same parity (deferred)¶
Once Phase 4 lands, abi_dump/abi_scan gain the same
depth/sources/build_info/DumpManifest/CompileContext/host-device-context
parity abi_compare gains in Phase 4. The source review explicitly framed
this as conditional on the "service convergence" phases landing first
(its own item 6), not a parallel, independent piece of work — don't start
this before Phase 4 is done.
Gate: abi_dump/abi_scan's MCP parameter sets are a strict superset
of abi_compare's post-Phase-4 parameter set for every concept dump
and compare share.
Progress: done. The gate is executable in
tests/test_typed_dump_request.py's TestPhase5ParityGate, as a signature
check over the three tools rather than prose — that is the failure this phase
exists to stop recurring (abi_dump sat at a five-argument subset of
abicheck dump for several releases with nothing noticing).
The phase's own framing was slightly wrong, and the correction is what
shaped the work. It reads "gain the same … parity abi_compare gains in
Phase 4", but Phase 4 gave abi_compare no depth/sources/build_info
parameters at all — it moved that tool onto CompareRequest, which carries
those fields (Phase 2), without exposing them as tool arguments. So there was
no parity to copy: abi_dump had to gain the concepts outright.
That is why the change is not only MCP surface. resolve_input has always
been the single source of truth for turning a path into a snapshot, but
everything a real dump does around it — inferring a collect mode,
embedding inline L3-L5 evidence, walking dependencies, enforcing that an
explicit --depth was reached — lived only in cli.py's dump_cmd. Adding
the arguments without a typed request would have meant a second copy of those
four steps inside mcp_server.py: precisely the invariant §2 exists to
protect. So dump got the request compare has had since ADR-037 D2:
api_types.DumpRequest— oneInputSpecplus the how-it-runs fieldsCompareRequestalso keeps at request level. Deliberately carries nothing about classification (policy, suppression, scope, severity, contract): a dump produces evidence and renders no verdict. Both requests now validate through one set of module-level helpers, sodumpandcomparereject an identical mistake with identical text — ADR-037 D9's front-end parity, extended across the two commands.service_dump_pipeline.run_dump_request— those four steps, over the same per-input primitivescompareresolves through.service_input_resolution— those primitives. Everything in it wasservice_compare_pipeline's private helpers (_resolve_side,_embed_side_build_source,_enforce_requested_depth), lifted out of the pair and re-expressed for one input, so a change to how an input resolves lands on both commands at once. The pair-shaped decisions stayed behind on purpose: the pair-wide C++20 dialect override exists because two sides must agree on a standard, and the concurrency rule is about two extractions running at once — neither means anything for a lone dump.
IMPORT_CYCLE_ALLOWLIST gains both new modules, under the terms
service_compare_pipeline was signed off on for the same reason in Phase 2
(CLAUDE.md "M1-3"): each is a split of an existing member, and every edge
they have is one the code already had one module over, moved rather than
added. mcp_server.py crossed the 2000-line hard cap on the new parameters,
so its argument-translation layer moved to mcp_server_inputs.py verbatim,
re-exported for existing callers and tests.
Three narrower things landed with it, each because leaving it out would have made the gate pass while the surfaces still disagreed:
abi_scangainedbuild_info, thecompile_context_optionsfamily, and the--againstconfig surface ADR-049 Phase 5 §6.4 already required it to have (policy/policy_file/suppression_file/contract_evaluation).abi_comparegainedcontract_mode— the CLI's--contract, and a fieldCompareRequestalready had. Adding it toabi_scanalone would have left the two tools disagreeing about ADR-049 Phase 6.--contract's two usage rules have one implementation (mcp_server_inputs._contract_mode_error) shared by both tools, sinceScanRequesthas novalidate()of its own to state them in.
Deliberately not done here, and not a hidden gap: the native dump CLI
still resolves its own inputs rather than building a DumpRequest. That is
the dump-side analogue of Phase 2's "structural half", and it is a real
piece of work — dump_cmd also owns --dry-run rendering, git/build-id
provenance stamping, fold_dump_provenance_into_json, compile-database reuse
and deprecation shims, none of which belong in a Tier-2 request. Phase 2
earned that migration on compare by first finding the seam
(resolve/classify) that made it possible; dump's equivalent seam is not
yet identified, and guessing at it inside a phase scoped to MCP parity is how
a second resolution path gets created rather than removed. Tracked below.
Phase 6 — ADR-049 sequencing constraint (no new work here)¶
Not a phase this plan implements — a standing constraint on
public-contract-default.md's own rollout:
its shadow evaluator must not become authoritative in any frontend until
CLI, Python, MCP, and Actions all construct the same resolved
CompatibilityEvaluationConfig and the same provenance receipt. Recorded
here only so a reader of this plan sees the explicit dependency; track
actual progress in that plan's own "Work breakdown," not here.
5. Out of scope¶
- Anything already covered by G30 (Actions tiers, a second external CI-integration pilot) or public-contract-default.md (ADR-049 rollout) — see "Where this came from" above.
- Producer/extension SPI (
abicheck.evidence_providersentry points) — not ready until ADR-049's evidence-provider identity model is live. - Any change to detector logic,
ChangeKindtaxonomy, or snapshot contents — this plan is request/response/tool-surface shape only. - A
--dry-run --format jsonnormalized-operation schema fordump/compare/scan(the source review's Level A / CLI-API gap) — a real, separate gap, but orthogonal to the service/MCP convergence this plan tracks; needs its own plan if picked up.
6. Definition of done¶
All met:
- [x] All four ADR-055 decisions (D1–D4) implemented and tested per their individual gates above.
- [x]
tests/test_mcp_server_unit.py'sabi_compareparity test passes against both the pre- and post-rewrite CLIcompareoutput for a representative input matrix. Two deliberate narrowings of the matrix this line originally listed, each for a reason rather than for convenience: binary-only/headers inputs need a real compiled library (the marker lanes this fast-suite test can't require), and the JSON-snapshot inputs it does use exercise the same request-building and classification path; andused_by-scoped runs are deliberately excluded because that scoping is applied after a result exists and has no CLI-comparable rendering here — the siblingTestAbiComparescoping tests own it. Snapshot inputs and severity-aware are both covered. - [x]
docs/reference/python-api-reference.mdanddocs/reference/mcp-tools-reference.mdregenerated and committed. - [x] ADR-055's status line updated from "Proposed" to "Accepted — implemented".
Every phase this plan owns is now closed. Migrating the compare CLI onto the
shared resolution — Phase 2's deferred structural half — is done (see that
phase's "Structural half" note), and Phase 5's abi_dump/abi_scan parity is
done (see its own note, including why the phase's original framing of what
it was copying was wrong).
One follow-up is deliberately left open rather than closed by assertion: the
native dump CLI does not yet build a DumpRequest, so dump has the
shape compare got in Phase 2 without the CLI having adopted it. Phase 5's
"Deliberately not done here" records what that migration needs first — a real
seam in dump_cmd separating evidence resolution from its provenance/dry-run
presentation layer, the way resolve/classify was found for compare. It
is not a regression and blocks nothing in this plan; it is where a dump-side
Phase 2 would start.