Documentation source
Multi-Tenant Workspaces
Create and switch between isolated organizations — each with their own data, members, roles, and agents. The URL is the sole source of truth for the active tenant, giving per-tab and per-device independence for free.
# Multi-Tenant Workspaces
The platform supports fully isolated multi-tenant workspaces. Each tenant (organization) has its own records, data types, agents, navigation config, members, and API keys. Users can belong to multiple tenants and switch between them at any time.
## Overview
Every database table is scoped by `tenant_id`. Row-level security (RLS) policies enforce this isolation — queries from one tenant cannot read or write another tenant's data, even for system admins.
**The URL is the sole source of truth for the active tenant** — see [ADR-0003](/adr/0003-url-as-tenant-source-of-truth). Each request resolves tenant freshly from the URL path (`/t/<slug>/…`), so two tabs on different tenants cannot drift, and devices do not share tenant state.
The resolution pipeline:
1. **Client request** — page URL is `/t/<slug>/…` (or a `/api/…` route called from a tenant page)
2. **Middleware** (`proxy.ts`) extracts the slug via `deriveTenantSlugFromRequest()` and sets the `x-tenant-slug` header on the rewritten request
3. **PostgREST `db-pre-request` hook** (`private.set_tenant_context`) reads the header, verifies membership in `user_tenants`, and pins a transaction-local `app.tenant_id` GUC
4. **RLS** reads the GUC via `get_active_tenant_id()` — the JWT and profile are no longer consulted
5. **Application code** uses `getTenantContext()` which reads the same header
If any step can't resolve a tenant, the GUC stays unset and RLS returns zero rows. Fail-closed by design.
## How It Works
### Creating a tenant
From **Admin > Tenant > Create Tenant**, provide a name and slug. The slug must be URL-safe (`[a-z0-9][a-z0-9-]*`). You become the `admin` of the new tenant automatically; navigate to `/t/<slug>/dashboard` to start working in it.
### Switching tenants
Tenant switching is **pure navigation**. The sidebar user menu renders each tenant as a `<Link href={tenantUrl(slug, "/dashboard")} />`. Clicking it navigates to that tenant's URL; middleware sets the header; RLS reads the new tenant. No API call, no session mutation, no `refreshSession()`.
Because there is no shared "active tenant" state on the server:
- **Two tabs can be on different tenants.** Tab A on `/t/oci/…` and tab B on `/t/ims/…` each resolve their own context per request. Switching in one does not affect the other.
- **Two devices can be on different tenants.** Device A on laptop stays on its tenant independent of device B on phone.
A `last-tenant-slug` cookie is written on each tenant navigation and used to redirect bare paths (e.g. `/dashboard`) to `/t/<last-slug>/dashboard`. This cookie is a UX hint only — it is never read for authorization.
**Tenant-scoped URLs:**
Any URL can be prefixed with `/t/[tenantSlug]/` to force a specific workspace context. For example, `/t/acme-corp/opportunity` renders the opportunity list for `acme-corp`. The middleware intercepts these paths, rewrites them to the underlying route, and sets `x-tenant-slug` so `getTenantContext()` and the DB hook both resolve the correct tenant. Use `tenantUrl(slug, path)` from `features/tenant/constants.ts` to generate these URLs programmatically.
### Membership and roles
Users join tenants via the `user_tenants` table with a `role_id` FK to the `roles` table.
| App role | DB slug | Access level |
|---|---|---|
| `owner` | `system_admin` | Full access; can manage admins |
| `admin` | `tenant_admin` | Manage members, agents, data types |
| `member` | `editor` or `member` | Create and edit records |
| `viewer` | `viewer` | Read-only access |
| `guest` | `guest` | Read-only; default on signup |
New signups receive `guest` role in the default tenant. Promote users via Admin > Members.
### Inviting members
Admins invite users by email from **Admin > Members > Add Member**. The invited user must already have an account. Select a role at invite time.
The "Allow access to public community" checkbox controls whether the user also receives a `guest` membership in the default tenant. It is unchecked by default so invited users land only in the org workspace.
### Default tenant and community access
The default tenant (`slug: "default"`, `id: 00000000-0000-0000-0000-000000000000`) is a shared workspace that cannot be deleted or renamed. Access to it is called **community access** and is explicitly controlled.
| Path | Community access |
|---|---|
| Self-signup | Auto-enrolled in the default tenant as `guest` |
| Admin adds an existing user via Admin > Members | Default tenant is **not** added unless "Allow access to public community" is checked |
| Admin invites a new user by email | Default tenant is **not** added unless "Allow access to public community" is checked |
In **Admin > Members**, each member row shows a globe icon with a toggle switch. Toggling controls a `user_tenants` row for the default tenant.
### Per-Tenant Branding
Each tenant can customize the sidebar header identity via a `BrandingConfig` stored in `tenant_settings` under the key `"branding"`.
```typescript
// features/branding/types.ts
interface BrandingConfig {
appName?: string;
logoUrl?: string;
logoIcon?: string;
tagline?: string;
}
```
**Resolution:** `getResolvedBranding()` (`features/branding/server/cached-queries.ts`) cascades platform defaults with the tenant-level `branding` setting. Cached per request via `React.cache()`.
## API Reference
### Server functions (`features/tenant/context.ts`)
| Function | Description |
|---|---|
| `getTenantContext()` | Resolves active tenant + user from the `x-tenant-slug` request header. Throws if no header or the user is not a member. Cached per request via `React.cache()`. |
| `getActiveTenantId()` | Returns the active tenant ID. Thin wrapper around `getTenantContext()`. |
| `getUserTenants()` | Returns all tenants the current user belongs to. |
| `createTenant({ name, slug })` | Creates a new tenant and makes the caller its admin. Does NOT switch active tenant — caller navigates to `tenantUrl(slug, ...)`. |
| `addTenantMember({ tenantId, email, role })` | Adds an existing user by email. Throws if the user is not found. |
| `removeTenantMember(tenantId, userId)` | Removes a user from a tenant. |
| `getTenantMembers(tenantId)` | Returns all members with their roles. Uses admin client. |
| `updateTenant({ tenantId, name?, logoUrl? })` | Updates tenant name or logo. Admin only. Cannot edit default tenant. |
| `updateMemberRole({ tenantId, userId, role })` | Changes a member's role. Admin only; cannot self-demote. |
| `ensureUserProvisioned()` | Creates a profile and, for users with no memberships, adds a default tenant membership. Called on first session. |
| `getCommunityAccessMap(userIds)` | Batch check — returns a `Set<string>` of user IDs with default-tenant access. |
| `grantCommunityAccess(userId)` | Upserts a `guest` membership in the default tenant. |
| `revokeCommunityAccess(userId)` | Removes the default-tenant membership. No server-side "active tenant" mutation — the URL drives which tenant a user sees on their next navigation. |
### API routes
| Route | Auth | Description |
|---|---|---|
| `POST /api/tenants` | Authenticated | Create a new tenant |
| `POST /api/tenants/members` | Admin | Add a member by email; supports `includeCommunityAccess` flag |
| `PATCH /api/tenants/members` | Admin | Change a member's role |
| `DELETE /api/tenants/members` | Admin | Remove a member |
| `GET /api/tenants/members/community-access` | Admin | Returns `{ [userId]: boolean }` map for all members |
| `POST /api/tenants/members/community-access` | Admin | Toggle community access: `{ userId, tenantId, enabled }` |
| `POST /api/auth/invite` | Admin | Invite a new user by email; supports `includeCommunityAccess` flag |
### Database
- **Hook:** `private.set_tenant_context()` — runs on every PostgREST Data API request via `ALTER ROLE authenticator SET pgrst.db_pre_request = 'private.set_tenant_context'`. Reads `x-tenant-slug`, validates membership, pins `app.tenant_id` GUC.
- **Function:** `public.get_active_tenant_id()` returns the GUC first, falls back to JWT `app_metadata.active_tenant_id` only for impersonated clients (`createImpersonatedClient` — API keys, background jobs).
- **Table:** `user_tenants` — `(user_id, tenant_id, role_id)` composite key. RLS policies on every tenant-scoped table call `get_active_tenant_id()` implicitly via `authorize()`.
## Design Decisions
**URL is the sole source of truth for authorization.** See [ADR-0003](/adr/0003-url-as-tenant-source-of-truth). Session, cookie, and JWT tenant state all caused cross-tab and cross-device drift. Every major B2B SaaS (GitHub, Linear, Vercel, Slack, Notion, Stripe) uses the URL as the sole source of truth. The PostgREST `db-pre-request` hook extends that model all the way into RLS.
**`switchTenant` is navigation, not an API.** Removing the `/api/tenants/switch` route eliminates the shared-state mutation that caused cross-tab drift. Switching tenants is now `<Link href={tenantUrl(slug, path)}>` — zero server round-trip.
**`last-tenant-slug` is a UX hint, never authorization.** The cookie remembers the user's last-visited tenant so bare paths (`/dashboard`) can redirect to `/t/<slug>/dashboard`. It is never consulted for RLS.
**Community access as a row, not a column.** Community access is modeled as the presence or absence of a `user_tenants` row for the default tenant. Same RLS policies, same permission checks.
**Workspace-scoped configuration uses the same 4-tier resolver.** Settings such as `agent_context` in `tenant_settings` resolve via `user > workspace > tenant > platform`. See [Workspaces](/docs/features/workspaces) for the full scope-resolver design and the list of the ten scope-aware tables.
**DB trigger does only profile creation.** `handle_new_user()` writes the `profiles` row and a default-tenant `user_tenants` row. It does not stamp `auth.users.raw_app_meta_data.active_tenant_id` — regular user JWTs no longer carry tenant state.
**Self-signup still lands in the default tenant.** `ensureUserProvisioned()` checks `count` on `user_tenants` for the user. Zero memberships means self-signup; one or more means the user arrived via an invite and already has a home.
## Manual Test Script
### Prerequisites
- Logged in as `admin` or `owner`
- A second user account available for invitation testing
### Happy Path
1. **Create a new tenant**
- Go to Admin > Tenant > Create Tenant, name "Test Org" / slug "test-org"
- Expected: Admin landing — then navigate to `/t/test-org/dashboard` via the sidebar switcher or a `<Link>`
2. **Verify data isolation**
- On `/t/test-org/<any-list>` — expected: empty
- Navigate back to original tenant via the sidebar user menu (clicking a tenant sends you to its `/dashboard`)
- Expected: Original records reappear
3. **Per-tab independence**
- Open a second tab at `/t/test-org/dashboard`
- In the first tab, navigate to `/t/<original-slug>/dashboard`
- Expected: Each tab stays on its own tenant; refreshing tab 2 does not flip it to tab 1's tenant
4. **Per-device independence**
- On a second browser/device, sign in and navigate to `/t/test-org/dashboard`
- Expected: Laptop stays on its tenant regardless of which tenant the phone is showing
5. **Invite a member without community access**
- Go to Admin > Members > Add Member
- Enter the second user's email; set role to "member"; leave "Allow access to public community" unchecked
- Expected: User appears with correct role; community access toggle off
6. **Bare-path redirect**
- Navigate to `/dashboard` (no tenant prefix)
- Expected: 307 redirect to `/t/<your-last-visited-slug>/dashboard` — uses the `last-tenant-slug` cookie
### Edge Cases
- **Duplicate slug:** Error on create — slug must be globally unique across all tenants
- **Tenant-scoped URL for a tenant you're not a member of:** `getTenantContext()` throws "Not a member of tenant …"; RLS returns zero rows via the DB hook
- **Bare path with no cookie (first login):** Falls back to first non-default membership (or default tenant if only membership)
### Regression Checks
- [ ] Data created in Tenant A is not visible in Tenant B
- [ ] Two tabs on different tenants stay independent across navigation
- [ ] Switching tenants is a `<Link>` click — no POST to `/api/tenants/switch` (endpoint deleted)
- [ ] `/t/<slug>/…` URL correctly scopes data for the duration of the request
- [ ] Guest users cannot see Admin tab; member/viewer users see "Access denied" if they navigate directly to `/admin`
- [ ] Invited users do not appear in the default tenant unless "Allow access to public community" was checked
- [ ] Self-signup users (zero prior memberships) do land in the default tenant
- [ ] Revoking community access does not break the user's navigation — URL drives tenant, not server state
## Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| "Not a member of tenant …" | URL has a slug for a tenant the user doesn't belong to | Navigate to one of the user's membership tenants via the switcher |
| Data from wrong tenant appearing | Extremely unlikely under URL-truth. If seen, the PostgREST `db-pre-request` hook did not fire — check Supabase logs for hook errors | Regenerate migrations; verify `pgrst.db_pre_request` is set on `authenticator` role |
| Tenant-scoped URL not resolving | Slug does not match any tenant | Verify the slug is correct; check the `tenants` table |
| Cannot edit tenant name | Trying to edit the default tenant | Only non-default tenants can be renamed |
| Community access toggle not visible | Viewing members from the default tenant, or viewer/member role | Toggle only appears for admins on non-default tenants |
| Bare path `/dashboard` shows wrong tenant | `last-tenant-slug` cookie points at an old tenant | Navigate to the correct `/t/<slug>/dashboard` — cookie refreshes. Or clear the cookie. |
| Realtime subscription empty for the correct tenant | Realtime doesn't run the `db-pre-request` hook; uses JWT path | Realtime RLS still relies on JWT `app_metadata.active_tenant_id` for impersonated flows — not a drift issue for user sessions (no claim written). Use tenant-scoped channel names. Follow-up: migrate realtime policies to membership-based. |
## Regression coverage
`proxy.ts` middleware has integration test coverage in `proxy.test.ts` protecting every guard that shipped in response to the 2026-04-16 cross-tenant data-leak incident. Each guard has a named test — if a future refactor removes a guard, the test name explains why it existed:
- **Prefetch / RSC cookie guard** — Next.js prefetches `/t/<other-slug>/*` links from the sidebar tenant switcher. The guard skips the `last-tenant-slug` cookie write when `next-router-prefetch`, `rsc`, or `purpose: prefetch` headers are present. Without the guard, each prefetch flipped the cookie on the current tab. Under the URL-truth model the cookie is no longer an authorization input, but stale prefetch-flips still misroute bare-path navigations (`/dashboard` → wrong tenant).
- **Cookie attribute preservation** — Cookies forwarded through a rewrite response MUST be passed as full cookie objects, not `set(name, value)`. The attribute-stripping variant caused refreshed auth tokens to land with wrong attributes, leading to intermittent auth failures.
- **API Referer tenant inference** — API routes called from a tenant-scoped page carry `x-tenant-slug` header forward via Referer parsing. The header is the sole input PostgREST's `db-pre-request` hook reads to pin `app.tenant_id` for that request, so missing it causes the data API to fall back to the impersonated-JWT path or fail closed.
See `proxy.test.ts` and the inline comments in `proxy.ts` for the full rationale.
## Tenant modules — self-contained per-tenant code (2026-05-13)
Tenants can own custom code (entity cards, detail views, share renderers, lib utilities) without polluting the platform. Each tenant lives at `features/custom/tenants/{tenantSlug}/` and registers itself through a typed contract.
### The contract
```ts
// features/custom/lib/tenant-module.ts
export interface TenantModule {
readonly tenantSlug: string;
register(): void;
}
```
A tenant module is a single `index.ts` that exports a `TenantModule` value. Components, detail views, and lib utilities nest under the same folder. The module's `register()` is called once at app bootstrap.
### Adding a tenant module
1. Create `features/custom/tenants/{slug}/index.ts`:
```ts
import type { TenantModule } from "@/features/custom/lib/tenant-module";
import { registerEntityCard } from "@/features/entities/components/entity-card/registry";
import { MyCustomCard } from "./components/my-custom-card";
export const myTenantModule: TenantModule = {
tenantSlug: "my-tenant",
register() {
registerEntityCard("opportunity", MyCustomCard, { tenantSlug: "my-tenant" });
},
};
```
2. Add to the barrel at `features/custom/tenants/index.ts`:
```ts
import { myTenantModule } from "./my-tenant";
export const tenantModules: readonly TenantModule[] = [myTenantModule];
```
3. The bootstrap site `features/custom/register-ui.ts` already imports the barrel and calls `registerAllTenantModules()` — no further wiring needed.
### Tenant-scoped slot resolution
`lib/ui-registry/` keys are namespaced. A tenant-scoped registration lives at `${kind}:${name}:${tenantSlug}` (e.g., `entity-card:patient:docs`); a global registration lives at `${kind}:${name}`. The same `Map<string, SlotRegistration>` backs both.
`<EntityCard>` reads `useTenantScopeSlug()` and passes it to `resolveCardTier()`. The resolver tries the tenant-namespaced key first, falls back to the un-namespaced (global) key. On tenant-less routes (`useTenantScopeSlug()` returns `null`), only global registrations apply — identical to today's behavior on share routes.
Four-tier resolver order is preserved exactly:
```
1a. code (tenant-scoped, if `tenantSlug` matches)
1b. code (global)
2. plugin (DB ui_renderer_bindings)
3. config (entity_types.config.ui.cardConfig)
4. default (schema-driven generic)
```
Platform-product registrations (opportunity, company, person, …) stay global. They are the platform's offering, not a tenant's customization.
### Isolation enforcement
ESLint enforces tenant module isolation via two complementary rules:
1. **Absolute escape** — `no-restricted-imports` bans `@/features/custom/tenants/*` imports from every file except `features/custom/tenants/index.ts` (the barrel) and tests.
2. **Relative escape (2026-05-13)** — `local/no-cross-tenant-relative-import` is a path-aware custom rule that detects when a relative import (`../../oci/foo` from inside `tenants/docs/components/x.tsx`) resolves to a sibling tenant. Closes the gap left by the absolute-import rule's literal-string match.
Within a tenant folder, relative imports stay legal (e.g. `./components/x`, `../lib/y`). Cross-tenant code goes through `features/custom/lib/` (shared helper) or the platform.
The platform bootstrap barrels are exempted from both rules: `features/custom/tenants/index.ts` (tenant module aggregation), `features/custom/tenants/server-tools.ts` (server-side tool registration), and `features/custom/tools/ui.ts` (client-side tool UI bundle).
### The DOC'S tenant module — canonical example
`features/custom/tenants/docs/` is the live example. It owns:
- 9 clinical entity cards (patient, visit, feedback, membership, therapy-plan, protocol, modality, screening-rule, evidence-claim)
- 6 operator-facing detail views (patient, protocol, visit, intake, membership, modality)
- The `protocol-presentation` public share renderer
- 27 composable sub-components
All registrations carry `tenantSlug: "docs"`. A future Marbella tenant that defines a `patient` entity type renders the schema-default card, not the DOC'S clinical card.
### Tenant-scoped tools (2026-05-13 follow-up)
Tools now live alongside their tenant module. Each tenant's `tools/{slug}/definition.ts` calls:
```ts
import { registerTool } from "@/features/tools/registry";
import { defineTenantTool } from "@/features/custom/lib/tenant-module";
registerTool(
defineTenantTool("docs", {
slug: "generateProtocolPrintout",
name: "Generate Patient Protocol Printout",
// ... rest of ToolDefinition (no manual tenantSlugs)
}),
);
```
`defineTenantTool()` stamps `tenantSlugs: [tenantSlug]` exactly once and throws if the caller's manual declaration disagrees — eliminates the copy-paste drift bug where a moved tool keeps the wrong slug.
**Server vs client load paths:**
- Server-side tool definitions are loaded via `features/custom/tenants/server-tools.ts`, imported from `features/tools/bootstrap.ts`. The tenant module's `index.ts` does NOT import its own `tools/` folder — that would pull `"server-only"` modules into the client bundle through `lib/providers.tsx`.
- Client-side tool UI (FormSpec, ViewSpec, tool-output-block, output renderer) lives in each tenant's sibling `tools-ui.{ts,tsx}` barrel. `features/custom/tools/ui.ts` (the central chat / view / tool client bundle) side-effect-imports each tenant's `tools-ui`. ESLint allows this — the central `tools/ui.ts` is in the exemption list.
**Migrated tools:** DOC'S `docs-protocol-printout`; OCI 5 Protoast tools (`ims-coverage-estimator`, `ims-product-selector`, `ims-risk-screener`, `oci-comparison-generator`, `oci-order-estimator`); IMS `ims-before-after-visualizer`; cbt-demo `generateExecutiveSummary`.
In development and tests, a code tool that reuses an existing slug with a
different tenant fence now throws during registration. Production still warns
instead of failing app bootstrap, but CI catches accidental cross-tenant tool
overwrites before merge.
### Tenant-scoped agent seeds (2026-05-13 follow-up)
Agents remain DB-managed at runtime. Tenant modules can now declare typed seed
metadata under `TenantModule.agents` so custom agents live beside the tenant's
tools and UI without adding a parallel in-memory agent registry:
```ts
import {
defineTenantAgent,
type TenantModule,
} from "@/features/custom/lib/tenant-module";
export const ociTenantModule: TenantModule = {
tenantSlug: "oci",
agents: {
"oci-lead-finder": defineTenantAgent("oci", {
slug: "oci-lead-finder",
name: "OCI Lead Finder",
description: "Researches building-supply accounts for OCI.",
icon: "radar",
systemPrompt: "Research qualified OCI accounts and connect findings to records.",
}),
},
};
```
`defineTenantAgent()` stamps `tenantSlug` exactly once and throws if a copied
agent declaration still names another tenant. Conformance tests key agent seeds
as `tenantSlug:slug`, and resolver tests prove tenant-owned DB rows do not
resolve across tenants while platform agents still do.
The seed declarations are not written to the database automatically yet. A
future server-only sync runner should resolve `tenantSlug -> tenant_id` and
upsert rows into the existing `agents` table keyed by `(tenant_id, slug)`.
### Entity-list / entity-form tenant scope (2026-05-13 follow-up)
`registerEntityList()` and `registerEntityForm()` accept optional `{ tenantSlug }`, mirroring `registerEntityCard()` / `registerEntityDetailView()`. All four entity-surface registries now have symmetric tenant-scope APIs. Omitting the option preserves the existing global behavior bit-for-bit.
### What's NOT in this PR
- Skills and DB seed manifests don't yet have a tenant-module contract. Until
they do, tenant-specific skill names should carry a tenant prefix to avoid
collisions.
- Workspace-scoped slot registrations are not yet supported. The same key-namespacing pattern extends naturally (`entity-card:patient:docs:wellness`) when needed.
See `documents/work/2026-05-13-tenant-modules-foundation/followups.md` for the full follow-up list.
## Tenant rendering hierarchy (2026-05-13)
The unified slot registry now resolves component overrides in a strict hierarchy (default `overridePolicy = "allow-db-override"`):
1. **Workspace DB binding** — `ui_renderer_bindings` row scoped to the active workspace.
2. **Tenant DB binding** — `ui_renderer_bindings` row with `workspace_id IS NULL` for the active tenant.
3. **Tenant code-tier registration** — `registerEntityCard(slug, Component, { tenantSlug })` and siblings.
4. **Platform code-tier registration** — the same helpers called without a `tenantSlug`.
5. **Fallback** — the platform's built-in renderer.
`code-locked` policy reverses the order so code-tier always wins over DB bindings; the migration uses this while staging a new component before letting DB bindings override.
### Canonical surface props
Every tenant component types against `CodeSurfacePropsOf<K>` from `lib/runtime/code-surface-props.ts`:
```ts
import type { CodeSurfacePropsOf } from "@/lib/runtime/code-surface-props";
export function MyPatientCard(props: CodeSurfacePropsOf<"entity-card">) {
// props.entity, props.entityType, props.fields, props.context.tenantSlug, props.actions (when interactive)
}
```
The `@deprecated` aliases (`EntityCardRenderProps`, `EntityListProps`, `EntityDetailViewProps`, `EntityShareViewProps`) point at the canonical types for the Branch C migration window. New code should import the canonical names directly.
`EntitySurfaceActions` lives on `props.actions` for client mounts (currently `EntityCard`). Server mounts (`EntitySlot` for list / detail / form / share) omit it because router/queryClient closures cannot cross the RSC payload boundary — interactive client subtrees inside those surfaces call `useEntitySurfaceActions({ tenantSlug })` themselves when needed.
### Declarative customizations (`TenantModule.entityTypes`)
Prefer the declarative path over imperative `register()`:
```ts
export const myTenantModule: TenantModule = {
tenantSlug: "my-tenant",
entityTypes: {
patient: {
card: MyPatientCard,
detail: MyPatientDetail,
detailTabs: {
notes: { label: "Notes", order: 100, component: MyPatientNotesTab },
},
requiredFields: ["intake_notes", "coach_notes"], // opt-in (D7)
},
},
};
```
Bootstrap processes declarative `entityTypes` BEFORE `register()` (D11), so an imperative call can intentionally shadow when needed.
### `entity-detail-tab` slot kind
Detail tabs are now first-class slots in the unified registry (kind `entity-detail-tab`, slot name `<typeSlug>:<tabId>`). Reads go through `resolveDetailTabs(typeSlug, { tenantSlug, workspaceId })`. The legacy `registerEntityDetailTab(typeSlug, tab)` helper is now a deprecation shim that writes into the same slot registry — declarative and imperative tab registrations converge on one source of truth visible to `resolveDetailTabs()`.
### Server-only guard for tenant components
Components under `features/custom/tenants/<slug>/components/**` and `.../details/**` are bundled client-side and CANNOT import `server-only`, `next/headers`, or anything under `features/*/server/**`. ESLint catches all three. Server logic moves into the tenant's `tools/` folder (loaded via `features/custom/tenants/server-tools.ts`).
### Conformance test runner
`features/custom/lib/conformance.test.ts` iterates `getRegisteredTenantModules()` and pins the shape of every declared `TenantModule.entityTypes` customization (tenantSlug well-formed, tab-id kebab-case, requiredFields distinct strings, …). Add a new tenant module → conformance asserts its shape automatically.
### Authoring rule
See `.claude/rules/tenant-modules.md` for the full authoring rule.
## Related
- [ADR-0003 — URL as tenant source of truth](/adr/0003-url-as-tenant-source-of-truth)
- [ADR-0012 — URL as workspace source of truth](/adr/0012-url-as-workspace-source-of-truth) — same URL-truth pattern extended to workspaces (`/t/<t>/w/<w>/...`)
- [ADR-0013 — Workspaces as scoped tenants](/adr/0013-workspaces-as-scoped-tenants) — workspaces mirror tenants on membership, role, and settings axes; data graph stays tenant-wide (ADR-0008)
- [Workspaces feature doc](/docs/features/workspaces) — workspace primitive, accent system, install gallery, scope-aware admin
- `.claude/rules/auth.md` — enforcement rules
- `documents/work/2026-04-17-tenant-url-source-of-truth/` — tenant URL-truth design spec
- `documents/work/2026-04-26-workspaces-as-scoped-tenants/` — workspace scope spec + plan