Appearance
ADR-0001 · Tenancy & identity model
Status: 🟡 Proposed · Depends on: — · Drives: ADR-0002
Context
The real backend has no tenant concept above a single user. profiles is the one and only identity hub (see Database & RLS) — one row per auth user, and every domain table across all three pillars hangs directly off profiles.id/user_id. There is no accounts, workspaces, organizations, or members table anywhere in the 58-table baseline schema.
The Claude Designs prototype has already designed past this. ui_kits/merchant-console-v2/CONTRACTS.md §2, explicitly labeled "Track B of the approved plan", specifies:
Account → Workspace(tenant) → Member(role)— one account may own or belong to many workspaces (a personal card plus a shop; a teacher's card plus a school club), every domain entity carries a workspaceId, and the workspace switcher changes the active tenant. This isn't scope creep invented mid-prototype — it's a contract the approved plan called for building toward. But it does not exist in the real schema, and several already-reviewed screens assume it does:
- Subscriptions' Custom Deals tab has org/seat pricing UI with no seat/department/multi-location data model behind it (tracker: "No Organization-above-Workspace entity").
- AdManager and the merchant console (mobile vs desktop) both assume a workspace switcher exists; the desktop console derives nav from
manifest.computeNav(workspace), mobile hardcodes a single workspace. - The dual-audience decision (locked 2026-07-14) requires individuals to use generic features with no business profile at all — so whatever model is chosen must not force a workspace/org onto every user.
Decision drivers
- Don't break individuals (biolink/qr-generator/business-card/reminders users with no business) — a workspace concept must stay optional, not mandatory.
- Franchise/multi-location/organization billing (Subscriptions' Custom Deals) needs a real entity above a single profile to attach seat counts and consolidated invoicing to.
- RLS currently isolates by
profile_id/user_id. Multi-member workspaces (staff logging into a shared business account) need isolation by workspace membership instead — a genuinely large RLS rewrite once it happens, not a small one. - Mobile north-star (Expo, shared backend) means whatever model is picked has to work for a native client too, not just the web console.
- Migration blast radius: retrofitting
workspace_idonto most of the 58-table schema is the single largest migration this program would undertake so far.
Options considered
A — Build the full Account → Workspace → Member model now
Add accounts, workspaces, workspace_members tables; every domain table gains a workspace_id (nullable → backfilled from the owning profile → eventually the isolation key); RLS rewritten to check workspace membership instead of auth.uid() = user_id.
-For: matches CONTRACTS.md exactly; unlocks staff/multi-role access and org-level billing in one pass.
- Against: by far the largest migration in this program's history — touches nearly every RLS policy in the schema — undertaken before a single paying customer has asked for multi-seat access.
B — Minimal Organization wrapper, billing-only, no multi-member RBAC yet
Add one lightweight organizations table; profiles.organization_id (nullable). Used only to attach seat count and consolidated/franchise billing to a group of profiles. profiles stays the operative identity for everything else — RLS, ownership, and every existing table are untouched.
- For: small, additive, expand-contract-friendly migration (one table, one nullable FK); unblocks Subscriptions' org/seat pricing immediately; zero impact on individuals (
organization_idstays null for them); doesn't touch a single existing RLS policy. - Against: doesn't give the console's workspace-switcher/staff-role UI a real backend — those screens stay presentational-only until a later phase.
C — Defer entirely
Keep profiles flat. Treat the prototype's org/seat and workspace-switcher UI as prototype-only scope, not committed to being built, until a real franchise customer signs up.
- For: zero migration cost today.
- Against: contradicts
CONTRACTS.md, which was already ratified as an approved-plan track — this would be reopening a decision rather than making a new one, and lets prototype/backend divergence keep growing as more admin screens get designed against a tenant model that isn't there.
Recommendation
B now, A staged behind real demand. Ship the lightweight organizations wrapper: it unblocks real subscription/billing data (ADR-0002 depends on it), costs one small additive migration, and — critically — never forces a workspace/organization concept onto an individual user, which the locked dual-audience decision requires. Explicitly do not build the full Workspace/Member/staff-role RBAC layer, or rewrite RLS around it, until multi-seat demand is real. Keep CONTRACTS.md's fuller model as the named target architecture for that future phase — this ADR doesn't reject it, it sequences it behind evidence instead of behind the prototype's UI.
Consequences
- ADR-0002 (subscription/billing) depends on the
organizationstable existing. - The console's workspace-switcher and staff-role UI (desktop
ui_kits/merchant-console-v2, AdManager's targeting-by-workspace assumptions) should be explicitly labeled "designed ahead of backend, Phase 2" rather than implied as already-backed. - No existing RLS policy, table, or
[ENFORCED]data-access rule changes as a result of adopting B.
Decision (2026-07-19) — workspace identity = business name, fallback member name
Product confirmed the identity contract for the eventual organizations/workspace layer (and for how the flat profiles row is labelled today): a workspace is identified by its business name, falling back to the individual/member name for individual accounts. Mapped to the real schema (verified against the baseline profiles table, 20260710134136_…:1328):
- Business/brand name →
profiles.brand_name(text). This is the workspace label for a business account. - Individual/member name →
profiles.full_name(text). This is the fallback label when there is no business (the dual-audience "individual" case), and it is also the account owner's name for a business. - There is no
business_nameordisplay_namecolumn — do not invent one;brand_nameis the business name. (A third genericprofiles.name varchar(255)exists but is the public-page entity name, not surfaced in Profile/Settings — leave it out of the identity label.) - When the
organizationswrapper (option B) is built,organizations.nameseeds frombrand_name; an individual never gets anorganizationsrow, so their identity resolves tofull_name— this is exactly what keeps a workspace/org concept from being forced onto individuals.
This also means the current onboarding flow (which collects both full_name and brand_name from every user with no business-vs-individual fork) diverges from the locked dual-audience decision — an individual should not be required to supply a brand_name. That reconciliation is specced in the Profile/Settings/Onboarding brief and logged in the tracker.
Open question for product
What actually triggers building the full Workspace/Member model — a specific paying customer requesting multi-seat/staff login, or a fixed roadmap date regardless of demand? This ADR assumes the former; confirm before treating Phase 2 as scheduled. (The D3 naming decision above settles how the layer identifies a workspace; it does not by itself schedule the build.)
Related
- Tracker: "No Organization-above-Workspace entity" (
documentation/portal/dev-tracker/tracker.md) - Subscriptions review
- Merchant console review (mobile/desktop workspace divergence)
ui_kits/merchant-console-v2/CONTRACTS.md§2 (Claude Designs project)