A deployment publishes its own route table, because the check that asked it could not fail
Decided
The API serves GET /api/routes, an unauthenticated list of every method and path the running process will dispatch, walked out of the live Express router stack. scripts/check-deployed-api.mjs now diffs that list against public/openapi.json instead of inferring routing from the status codes of unauthenticated probes.
What was wrong. The previous check asked, for each documented path, "did a router claim this request?" and read the answer off an unauthenticated call: a 404 carrying error.code === "not_found" meant no, anything else carrying our error envelope meant yes. That inference is sound only if an unmatched path reaches the application's 404 handler. It does not. All four sub-routers mount their guards on the router — backend/src/routes/quotes.routes.ts:26, work-orders.routes.ts:25, integrations.routes.ts:52 — so requireAuth runs before Express matches a path *within* the router, and
POST /api/v1/quotes/{id}/checkout-session -> 401, envelope -> "routed"
POST /api/v1/quotes/{id}/route-that-is-not-real -> 401, envelope -> "routed"are the same answer. Every one of the paths under /api/v1 was passing vacuously. The only failure the check could detect was a whole router going unmounted, because an unmounted router does fall through apiRouter to the 404 handler — and that is precisely the failure it was written for, in the incident where fourteen backend commits had never reached the API repository. It caught the bug it was built for and then silently lost the ability to catch the general case, which is the worst place for a guard to end up: still green, still scheduled, still believed.
This was found while trying to prove the checkout route from D-025 was live. The live host answered 401 for the real path and 401 for a deliberately invented sibling, so the probe proved nothing about either.
What was rejected.
*Move the guards to individual routes.* This would make the probe work again — an unmatched path would 404 — and it is the wrong trade. A guard mounted on the router is inherited by a route added next month; a per-route guard depends on whoever adds that route remembering. Weakening an authorisation pattern so that a monitoring script can use a side effect of it is trading a real control for a convenience.
*Parse the source in CI.* backend/scripts/generate-openapi.ts already does this and it is right for what it does — it reconciles the document against src/routes/*.ts at generate time and fails the build on drift. It cannot answer this question. The deployed API is a build artefact of a *different* repository, published by git subtree split, and no amount of reading source in this repository's CI describes what that artefact dispatches. The entire class of failure here is "the two repositories disagree", so any check that consults only one of them is checking the wrong thing.
*Authenticate the check.* Logging in would let it distinguish 404 from 403 on every route. It also gives a scheduled job production credentials, needs a seeded tenant that must stay seeded, and makes the check fail for reasons — expired password, rotated secret, drifted fixture — that have nothing to do with the contract. A check that needs a fixture is a check that rots.
Why an endpoint rather than a build artefact. A file written at build time describes what was built, which is the same thing the OpenAPI document already claims. The question is what is *running*. collectRoutes walks app._router.stack, the structure Express itself dispatches against: if a route is in there it is served, because being in there is what being served means.
What it discloses, and why that is acceptable. Methods and paths — the same methods and paths public/openapi.json publishes at https://novelsystems.ca/openapi.json. No handlers, no schemas, no configuration, no data. A route table is not a secret; the authorisation on each route is the control and it is unchanged. If a future route is genuinely meant to be undiscoverable it does not belong in the published document either, and the honest fix is to say so in manifest.ts rather than to rely on nobody guessing the path.
Where it refuses to guess. Express 4 keeps no record of the string a router was mounted under, only the RegExp it compiled from it, so mountPath decodes the literal form and *throws* on anything else — a parameterised mount, or a shape a future Express emits. Likewise an application with no router stack throws rather than returning an empty array. Both refusals are deliberate and both are load-bearing: a manifest that is quietly short makes check:deployed-api report a missing endpoint on a healthy deployment, or worse, go green against a service serving something else. That is the failure this whole entry exists to remove, and reintroducing it through a shrug in the walker would be the same bug wearing a different hat.
What would make this wrong. If the route table ever stops being the thing that decides whether a request is served — a gateway in front that routes on something else, or per-route feature flags that make a mounted route conditionally absent — then the manifest describes intent rather than behaviour and the diff becomes decorative. At that point the check has to move to whatever the new arbiter is.
Evidence: backend/src/routes/manifest.ts; backend/src/routes/index.ts (the endpoint); routesResponseSchema in backend/src/schemas/common.schema.ts; ten cases in backend/test/route-manifest.test.ts, including one asserting the manifest distinguishes a guarded route from a path that merely resolves to the guard; three HTTP cases in backend/test/api-integration.test.ts; scripts/check-deployed-api.mjs.