The public read surface gets its OWN versioned contract — same machinery as v1, its own version, document and drift guard (the spine + the three core reads)
Opened by Zhu Yue ·
Type: code · Executor: coding_agent · Repo: motir-core · ONE PR.
⚠️ THE DECISION, taken 2026-08-30 — and it is NOT "fold it into v1"
public-surface-hosts.md §3 left this card to decide where the public read surface is published. Three options were weighed against shipped code; the answer is its own versioned contract, generated by the same machinery as v1 — not folded into the v1 document, and not hand-written.
Folding into /api/v1 was investigated first and rejected on three measured grounds, each read from origin/main:
/api/v1is authenticated BY CONSTRUCTION.withV1Routetakes a requiredoptions.permission: PermissionKey— "a route that declares none fails MOTIR-1861's guard" — runsauthenticateApiTokenas step 1 with a 401 on failure, and hands its handler a non-optionaluserId,workspaceIdand aServiceContextbound to the token's workspace. Its rate limiter keys per token, deliberately.app/api/public/*is anonymous by design. Moving these routes under that prefix means either bypassing the wrapper — routes under/api/v1that do not behave like v1, failing four guards that walkapp/api/v1— or widening a shipped auth contract so it has an anonymous mode. The second is a re-architecture of the API's security model to solve a documentation problem.- The response shapes differ. Every v1 collection references
ListEnvelope/RankedListEnvelopeand v1'serrorResponse; these routes return the DTO raw and{ code }on 404. One document whose operations disagree about their envelope is a document that has to explain itself. - The version would lie.
V1_CONTRACT_VERSIONis stamped onX-Motir-Api-VersionbywithV1Route. Routes that do not use the wrapper stamp nothing, so a client reading oneinfo.versioncovering both surfaces gets a header on half the paths. A version number is a promise; one covering two surfaces with different stability is unreadable.
And a hand-written second document is the mirror this epic keeps arguing against — so the answer is neither. The public contract is GENERATED, from the same primitives (lib/api/v1/openapi/operation.ts's declaration type, the registry pattern, emit.ts's assembly), with its own registry and its own version. One source of truth per surface, one generator shared.
The honest cost, stated in the ADR amendment this card writes: two documents exist. That is the price of two genuinely different contracts, and it is smaller than either alternative — an anonymous hole in an authenticated contract, or a copy that rots.
What this card ships — the SPINE and three reads
It is deliberately not all eleven operations. The remaining eight and the totality guard are MOTIR-3990, because a card that also declares eleven operations with their schemas exceeds the estimation gate's one-hour run, and splitting at the spine is where the seam actually is.
PUBLIC_CONTRACT_VERSION— a leaf module with no imports, for the same reasonlib/api/v1/contractVersion.tsis one: the emitter and any future response header must read the same value without one depending on the other. Semver, with the same MAJOR/MINOR/PATCH meaning v1's records.- The operations module + registry for the public surface, in the
operations.tsshape the v1 resources use. - Three READ operations, which prove the pipeline end to end on the paths
motir-marketingneeds first:GET /api/public/p/{identifier}(the subject, MOTIR-3945),GET /api/public/explore,GET /api/public/categories. - The published document at its own path, emitted from the registry.
- The DEPRECATION POLICY, written down: what an additive change is, what a breaking one is, and what a consumer is guaranteed. This is the half that makes it a contract rather than a description.
⚠️ The guard belongs in THIS repository
A contract test living only in motir-marketing reports that motir-core broke it, after it has shipped. The guard worth having fails in motir-core's own CI, on the pull request that changes a response shape, naming the consumer. A consumer-side check is a smoke alarm in the wrong building.
Boundary
- No route moves and no URL changes. The paths stay
/api/public/*— see the decision above. withV1Routeis not touched. Widening it is the option this card rejected.- The remaining eight operations and the totality coverage guard are MOTIR-3990's.
- It gates nothing — whether the surface exists on a self-hosted build is MOTIR-3908's.
Acceptance criteria
PUBLIC_CONTRACT_VERSIONexists as an import-free leaf module, with its semver meaning documented aslib/api/v1/contractVersion.tsdocuments v1's.- The public document is EMITTED from a registry of operation declarations — no path, method or schema is written twice — and is served at its own published path.
- The three read operations above appear in it with their real response schemas, and a test asserts each documented schema matches what the route actually returns.
- The deprecation policy is recorded: the allowed additive changes, what constitutes a breaking one, and the guarantee a consumer gets. Prose, in the ADR or beside the version module, cited from both.
- A drift guard runs in
motir-core's CI and fails on a pull request that changes one of the three documented response shapes without changing the document. docs/decisions/public-surface-hosts.mdgains an amendment recording this decision, the three grounds for rejecting the fold, and the two-document cost.- Nothing a visitor sees changes; no route's behaviour changes.
- ≥90% coverage on the files this PR touches.
Context refs
motir-core/lib/api/v1/route.ts—withV1Route, the requiredpermission, the 401 and the workspace-bound context: the evidence for ground 1motir-core/lib/api/v1/contractVersion.ts— the leaf-module pattern and the semver meaning to mirrormotir-core/lib/api/v1/openapi/{operation,registry,emit,envelopes,errorResponse}.ts— the machinery reused, and the envelopes that are v1's alonemotir-core/tests/api/v1/openapi-{operations-coverage,registry,drift-guard,spec-route}.test.ts— the four guards that walkapp/api/v1motir-core/app/api/public/— the surface being publishedmotir-core/docs/decisions/public-surface-hosts.md§3 — the question this card answers
Discussion
No comments yet.
Adding to this discussion signs you in on app.motir.co and brings you back to this request.