⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
#9531 drove the route↔spec ratchet to zero and deleted its baseline: every route createApp() serves now has an operation, isProtectedPath is gone, and the settings-parity script has been replaced by a compile-time assertion. Three of its deliverables did not land with it, and the issue is closed, so they have no home.
1. The 38 inline request schemas are still in src/api/routes.ts. Measured on main: 39 z.object( literals and 75 .safeParse(/.parse( call sites in that one file. #9531's requirement 2 was to relocate them to @loopover/contract "so the 38 inline schemas in routes.ts are gone at the end and any MCP tool wrapping the same route references the same schema object rather than a copy." Today an MCP tool and its route validate against two independently-authored shapes, which is exactly the duplication #9515 exists to remove — and it is not theoretical: #9518's migration found FleetRegisterInstallationInput never declaring the registered field its route accepts, making the opt-out unreachable over MCP.
2. control-plane/src/http-app.ts has no zod and no spec at all. Seven routes (GET /health, POST /v1/tenants, GET /v1/tenants, POST /v1/tenants/rollout, PATCH /v1/tenants/:name/orb-installation, DELETE /v1/tenants/:name, POST /v1/orb/webhook) and zero z.object in the file. Its only contract is prose plus the hardcoded fetches in packages/loopover-miner/lib/tenant-client.ts, whose TenantRecord is literally Record<string, unknown> — the client cannot tell a caller what a tenant even has on it. #9531 requirement 7.
3. The four self-host infra endpoints in src/server.ts are unspecced. /health (:1100), /ready (:1105), /metrics (:1112), and /setup + /setup/callback (:1120, :1134, :1154) are served by the Node entrypoint's own fetch handler, before the Hono app, so the ratchet does not see them and the published document does not mention them. A self-host operator wiring a healthcheck has prose only. #9531 requirement 7.
Requirements
- Relocate every inline request schema in
src/api/routes.ts into @loopover/contract, and have the routes import them. Where an MCP tool already declares a schema for the same payload, the two must become one exported object — not two that happen to agree.
- Give
control-plane/src/http-app.ts zod request/response schemas, register its routes through the defineRoute seam, emit its own committed OpenAPI document, and add a drift check for it to test:ci mirroring ui:openapi:check.
- Type
packages/loopover-miner/lib/tenant-client.ts from those schemas: TenantRecord stops being Record<string, unknown> and becomes z.infer of the control-plane's own tenant schema, and each call parses its response rather than casting it.
- Spec the four self-host infra endpoints under a
selfhost-infra tag. They are served outside the Hono app, so they need spec entries plus a test asserting the specced set matches the paths src/server.ts actually intercepts — the ratchet cannot see them, and an unwatched hand-list is the rot this epic keeps finding.
npm run ui:openapi regenerated and committed; test:ci green.
⚠️ Required pattern: relocated schemas keep their current runtime behavior exactly — this is a move, not a re-validation. A payload the route accepts today must still be accepted, and one it rejects must still be rejected with the same status. Where a relocated schema and an MCP tool's copy genuinely disagree, the disagreement is a defect: fix it and name it in the PR, do not widen one side to match the other.
Deliverables
Test Coverage Requirements
src/** is inside Codecov's src/** include and the 99% branch-counted patch gate applies; packages/loopover-contract/src/** and packages/loopover-miner/lib/** are also in vitest's coverage.include. Every relocated schema needs both an accept and a reject case at its route, because a move that silently loosens validation looks identical to a correct one. The self-host infra parity test is the named regression test for deliverable 5 and must derive the intercepted path list from src/server.ts, not restate it.
Expected Outcome
No request shape in this repo is written down twice. The control plane stops being the one surface with no machine-readable contract, its admin client knows what a tenant is, and a self-host operator can read the endpoints their deployment serves out of the published document.
Links & Resources
src/api/routes.ts:479 onward; control-plane/src/http-app.ts:117-308; packages/loopover-miner/lib/tenant-client.ts:31; src/server.ts:1100-1160. Completes the unlanded requirements 2, 3, and 7 of the closed #9531. Part of #9515.
Context
#9531 drove the route↔spec ratchet to zero and deleted its baseline: every route
createApp()serves now has an operation,isProtectedPathis gone, and the settings-parity script has been replaced by a compile-time assertion. Three of its deliverables did not land with it, and the issue is closed, so they have no home.1. The 38 inline request schemas are still in
src/api/routes.ts. Measured onmain: 39z.object(literals and 75.safeParse(/.parse(call sites in that one file. #9531's requirement 2 was to relocate them to@loopover/contract"so the 38 inline schemas inroutes.tsare gone at the end and any MCP tool wrapping the same route references the same schema object rather than a copy." Today an MCP tool and its route validate against two independently-authored shapes, which is exactly the duplication #9515 exists to remove — and it is not theoretical: #9518's migration foundFleetRegisterInstallationInputnever declaring theregisteredfield its route accepts, making the opt-out unreachable over MCP.2.
control-plane/src/http-app.tshas no zod and no spec at all. Seven routes (GET /health,POST /v1/tenants,GET /v1/tenants,POST /v1/tenants/rollout,PATCH /v1/tenants/:name/orb-installation,DELETE /v1/tenants/:name,POST /v1/orb/webhook) and zeroz.objectin the file. Its only contract is prose plus the hardcoded fetches inpackages/loopover-miner/lib/tenant-client.ts, whoseTenantRecordis literallyRecord<string, unknown>— the client cannot tell a caller what a tenant even has on it. #9531 requirement 7.3. The four self-host infra endpoints in
src/server.tsare unspecced./health(:1100),/ready(:1105),/metrics(:1112), and/setup+/setup/callback(:1120,:1134,:1154) are served by the Node entrypoint's own fetch handler, before the Hono app, so the ratchet does not see them and the published document does not mention them. A self-host operator wiring a healthcheck has prose only. #9531 requirement 7.Requirements
src/api/routes.tsinto@loopover/contract, and have the routes import them. Where an MCP tool already declares a schema for the same payload, the two must become one exported object — not two that happen to agree.control-plane/src/http-app.tszod request/response schemas, register its routes through thedefineRouteseam, emit its own committed OpenAPI document, and add a drift check for it totest:cimirroringui:openapi:check.packages/loopover-miner/lib/tenant-client.tsfrom those schemas:TenantRecordstops beingRecord<string, unknown>and becomesz.inferof the control-plane's own tenant schema, and each call parses its response rather than casting it.selfhost-infratag. They are served outside the Hono app, so they need spec entries plus a test asserting the specced set matches the pathssrc/server.tsactually intercepts — the ratchet cannot see them, and an unwatched hand-list is the rot this epic keeps finding.npm run ui:openapiregenerated and committed;test:cigreen.Deliverables
z.object(request-schema literals left insrc/api/routes.ts; all imported from@loopover/contractcontrol-planeroutes registered through the seam with zod schemas, its own committed spec, and a drift check wired intotest:citenant-client.tstypes itself from the control-plane schemas and parses responses;TenantRecordis no longerRecord<string, unknown>selfhost-infra, with a test pinning the specced set against whatsrc/server.tsinterceptsapps/loopover-ui/public/openapi.jsonregenerated and committedTest Coverage Requirements
src/**is inside Codecov'ssrc/**include and the 99% branch-counted patch gate applies;packages/loopover-contract/src/**andpackages/loopover-miner/lib/**are also in vitest'scoverage.include. Every relocated schema needs both an accept and a reject case at its route, because a move that silently loosens validation looks identical to a correct one. The self-host infra parity test is the named regression test for deliverable 5 and must derive the intercepted path list fromsrc/server.ts, not restate it.Expected Outcome
No request shape in this repo is written down twice. The control plane stops being the one surface with no machine-readable contract, its admin client knows what a tenant is, and a self-host operator can read the endpoints their deployment serves out of the published document.
Links & Resources
src/api/routes.ts:479onward;control-plane/src/http-app.ts:117-308;packages/loopover-miner/lib/tenant-client.ts:31;src/server.ts:1100-1160. Completes the unlanded requirements 2, 3, and 7 of the closed #9531. Part of #9515.