Date: 2026-07-27
Status: Specified. The table is binding on any tier routing added to the remediation graph.
The learned policy is deferred with a stated trigger.
Scope: How a Finding is routed to the cheapest strategy that will actually work, and how
that routing stays auditable and provably complete as oasdiff's rule set grows.
(Written when nothing routed. A tier cascade now exists, the decision table below selects the tier, and the catalogue that gives it jurisdiction is loaded once per run — see "What is built" at the end of this document for the two limits that remain.)
Today there is no routing. src/sync/remediate/graph.py sends every finding to one
remediator, and patch calls a full agent loop regardless of how mechanical the change is.
That is correct for a walking skeleton and wrong for the economics: the stated advantage is
"knowing which change kinds are safely mechanical is what lets Sync skip a model call and beat
competitors on both cost and merge rate." A model call for a change a codemod handles is pure
cost, and worse, it is nondeterministic cost against a deterministic problem.
The naive fix — a big if chain over change kinds — fails for a reason worth stating before
designing around it.
Measured from the pinned binary, oasdiff 1.26.1, via oasdiff checks --format json:
| Count | |
|---|---|
| Total checker rules | 506 |
level=error |
212 |
level=info |
264 |
level=warning |
30 |
VendorChange.kind is record["id"] — one of those 506 identifiers. A hand-written table
with 506 rows is not maintainable by one person, and would silently rot on every oasdiff
release. That is the constraint the design has to survive.
The escape is that each rule carries structured metadata, and routing can key on the metadata rather than on the identifier. Across the 212 breaking rules:
| Axis | Distribution |
|---|---|
direction |
request 118, response 81, none 13 |
kind |
structure 54, constraints 54, type 25, existence 24, lifecycle 21, values 20, requiredness 14 |
action |
change 60, remove 57, add 43, decrease 25, increase 22, generalize 4, specialize 1 |
Seven kind values and eight action values is a grid a person can reason about. Five hundred
and six identifiers is not.
Both were found by running the pinned binary against the committed fixture pair, and both bear on routing.
oasdiff breaking returns warning-level records, not only errors. The fixture pair
produces exactly two records — request-property-removed and
response-optional-property-removed — and oasdiff checks classifies both as
level=warning, not error. Neither appears in the 212-rule breaking set. So the output of
run_oasdiff_breaking is a mix of severities, not a uniform one.
to_vendor_changes discards that severity. It sets severity="breaking" unconditionally
for every record. An endpoint deleted without deprecation and an optional response property
removed arrive downstream indistinguishable. Routing wants that distinction — it is one of the
three or four things most predictive of whether a change is mechanically patchable — and today
it is thrown away one line after being received.
A related trap for whoever fixes this: the JSON records from oasdiff breaking carry level
as an integer (the fixture's two records both report level: 2, which cross-references to
warning in oasdiff checks), while oasdiff checks reports level as a string. The two
surfaces do not agree on the type, and only the warning value has been confirmed by
cross-reference here. Anything mapping between them must verify the encoding rather than assume
it.
Preserving oasdiff's level is a prerequisite for this design, and it is deliberately not
done in this document: severity flows into Finding, which the MCP graph surface consumes,
so it is a contract change that wants its own task rather than a drive-by edit.
Four, not three. The zeroth is the one an implementation would otherwise miss.
| Tier | Strategy | Cost |
|---|---|---|
| −1 | No patch. Report only. | Nothing |
| 0 | Deterministic codemod. No model call. | Nothing |
| 1 | Constrained model call — a single edit against a named template. | One call |
| 2 | Full agent loop, as today. | Many calls |
kind=lifecycle covers 21 of the 212 breaking rules, and reading them makes the category
obvious:
api-deprecated-sunset-missing endpoint deprecated without sunset date
api-deprecated-sunset-parse endpoint deprecated with invalid sunset date
api-invalid-stability-level invalid stability level
sunset-deleted sunset deleted
api-sunset-date-too-small deprecated endpoint sunset before min required deprecation days
Every one of these is a complaint about how the vendor documented a deprecation. None
describes a change to the shape of anything the customer's code sends or receives. There is no
edit that resolves api-deprecated-sunset-missing in a consumer's repository, because the
defect is in the vendor's specification.
Routing these to an agent produces a confident patch against code that was never wrong. That is the most expensive failure mode available — it spends the reviewer trust that the whole precision-over-recall position exists to protect. They are real findings and worth surfacing; they are simply not remediation findings.
Default-deny. A finding reaches tier 0 only by matching an explicit rule; everything unmatched falls through to tier 2. The fall-through direction matters: an unrecognised change routed to an agent costs money, while an unrecognised change routed to a codemod corrupts code.
Evaluated top to bottom, first match wins — DMN's FIRST hit policy, which is the only one
that makes the ordering itself part of the specification rather than an accident.
| # | kind |
action |
direction |
Additional condition | Tier |
|---|---|---|---|---|---|
| 1 | lifecycle |
any | any | — | −1 |
| 2 | any | any | any | changed_field() is None and the rule is field-scoped |
2 |
| 3 | existence |
remove |
response |
field is read at exactly one call site | 0 |
| 4 | existence |
remove |
request |
field is passed as a literal, not a variable | 0 |
| 5 | requiredness |
change |
request |
the property already has a value at the call site | −1 |
| 6 | existence |
add |
request |
the added property is required | 1 |
| 7 | type |
any | any | — | 2 |
| 8 | structure |
any | any | — | 2 |
| 9 | (fall-through) | 2 |
Rows 3 through 6 are the entire tier-0 and tier-1 surface, deliberately. Each carries an additional condition drawn from the call site, not from the change — which is the point of having an API Dependency Graph at all. "A response property was removed" is not enough to justify a mechanical edit. "A response property was removed and exactly one call site reads it" is.
Row 5 is worth reading twice: a request property becoming required needs no patch when the call site already passes it. The graph knows that. Without the graph you would have to guess, and guessing means either a needless PR or a missed break.
The same logic in a conditional chain is untestable in the way that matters. Three properties come from making the table data:
Completeness is checkable. Every rule ID oasdiff checks emits either matches a row or
provably reaches the fall-through. A test asserts this against the pinned binary, so a new
oasdiff release that adds a check fails CI rather than silently routing an unknown kind. This
is the property that makes the design survive a dependency that grows.
Overlap is checkable. Two rows whose conditions can both be true, with different tiers, is a
contradiction — findable by inspection when the rows are data, invisible when they are nested
ifs.
The routing decision is loggable as data. migration_outcome already has strategy and
tier columns. With a table, the row number that fired is recordable too, which is what makes
"tier 0 was wrong for this kind" an answerable question later instead of an archaeology project.
RETE and a production-rule engine are the wrong tool here and are named so nobody reaches for them: RETE's incremental matching pays off across thousands of rules and a continuously mutating fact base. This is nine rows evaluated once per finding.
The insertion point already exists. src/sync/remediate/graph.py has:
builder.add_conditional_edges(
"prepare",
nodes.route_after_prepare,
{"patch": "patch", "abandon": "abandon"},
)Routing becomes additional destinations out of that same node — a codemod node and a report-only
node alongside patch and abandon. RunState gains tier and strategy, which
migration_outcome already expects as columns, so nothing downstream needs inventing.
route_after_static already models the discipline any new predicate must follow: it branches on
verify_ok, an explicit boolean a node set deliberately, rather than on whether diagnostics
happens to be non-empty. A real tsc failure can exit non-zero with nothing on either stream.
Routing predicates read state that was set on purpose.
Not now, and the trigger is stated so the question is settled rather than revisited.
The table is the policy until migration_outcome holds enough labelled attempts to evaluate an
alternative offline — that is, to score a proposed policy against logged outcomes without
deploying it to real pull requests. That capability, not row count, is the gate: routing errors
here cost a wrong patch against a real repository, so a policy that has only been evaluated
online has been evaluated on customers.
Two things must be true before that evaluation is even possible, and both are cheap to arrange now:
- Every attempt records the tier that was chosen and the row that chose it, including attempts that were abandoned. Abandoned attempts are the negative class; a corpus of successes alone cannot evaluate a router.
- Features are computed by one shared function, used both when routing live and when fitting offline. Two implementations that agree today diverge on the first edge case, and the failure is silent — good offline scores, bad live routing.
The minimum sample count at which a learned policy beats a fixed table is a real number in the contextual-bandit literature, and this document does not state it, because the research pass that would have established it did not complete. Do not invent one. Establish it, or keep the table.
- Completeness against the pinned binary. Parse
oasdiff checks --format json; assert every ID either matches a row or reaches the fall-through, and that the fall-through is tier 2. Pin the oasdiff version in CI so this test means something — it is already pinned to 1.26.1 in.github/workflows/ci.ymlfor exactly this reason. - No two rows contradict. For every pair of rows assigning different tiers, assert their conditions are disjoint or that the earlier row is intended to win, with the intent recorded.
- Tier −1 emits no patch. A
lifecyclefinding must produce a report and reachENDwithout enteringpatch. Assert on the node sequence, not on the absence of a diff — a patch node that ran and produced nothing is a different bug wearing the same result. - Every tier-0 rule is proven on a fixture pair where the codemod's output is asserted exactly. A mechanical transform whose result is not pinned is not mechanical.
- The routing decision reaches the corpus. Assert
migration_outcome.tierand.strategyare populated from the real routing decision, not defaulted. A column that silently stays null destroys the only measurement that would justify the next version of this table.
The problem statement above describes the state before any tiering existed. All three of its parts have since changed, the third only partly.
A tier cascade runs. build_remediator at src/sync/cli.py:119 builds a TieredRemediator
over LiteralSwapRemediator, ParameterOmitRemediator, ParameterRenameRemediator,
PropertyOmitRemediator and TerminalTier(AgentRemediator()), and cli.py:867 hands it to
build_graph. So patch no longer calls a full agent loop regardless: the deterministic tiers
are tried first and the agent is reached only when they decline.
The tier-0 codemods exist. src/sync/remediate/literal_swap.py,
src/sync/remediate/property_omit.py and src/sync/remediate/parameters.py are the
deterministic strategies, built on the rule and span implementations in
src/sync/route/templates.py.
The decision table drives the routing, and reaches production twice.
src/sync/remediate/tiered.py imports route() at line 43 and calls it at line 319: the tier
the table assigns narrows which remediators are eligible, and a tier −1 route raises
NoPatchWarranted so no remediator is consulted at all. The table is also consulted a node
earlier — _decide_tier at src/sync/remediate/nodes.py:72 calls it inside locate, which is
where the table's inputs are established, and stores the tier and the row on RunState. The two
calls cannot disagree while they are given the same inputs, because both go through
tiered.routing_facts(), one pure function; locate may be handed no clone, in which case its
answer is an upper bound on the tier propose settles on rather than a contradiction of it.
Two qualifications bound what that is worth today, and each is a separate piece of work.
route() keys on a catalogue record from run_oasdiff_checks(), which TieredRemediator takes
as a constructor argument. src/sync/cli.py:865 loads that catalogue once per run and hands the
same object to build_remediator and to build_graph, so the table has jurisdiction and there
is one table rather than two that could drift. A tier −1 finding now reaches a report node
without entering patch; before that wiring it ran the patch node three times, spent the whole
static-attempt budget, and wrote the routing message into abandon_reason — the column where
routing is supposed to learn which change kinds are not mechanically safe.
Of the two mechanical rows, row 4 now fires and row 3 still cannot. routing_facts in
tiered.py establishes three of its four facts: field_resolved and value_already_passed
from the one call site it is handed, and field_passed_as_literal from the clone, by reading
the call itself through sync.route.templates.argument_is_literal_at — the same file the
codemod is about to edit, parsed by the same scoping, so router and codemod cannot disagree
about which call they mean. Every way that reading can fail answers None rather than False,
so absent evidence still never reads as permission. call_sites_reading_field remains
unestablishable here: it is a count across the whole graph and propose is handed one site with
no reader for the rest. So the request side of tier 0 is reachable and the response side is not,
and a response-property removal still costs an agent run.
The row that decided is produced, carried one node further than it used to be, and still not
recorded. route() returns it, locate puts it on RunState as routing_row, and the report
node names it in the reason a tier −1 finding carries (nodes.py:631) — which is prose for a
human, not a column. TieredRemediator still offers it through an on_route callback that
nothing in src/ passes, and migration_outcome still has no column for it. So the consequence
the Verification section names last stands unchanged: migration_outcome.tier records which
tier ran, not which row selected it, and "tier 0 was wrong for this change kind" remains
archaeology rather than a query.