Skip to content

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_id onto 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_id stays 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 organizations table 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_name or display_name column — do not invent one; brand_name is the business name. (A third generic profiles.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 organizations wrapper (option B) is built, organizations.name seeds from brand_name; an individual never gets an organizations row, so their identity resolves to full_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.)

  • 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)