Skip to content

Response bodies are never checked against the schemas that declare them — staged plan, not a repo-wide sweep #3877

Description

@os-zhuang

Tracking issue for the generalizable part of #3676 / #3833 / #3847 / #3870.

What those four shared

Each was a body that did not match the schema declaring it, and each survived a green suite for the same reason: every test asserted the emitted body against a hand-written literal. Comparing output to a literal proves the code does what the test author believed. It cannot prove the code does what the contract declares. Nothing had ever put the emitted value and the declared schema in the same assertion.

# Gap Resolution
#3676 request declared namespace/keys filters no server read trimmed the declaration
#3833 field-labels derivation scanned a retired key dialect → {} for every provider fixed impl, shared the derivation
#3847 labels entries emitted as bare strings under a schema declaring { label, help?, options? } fixed impl to match contract
/i18n/locales emitted a bare string[] under a schema declaring descriptors; the two serving surfaces disagreed fixed impl, shared the mapping (#3870)

The fourth was found by the conformance suite #3870 added, on its first run. That suite is the pattern this issue is about — the question is how far it should be pushed, and how.

Measurements

I claimed in #3870 that the route → declaring-schema mapping "exists today and is unused". That is wrong at repo scale and I want it corrected in the record before anyone plans against it. It is true only of plugin-rest-api.zod.ts's own 25-route table.

Metric Value
Mounted routes across the 5 route ledgers 237
…of which disposition: 'sdk' (the surface worth gating) 215
Route blocks in plugin-rest-api.zod.ts carrying responseSchema 25 (≈10% of mounted)
Ledger entries carrying a schema reference 0
Existing envelope-conformance files 4 (storage error+success, i18n error+success)

The ledgers record route → disposition → SDK method. They carry no schema reference. So for ~90% of the mounted surface the problem is not "emitted ≠ declared" — it is that nothing is declared to check against.

Spot check: the dispatcher's automation domain serves /flows, /actions, /connectors, /_status; the automation category declares /trigger and /actions. Most served routes have no response schema at all.

Second cost axis: thin vs deep emit sites

Of the dispatcher domains' 86 success emit sites:

  • 32 inline literals — the body is built at the emit site; drivable with a mock service, cheap.
  • 54 forwarded (deps.success(result)) — the body comes from objectql/drivers; needs a booted stack.

Per domain: i18n 5/0, data 0/6, meta 8/14, packages 4/13, automation 11/8.

Note i18n was the cheapest family in the repo — and it still took a PR.

Staged plan

Stage Scope Size Character
A Conformance for families that already have declarations and thin emit sites: discovery (2), analytics (3), automation's declared routes (3), notification (8) ~4–6 PRs, each ≈ #3870 mechanical, low risk
B Deep families (data / meta / batch) ride the existing packages/qa/dogfood integration suite rather than new harnesses needs design
C Author response schemas for the ~190 routes that have none largest contract design, not test work
D The actual ratchet: a route declaring responseSchema with no conformance coverage fails CI small only meaningful after A/B

Risks

  1. Violation rate unknown, and the one sample is bad. i18n yielded four defects across five routes. Even a 20% rate across Stage A implies ~30 breaking wire changes.

  2. Each fix is a judgement call, not a mechanical substitution. This is the most under-estimated part. GetTranslationsRequest declares namespace / keys that no server reads — declared ≠ enforced #3676 concluded delete the declaration (the capability could not be delivered and nobody wanted it); GetFieldLabelsResponse declares rich label entries; both surfaces emit bare strings #3847 concluded change the implementation to satisfy it (it could be delivered, the data was already loaded, and it was the more useful surface). Opposite directions, decided by consumer sweeps and by where the data lives. That cannot be batched.

  3. Blast radius stops being nil. The four fixes were safe because objectui does not call those endpoints. data and metadata are not like that — they are the hot paths objectui and the SDK actually use. One wrong call there breaks the console.

  4. Turning the ratchet on early reds the build — ~190 undeclared routes would trip it. Stage D must come last.

Recommendation

Do not open this as a repo-wide sweep. Work Stage A family by family, one PR each, ordered by thin emit sites × has declarations × low consumer risk. By that ordering the next family is discovery or notification, explicitly not data/meta.

Do not schedule Stage C at all. A route's response schema is a product decision about what that endpoint promises. Mass-producing ~190 of them would create a batch of declarations nobody validated — which is precisely how the four defects above came to exist. Let schemas get authored when a route is touched for other reasons.

Smallest useful first step

Add an optional responseSchema field to the route-ledger entry type. It turns "what does this route declare" into queryable data, is the prerequisite for Stage D, and can be filled in incrementally alongside Stage A — no need to populate it in one pass, and it creates no unvalidated declarations.

Not covered by existing gates

check:liveness does not overlap: it is registry-rooted over authorable metadata types, not API request/response schemas. That is exactly the blind spot all four defects lived in.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions