Skip to content

Latest commit

 

History

History
283 lines (224 loc) · 16 KB

File metadata and controls

283 lines (224 loc) · 16 KB

Sync — The Routing Matrix

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.

The problem

(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.

What the domain actually looks like

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.

Two facts about oasdiff that the current code loses

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.

The tiers

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

Tier −1 exists because 21 breaking rules are not about the consumer's code

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.

The table

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.

Why a decision table rather than an if chain

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.

Where it goes in the graph

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.

The learned policy, and when it is allowed

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.

Verification

  • 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.yml for 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 lifecycle finding must produce a report and reach END without entering patch. 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.tier and .strategy are 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.

What is built

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.