ADR-017 — Three-surface architecture (console · tenant app · admin retirement)
canonical· tech-lead · updated 2026-09-06 · source
- Status: Accepted — amended 2026-08-31 (José): one domain per tenant, role-gated operator
access — the separate operator host (primary
admin.<tenant-domain>/ fallback<tenant>.operador.tuempresa.digital) is retired. See § Amendment (2026-08-31). Superseded in part by ADR-018 (2026-09-06, Phase 1 of 3: staff/operator auth onapps/admin,apps/console, andapps/chatmoves to better-auth — § 2 "operators authenticate with Clerk" and the Clerk half of § Auth design no longer hold for those three apps.apps/portal's/operator/*surface (the actual "operator" this section describes) stays on Clerk until Phase 3 (#1538); the three-surface topology, the path-gated operator surface, and the route-group/cookie-namespace split stand throughout. - Date: 2026-07-10 · amended 2026-08-31
- Owner: Tech Lead
- Related issues: effort #1257 (this decision's effort) · #1258 (this ADR) · #1259 (auth
spike + base-app decision, recorded in its comment) · #1261 (console absorbs the platform
level) · effort #1262 (comprender → portal migration) · supersedes effort #1253 (closed —
admin URL work has no future on a frozen surface) · #1428 (the
admin.<tenant>TLS bug that triggered the amendment) · #1435 (role-gated operator surface on the tenant domain) · effort #1438 (shipped the amendment: path-gated operator surface + operador.* 308) · #1414 (host-resolution work made moot by the amendment) · effort #1444 (client-owned custom domains administrable from the console — brings them IN scope)
Context
- The 2026-07-09/10 QA pass found FOUR shipped surfaces with overlapping operator functionality:
apps/admin(Clerk, platform + project operator screens),apps/console(superadmin internals),apps/clients/comprender(white-label operator app, Clerk, host-resolved), andapps/portal(multi-tenant student app, better-auth, host-resolved — ADR-016). Comprender had re-implemented most of admin's operator screens; both drift independently. - José's decision (2026-07-09/10 QA pass): collapse to three surfaces with one owner each — a superadmin config surface, ONE white-label tenant app, and a staged retirement of admin.
- Forces: one deploy per population, no duplicated operator screens, per-tenant custom domains
(Host resolution, ADR-016), the thin-app architecture (ADR-008) that makes app shells cheap,
and the import boundaries of ADR-012 (shared logic flows
through
@tedos/core/@tedos/ui, never app→app).
Decision
1. apps/console = the superadmin tenant-config surface
The console (Amber/ink, console.tuempresa.digital) is where WE configure tenants: module
enablement per tenant, connections, limits — plus the platform level it absorbs from admin
(projects list + creation, tenant config screens, the Plan-Engine builder stack) — sub #1261.
2. ONE multi-tenant white-label tenant app — operators AND students
- A single Host-resolved app on ONE domain per tenant (
<tenant>.tuempresa.digital) serves both populations with role-split layouts (operator chrome vs student chrome — route groups, not separate apps). Access is differentiated IN THE APP by role, never by host (amended 2026-08-31 — see § Amendment). - Fully separate auth stacks: operators authenticate with Clerk; students with better-auth phone OTP (ADR-016). Never shared sessions — two populations, two identity models, zero cookie/session overlap. Coexistence design: § Auth design (spike #1259) below.
3. apps/portal is the BASE of the tenant app
Decided from the standards audit (evidence in the #1259 comment, 2026-07-10):
- Portal wins the load-bearing standards: native ADR-016 multi-tenancy end-to-end (Host
resolution, runtime branding, per-tenant auth cookie prefix),
React.cacheon 15/15 data-fns (comprender 0/10), client-only-on-leaves RSC,type-*typography (145 uses vs 62 rawtext-*in comprender), and active momentum (store/cart/course interior). - Comprender's assets migrate INTO portal (effort #1262): the dual-rail ClientShell chrome
(promoted to
@tedos/uiin transit), module/feature/tier gating (enabledModules+requireModule/requireFeatureover@tedos/core/modules), and the 10 operator screens as portal shells (data-fns wrapped inReact.cache, rawtext-*converted totype-*in transit). Comprender then retires. Its brand hardcoding, env-fixture tenant role, and missing caching are why it is not the base.
4. apps/admin is DEPRECATED — frozen now, staged retirement
- Freeze rule (hard, effective immediately): nothing new lands in
apps/admin— no new screens, seams, tokens, or fixes beyond keeping the build green. A PR adding net-new admin functionality is a review blocker. - Staged retirement: admin is deleted once the console absorbs the platform level (#1261). The unique salvage (operator seams that exist ONLY in admin: credentials, student-binds, video-connections, lessons-actions; the projects seam + builder stack) is inventoried in #1257's body — everything else is dead weight and deletes with the app.
Scope boundaries
- This ADR fixes the surface topology + auth-stack split. Screen design (Designer), the migration order inside #1262, and the console screens' shape (#1261) are their efforts' calls.
- It does not change ADR-016's student-auth decision — it extends where that portal grows.
Consequences
Positive
- One tenant app = one deploy, one screen implementation per operator surface; the admin/comprender drift class of QA findings disappears structurally.
- QA-finding fixes land in their final home, not the frozen one: operator-screen fixes → the tenant app (portal, post-#1262); tenant provisioning/config fixes → console (#1261); student-flow fixes → portal as today. Admin gets no fixes beyond build-green.
- Portal's standards (ADR-016 multi-tenancy,
React.cache, RSC discipline,type-*) become the floor for every migrated screen — the migration is also a quality pass.
Negative / trade-offs
- Two auth stacks in ONE app is real complexity (route-group isolation, cookie namespaces, proxy ordering) — accepted deliberately; the design is the § below and José ratifies it.
- Until #1261/#1262 land, admin remains deployed-but-frozen — a window where the retired surface still serves the platform level. Kept short by sequencing both efforts next.
- Migrating 10 operator screens is churn comprender already paid once — the audit says paying it again into the standards-compliant base is cheaper than backfilling portal's standards into comprender.
Follow-ups required
- #1261 — console absorbs the platform level (projects seam, tenant config, builder stack home).
- Effort #1262 — comprender assets migrate into portal; comprender retires.
- #1257 scope 3 — salvage the 6 admin-only operator seams into the tenant surface.
- Admin freeze note in admin's README + the
.clauderules (same effort #1257). - José ratifies the § Auth design (spike #1259) proposal below.
Alternatives considered
| Option | Why not |
|---|---|
| Comprender as the tenant-app base | Fails the standards audit: 0/10 React.cache data-fns, brand hardcoding, env-fixture tenancy, 62 raw text-*; backfilling costs more than migrating (#1259 comment). |
| Two tenant apps (operator + student) forever | Two deploys + two Host-resolution stacks per client domain, duplicated chrome/gating, and a cross-app auth story anyway — the QA pass showed the duplication cost live. |
| Keep admin as the operator surface | Admin is not white-label (Cian skin, no Host resolution) and duplicates the tenant app screen-for-screen; freezing it is what removes the drift. |
| Big-bang admin deletion now | The platform level (projects, config, builder) has no other home until #1261 — staged retirement keeps the lights on without accepting new drift. |
References
- Effort #1257 (scope + salvage inventory) · #1259 comment (base-app evidence, 2026-07-10)
- ADR-016 — student auth (better-auth) +
apps/portal - ADR-012 — no app→app imports; shared logic via
@tedos/core, generic UI via@tedos/ui - ADR-008 — thin-app architecture (shells over packages)
.claude/rules/color-scope.md— console = Amber/ink; the tenant app skins per tenantdecisions/log.md(2026-07-10)
Amendment (2026-08-31) — one domain per tenant, role-gated operator access
Decided by José 2026-08-31 (#1428). Supersedes the original operator-entry scheme below
(primary admin.<tenant-domain> subdomain, fallback <tenant>.operador.tuempresa.digital).
The decision
- ONE domain per tenant:
<tenant>.tuempresa.digital. No separate operator host —admin.<tenant-domain>and<tenant>.operador.tuempresa.digitalare both retired as patterns. - Access is differentiated IN THE APP by role, not by host: operators authenticate with Clerk and reach operator screens via role gating; students/public use better-auth on the same domain.
- Client-owned custom domains are IN scope (effort #1444, 2026-08-31). A tenant's
store/student surface may live on the client's OWN domain (e.g.
tienda.comprender.mx): the domain is stored asproject.domain(UNIQUE, #1446), added to the portal's Vercel project and DNS-verified from the console (Configurar → Dominios, over the #922/#930 provisioning API), and then Host-resolved by the SAME multi-tenant portal deployment like any<tenant>.tuempresa.digitalhost — no per-tenant build. Role gating is unchanged: the operator surface stays path-gated on whichever host serves the tenant. Flow + env:cd-and-domain-provisioning.md. console.tuempresa.digitalis unchanged — José-only superadmin, not a tenant surface.
Rationale
- Vercel wildcard certs cover ONE label:
admin.<tenant>.tuempresa.digitalis a second-level subdomain with no TLS under*.tuempresa.digital— the #1428 bug. - Clerk satellite-domain pricing was evaluated and rejected.
- One host removes the whole host-resolution problem — #1414's premise disappears.
Migration (#1435) — SHIPPED (effort #1438, 2026-08-31)
The role-gated operator surface is live on the tenant domain: proxy.ts gates /operator/* (+
Clerk internals) by PATH with the same Clerk session check, and the API keeps authorizing
org → projectId → role per request. The retired hosts — admin.<tenant-domain> and
<tenant>.operador.tuempresa.digital — now answer a permanent 308 redirect to the same
path + query on https://<tenant>.tuempresa.digital at the proxy edge
(apps/portal/src/lib/operator-host.ts holds only that mapping). The host-detection code is
removed: isOperatorHost, apexHostFromOperatorHost, the PORTAL_OPERATOR_HOST override, the
#1412 layout exemption (every served path now resolves its tenant), and the proxy apex→operator
bounce that built dead admin.<host> URLs. The root layout keys the neutral operator chrome off
a proxy-set surface marker (apps/portal/src/lib/surface.ts) instead of the Host.
Auth design (spike #1259)
Accepted (José, 2026-07-10); operator entry AMENDED 2026-08-31 — the original
admin.<tenant-domain>subdomain entry is retired; operator access is role-gated on the single tenant domain (§ Amendment above). The route-group split and cookie-namespace design below stand. How Clerk (operator) and better-auth phone OTP (student) coexist in ONE Next.js app (apps/portal) with zero session sharing.
Route-group split
- Two root-level route groups, each with its own layout, chrome, and auth provider:
(student)(today's(portal)group — courses, cart, invoices, certificates) and(operator)(the migrated comprender/admin operator screens). ClerkProvidermounts ONLY inside the(operator)layout — Clerk JS never ships in the student bundle. The(student)layout keeps ADR-016'sgetSession()as its authoritative check; the(operator)layout uses Clerk'sauth(). Neither layout ever reads the other stack's cookie — that is the zero-sharing invariant, enforced by construction.
Cookie namespace isolation
- Student (better-auth): already tenant-namespaced — the API scopes each project's instance
via
advanced.cookiePrefix(ADR-016 security finding #2), so the session cookie is<tenant-prefix>.session_token(__Secure-over https), host-only (noDomainattribute) on the tenant apex host. - Operator (Clerk): Clerk's
__session/__client_uatcookies live host-only on the tenant domain alongside the student jar — distinct cookie names, never read across stacks (amended 2026-08-31; was: operator-entry host only). Neither stack ever sets a wideDomain=.<tenant-domain>cookie — host-only on both sides is the rule.
proxy.ts ordering
- ONE proxy on the single tenant host, branching on path (amended 2026-08-31; was: Host
first, then path):
- Operator paths → the Clerk gate (
clerkMiddleware-style protection wraps only this branch); unauthenticated → Clerk sign-in; authenticated → role gating in the(operator)layout. - Student/public paths → the existing optimistic better-auth gate (cookie-suffix presence
check,
apps/portal/src/proxy.ts), public paths unchanged (/ingresar,/api/auth,/verificar,/terminos).
- Operator paths → the Clerk gate (
- Gates stay optimistic in the proxy (cookie presence, no network call — Next guidance); the authoritative per-stack validation stays in each route group's layout.
Operator entry — single tenant domain, role-gated (amended 2026-08-31)
The spike's original subdomain recommendation (admin.<tenant-domain>, fallback
<tenant>.operador.tuempresa.digital) is retired — see § Amendment (2026-08-31) for the
decision, rationale (the #1428 wildcard-TLS gap, Clerk satellite pricing), and the #1435
migration. Operators enter on <tenant>.tuempresa.digital; the (operator) route group's Clerk
auth + role gating is the boundary. Hard origin isolation is traded away deliberately: the
zero-sharing invariant is carried by the cookie-namespace + route-group design above (a code
property, not a browser origin property).