Skip to content

chore(guard): extend the route-envelope check to the dispatcher domains (#4038 follow-up) - #4056

Merged
os-zhuang merged 1 commit into
mainfrom
claude/envelope-drift-route-modules-v2zoky
Jul 30, 2026
Merged

chore(guard): extend the route-envelope check to the dispatcher domains (#4038 follow-up)#4056
os-zhuang merged 1 commit into
mainfrom
claude/envelope-drift-route-modules-v2zoky

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes the blind spot I documented in #4049, and turns #4053's warning into a CI failure instead of an issue nobody reads.

Guard-only — no product code changes. One file plus a changeset.

The gap

The platform answers REST from two kinds of file:

how it answers scanned before
route modules (*-routes.ts) res.json(...)
dispatcher domains (runtime/src/domains/*.ts) RETURN { status, body } for a central sender

The second never calls res.json, so the scan couldn't see it by construction — not an oversight in the scan, a category it wasn't built for.

That cost something real. /share-links emitted its payload under both data and a legacy link / links for as long as nothing looked, and it surfaced only because #3983's consumer sweep happened to walk past it — #4038, fixed in #4049. Exactly the "a module nobody thought to check never gets audited" argument that made #3843's scan repo-wide in the first place, one level up.

What it counts

Hand-built response: { … } literals per domain. A domain answering only through deps.success / deps.error / deps.routeNotFound / deps.errorFromThrown hand-builds zero and cannot drift — the envelope lives in one place for all of them. Any hand-built response now needs a declared count plus a note.

Hand-building is not automatically wrong, which is the part worth getting right rather than banning outright. Four kinds show up and only the last is drift:

  1. Enveloped, but the helper can't express it. deps.success hardcodes status 200, so a 201 must be assembled by hand; deps.error carries no headers, so a 405 with Allow: must be too. → keys.ts, share-links.ts, one in mcp.ts
  2. Passthrough of a body this dispatcher doesn't own — an upstream Web Response, another service's result. → mcp.ts, ai.ts
  3. A foreign wire format a client library requires/auth answers better-auth's shapes because better-auth's client parses them. → auth.ts ×4
  4. Drift.
✓ Dispatcher domains — 16 audited: 11 helper-only, 5 with declared hand-built responses (1 ratcheted)
  ⚠ ratchet #4053: packages/runtime/src/domains/ai.ts — …

The ratchet is the point

ai.ts#4053. GET /ai/agents is disposition: 'sdk' but unenveloped, and the SDK compensates by reading .agents off the raw body. Convert it the way the other five were converted — payload directly under data — without changing cloud and the SDK in the same batch, and client.ai.agents.list() returns an empty list, silently. Which is indistinguishable from the legitimate "no AI service configured" state, so nobody notices.

I filed that as an issue an hour ago. An issue only helps someone who happens to read it; a ratchet stops the person who doesn't. That's the actual value here — the rest of the table currently catches nothing, and I'd rather say so than oversell it.

A false positive the self-test caught mid-write

Matching the response key alone counted a payload field named responsedeps.success({ response: … }) — as a hand-built response. It now requires response to be a sibling of handled, which is what makes the pair an HttpDispatcherResult rather than just an object with that key.

Worth flagging because it's the same over-broad-match class as privateOk in #4037, and because the assertion that caught it was one I wrote expecting it to pass.

Verification

gate result
check:route-envelope --self-test passes — 5 new assertions (helper-only, hand-built caught, mixed file, commented-out ignored, payload-field-named-response ignored)
check:route-envelope 8 route modules: 7/0/1 · 16 domains: 11 helper-only, 5 declared, 1 ratcheted
guard actually bites added a hand-built response to a helper-only domain (ui.ts) → fails with the site and the four-kind prompt, exit 1
check:error-code-casing · check:console-sha · check:nul-bytes · check:role-word · check:org-identifier clean
eslint --no-inline-config clean

Empty changeset — CI guard, releases nothing.


Generated by Claude Code

…ns (#4038 follow-up)

The platform answers REST from two kinds of file. Route modules (*-routes.ts)
call `res.json(...)`, which this scan counted. Dispatcher domains
(runtime/src/domains/*.ts) RETURN `{ status, body }` for a central sender, so they
never call `res.json` and were invisible to it by construction.

That gap cost something real: /share-links emitted its payload under both `data`
and a legacy `link`/`links` for as long as nothing looked, and it surfaced only
because #3983's consumer sweep happened to walk past it (#4038, fixed in #4049).
I noted the blind spot there; this closes it.

The new table counts hand-built `response: { … }` literals per domain. A domain
answering only through the deps.* helpers hand-builds zero and cannot drift; any
hand-built one now needs a declared count plus a `note` saying why. Hand-building
is not automatically wrong — four kinds show up and only the last is drift:

  1. enveloped, but the helper cannot express it — deps.success hardcodes 200 so a
     201 is assembled by hand, and deps.error carries no headers so a 405 with
     `Allow:` is too (keys.ts, share-links.ts, one in mcp.ts)
  2. passthrough of a body this dispatcher does not own (mcp.ts, ai.ts)
  3. a foreign wire format a client library requires — /auth answers better-auth's
     shapes because better-auth's client parses them (auth.ts x4)
  4. drift

16 domains audited: 11 helper-only, 5 declaring hand-built responses, 1 ratcheted.
The ratchet is ai.ts -> #4053, where GET /ai/agents is SDK-addressable but
unenveloped and the SDK compensates by reading `.agents` off the raw body —
converting it without changing cloud and the SDK together makes
`client.ai.agents.list()` return an empty list silently, which is indistinguishable
from the legitimate "no AI service configured" state. That warning now fails CI for
whoever tries it, instead of sitting in an issue nobody reads.

Five self-test assertions cover the new scan. One caught a real false positive
while it was being written: matching the `response` key alone counted a PAYLOAD
field named response (`deps.success({ response: … })`) as a hand-built response.
It now requires `response` to be a sibling of `handled`, which is what makes the
pair an HttpDispatcherResult rather than just an object with that key.

Verified the guard actually bites: adding a hand-built response to a helper-only
domain fails with the site and the four-kind classification prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 7:18am

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tooling labels Jul 30, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review July 30, 2026 07:21
@os-zhuang
os-zhuang merged commit 3eb776a into main Jul 30, 2026
15 checks passed
@os-zhuang
os-zhuang deleted the claude/envelope-drift-route-modules-v2zoky branch July 30, 2026 07:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants