Skip to content

Answer a retired route with 410 rather than a bare 404 - #828

Merged
MBombeck merged 2 commits into
mainfrom
feat/retired-routes
Aug 23, 2026
Merged

Answer a retired route with 410 rather than a bare 404#828
MBombeck merged 2 commits into
mainfrom
feat/retired-routes

Conversation

@MBombeck

Copy link
Copy Markdown
Owner

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-mode on 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 /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 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", removedIn and replacedBy. 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 what apiError(...) produces, so "the standard envelope" is checked rather than copied.

GET /api/meta/capabilities carries 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.ts already refused a published path with no route behind it. It now models three states rather than two: a registered path must be absent from src/app and 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.md and the header of doctor-report-pdf-core.ts both 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 new Retired tag. openapi-sharing-denial.test.ts made the same published-implies-route assumption and learned the third state from the same registry.

Gates: typecheck, lint, format:check, openapi:generate and openapi:check, 21,570 unit tests and the production build.

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.
@MBombeck
MBombeck merged commit 1ae21b5 into main Aug 23, 2026
23 checks passed
@MBombeck
MBombeck deleted the feat/retired-routes branch August 23, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant