# Tenancy — Person, Org, Client V3's defining architectural concept. V2 had only "user". V3 needs to model the actual operating reality: one human (transquinnftw) can be a standalone provider AND own an agency-shaped Org (Cocotte) AND be a member of someone else's Org. ## The three concentric layers ``` PLATFORM (Lilith Apps ehf — the tech company, sole infrastructure owner) └─ PROVIDER (the tenant boundary — a Person, optionally overlaid by an Org) └─ CLIENT (the provider's customers — bookings, inbound messaging) ``` Platform is operated by Lilith Apps. Providers are the customers of the Platform. Clients are the customers of the Providers. Every queryable row belongs to exactly one Provider, either via `user_id` (Person) or via `org_id` (Org). ## Person-first, Org-as-overlay - **Person** is the primary tenant. Every Provider starts as one. A Person has a profile, inbox, bookings, analytics, and may have public surfaces (`{provider}.com`, `{provider}.my`). - **Org** is an optional overlay. 1 owner (a Person), N admins (Persons), N members (Persons). An Org has its own dashboard, brand sites, members, and org-level analytics. - A Person can be in zero, one, or many Orgs simultaneously. Same human, multiple tenancy contexts. The `provider-portal` nav exposes a context switcher: `Personal | Cocotte | …`. ### Worked example - `transquinnftw` is a Person with `transquinnftw.com`, inbox, bookings, analytics. - `cocotte` is an Org with `cocotte.maison`, member roster, org-level analytics. - `transquinnftw` is `cocotte`'s owner. When logged in, the context switcher reads `[ Personal | Cocotte ]`. Switching emits a new JWT scoped to that context (see below). ### What this is NOT - ❌ Slack/Notion "workspace" tenancy where each login is one context. - ❌ Single-Person-per-Org. Orgs can grow members. - ❌ Mandatory. Most Providers will operate as Person-only and never touch an Org. ## Schema additions ```sql CREATE TABLE orgs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, owner_id UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE org_members ( org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, user_id) ); CREATE INDEX idx_org_members_user ON org_members(user_id); ``` Existing tables that can belong to either a Person OR an Org gain optional `org_id`: ```sql ALTER TABLE bookings ADD COLUMN org_id UUID NULL REFERENCES orgs(id); ALTER TABLE brands ADD COLUMN org_id UUID NULL REFERENCES orgs(id); ALTER TABLE analytics_events ADD COLUMN org_id UUID NULL REFERENCES orgs(id); -- When org_id IS NULL, the row belongs to the Person identified by user_id. ``` **Invariant:** every row that holds Provider data has either `user_id` set or `org_id` set (or both — for rows authored by a Person acting in an Org context). ## JWT context ```typescript interface SessionToken { user_id: string; device_id: string; // Optional — set only when the user switched into an Org context. org_id?: string; org_role?: 'owner' | 'admin' | 'member'; } ``` - No `org_id` claim → Personal context. Queries scope to `WHERE user_id = $session.user_id`. - `org_id` claim → Org context. Queries scope to `WHERE org_id = $session.org_id`, with the API enforcing that the user is a member via `org_members`. - Context switching emits a fresh JWT; the old one is invalidated server-side (token-rotation pattern, not a permission re-evaluation). ## Defense in depth: RLS For V3 launch we ship Option A from `../DESIGN.md §11` — **row-level tenancy in one shared `platform.db`**, with API-layer filtering as the primary guard and Postgres Row-Level Security policies as defense in depth. ```sql ALTER TABLE bookings ENABLE ROW LEVEL SECURITY; CREATE POLICY bookings_tenant_isolation ON bookings USING ( user_id = current_setting('app.user_id')::uuid OR org_id IN ( SELECT org_id FROM org_members WHERE user_id = current_setting('app.user_id')::uuid ) ); ``` The app sets `SET LOCAL app.user_id = $1` at the start of each request (postgres.js pattern, compatible with pgBouncer transaction mode). RLS is the floor — if the API layer forgets a WHERE clause, RLS catches it. DB-per-tenant (Option B) is deferred until scale demands it (~100+ Providers). The schema is designed to remain compatible with that migration. ## What this affects across the codebase - `@features/sso` — JWT shape, context-switch endpoint, JWT rotation - `@features/api` — every query takes the session token and sets `app.user_id`; org-aware filters - `@apps/provider-portal` — context switcher in nav, org dashboard view - `@features/org-analytics` (was `user-data`) — analytics roll up to both user and org level - Every new table — gets `user_id` AND optional `org_id` from day one Related: `../DESIGN.md §2, §5, §11`, [naming.md](./naming.md), [onboarding-provider.md](./onboarding-provider.md).