The public contract reaches TOTALITY — the remaining nine operations, and a coverage guard that fails on a route with no declaration
Type: code · Executor: coding_agent · Repo: motir-core · ONE PR.
MOTIR-3946 builds the public contract's spine and proves it on three reads. This card makes it TOTAL — every route in app/api/public/* declared, and a guard that fails when one is not.
⚠️ Corrected 2026-08-30 — it is NINE operations, not eight
Measured on the merged spine: app/api/public/ holds 11 route FILES exporting 12 operations — 8 GET reads and 4 writes (POST on follow, subscribe and requests; DELETE on follow). MOTIR-3946 declared three, so nine remain. The table below always enumerated nine methods across eight files; only the heading said eight. The count is also recorded in docs/decisions/public-surface-hosts.md AMENDMENT 1 §F.
Why the split is here and not somewhere else
The spine card decides where the contract lives and what a version means; this one fills it in. Splitting after three operations is not arbitrary: three is what it takes to prove the pipeline over a path parameter, a query collection and a bare list, and everything after that is the same act repeated. Doing all twelve in one card puts it past the estimation gate's one-hour run for no gain in confidence.
The remaining NINE
Read the set from the filesystem rather than this list — it is a reading of 2026-08-30 and the guard below is what makes it true:
| route | method | note |
|---|---|---|
p/[identifier]/tree | GET | viewer-aware read (actorUserId ?? null), not gated |
p/[identifier]/items | GET | same |
p/[identifier]/roadmap | GET | same |
p/[identifier]/changelog | GET | same; cursor-paged |
p/[identifier]/subscribe | POST | anonymous WRITE — an email subscribe |
p/[identifier]/follow | POST, DELETE | the one session-GATED pair (401); document the 401 as part of the contract, not as an omission |
projects/[projectId]/requests | POST | anonymous write |
projects/[projectId]/requests/duplicates | GET | anonymous read |
The writes are the interesting half. A consumer that only reads can tolerate a loose contract; one that POSTs needs the request body documented too, and needs to know what a rejection looks like. Declare request schemas, not just responses.
What the spine already hands you — do not rediscover it
lib/api/public/openapi/emit.tshoists$defsintocomponents.schemasand rewrites the pointers. zod attaches a.meta({ id })subschema to the root of ONE conversion, so a document assembled without the hoist fails validation withCan't resolve #/$defs/…. Declare shared shapes with.meta({ id })and the emitter does the rest;contract-document.test.tswalks every$refto a component that exists.PublicOperationcarries nopermissionand no envelope union — deliberately, and the reason is AMENDMENT 1 §B.follow's 401 is declared like any other error response, onpublicErrorSchema.- Read every route's actual return before declaring it.
/api/public/categoriesanswers{ categories }, not a bare array; the natural guess would have documented a shape no client receives. The drift test is what catches this — extend it, don't trust the DTO type. PUBLIC_CONTRACT_VERSIONmoves to1.1.0when these land: nine new operations are §D-additive. Read it offorigin/mainbefore merging, per the serialized-resource note.
⚠️ The guard is the deliverable, not the operations
A coverage guard that WALKS app/api/public and fails on any exported method with no declaration — the property tests/api/v1/openapi-operations-coverage.test.ts already provides for v1, which its own registry header credits for keeping the list from falling behind a route added later. Without it, this card is a snapshot; with it, a route added in six months cannot ship undocumented.
Watch it fail before leaving it green. Add a throwaway route, see the guard name it, remove it. A guard nobody has watched go red is indistinguishable from one that never runs.
⚠️ If the guard reaches the filesystem, it owes the structural-guard lane. tests/ci-structural-guards-lane.test.ts derives membership and will name the file; the lane's purity rule then forbids importing lib/, so plan for a source-reading guard or a split (the shape tests/seo/robots-signed-in-coverage.test.ts was moved to on this branch).
Boundary
- No route moves, no behaviour changes,
withV1Routeuntouched — all settled by 3946's decision. - It does not gate the surface — MOTIR-3908's.
- It does not build a consumer.
Acceptance criteria
- Every method exported by every route under
app/api/public/has an operation declaration, with a response schema and — for the writes — a request schema. follow's 401 is documented as a declared response, so its gatedness is part of the contract rather than a surprise.- The coverage guard walks the filesystem and fails on an undeclared method, and the pull request records it being watched failing.
- The drift guard from 3946 now covers every documented shape, not only the three.
- The emitted document is valid and its operation count equals the route-method count — asserted, so neither can drift from the other.
PUBLIC_CONTRACT_VERSIONmoves, with a version-history line naming the operations added.- Nothing a visitor sees changes; no route's behaviour changes.
- ≥90% coverage on the files this PR touches.
Context refs
- MOTIR-3946 — the spine, the decision and the version this completes
motir-core/app/api/public/— the population; the filesystem is the authoritymotir-core/lib/api/public/openapi/— the registry, schemas and emitter to extendmotir-core/tests/api/public/— the document, route and drift guards to extendmotir-core/tests/api/v1/openapi-operations-coverage.test.ts— the walk-and-fail idiom to mirror