0019 — Organization model: the multi-tenant SaaS envelope

Status note: Accepted 2026-05-04. The four §3 questions resolved per workshop output as follows:

  • §3.1 (multi-property scoping): stay alongside — both org and property checks run. Single-property orgs hide the "assigned properties" UI (refinement N2).
  • §3.2 (org-admin / property-admin): strawman role split locked. org_admin invites peer org_admins (Slack/Linear convention). PM impersonation audit log tags both actingAs and realUser per refinement N3.
  • §3.3 (Stripe subscription): per-org. Per-property invoicing reserved for Phase 2+ if a customer demands it.
  • §3.4 (sandbox / test orgs): both flags compose. Property.isTest for demo property in real org; Organization.isSandbox for throwaway org. Auto-archive: 7d post-PR-merge for preview-deploy sandbox orgs; 30d for sales-demo sandbox orgs; never archive pinned: true.

1. Context

1.1 What exists today

PropFlow is a multi-tenant SaaS — multiple PM organizations are expected to use the platform — but the data model and access controls treat the platform as effectively single-org today:

1.2 What's missing

The Person identity model in ADR-0018 assumes an organization boundary that does not exist:

The forcing function: ADR-0018 (Person identity model) cannot ship Phase 1 without an org-scoping decision. Plan A in the action plan estimated 1.5 weeks engineer-time for org modeling and explicitly placed it in a new Phase 0.5 between Phase 0 and Phase 1. This ADR is the formal proposal for that work.

1.3 The deeper question

PropFlow is heading toward a multi-tenant SaaS reality (see Sean's GTM trajectory). The question this ADR answers is: what is the unit of tenancy in PropFlow's data model? Today the implicit answer is "the deployment" — one deployment, one tenant. The well-trodden alternative is a first-class Organization entity owning Properties, Users, Subscriptions, and (per ADR-0018) Persons. Same shape as Slack's Workspace, Linear's Workspace, Stripe Connect's Account, Vercel's Team.

2. Decision

Introduce Organization as a first-class entity. Property, User, Person, and Subscription all become org-scoped. Person never crosses the org boundary. Org-scoping is the outer envelope; existing property-scoping stays alongside as the inner privacy boundary within an org.

The four sub-decisions below each have a workshop counterpart in §3 — strawman-and-react.

2.1 The Organization entity

// src/lib/data/types.ts
interface Organization {
  id: string;                      // org_<uuid>, never reused
  name: string;                    // display name; PM-controlled
  slug: string;                    // URL-safe, unique within platform (org URLs, future)
  status: 'active' | 'suspended' | 'archived';
  plan: 'free' | 'starter' | 'pro' | 'enterprise';  // pricing tier
  isSandbox: boolean;              // throwaway-org flag — see §2.4
  settings: OrganizationSettings;  // cross-property defaults (branding, voice config, auto-sync)
  createdAt: string;
  updatedAt: string;
  // Lifecycle audit
  createdByUserId: string;         // the User.id of the human who first onboarded the org
  archivedAt?: string;
}

interface OrganizationSettings {
  // Cross-property defaults — overridable per-property where the per-property
  // setting type allows it. Mirrors today's AppSettings shape but scoped to
  // one org instead of platform-blanket.
  emailsEnabled: boolean;
  smsEnabled: boolean;
  emailShadowMode: boolean;
  vendorQuoteEmails: boolean;
  vendorDispatchEmails: boolean;
  enabledModules: EnabledModules;
  // Branding (Phase 0.5 ships none of this; reserved for Phase 1+):
  logoUrl?: string;
  primaryBrandColor?: string;
}

DynamoDB layout, single-table convention:

PK                    SK              GSI1PK (slug lookup)
─────────────────     ─────────       ────────────────────
ORG#org_abc           PROFILE         SLUG#acme-realty
ORG#org_abc           SETTINGS        —

Organization lives in the main propflow-prod table. Slug uniqueness is enforced via a GSI lookup (reusing the existing GSI1 namespacing pattern from ADR-0018: SLUG#<value>).

2.2 Foreign keys: Property, User, Person

Every entity that today acts as a top-level "owned" record gets an organizationId foreign key, immutable after creation:

Entity New field Notes
Property organizationId: string Required after backfill. Properties never move between orgs.
User organizationId: string | null Null only for the platform-staff admin role (PropFlow staff are not in any org).
Person (ADR-0018) organizationId: string Required at create time. Same human at two orgs = two Persons. This is the ADR-0018 unblocker.
SubscriptionInfo Moves from AppSettings.subscription to Organization.subscription See §2.5.

Cross-org reads are disallowed at the data layer. Property.organizationId !== caller.organizationId returns null from every repository, regardless of role. The only exception is the platform-staff admin role, whose User has organizationId: null and bypasses all org checks (just like today's property scope bypass). This preserves PropFlow staff's debugging access.

2.3 Org-scoping enforcement layer

A new module src/lib/platform/auth/org-scope.ts mirrors the existing scope.ts pattern:

// Get the org scope for the caller. Null = platform staff (admin), bypasses all org checks.
function getUserOrgScope(user: User): string | null {
  if (user.role === 'platform_admin') return null;  // PropFlow staff
  if (!user.organizationId) throw new ForbiddenError('User missing organizationId');
  return user.organizationId;
}

// Filter array of items by orgId. Platform-staff bypass.
function scopeByOrg<T extends { organizationId?: string | null }>(
  items: T[],
  scope: string | null,
): T[] { ... }

// Single-item check.
function isInOrgScope(itemOrgId: string | undefined, scope: string | null): boolean { ... }

Existing property-scoping stays alongside. The order of checks at every read site:

1. Org check — caller's organizationId === item.organizationId? (or platform staff)
2. Property check — caller's assignedPropertyIds includes item.propertyId? (existing)

A withOrgAndPropertyScope(user, item) helper composes both. Migration is mechanical: every existing isInScope(propertyId, scope) call gains an org check upstream.

2.4 Role hierarchy — split admin into two

Today's admin role conflates two distinct positions: PropFlow staff (cross-org, all-properties) and operator-side org leadership (cross-property, single-org). Phase 0.5 splits them:

Role New name Scope Bypass org check? Bypass property check?
(was admin) platform_admin All orgs, all properties — PropFlow staff yes yes
NEW org_admin One org, all properties in it no (own org only) yes (within own org)
(was property_manager) property_manager (unchanged) One org, assigned properties no no
(was leasing_agent) leasing_agent (unchanged) One org, assigned properties no no
(was maintenance) maintenance (unchanged) One org, assigned properties no no
(was viewer) viewer (unchanged) One org, assigned properties no no

Audit tagging (refinement N3 from workshop output): every action initiated under impersonation is tagged with both actingAs: { userId, role, orgId } and realUser: { userId, role } in the audit log. Distinguishes "PropFlow staff debugging an org-admin issue" from "actual org-admin action." actingAs matches the role-being-impersonated; realUser matches the authenticated session.

The ADMIN_EMAILS env-var promotion path stays — listed emails are auto-promoted to platform_admin on first sign-in. Backfill: every existing User whose email is in ADMIN_EMAILS becomes platform_admin; everyone else becomes org_admin of the default org if assignedPropertyIds is empty (they previously had unscoped access by virtue of being admin), otherwise their existing role stays. This is a data-migration choice with audit-log implications — workshop §3 confirms.

ROLE_HIERARCHY becomes:

platform_admin   (level 0) — PropFlow staff
org_admin        (level 1) — operator-side org leadership
property_manager (level 2)
leasing_agent    (level 3)
maintenance      (level 3)
viewer           (level 4)

canInviteRole rules: platform_admin invites anyone; org_admin invites within own org down to property_manager+; property_manager invites within assigned properties down to viewer.

2.5 Subscription model — per-org

AppSettings.subscription (platform singleton) moves to Organization.subscription. Each org has its own Stripe customer + subscription. Pricing stays per-unit (existing unitCount semantics) — total units = sum of Property.totalUnits across the org's active properties.

interface Organization {
  // ... §2.1 fields
  subscription?: SubscriptionInfo | null;   // moved from AppSettings
}

Backfill: today's single platform subscription becomes the default org's subscription. PropFlow's billing today bills one Stripe customer for the whole platform — that customer becomes the default org's customer. No Stripe-side migration required for the existing single-tenant billing relationship; new orgs get new Stripe customers via the existing /api/billing/checkout flow, which gets a new organizationId parameter.

AppSettings itself becomes platform-staff-only — for global feature flags PropFlow controls (e.g., emergency kill switches). Operator-level settings move to Organization.settings.

2.6 Sandbox / test orgs

Two distinct concepts, kept separate:

Concept Field Purpose
Sandbox property within a real org Property.isTest: boolean (existing) Demo property inside a real operator's account. Hidden from their dashboard metrics. Used today for Jose's seed property + onboarding-time demo.
Sandbox organization Organization.isSandbox: boolean (new) Throwaway org. Vercel preview deploys spin one up per branch; sales demo orgs; integration-test orgs. Hidden from billing aggregates. Auto-archived after N days of inactivity (Phase 1+ work, not Phase 0.5).

A sandbox org can have non-test properties (preview-deploy data) and a real org can have test properties (Jose's seed). The two flags compose, they don't subsume.

2.7 Cross-org tenant transfers — explicitly out of scope

A tenant who moves from a building managed by Org X to one managed by Org Y becomes two Persons, one in each org. Their conversation history, AI notes, and language preferences from Org X do not follow them into Org Y. This is a privacy-by-design choice, not a bug — surfacing Org X's PM-authored notes to Org Y would be a data leak.

If Org X and Org Y agree to transfer the relationship, the manual operation is "Org Y's PM creates a fresh Person + claims at Org Y." Phase 6+ may revisit if customer demand surfaces, but the default position is hard isolation.

2026-05-17 amendment — Phase 6a (ADR-0020) cross-org Person collapse

ADR-0020's Q1 acceptance decision permits cross-org Person collapse: a single Person row can be referenced by multiple Organizations through their respective PersonRole rows. The narrative in §2.7 above is partially superseded — the rule "tenants who move become two Persons" is no longer absolute. The two cases now split:

Org isolation is preserved at the access layerPersonRole rows stay org-scoped (scope: { organizationId } | { propertyId } | { vendorId }), queries still filter by scope; the identity layer collapses at the Person row only. Reads enforce reachability via the PersonRole-membership check in IPersonRepository.getPerson (Phase 6a Deliverable 7); a caller in Org Y cannot see Org X's PersonRole rows for the same Person unless an Org Y-scoped role exists.

See docs/data-model-migration/decisions/2026-05-17-adr-0020-persona-pass.md §Q1 for the full reasoning and operator-review queue posture.

2.8 What stays unchanged

3. Open questions for the workshop

These four questions are the load-bearing ones from Plan A. Each has a strawman lean baked into §2; the workshop confirms or overrides.

3.1 Multi-property scoping — collapse, or stay alongside?

Option A (lean): Org-scoping wraps property-scoping. Both checks run. A leasing-agent at Property X in Org Acme cannot see Property Y in Org Acme.

Option B: Org membership implies all-property access within the org. assignedPropertyIds collapses; User.organizationId is the only access boundary.

Why §2 picks A: Multi-property orgs already exist (today, via the property_manager role + assignedPropertyIds). Collapsing the property layer would expand every leasing-agent's access at orgs with multiple properties — a silent privilege escalation. Keep the boundary.

What the workshop confirms: Are there real operator personas at our target customer profile where property-level isolation is not wanted? (E.g., small operators where every PM sees everything?) If yes, the right answer is to keep both checks but make property-level scoping optional per role (e.g., org_admin always sees all properties; property_manager honors the assignment list).

3.2 Org-admin vs property-admin role semantics

Strawman (§2.4): org_admin = full access within the org; property_manager = full access within assigned properties; the existing admin role becomes platform_admin reserved for PropFlow staff.

What the workshop confirms:

3.3 Stripe subscription model — per-org, per-property, or hybrid?

Strawman (§2.5): Per-org subscription. Pricing stays per-unit, summed across the org's active properties.

Why §2 picks per-org: Mirrors how every multi-tenant SaaS bills (per workspace/team/account, not per project). Simplifies churn — canceling an org is one Stripe operation. Avoids the ops headache of "this org has 5 properties on different billing cycles." Keeps the existing per-unit pricing intact (no contract changes).

What the workshop confirms:

3.4 Sandbox / test orgs — what hides them?

Strawman (§2.6): Organization.isSandbox: boolean for throwaway orgs (preview deploys, demos); existing Property.isTest stays for "demo property inside real org."

What the workshop confirms:

4. Consequences

4.1 Becomes easier

4.2 Becomes harder

4.3 Follow-up work this commits us to

5. Alternatives considered

A. Stay implicitly single-org forever

Cheapest. Ship the language feature today; ship Person without an org boundary; treat the platform as a single operator account.

Rejected. PropFlow's GTM trajectory is multi-tenant; the second customer onboarded ships data into a system with no isolation. Privacy violations are inevitable. Also leaves ADR-0018 stuck at "decide org scope later" — the question doesn't go away; deferring it makes the eventual migration harder.

B. Per-property scoping is enough — skip the org layer

Existing assignedPropertyIds already scopes data per-user-per-property. The argument: there's no need for a parent grouping; properties are the tenancy unit.

Rejected. Per-property scoping doesn't carry a billing relationship, doesn't carry org-level settings (branding, defaults), doesn't carry a role like "this person leads operations across all our properties." Stripe customers, settings, and operator-leadership roles need a parent. Also, ADR-0018's identity stitching needs to know the set of properties that constitute one operator's universe — assignedPropertyIds per-user doesn't aggregate that cleanly.

C. External multi-tenancy library (e.g., a Postgres row-level-security framework, or a PaaS like Auth0 Organizations)

Considered. PropFlow's data layer is DynamoDB single-table — RLS frameworks are SQL-native and don't fit. Auth0 Organizations is auth-side only; it'd handle "which org is this user in" but wouldn't help with property/person/subscription scoping. The right shape is internal data modeling, not bolted-on.

Rejected as the wrong tool.

D. Org-as-a-property-of-Property — denormalize, no parent entity

Stamp organizationId on every Property/User/Person, but don't create an Organization table. The org is just a string identifier.

Rejected. We need somewhere to put org-level state: name, slug, plan, settings, subscription, isSandbox, lifecycle. Without a parent record, that data scatters across the app or sits in env vars. Also breaks the natural "list all orgs" admin query for PropFlow staff.

6. References