Decision (ADR) — public tenant addresses: the base domain, what a subdomain names, the customer-domain lifecycle on Fly certificates, the one-primary canonical, the never-released rename, and the tier gate
Write docs/decisions/public-tenant-addresses.md — the record every other card in this story builds against — and amend public-surface-hosts.md where its §4 reversal condition hands this story a question. Convention as work-item-type-taxonomy.md sets it: Status → Context → Decision → Consequences, load-bearing facts pinned in tables, one authoritative source per shape. No application behaviour ships here; what it freezes is what makes the store, the services, the router and the settings pane buildable.
The questions, and the recommendation each arrives with
Each is answered from rung 1 (the mirror, fetched — the observation and its URL go in the record) and rung 2 (shipped code on origin/main, cited by path). The recommendations below are the planner's; the ADR confirms or overturns each with the evidence beside it.
| # | question | recommendation, with its grounds |
|---|---|---|
| Q1 | Which base domain do tenant subdomains hang off? | A separate registrable domain, not a subdomain of motir.co. public-surface-hosts.md §4's reversal condition says so and why: a base on a subdomain forces three-level addresses a *.motir.co certificate does not cover, and per-tenant content under the session's registrable domain multiplies the exposure §4 accepted once. Every mirror does this (notion.site, canny.io, statuspage.io, github.io, vercel.app). Name a ranked shortlist and check each candidate's availability by RDAP (https://rdap.org/domain/<name>) — an agent-runnable check — so the buying card gets a list, not a guess. Registrar stays Spaceship (marketing-site-hosting.md §3 — no new vendor, no subprocessor row). |
| Q2 | What does a subdomain NAME — an organization, a workspace, or a project? | The WORKSPACE. The story's own criterion says a workspace claims it; Project.identifier is unique per workspace (prisma/schema.prisma, ProjectKeyAlias @@unique([workspaceId, identifier])), so acme.<base>/<identifier> is unambiguous where an organization-level address is not (two workspaces of one org may both hold PROD). Organization.slug @unique (organization-url.md §3) is substrate and stays untouched — say so. The workspace root acme.<base>/ lists that workspace's public projects (or is the one project when there is one). Mirror: Notion (a workspace claims one notion.site domain), Canny (a company subdomain, boards under it). |
| Q3 | What does a customer domain name? | ONE public project, at its root — roadmap.acme.com/board, the way a Statuspage page or a Productboard portal answers at the root of its domain. So an address is either workspace-subdomain + project path or customer-domain root; the store (next card) holds both shapes in one table. |
| Q4 | Which customer hostnames are accepted, and how is ownership proven? | Any hostname the customer can point — a subdomain by CNAME to the app's Fly hostname, an apex by A/AAAA to the app's IPs (Fly issues both; marketing-site-hosting.md §3 already relies on this shape at motir.co). Ownership is proven by a TXT record (_motir-verify.<host> carrying a per-address token) BEFORE a certificate is requested — Notion's shape; Canny relies on the CNAME alone. State why the TXT is worth its friction (a domain must not be claimable by whoever points it first). |
| Q5 | Who issues and renews the certificate? | Fly, per hostname, on the motir-marketing app, driven from motir-core through Fly's certificates API (POST /v1/apps/{app}/certificates/acme · …/{hostname}/check, and fly certs add for the wildcard the human runs). Fly renews; we surface the state. The wildcard *.<base> covers every tenant subdomain in ONE certificate (_acme-challenge CNAME delegation). Rejected: Cloudflare for SaaS (a third vendor and a subprocessor row — marketing-site-hosting.md §5's reasoning), a self-run ACME client (a second certificate system beside the one the platform already operates). Note Let's Encrypt's per-registered-domain limits and why they do not bind here. |
| Q6 | The canonical rule. | Exactly one PRIMARY address per project. Default primary: motir.co/p/<identifier> until a subdomain is claimed, then the subdomain address; a customer domain becomes primary only by an explicit make primary (Canny's make primary — "the canonical source"). Every other address for the same page 301s to the primary; canonical, og:url, JSON-LD @id, the sitemap and the Atom feed's links all name the primary. Consequence stated: once a primary exists off motir.co, motir.co/p/* for that project is a redirect — which REDUCES the §4 exposure. |
| Q7 | Rename, and the reserved set. | An old subdomain keeps redirecting and is never released (Atlassian: a changed site URL "remains as a redirect" and cannot be re-used by another organization; ProjectKeyAlias is the in-repo precedent). Renames are capped. ⚠️ AMENDED ON THE RECORD (MOTIR-4206's own run, 2026-09-03): this card asserted “Atlassian caps at 3”. Read from the cited surface (https://support.atlassian.com/organization-administration/docs/update-a-product-url/) the documented figure is 15 — “You can update the subdomain up to 15 times.” The instruction (decide a number) stands; the number offered as grounds was not the mirror's. The ADR decides 5, with the deviation's use case stated. Planning bug filed under MOTIR-1465. The reserved-name set is a constant in code, enumerated in the ADR: Motir's own hosts (www, app, api, mail, status, docs, help, support, blog, admin, assets, cdn, motir, moooon), single characters, a minimum length, and the impersonation list. |
| Q8 | The tier gate. | A new EntitlementKind custom_domains and maxCustomDomains on PmEntitlements (lib/billing/entitlements.ts), read through entitlementsService like every §4 cap — tenant subdomains are free for every tier; custom domains are gated. The ADR NAMES the gate and its provisional seed values (free: 0, scaled: 5, enterprise / meta: unlimited) and states that the VALUES are billing-tiering.md's to change (Story 8.1 / 8.6) — the story ships the capability and the gate it reads, not the price (Notion, Canny and Statuspage all gate custom domains to paid). |
| Q9 | The open-core line. | The whole capability is cloud-only behind isCloud() — a self-hosted build has no public projects (public-surface-hosts.md §5), so it has no addresses; the settings pane and every route inherit publicSurfaceUnavailable() / isCloud(). |
| Q10 | Branding on a customer address. | The public chrome stays Motir's on every address — no white-label in this story. Recorded as a DECISION with a reversal condition (a paying customer asks for it), not as a deferral. |
What the ADR also owes
- The §4 reversal condition, revisited on the record.
public-surface-hosts.md§9 names this story as where the separate-domain decision is taken; write the amendment that closes it (read the last AMENDMENT heading onorigin/mainbefore opening the PR — the ordinal moved twice in one day on MOTIR-4108; it is AMENDMENT 5 as of 2026-09-02).organization-url.md's reversal condition is not triggered — Q2 explains why the address names a workspace, not an organization — say so in one line. - Every deferral in the record cites a card. The Public Suffix List submission → the PSL card; the per-tier values →
billing-tiering.md(Story 8.1); nothing else may be left "for later" without a key. - The configuration it decides is NAMED here, so the provisioning card can run before any code lands:
MOTIR_PUBLIC_TENANT_DOMAIN(the base, on both apps — onmotir-marketingas aNEXT_PUBLIC_*build arg, the waylib/siteOrigin.tsis configured), andFLY_CERTS_TOKEN+FLY_CERTS_APPonmotir-core(a token scoped to themotir-marketingapp only, followingFLY_FLEET_API_TOKEN's "never the token something else deploys with" rule inlib/orchestrator/adapters/fly/flyMachines.ts). - The consequences table, card by card, binding the store, the subdomain service, the certificates adapter, the lifecycle, the host contract, the router and the canonical card.
Acceptance criteria
docs/decisions/public-tenant-addresses.mdexists on the PR, structured Status → Context → Decision → Consequences, and answers Q1–Q10 each with a decision, at least one rejected alternative, and the rung-1 observation (mirror + URL, as fetched) and/or rung-2 path it rests on.- Q1 names a ranked shortlist of base-domain candidates with each one's RDAP availability reading and the command that produced it.
- Q7 enumerates the reserved-name set and the rename cap as literal values, and Q8 names the entitlement kind, the field and its provisional per-tier values, citing
billing-tiering.mdas the owner of the numbers. - The record names every environment variable this story introduces, on which Fly app each lives, and whether it is a runtime secret or a
NEXT_PUBLIC_*build argument. public-surface-hosts.mdgains the amendment closing §4's reversal condition, numbered from the last heading onorigin/mainat merge time, and its §9 entry for MOTIR-3878 is struck or updated.- Every "later" / "deferred" / "not this story" sentence in the record cites a work-item key or an ADR that owns it (grep the file for the phrase list before opening the PR).
- No application code, schema or copy changes in this PR.
Context refs
motir-core/docs/decisions/public-surface-hosts.md— §4 (the cookie, the deviation, the reversal condition), §5 (cloud gate), §9 (what it does not decide), AMENDMENT 4 §B (sameSite: 'lax')motir-core/docs/decisions/organization-url.md— §3 (Organization.slugstays as substrate) and its reversal conditionmotir-core/docs/decisions/marketing-site-hosting.md— §3 (Spaceship zone, FlyA/AAAA, the apexMX/TXTconstraint), §5 (subprocessor reasoning)motir-core/docs/decisions/billing-tiering.md·lib/billing/entitlements.ts(PmTier,EntitlementKind,PM_ENTITLEMENTS) ·lib/services/entitlementsService.tsmotir-core/prisma/schema.prisma—Project.identifier,ProjectKeyAlias,Organization.slug @uniquemotir-core/lib/publicProjects/urls.ts(publicSiteOrigin) ·cors.ts·returnTarget.ts— the three places that currently assume ONE public originmotir-core/lib/orchestrator/adapters/fly/flyMachines.ts— the Fly boundary and its token rule- MOTIR-3879 — the ADR this one sits beside; MOTIR-4108 — the amendment whose ordinal moved twice
- Fly —
https://fly.io/docs/networking/custom-domain/·https://fly.io/docs/networking/custom-domain-api/·https://fly.io/docs/flyctl/certs/ - Notion —
https://www.notion.com/help/connect-a-custom-domain-with-notion-sites; Canny —https://help.canny.io/en/articles/1355038-setting-up-your-custom-domain; Statuspage —https://support.atlassian.com/statuspage/docs/set-a-custom-domain-and-ssl/; Atlassian —https://support.atlassian.com/organization-administration/docs/update-your-product-and-site-url/