Context
Part of #9515. The Worker's route table and its OpenAPI spec are two disconnected hand-written lists: createApp() (src/api/routes.ts:1154) registers 242 routes; buildOpenApiSpec() (src/openapi/spec.ts:141) hand-registers 128 paths / 151 operations. Measured delta: 91 live routes have no spec entry — including every ORB management surface this epic wants MCP to drive (POST /v1/orb/token, /v1/orb/relay*, /v1/orb/webhook, /v1/orb/oauth/callback, all /v1/internal/orb/*, /v1/internal/fleet/analytics, /v1/internal/ops/stats, /v1/app/fleet/config-push, /v1/app/kill-switch, the DLQ admin quartet, PUT /v1/app/installations/:id/agent/bulk-settings, ~20 /v1/internal/jobs/*). Nothing in CI can detect this: ui:openapi:check only compares the generated file to the generator, and test/unit/openapi.test.ts is a hand-allowlist that never enumerates app.routes.
Compounding it: request bodies are validated by 38 inline zod schemas in src/api/routes.ts (:479+) that never appear in the spec; response schemas exist only in src/openapi/schemas.ts and never validate anything at runtime; security metadata is bolted on post-hoc by a path-prefix list (isProtectedPath(), spec.ts:2090) that is a second implementation of the real gates (requiresApiToken, requireAppRole, the /v1/internal/* middleware) and already disagrees with them; the spec has tags: [] and no systematic operationIds; scripts/check-openapi-settings-parity.ts papers over hand-mirroring for exactly 2 of ~150 schemas. The self-host infra endpoints (/health, /ready, /metrics, /setup* in src/server.ts:1052-1180) and the entire control-plane/ Worker (control-plane/src/http-app.ts — no zod at all; its only client contract is prose + the hardcoded fetches in packages/loopover-miner/lib/tenant-client.ts) are outside the contract entirely.
Decision record — recorded here, not re-litigated per PR
A local registration shim, not @hono/zod-openapi. One wrapper — e.g. defineRoute({ method, path, tags, operationId, auth, request, responses }, handler) — that (i) registers on the Hono app, (ii) parses/validates the request with the zod schemas, (iii) contributes the spec entry to the registry the existing @asteasolutions/zod-to-openapi pipeline already consumes, and (iv) derives the security stanza from the same auth value the runtime middleware enforces. Rationale: 242 routes migrate incrementally (a shim coexists with plain app.get(...); @hono/zod-openapi would force a createApp() rewrite and re-couple the zod version), and the emit pipeline already exists. Overturn condition: only if the shim ends up reimplementing a majority of @hono/zod-openapi's surface — record here first.
Runtime response validation posture (program principle 2): responses are validated against their declared schema (a) always in vitest/CI, (b) at runtime behind LOOPOVER_VALIDATE_RESPONSES — default ON for the self-host container and local dev, default OFF on the production Worker (latency/CPU budget), failures logged + captured as errors, never 500ing an otherwise-good response. Exact env-var semantics documented in the generated env reference.
Requirements
- Build the shim (with tags + stable operationIds mandatory per route) and migrate a pilot of 5 routes end to end, including at least one currently-unspecced ORB management route (
GET /v1/internal/orb/instances) and one /v1/app/* role-gated route.
- The ratchet check (new, wired into
test:ci): enumerate the real Hono route table (app.routes) and fail if any route lacks a spec entry, with a committed baseline allowlist of the currently-unspecced 91 that may only shrink (same ratchet mechanics as branding-drift-baseline.json). Reaching zero retires the baseline file.
- Relocate the pilot routes' request schemas into
@loopover/contract, establishing the pattern the bulk migration follows. Migrating the remaining ~237 routes is deliberately NOT in this issue — it is a mechanical, reviewable-in-batches job that the ratchet baseline drives to zero, tracked separately so this issue stays a single reviewable PR.
isProtectedPath() and applySecurityMetadata() are deleted; security stanzas derive from each route's declared auth, and a meta-test asserts declared auth matches the enforcing middleware's behavior for every route class (public / token / session+role / internal).
check-openapi-settings-parity.ts is retired once RepositorySettingsSchema/RepoSettingsPreviewSchema are z.infer-derived from the same source the handlers serialize (its 2-schema regex diff becomes obsolete by construction).
- Same seam for the other HTTP surfaces:
control-plane/src/http-app.ts adopts zod + the shim and emits its own committed control-plane/openapi.json (with its own drift check); packages/loopover-miner/lib/tenant-client.ts types its requests/responses from those contract schemas. The four self-host infra endpoints in src/server.ts get spec entries (a selfhost-infra tag) so the self-host deployment's full HTTP surface is described.
- Full branch-counted coverage on the shim + every migrated batch; defects found in migrated handlers fixed in-batch.
Non-goals
- MCP tool registration (that's the contract/migration sub-issues — though route request/response schemas landing in
@loopover/contract is exactly what lets MCP tools reference them later).
- New endpoints; behavior changes beyond validation-rejecting inputs that were already invalid per the existing inline schemas.
Deliverables
Expected outcome
The seam exists and is proven on real routes, and adding a new route without a spec entry fails CI — the baseline can only shrink. Request and response shapes are runtime-validated wherever the posture above says so. Driving the baseline to zero (and thereby fully describing the ORB management surface) is the follow-on migration issue's job; this issue makes that work mechanical and makes regression impossible.
References
Part of #9515. Independent of #9517 (can run in parallel). Unblocks #9282's full migration and the ORB management tool family.
Context
Part of #9515. The Worker's route table and its OpenAPI spec are two disconnected hand-written lists:
createApp()(src/api/routes.ts:1154) registers 242 routes;buildOpenApiSpec()(src/openapi/spec.ts:141) hand-registers 128 paths / 151 operations. Measured delta: 91 live routes have no spec entry — including every ORB management surface this epic wants MCP to drive (POST /v1/orb/token,/v1/orb/relay*,/v1/orb/webhook,/v1/orb/oauth/callback, all/v1/internal/orb/*,/v1/internal/fleet/analytics,/v1/internal/ops/stats,/v1/app/fleet/config-push,/v1/app/kill-switch, the DLQ admin quartet,PUT /v1/app/installations/:id/agent/bulk-settings, ~20/v1/internal/jobs/*). Nothing in CI can detect this:ui:openapi:checkonly compares the generated file to the generator, andtest/unit/openapi.test.tsis a hand-allowlist that never enumeratesapp.routes.Compounding it: request bodies are validated by 38 inline zod schemas in
src/api/routes.ts(:479+) that never appear in the spec; response schemas exist only insrc/openapi/schemas.tsand never validate anything at runtime; security metadata is bolted on post-hoc by a path-prefix list (isProtectedPath(),spec.ts:2090) that is a second implementation of the real gates (requiresApiToken,requireAppRole, the/v1/internal/*middleware) and already disagrees with them; the spec hastags: []and no systematic operationIds;scripts/check-openapi-settings-parity.tspapers over hand-mirroring for exactly 2 of ~150 schemas. The self-host infra endpoints (/health,/ready,/metrics,/setup*insrc/server.ts:1052-1180) and the entirecontrol-plane/Worker (control-plane/src/http-app.ts— no zod at all; its only client contract is prose + the hardcoded fetches inpackages/loopover-miner/lib/tenant-client.ts) are outside the contract entirely.Decision record — recorded here, not re-litigated per PR
A local registration shim, not
@hono/zod-openapi. One wrapper — e.g.defineRoute({ method, path, tags, operationId, auth, request, responses }, handler)— that (i) registers on the Hono app, (ii) parses/validates the request with the zod schemas, (iii) contributes the spec entry to the registry the existing@asteasolutions/zod-to-openapipipeline already consumes, and (iv) derives the security stanza from the sameauthvalue the runtime middleware enforces. Rationale: 242 routes migrate incrementally (a shim coexists with plainapp.get(...);@hono/zod-openapiwould force acreateApp()rewrite and re-couple the zod version), and the emit pipeline already exists. Overturn condition: only if the shim ends up reimplementing a majority of@hono/zod-openapi's surface — record here first.Runtime response validation posture (program principle 2): responses are validated against their declared schema (a) always in vitest/CI, (b) at runtime behind
LOOPOVER_VALIDATE_RESPONSES— default ON for the self-host container and local dev, default OFF on the production Worker (latency/CPU budget), failures logged + captured as errors, never 500ing an otherwise-good response. Exact env-var semantics documented in the generated env reference.Requirements
GET /v1/internal/orb/instances) and one/v1/app/*role-gated route.test:ci): enumerate the real Hono route table (app.routes) and fail if any route lacks a spec entry, with a committed baseline allowlist of the currently-unspecced 91 that may only shrink (same ratchet mechanics asbranding-drift-baseline.json). Reaching zero retires the baseline file.@loopover/contract, establishing the pattern the bulk migration follows. Migrating the remaining ~237 routes is deliberately NOT in this issue — it is a mechanical, reviewable-in-batches job that the ratchet baseline drives to zero, tracked separately so this issue stays a single reviewable PR.isProtectedPath()andapplySecurityMetadata()are deleted; security stanzas derive from each route's declaredauth, and a meta-test asserts declaredauthmatches the enforcing middleware's behavior for every route class (public / token / session+role / internal).check-openapi-settings-parity.tsis retired onceRepositorySettingsSchema/RepoSettingsPreviewSchemaarez.infer-derived from the same source the handlers serialize (its 2-schema regex diff becomes obsolete by construction).control-plane/src/http-app.tsadopts zod + the shim and emits its own committedcontrol-plane/openapi.json(with its own drift check);packages/loopover-miner/lib/tenant-client.tstypes its requests/responses from those contract schemas. The four self-host infra endpoints insrc/server.tsget spec entries (aselfhost-infratag) so the self-host deployment's full HTTP surface is described.Non-goals
@loopover/contractis exactly what lets MCP tools reference them later).Deliverables
defineRouteshim + pilot 5 routestest:ciwith shrinking baseline → zerotags/operationIdmandatory on every route the shim registersisProtectedPathdeleted; settings-parity script retiredcontrol-planespec + typed tenant client; self-host infra endpoints speccedLOOPOVER_VALIDATE_RESPONSESruntime gate implemented + documentedExpected outcome
The seam exists and is proven on real routes, and adding a new route without a spec entry fails CI — the baseline can only shrink. Request and response shapes are runtime-validated wherever the posture above says so. Driving the baseline to zero (and thereby fully describing the ORB management surface) is the follow-on migration issue's job; this issue makes that work mechanical and makes regression impossible.
References
Part of #9515. Independent of #9517 (can run in parallel). Unblocks #9282's full migration and the ORB management tool family.