Answer a retired route with 410 rather than a bare 404 - #828
Merged
Conversation
A route removed on purpose still leaves a client calling it. As reported in iOS #94: between 2026-08-08 and 2026-08-20 the native client asked `/api/auth/me/research-mode` on every GLP-1 detail screen, got a bare 404, and painted a permanent error card with a retry button aimed at the same removed path. Twelve days, on every reachable instance including the public demo, which was built after the removal. Deleting the route was right and the removing commit reasons it out in full; the answer said nothing. A 404 from a deployment is indistinguishable from a broken build, a reverse proxy pointed at the wrong upstream, or a typo in the caller's own path, so reading it as transient and retrying was the correct reading of the only evidence there was. One registry, `src/lib/http/retired-routes.ts`, carrying the path, the release that removed it, the replacement where there is one, and one sentence on why. Every other surface reads it. 410 over a 404 with an error code because it is the status whose entire meaning is "this existed here and is gone": a client that understands nothing else about this API still learns not to retry. The code is not dropped, it rides in the same envelope with `removedIn` and `replacedBy` for a client that reads further. The status is the floor, the code is the detail. Answered in the proxy, before routing, rather than by a catch-all route module: a catch-all under `/api` would intercept every unmatched path and make the ordinary 404 ours to manufacture, which is a far wider blast radius than the problem. The proxy already owns the exact-path decisions of this shape. It reads nothing off the request but the path, so an anonymous caller and an authenticated one get identical bytes and the tombstone cannot become an authentication oracle. Seeded from the removed-route set: every route path under `src/app` at every release tag, unioned and differenced against the current tree. Twenty-seven paths have been removed over the project's history; the five registered here are the ones that were published or client-facing, and each removal is pinned to its release by the route's presence at one tag and absence at the next. Whether any client still calls one of them is a separate question that evidence in this repository cannot answer, and the registry deliberately makes no claim about it.
…nd the coverage guard Three surfaces, one source. The registry the proxy answers 410 from now also emits the tombstones into `docs/api/openapi.yaml` and rides on `GET /api/meta/capabilities`, so a retirement cannot be honoured at runtime and invisible everywhere a client could have read it first. The contract publishes each retired path with 410 as its only response, the removing version, and the replacement. `deprecated: true` is imprecise — the word means "still works, stop using it", and these do not work — but it is what makes a generator warn at the call site, and the description carries the exact truth the flag approximates. `security: []` because the answer is produced before any handler and reads no credential; publishing it as authenticated would tell a client to sign in to be told a path is gone. The capability read carries the same list. That surface is the one a client already polls on launch, so a retirement can reach it before the failing call rather than through it. The coverage guard gains a third state. Published and exempted both describe a route that exists, so removing one used to leave exactly one legal move — erase it from the contract as well — and that move is invisible to every client already calling it. A registered path is now required to be absent from `src/app` AND present in the contract, which is the one combination the phantom-path case rejected outright. Both ways out are named in the failure message. Its docblock says what the state cannot do, because the first draft overclaimed: a check over one commit's state cannot see a deletion, so it cannot insist that a removal should have been registered. Proving that needs the previous release tag, and the job running this suite checks out at depth one with no tags, which would make the check one that silently cannot fail. What is held instead is the registration's integrity, in three directions and with the mutation for each written down. The sharing-denial guard learns the same state: a retired path resolves no record fence because it reaches no handler.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the ask in the native tracker: a route that has been removed should say so, rather than answering like a route that never existed.
What went wrong to prompt this
Between 2026-08-08 and 2026-08-20 the native client called
GET /api/auth/me/research-modeon every GLP-1 detail screen, got a 404, and turned it into a permanent error card over the drug-level curve with a retry button pointing at the same removed route. Twelve days, on every reachable instance including the public demo, which was built after the removal.Removing the route was right. What was wrong is that a bare 404 is indistinguishable from a broken deploy or a routing mistake, so the client read it as transient. A client that can tell the two apart degrades correctly on the build it already has, instead of waiting for a new one.
The shape
One registry,
src/lib/http/retired-routes.ts. Each entry carries the path, the version that removed it, the replacement if there is one, one sentence of reason, and the verbs it used to export. The proxy, the contract, the capability payload and three guards all read it; nothing restates it.The answer is 410 Gone, in
src/proxy.ts. A catch-all under/apiwould intercept every unmatched path and make the ordinary 404 ours to manufacture, which is a far wider blast radius than the problem. The proxy already owns exactly this shape of exact-path decision. It sits after the worker and legacy-redirect exits and before the demo-mode block and the page session gate: a retired path is retired on a demo deploy too, and a logged-out caller must not be sent to authenticate for something that no longer exists.It reads nothing but the pathname, so anonymous and authenticated callers get byte-identical responses. Not an auth oracle by construction, rather than by care.
The body is the ordinary envelope with
meta.errorCode = "route.retired",removedInandreplacedBy. It is built as a plain object because the proxy bundles separately from the route tree, and a test asserts it is byte-identical to whatapiError(...)produces, so "the standard envelope" is checked rather than copied.GET /api/meta/capabilitiescarries the same list, from the same constant, so a client learns about a retirement without comparing versions. That was the third thing the native side asked for.The guard, which is the point
The first thing they asked for was that we say it in a ticket when it happens. That is a promise, and a promise is what failed here. So it is structural instead.
openapi-route-coverage-guard.test.tsalready refused a published path with no route behind it. It now models three states rather than two: a registered path must be absent fromsrc/appand present in the contract, which is the exact combination the phantom-path case used to reject outright. Removing a route now has two legal answers instead of one, and the failure message names both.Five mutations, each run and confirmed red: dropping an exemption, dropping a path from a route module, deleting a live route module, recreating a retired one, and unwiring the retired paths from the barrel.
One limit is written into the docblock rather than papered over. An earlier draft claimed that deleting a registry entry would fail the phantom case. It does not — the contract entries are generated from that same constant, so both sides vanish together. More generally, a check over one commit's state cannot see a deletion, so it cannot insist a removal should have been registered. Proving that needs the previous release tag, and this job checks out at depth 1 with no tags, so a history comparison here would be a check that silently cannot fail.
What the sweep found in this repository
Diffing the route set at every
v*tag against today gives 27 removed paths. An earlier six-tag sample said 14: a path added and removed between two samples is invisible to both, and that trap is now recorded beside the number.Five are registered. The rest are excluded with the reason in the module docblock: the admin-console feedback routes (cookie-only, never published, no client outside the operator's browser) and the moodLog family (operator surface, upstream long dead, held removed by its own guard).
Two stale references in this repository, neither a call:
CONTRIBUTING-AI.mdand the header ofdoctor-report-pdf-core.tsboth name/api/doctor-report, a family that no longer exists.Whether any client still calls a retired path is deliberately not claimed here. That evidence lives in the client's repository, and an inference from this side would be a guess wearing a citation. The registry docblock says so.
Contract: one operation per registered verb, 410 as the only response,
deprecated: true(imprecise, since it means "still works", but it is what makes generators warn — the description carries the exact truth),security: [], and a newRetiredtag.openapi-sharing-denial.test.tsmade the same published-implies-route assumption and learned the third state from the same registry.Gates: typecheck, lint,
format:check,openapi:generateandopenapi:check, 21,570 unit tests and the production build.