Build-in-public is a CLOUD feature — MOTIR_CLOUD gates /explore and /p/*, and a self-hosted Motir is single-tenant
Build-in-public is a CLOUD feature. A self-hosted Motir is a team doing project management for itself — single-tenant, no directory of other people's projects, no public reading surface for strangers. This story makes that true in the product instead of true only in intent.
What is true today (read from origin/main, 2026-08-29)
motir-core ships app/(public)/explore/ (the square + topic/[slug]) and app/(public)/p/ (nine routes: the project, board, roadmap, tree, items, an item, changelog, the changelog feed, requests), over publicProjectsService (~14 read methods), projectSquareService and projectTagsService, plus app/api/public/ (10 anonymous routes). All of it is on by default in every build, self-hosted included. A self-hoster who deploys motir-core today gets a cross-tenant project directory they did not ask for and cannot turn off.
The mechanism already exists — this story uses it, it does not invent one
lib/billing/availability.ts carries isCloudBilling(), reading an explicit MOTIR_CLOUD env flag, decided in docs/decisions/billing-tiering.md §6: "Billing AND the §4 entitlement caps exist ONLY on Motir cloud; a self-hosted (GPL-3.0) build is uncapped and shows no checkout, no paywall, no caps." Its own comment states the discipline this story inherits: the flag is EXPLICIT and default false, deliberately NOT inferred from the presence of other config, "so a self-hoster who connects their OWN motir-ai is never force-billed."
The naming needs one decision this story makes rather than assumes: isCloudBilling() answers "is this a billing build?", and this story asks "is this a cloud build?". Read the ADR's §6 rule that two questions get two functions even when they read one variable, and follow it — a shared isCloud() beside it, or a second named predicate, but not a caller of isCloudBilling() from a non-billing surface.
The gate is PRESENT-but-off, not absent
This follows the shape already decided for the AI on 2026-06-16 and recorded in the chat front-door design: present-but-gated, NOT absent, NOT silently broken. A self-hosted build does not ship a broken route or a stack trace; it ships a deliberate answer. What that answer IS — a 404, or a short page saying this is a Motir Cloud feature — is this story's to decide and to state, because a 404 is a decision, not a default.
Boundary — where this story ENDS
- It does not move any URL. Which hostname
/exploreand/p/*answer on is MOTIR-3876's and MOTIR-3877's. This story is about whether they answer AT ALL on a self-hosted build, and the two are independent: the gate is correct wherever the routes live. - It does not touch
/docs. Those pages describe the software and a self-hoster needs them for their own build. - It does not touch
/legal— that content leaves the repository entirely, which is its own story. - It does not make
motir-coresingle-tenant. Workspaces, RLS and the tenancy model are unchanged; this is about which SURFACES a non-cloud build serves, not about the schema. - It does not gate
app/api/public/*by accident and it must not gate it by omission either — those 10 routes are the same feature. Decide them explicitly and say so.
Verification recipe
- Build with
MOTIR_CLOUDunset. Request/explore,/explore/topic/<slug>,/p/<identifier>and each of its tabs — every one gives the decided not-available answer, and none gives a stack trace or a 500. - Request each
app/api/public/*route on the same build — the decided answer, consistently with the pages. - Set
MOTIR_CLOUD=trueand repeat — every surface renders exactly as it does today. - Confirm the authenticated application is untouched in both builds: sign in, open a project, a board and an item.
Acceptance criteria
- Every child is
done; the repository set ismotir-corealone. - With
MOTIR_CLOUDunset,/explore,/explore/topic/*,/p/*and theapp/api/public/*routes all give the decided unavailable answer; with it set, all render as today. Both arms are tested — the self-host arm is the one that has never existed, so it is the one the suite must actually exercise. - The predicate is a named function beside
isCloudBilling(), readingMOTIR_CLOUD, and no non-billing surface callsisCloudBilling()— asserted by a test, in the shapetests/hosting/appUrlSeam.test.tsalready uses for single-reader rules. - The unavailable answer is the same on every gated surface, and the card records WHAT it is and why.
- Nothing in
app/(authed)/changes behaviour in either arm. - ≥90% coverage on every file the story's PRs touch.
Context refs
motir-core/lib/billing/availability.ts—isCloudBilling(),MOTIR_CLOUD, and the explicit-flag disciplinemotir-core/docs/decisions/billing-tiering.md§6 — the cloud-vs-self-host build gate and its two-questions-two-functions rulemotir-core/lib/ai/planningConfig.ts—isAiPlanningConfigured(), the OTHER flag this must not be confused withmotir-core/app/(public)/explore/·app/(public)/p/·app/api/public/— the surfaces gatedmotir-core/lib/services/{publicProjectsService,projectSquareService,projectTagsService}.ts— the services behind them, which are NOT deleted
Advisory dispositions
reference→ MOTIR-3876 and MOTIR-3877 (severityadvisory): not consumed, and noblocked_byis owed. Both are named in this story's BOUNDARY, which exists to say they are somebody else's — the gate is correct wherever those routes are addressed, and those stories are correct whether or not the gate has landed. The two are genuinely parallel and wiring an edge would serialise work that shares no output.validate_work_itemon this story readsvalid: true,blockers: [], which is the intended shape rather than an oversight.
Comments (0)
No comments yet — be the first to weigh in.