0019 — Organization model: the multi-tenant SaaS envelope
- Status: Accepted
- Date: 2026-05-04
- Deciders: Gera (Jose) — direct endorsement; sane (Sean) and feed (Fede) lenses via persona pass on 2026-05-04 (workshop output)
- Related: ADR-0018 (Person identity model — depends on this), identity-model-action-plan.md §Plan A, identity-model-shortcomings.md §2.1, §2.2
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_admininvites peer org_admins (Slack/Linear convention). PM impersonation audit log tags bothactingAsandrealUserper 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.isTestfor demo property in real org;Organization.isSandboxfor throwaway org. Auto-archive: 7d post-PR-merge for preview-deploy sandbox orgs; 30d for sales-demo sandbox orgs; never archivepinned: 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:
- No
Organizationentity. Verified by grep againstsrc/lib/data/types.ts— there is noOrganization,Tenancy, orWorkspacetype. Properties, Users, Subscriptions, Settings all live as siblings under no parent. - Subscription is a platform singleton.
AppSettings.subscription: SubscriptionInfo | null(src/lib/data/types.ts:2051–2058) — one Stripe customer, one Stripe subscription, for the entire deployment. Unit count is summed across every property in the system, not per-org. - User → Property is direct, with no parent grouping.
User.assignedPropertyIds: string[](src/lib/data/types.ts:1929). Property scoping lives at the data filter (src/lib/platform/auth/scope.ts) — admin sees all, others see only assigned. adminrole is platform-superuser.getUserPropertyScopereturnsnullforadmin, meaning unconditional cross-property access. There is no role between "platform-superuser" and "property-scoped manager."Property.ownerId— aUser.idreference (src/lib/data/types.ts:107) used by AppFolio sync to resolve per-user KMS-encrypted PMS credentials. The closest thing PropFlow has today to "this property belongs to this operator." It's a credential-routing key, not a tenancy boundary.Property.isTest: boolean(types.ts:95) — hides the property from non-admin users and the dashboard metrics. Property-level sandbox switch.ADMIN_EMAILSenv var auto-promotes specific emails to platform-admin on first sign-in. Today: Fede, Sean, Gera. This is how PropFlow staff get superuser access.
1.2 What's missing
The Person identity model in ADR-0018 assumes an organization boundary that does not exist:
- Person scope is undecided. Same human at two PM orgs — one Person or two? ADR-0018 leaned org-scoped but couldn't lock it without an
Organizationentity. - Within-org cross-property stitching has no privacy envelope. ADR-0018 §4c says "stitch suggestions are scoped to within-PM-organization" — but PropFlow has no PM-organization concept to scope by.
- No way to onboard a second PM org cleanly. Today, onboarding a second PM means a second deployment or a fork. The platform was built as if there will always be one operator.
- TCPA / data-residency obligations are platform-blanket. Compliance state lives in its own table keyed by phone (TCPA-correct), but PM-org-level operational settings (auto-sync defaults, branding, billing) have nowhere to attach.
- Sandbox vs production is a per-property toggle. No way to spin up a fully sandboxed organization for a Vercel preview deploy or for a sales demo separate from any real tenant data.
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:
- Tenant-side moves (Org X tenant → Org Y tenant): unchanged. PM-authored notes and conversation history stay org-scoped. Two
PersonRolerows scoped to different property scopes; whether they point at one Person or two Persons is an operator decision surfaced via the/admin/dev/identity-backfill/queueoperator review queue (Phase 6a Deliverable 10 cross-org collision flow). - PropFlow staff / platform-admin (one human, work-visible across multiple customer orgs): one Person, multiple
PersonRolerows. The syntheticorg_propflow_stafforg (PROPFLOW_STAFF_ORG_ID) holds the platform-admin Person's identity attributes; org-scoped roles point at the same Person from each customer org they support.
Org isolation is preserved at the access layer — PersonRole 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
- TCPA /
SmsConsentRecord(src/lib/data/dynamo/compliance.ts) — separate table, keyed by E.164 phone, not org-scoped. TCPA is a per-phone legal obligation; opt-outs follow the phone across orgs (a person who opts out via Org X's number is opted out for any future contact through Org Y's number too, if they ever land in Org Y by some path). Document this explicitly: compliance is platform-level, business data is org-scoped. - Better Auth
Usertable — already separate from main DynamoDB table. Stays. TheUser.organizationIdfield is added in main table'sUsershape, not in the auth table; the auth row remains identity-only. - Property-level scoping — every existing
scope.tshelper continues to work, gains an org wrapper at call sites. ADMIN_EMAILSauto-promotion — keeps working, just promotes toplatform_admininstead ofadmin.
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:
- Should org_admin be able to invite other org_admins, or only property_manager+? (Default: yes, can invite peers — matches Slack/Linear; admins are mutually trusting within an org.)
- Can a property_manager elevate their own role within an org, or only an org_admin? (Default: only org_admin can promote.)
- The migration question: today's
adminusers — auto-migrate toorg_adminof the default org, orplatform_admin? §2.4's heuristic (ADMIN_EMAILS → platform_admin, else org_admin of default org) is the engineering proposal; an explicit Fede + Sean signoff captures the audit trail.
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:
- Are there contractual reasons (POs, invoicing requirements) why a single operator would want per-property billing? (E.g., a holding company that books each building's Clara cost to that building's P&L.) If yes, per-org is still right but invoice-line-itemization becomes a Phase 2+ requirement.
- Does pricing per active unit need a grace period when properties archive (don't charge until end-of-period)? (Default: yes — match how AppSettings.subscription works today.)
- Does the platform still bill PropFlow staff for
platform_adminaccess? (Default: no — staff users are not org members and not billed.)
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:
- The default rule: sandbox orgs are hidden from billing aggregates, hidden from the PropFlow-staff "list all orgs" admin view by default (toggle to show), and auto-archived after N days of inactivity. N = 30? 90?
- Vercel preview deploys today write to
propflow-prod's shadow tables — does the preview-deploy lifecycle bind to a sandbox org per branch, or a single sharedorg_preview? (Lean: per-branch, isolated, archived on PR merge or after 7d.) - Are there contractual / sales-engineering personas where a sandbox org needs to look "indistinguishable from real" to the user being demoed? (Yes — sandbox orgs render normally to their users; the flag only affects PropFlow-staff aggregates and lifecycle.)
4. Consequences
4.1 Becomes easier
- ADR-0018 unblocks. Person + IdentityClaim get a clean
organizationIdforeign key. Phase 1 of the identity rollout can begin once this ADR is Accepted. - Multi-tenant SaaS is now possible. Onboarding a second PM org no longer requires a deployment fork. The default org is one of many.
- Cross-org privacy is enforced at the data layer. A bug in route handlers or middleware can no longer leak data across orgs — repository reads respect
organizationIdregardless of caller intent. - Sandbox orgs make preview deploys clean. Each Vercel preview branch gets its own org; no more cross-pollution between branch-level test data and prod-shadow data.
- Per-org billing matches operator mental models. "Acme Realty's PropFlow bill" is a thing; "the platform's bill" stops being a thing once there's more than one operator.
4.2 Becomes harder
- Every read path needs an org check. ~24 API routes (per the deep-plan grep) gain a
getUserOrgScopecall upstream of existing scoping. Mechanical but exhaustive — Phase 0.5's main implementation cost. - Default-org backfill is a real migration. One-shot script: create an
org_default, stamp it on every existing Property/User/Person, migrateAppSettings.subscriptiononto it, audit. Idempotent + dry-run + shadow + primary modes mirror Plan B's discipline. - Role hierarchy expands.
platform_adminvsorg_adminis a meaningful split with audit-log + invite-flow implications.ROLE_PERMISSIONS,ROLE_HIERARCHY,canInviteRole,routeToEntity,getNavItemsall gain a new role. - Tests gain a fixture dimension. Every test that creates a Property/User/Tenant gains an org scope.
makeOrgWithUserAndProperty(...)becomes the standard fixture. Existing fixture builders gain a default org reference during the migration. - Onboarding flow needs an org-creation step. First-time signup has to either join an existing org (via invite) or create one (the default for PropFlow staff onboarding their first customer). Phase 0.5 ships the schema + backfill; the onboarding UI gains an org-creation step in Phase 1+.
AppSettingsshrinks. Operator-level settings move toOrganization.settings.AppSettingsbecomes platform-staff-only. Migration path: split the shape, dual-read for one bake period, drop the legacy fields.
4.3 Follow-up work this commits us to
- Phase 0.5 implementation — schema + backfill + enforcement layer + role split. ~2 weeks engineer-time per Plan A.
- Onboarding org-creation UI — Phase 1, after the schema lands.
- Org-admin nav + settings surface — Phase 1+. Today there's no "manage your organization" page because there are no organizations; this becomes a real surface (member management, org settings, billing portal entry-point).
- Per-org branding — the
OrganizationSettings.logoUrl/primaryBrandColorfields are reserved but not lit up in Phase 0.5. White-labeling becomes possible later. - Audit log for org-scope violations. Any read that returns null because of an org check failure should log a
security.org_scope_deniedmetric. Repeated denials from one user are a signal worth surfacing. agents/clara/libparallel data layer — same rule as ADR-0018 §7.4. The Lambda'stypes.tsgainsOrganization+organizationIdforeign keys in lock-step withsrc/lib/data/types.ts. CI drift gate (Plan H §7.4) catches divergence.
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
- ADR-0018 — Person identity model — the consumer of this decision; cannot ship Phase 1 until this is Accepted.
- identity-model-action-plan.md §Plan A — the Phase 0.5 deliverables and effort estimate this ADR formalizes.
- identity-model-shortcomings.md §2.1, §2.2 — the two Critical issues this ADR resolves.
src/lib/data/types.ts— currentProperty,User,AppSettings,SubscriptionInfoshapes.src/lib/platform/auth/scope.ts— current property-scoping helpers; this ADR adds an org-scoping sibling.src/lib/platform/auth/permissions.ts— current role hierarchy; this ADR splitsadminand addsorg_admin.- Slack — Workspace as the unit of tenancy.
- Linear — Workspace + Team hierarchy (Property is closer to Linear's Project; Org is Workspace).
- Stripe Connect — Account as the operator-side billing entity.
- Vercel Teams — Team as the org primitive; per-team billing.