A self-hosted build still publishes an OpenAPI contract for a public surface it does not serve — /api/openapi/public.json is outside the gate
The defect
MOTIR-3908 makes the public-projects capability ABSENT off-cloud: every route under app/api/public/ answers 404 { code: 'NOT_FOUND' } when MOTIR_CLOUD is unset.
GET /api/openapi/public.json is not under that path and was not gated. On a self-hosted build it still serves the full published contract — twelve operations, their parameters, their response schemas — for twelve routes that all answer 404. A reader (or a generated client) is handed a document describing a surface the instance does not have.
It is the shape the story explicitly forbids one surface over: "A self-hosted build does not ship a broken route or a stack trace; it ships a deliberate answer."
Why it was not simply gated in MOTIR-4034
Reproduced on origin/main 4f3121228: app/api/openapi/public.json/route.ts declares
export const dynamic = 'force-static';
A force-static route is evaluated at BUILD time, so an isCloud() call inside the handler reads the flag of the machine that built the image, not of the deployment running it. A self-hoster who later sets MOTIR_CLOUD=true would keep the build-time answer. That makes the gate a real decision with a cost — drop force-static (the route becomes dynamic, losing a cache the comment justifies as "it changes only when the code does"), or gate at a layer that runs per-request — rather than the two-line change the sibling routes took. Out of scope for that card, and its own decision here.
The three candidate answers, none obviously right
- Drop
force-staticand gate in the handler. Simplest; costs the static cache on a document that is genuinely static per deploy. - Gate in the proxy / middleware, which runs per request whatever the route's rendering mode. Keeps the cache; puts one more path in the matcher.
- Leave it served, and say so. Defensible — the document describes the PRODUCT, and
/docsis deliberately not gated for the same reason. If this is the answer it needs to be written down, because right now it is not a decision, it is an omission.
Whichever is chosen, tests/api/public/cloud-gate-totality.test.ts should reach it: today that guard enumerates app/api/public/** and this route is outside the tree it walks, which is exactly why the gap survived a totality check.
Acceptance criteria
- The disposition of
GET /api/openapi/public.jsonon a build withMOTIR_CLOUDunset is DECIDED and recorded in the route's own header, with the reason. - If it is gated, both arms are tested and the
force-staticconsequence is stated: the answer must follow the DEPLOYMENT's flag, not the builder's. - If it is deliberately left served, the reasoning is in the route header and
cloud-gate-totality.test.tscarries an assertion recording that as intent rather than as an oversight. - Nothing under
app/api/public/changes behaviour in either arm.
Context refs
motir-core/app/api/openapi/public.json/route.ts— the route and itsforce-staticdeclarationmotir-core/lib/publicProjects/cloudGate.ts— the gate the sibling routes usemotir-core/tests/api/public/cloud-gate-totality.test.ts— the totality guard, and the tree it walksmotir-core/docs/decisions/public-surface-hosts.md— the arrangement this serves