Fede wants a real hard floor: a request for the wrong company returns nothing even if application code forgets to filter — not per-route if-statements. This page collects the research and, later, the decision.
Status: Research — input to the decision · written 2026-09-07 · no recommendation yet, Fable writes the decision
Why this page exists. This is the hub page for the isolation-architecture initiative — the research below, the exhaustive product audit further down, and the decision itself (still pending) all live here rather than spread across separate docs. The new-customer onboarding walkthrough (Sep 7) is what first caught this live: a real, non-staff org_admin account with zero properties of its own saw another company's real tenants, work orders, and conversations on five pages. That's the incident that makes "hard floor" a real requirement rather than a nice-to-have. The portfolio-architecture "HOW" and portfolio-architecture options pages cover an adjacent but distinct question — which company or companies are modeled as running a given building — not the security boundary between two different customers' data that this page is about (see §5's note on X10-X17 below for exactly where they diverge). This page starts with the research on how other systems enforce that floor, then an exhaustive audit of where today's floor actually holds and where it doesn't; the decision itself — which option, in what order — comes later.
Sections on this page: Decision · Adversarial audit · Tenancy map · Research · Podcast episode (audio pending)
Status, Sep 8: Fede approved the two-step shape below ("yes") and ruled it is not being built right now. No build starts, no owner is assigned, until he schedules it. Owner when scheduled: Gera. Not this week (Fede, Sep 8). The Sep 7 stopgap (company admins see only their own company; fail-closed on empty scope) stays as the protection in the meantime.
Proposed — pending Fede's pick
Step one — make forgetting impossible (3–4 weeks). Every function that reads data has to be given "which company is asking" as a required input — there's no default, so code that skips it doesn't compile. A lint rule and an automated check block anyone from adding a new one that skips it. Reads get bounded at the moment the question is asked, not filtered afterward — the system turns "which company" into "which specific properties that company owns" and only ever asks the database about those. Reading the entire table at once is banned outright. Staff who legitimately need to see across every company use a separate, logged "all companies" mode — never a read that simply forgot to ask. The 662-path test we ran by hand becomes a permanent, automatic check that runs on every future change: log in as one company, confirm zero rows from any other company come back, for every single one of those paths. This is enforced by the code itself refusing to run, not by a human remembering to write an if-statement.
Step two — make the database itself refuse, even if the code has a bug (6–10 weeks, starts after step one). Every record gets the company id filled in, worked out from the property it belongs to. The company id becomes the lead part of how records are organized and looked up, on every table and index. Each request gets its own short-lived database key that AWS itself restricts to that one company's data — so even a bug in our code can't reach another company's rows, because the credential physically can't. Staff get their own separate, logged credential for cross-company work. The one gap step two doesn't need to solve on its own — reading a whole table at once — is already closed by step one's ban.
Rejected approaches: a separate database table per customer and a separate cloud account per customer both create real operational cost once we're serving 50 customers and staff still need to see across all of them. Rewriting the whole product onto a different type of database was rejected too — it would take a full rewrite to get the same protection step two gives us on the database we already have.
This isn't a workaround, it's the rule while the real fix is being built: nobody outside PropFlow gets the company-admin role — including Western Slope's Jay. Nobody outside the company holds that role today, so the exposure stays theoretical while step one is built. It stops being theoretical the moment an outside person gets that role, so this policy holds until step one ships.
The automatic test suite runs against The Willows and the sandbox, using logins that have never touched those companies before. The report that comes back says what was fixed, shows the wall in place, and explains why the same leak can't happen again. Fede doesn't have to test it himself.
Stopgap ordered Sep 7 (draft in progress): a company-admin login now only sees their own company's properties; five routes that had no filter at all are being scoped to that. This is not the wall — it's a patch to hold the line while step one is actually built.
Research — input to the decision. No recommendation here; Fable writes the decision.
The problem, in three sentences. Today one AWS credential and one DynamoDB table serve all customers, so the only thing standing between company A and company B's data is whether every route remembers to filter by company — and the onboarding walkthrough already caught five pages that forgot (dashboard, leasing, maintenance, tenants, conversations all bled another company's real data to a zero-property login). A "hard floor" means the wrong-company request comes back empty even when the code forgets, which requires either the database engine itself to refuse the row, or a code layer positioned so no route can physically reach the data store without first supplying a tenant. Neither is currently true for us: there is no per-request credential scoping and no data-access layer that a route is forced to go through.
| Option | What enforces it | Residual holes | Effort (2 eng + agents) | Runtime cost | Who uses it at 50–500 tenants |
|---|---|---|---|---|---|
| A. App-layer tenant context + CI + adversarial tests | Code discipline (AsyncLocalStorage-backed context, DAL that refuses to run without it, lint/CI banning raw table access) | Any code path that bypasses the DAL (raw SDK call, a new Lambda, a script) is unprotected; no DB-level backstop | 2–4 weeks to build the DAL + CI rule + a fuzz suite that logs in as tenant A and checks zero tenant-B rows on every endpoint | ~0 (no extra AWS calls) | Very common — this is the default pattern for most Node/Rails/Django SaaS at this scale before they invest in DB-enforced isolation |
| B. Per-request STS credentials + LeadingKeys on existing table | DynamoDB/IAM itself, via dynamodb:LeadingKeys tied to a session tag minted per request | Does not cover Scan; does not cover aggregate/cross-tenant staff queries; one static credential still exists for anything that legitimately spans tenants (staff, Temporal workers, batch jobs) | 6–10 weeks — needs a full re-key (every PK/SK and every GSI PK must lead with tenant id), a token-vending Lambda, and STS call on every request path (API, Lambda ingress, Temporal workers) | STS AssumeRole adds ~100–300ms cold, ~sub-10ms once cached per session; session policy plaintext capped at 2,048 characters and 50 session tags (128-char keys, 256-char values) per AWS's own quota page | Common in AWS-native SaaS reference architectures (AWS SaaS Factory's own worked examples); less common as a bolt-on to an existing un-rekeyed table |
| C. Table per tenant | AWS account/IAM resource boundary — each tenant literally has its own table | None from the DB's own boundary for isolation, but GSI/queries that need cross-tenant staff views must fan out or use a separate rollup; per-table cost and connection/quota overhead grows with tenant count | 3–5 weeks migration (schema is already single-table so mostly automation + backfill scripting), ongoing per-tenant provisioning work | DynamoDB on-demand billing means no extra $ per table, but 50 tables means 50x the operational surface (backups, metrics, alarms, IAM roles) unless heavily automated | Used by SaaS vendors serving a small number of large/regulated tenants (silo model per AWS's own naming) — less common past a few dozen tenants without heavy automation |
| D. Account per tenant | AWS account boundary itself (hardest wall — separate root, billing, IAM) | Cross-tenant staff views and shared services (Temporal, ElevenLabs webhooks) now need cross-account roles; onboarding a customer means provisioning a whole AWS account | Months, not weeks — new CI/CD, new cross-account networking, new staff tooling; realistically a re-platform | Meaningfully higher — per-account minimums, cross-account data transfer, duplicated infra per tenant | Reserved for enterprise/regulated software (health, government, defense) or a handful of very large accounts — essentially never used for a 50-tenant SMB SaaS book like ours |
| E. Move to Postgres + row-level security | The database engine itself, via CREATE POLICY evaluated against a per-transaction session variable (e.g. current_setting('app.tenant_id')) | Any DB role with BYPASSRLS or superuser skips the policy entirely; a pooled connection that uses plain SET instead of SET LOCAL can leak one tenant's context into the next pooled request; RLS itself doesn't stop a bug in how the app decides which tenant id to set | This is not a tweak — it's a rewrite of the data layer: modeling ~500 units/3 customers worth of single-table DynamoDB access patterns as relational tables, migrating Temporal workflow state, and rebuilding every GSI-shaped query as SQL. Order of months for a 2-engineer team even with heavy agent help | Managed Postgres (RDS/Aurora/Neon/Crunchy Bridge) at our data size is inexpensive; RLS itself has modest per-query planner overhead, not a meaningful cost line at 500 units | Extremely common — this is the default recommended pattern from Supabase, Crunchy Data, and most Postgres-native multi-tenant SaaS advice at exactly our scale (dozens to low hundreds of tenants) |
AWS's own SaaS tenant-isolation guidance names three models: silo (a table per tenant, full isolation, higher operational cost), pool (one shared table, isolation enforced at the row/item level through the partition key), and a bridge model that mixes the two by layer — e.g. a pooled web tier in front of siloed storage.1 For the pool model, AWS's pattern is to make the tenant id the leading segment of the partition key on the base table and every GSI, then use the dynamodb:LeadingKeys IAM condition key — paired with attribute-based access control (ABAC) and STS session tags (aws:PrincipalTag/TenantID) — so a single IAM role can serve unlimited tenants, each one only ever seeing rows whose partition key matches the tenant id burned into their session's temporary credentials.2,3 Credentials are minted per request (or per user session) by a small "token vending machine" — typically a Lambda that calls sts:AssumeRole with a session tag and hands back short-lived, tenant-scoped credentials; AWS's own worked example is for S3 but the same TVM shape is described for DynamoDB.4 Two hard limits from AWS's own docs: an AssumeRole session policy's plaintext is capped at 2,048 characters, and you can pass at most 50 session tags (128-char keys, 256-char values) — comfortably enough for a tenant-id tag at our scale.5 AWS's own documentation is explicit about what this does not cover: LeadingKeys is excluded from Scan entirely — "the list of actions does not include permissions for Scan because Scan returns all items regardless of the leading keys" — so any Scan-based code path (a common shortcut in single-table designs) is invisible to this control, and it says nothing about aggregate queries or the legitimate cross-tenant views platform staff need.6
The pattern real multi-tenant products use is to never give staff a permanent "see everything" credential; instead staff access is minted the same way tenant access is, just with a different, explicitly-tenant-scoped or explicitly-broadened role — a separate admin API that takes a tenant id as an argument, impersonation sessions that are logged and time-boxed, or "break-glass" accounts that are normally locked and require an approval step (commonly a two-person rule) to activate, with every activation and action audited.7 Applied to our case: platform_admin should look like a staff member assuming a role that is itself scoped per-request to one named company (chosen explicitly, logged), not a blanket bypass baked into normal auth resolution — which is closer to what today's @propflowai.co-domain override actually does (any staff sign-in silently gets full cross-company visibility with no explicit per-action tenant choice or audit trail).
Teams that can't (yet) push isolation into the database itself use a mandatory request-scoped tenant context — in Node/TypeScript, AsyncLocalStorage carrying a TenantCtx that every data-access method requires by type, so a repository call literally cannot compile or run without a tenant in scope — combined with lint/CI rules that ban any raw table client call outside that one data-access layer, and adversarial tests that log in as tenant A and assert zero tenant-B rows across every endpoint on every deploy.8 This is a discipline-enforced wall, not a database-enforced one: it stops the class of bug this doc's onboarding walkthrough found (a route that forgot to filter), but nothing stops a new script or Lambda from reaching the table directly outside the DAL. Separately, authorization engines like Oso, Cerbos, OpenFGA, and AuthZed/SpiceDB (the two Zanzibar-style relationship-based ones) answer a different question — "is this specific user allowed to do this specific action on this specific object" — and are not, by themselves, tenant-data-isolation tools; OpenFGA's own multi-tenancy, for instance, is enforced by application-level store IDs, not cryptographic or DB-level separation.9 They complement a tenant-isolation wall (fine-grained permissions within a tenant) but don't replace one.
Postgres row-level security is the closest relational analog to DynamoDB's LeadingKeys approach: a policy attached to a table (CREATE POLICY ... USING (org_id = current_setting('app.tenant_id')::uuid)) filters every query at the engine level once the connection sets that session variable — Crunchy Data documents exactly this pattern, and Supabase's own guide layers the same idea on top of its JWT-derived auth.uid().10 The production footgun both note: the value must be set with SET LOCAL inside a transaction, never plain SET, or a connection pooler (PgBouncer/Supavisor in transaction mode) can leak one tenant's context into the next pooled request — and any role with BYPASSRLS or superuser skips the policy outright. Schema-per-tenant and database-per-tenant are the relational versions of the silo model, with the same operational-overhead-grows-with-tenant-count tradeoff as DynamoDB table-per-tenant. None of this is a recommendation to move off DynamoDB — it's what the cost line would look like if we did: re-modeling the existing single-table access patterns as relational tables, migrating Temporal workflow state, and rebuilding every GSI-shaped query as SQL, which is a rewrite measured in months, not weeks, for a 2-engineer team even with heavy agent help.
dynamodb:LeadingKeys definition and example policies: docs.aws.amazon.com/amazondynamodb/.../specifying-conditions.htmlCodebase + production audit, 2026-09-07 · read-only, worktree ~/.claude/propflowai-lookup at commit a9ba8eadd8 (origin/main) · production counts via DYNAMODB_TABLE_NAME reads against the live table. Facts below are grep/read/scan results with file:line citations; anything not directly read is labeled "inferred" or "sampled."
Only 5 of the ~20 entity types checked put organizationId anywhere on the row at all, and none put it in the table's primary partition key — the primary key is built from propertyId almost everywhere (PK=PROP#{propertyId}, e.g. src/lib/data/dynamo/property.ts:5-10). Two entities (Occupancy, Prospect/Inquiry) do query a secondary index (GSI3) whose leading key IS the organization id — the one place in the codebase where "wrong company" is structurally excluded from a query's result set, not filtered out afterward. Everything else derives "which company" only by a second lookup: read the row's propertyId, then read that Property, then read its (optional, nullable) organizationId.
| Entity | organizationId on the row? | In a DynamoDB key? | Prod rows / org coverage | Notes |
|---|---|---|---|---|
| Property | Yes, optional attribute (types.ts:2871) | No — PK=PROP#{id} | — | getProperties() reads every property, then filters in app code (property.ts:154-164); a null organizationId is treated as "belongs to no org," not an error |
| Person | Yes, required (types.ts:14898) | No primary key; claim sub-rows key on it (claimPK(organizationId,...), persons.ts:241) | 9,076 rows, 100% coverage | Cross-org guard is a conditional write, not key isolation (persons.ts:487-507) |
| TenantOccupancy | Yes | Yes — GSI3 leading key occupancyEntityGSI3PK(organizationId) (occupancies.ts:76,259) | 266 rows, 100% coverage | Real DB-level query bound — but only via findOccupancyById(id, organizationId); the caller still supplies the org id, unverified against the caller's identity |
| ProspectInquiry | Yes | Yes — GSI3 leading key inquiryEntityGSI3PK(organizationId) (inquiries.ts:321,886) | 629 rows, 100% coverage | Same pattern and same caveat as Occupancy |
| Household / HouseholdMember | Yes | Partial — GSI3 keyed by org (households.ts:102); primary PK is householdPK(id) | 2,607 / 90+ rows, 100% coverage | — |
| VendorMembership | Yes | No | 837 rows, 100% coverage | — |
| PurchaseOrder | Yes, required (purchase-orders.ts:87) | Partial — GSI2 PONUM#{organizationId}#{poNumber}; primary PK still PROP#{propertyId} | — | — |
| Conversation | No field at all (types.ts:8907) | No — PK=PROP#{propertyId} | 3,499 rows, 0% (attribute doesn't exist) | Org reachable only via Property lookup |
| WorkOrder | No field (types.ts:6054) | No | 2,959 rows, 0% | — |
| Turnover | No field (types.ts:7460) | No — PK=PROP#{propertyId} (turnover.ts:276) | 63 rows, 0% | organizationId appears only as a local variable resolved at write time from property.organizationId — never persisted |
| RenewalSaga | No field (types.ts:11174) | No — keyed by personId/propertyId only | 141 rows, 0% | — |
| Tour | No field | No | 211 rows, 0% | — |
| Unit / Lease | No field | No — PK=PROP#{propertyId} | 409 / 473 rows, 0% | — |
| EscalationMatter | No field — has propertyId commented "HARD SCOPE" (types.ts:16719,16726) | No — PK=CONV#{conversationId} | 109 rows, 0% | Org only derivable transitively: matter → property → org |
| KnowledgeSection / Settings | No field anywhere in the file | No — property-scoped or fully global (PK_CONFIG) | — | Some Settings rows are platform-wide, not even property-scoped |
| User | Comment says "carries no organizationId post Phase 6d-pt2" (dynamo/user.ts) | No | 17 rows, 11 missing (6 carry it anyway — likely pre-cutover rows) | Org membership resolved separately (memberships.ts) at request time, not from this field |
| VendorCompany | No field — removed per ADR-0033 (comment, property.ts:922) | Only on the contact-point sub-row's GSI sort key | 832 rows, 100% missing (by design) | Vendor is deliberately cross-org (one vendor can serve multiple companies); org lives on the membership row, not the vendor |
Row counts for Conversation/WorkOrder/Turnover/RenewalSaga/Tour/Unit/Lease/EscalationMatter are full prod counts (a background sub-audit's direct table reads); Person/Household/Occupancy/Inquiry/VendorMembership likewise. A separate 75,740-row segmented sample across the rest of the table (operational/audit entities — AutomationRun 52,839 rows, TOOL_LOG 7,769, Message 2,131, DispatchDecision 722, SmsStatus 577, EmailIngestion 894, etc.) found 0% organizationId coverage on every one of those operational tables — the gap isn't confined to core domain rows, it runs through the logging/audit trail too. A separate sampled scan found 7 distinct organizationId values live in prod today: org_jpco, org_western_slope, org_riverbend, plus org_sandbox, org-test, org_unrouted, org_propflow_staff (sandbox/test/staff scaffolding) — i.e. real paying-customer orgs number in the low single digits today.
In one sentence: organizationId becomes known exactly once, at the route-handler auth call — and is dropped one layer down, at the repository, which will run for any id handed to it regardless of who's asking.
src/middleware.ts) never touches organization identity — only session/MFA/redirect logic.enrichAuthenticatedUser (src/lib/platform/auth/helpers.ts:149-220) is the first and only place organizationId is attached, via getUserOrganizationId(user.id), and only when a route calls it.getUserOrgScope (org-scope.ts:34-47) and getUserPropertyScope (scope.ts:26-29) are opt-in functions a route chooses to call before reading data; nothing forces the call.AsyncLocalStorage-backed RequestContext exists (src/lib/platform/request-context.ts) but carries only requestId/channel/tenantId/senderId for log correlation. Zero references to it inside src/lib/data/dynamo/*.ts — it does not reach the data layer.src/lib/data/store.ts (184 exported read functions) and src/lib/data/dynamo/*.ts (108 more) take raw ids as plain arguments. 27 of 292 (9%) accept an organizationId/orgId parameter at all; the other 265 (91%) — including getProperty, getUnits, getTenants, getConversation, getWorkOrder, getTurnover — take no tenant argument whatsoever and will return the row for any caller. A second, independent pass over store.ts alone counted 173 read functions with only 6 taking organizationId — same single-digit-percent pattern from a different count.export async function getProperties(organizationId?: string) (store.ts:361) — its own comment says "omitted = the whole platform, supplied = that org only." The dashboard-stats route (src/app/api/dashboard/stats/route.ts) and the leasing/tenants/conversations loaders named in §3 all call the unscoped form. The parameter existing doesn't help when nothing forces callers to pass it.The org gap above is one failure mode. The older, narrower boundary — a property manager sees only their assignedPropertyIds — is implemented by the exact same shape: an entirely post-fetch, in-application filter, with no database participation at all.
src/lib/platform/auth/scope.ts, 126 lines): getUserPropertyScope returns either null ("all properties" — platform_admin and org_admin both get this) or a Set<string> of assigned ids. scopeByProperty, scopeProperties, and isInScope are pure functions — Array.filter/Set.has over data that has already been fetched. None of them issue a query or touch a key condition. isAccessDenied (added by ticket #38, a fail-closed gate for by-id routes) is the same pure check with the null-user case inverted.isInScope/isAccessDenied; of those, 24 use the fail-closed isAccessDenied form specifically. Every one of the 5 confirmed-unfiltered surfaces named in the task (dashboard compute.ts:279 + route.ts:38-39, load-prospects.ts:96-97, load-tenants-list.ts:36, conversations/route.ts:119-120,224) imports only getUserPropertyScope/scopeByProperty from scope.ts and never calls anything from org-scope.ts — directly re-read and confirmed live on origin/main. load-maintenance-manuals.ts applies no scope at all (its own comment: "manuals carry no per-user property scope").docs/audits/2026-07-06-fable-deep-audit.md, finding #37) found ~40 by-id routes gated only by isInScope/property-scope, e.g. properties/[id]/route.ts:39-42,113-116 — and because getUserPropertyScope returns null ("all properties") for org_admin too, an org_admin of Company B could PATCH/DELETE Company A's property and reach its tenants, conversations, turnovers, and renewals. The fix that shipped is isPropertyScopeDenied (auth/helpers.ts:759-794), which composes property scope with the new isPropertyInOrgScope (org-scope.ts:203) — but it is referenced in only 35 files today, and the codebase's own drift test (src/__tests__/property-scope-surface-registry.drift.test.ts, a hand-maintained registry of every route reading a query-string ?propertyId=) still carries 68 classified surfaces with the verdicts: guarded-in-route 16, guarded-downstream 6, admin-gated 15, cron-gated 6, org-envelope-gap-held 3 (an acknowledged, deliberately-unfixed org gap — the test file's own comment: "an org admin is not bounded to their own organization... needs its own change, not a drive-by"), and unverified-legacy 22 (property-scoped, org leg never individually checked).| Surface | Count | How counted |
|---|---|---|
| API route files | 430 | find src/app/api -iname route.ts |
| ...of which cron-triggered | 38 | path contains cron |
Server-side data loaders (load-*.ts) | 80 | find src -iname "load-*.ts" |
| Files under a Clara/agent "tools" path | 123 | find src -path "*tools*" -iname "*.ts", tests excluded |
Files anywhere in src/app+src/lib that reference at least one scope helper | 162 | grep -rl "getUserOrgScope\|scopeByOrg\|filterVisibleProperties\|getUserPropertyScope\|withOrgAndPropertyScope" |
430 routes + 80 loaders + 123 tool-handler files is a surface well over 600 files that can originate a data read (with some overlap, since loaders are called from routes); only 162 of them touch a scope helper of any kind — and, per §3, "touches a scope helper" routinely means the property-only helper, not the composed org+property one. A second, independently-run count landed close but not identical (432 routes, 37 loaders, 114 tool files, 132 scope-helper references) — the loader gap (80 vs 37) likely comes from a narrower glob; both counts agree on the order of magnitude: roughly 500-600 candidate read surfaces, with somewhere around a quarter to a third referencing any scope helper at all, correct or not. This is a lower bound on the org-blind surface, not an exhaustive count — it would take exercising every route as a real non-staff user to know the true number, the same method the referenced onboarding walkthrough used to catch the 5 live examples above.
docs/adr/0019-organization-model.md, accepted 2026-05-04) introduced Organization as a first-class entity and declared org-scoping "the outer envelope" with property-scoping as "the inner privacy boundary within an org" — a design decision, not yet a structural guarantee. Its own §1.1 candidly states the platform was "effectively single-org" before this ADR.org-scope.ts mechanism — read in full: scopeByOrg, isInOrgScope, and withOrgAndPropertyScope are pure in-memory array filters, structurally identical to scope.ts's property-scope functions (§3). getUserOrgScope is fail-closed by design (throws rather than silently granting cross-org access when a non-staff user has no resolved org) — a real, deliberate safety property, but only for callers that invoke it.?propertyId= fails CI until a human classifies it with one of 6 verdicts (§3). It makes an unclassified surface visible; it does not prove any surface is correctly scoped, and its own header comments say so explicitly.ci.yml:2970, job ddb-escapes) — despite the name, this does not gate tenancy at all. Per its own comment block, it blocks a PR that adds a test file talking to real DynamoDB (a test-hygiene ratchet against flaky/slow CI, tracing back to incidents #4669/#4679), with 107 pre-existing offenders grandfathered in an allowlist. It has nothing to do with cross-tenant access.infra/temporal-worker/iam-task-runtime-policy.json grants the worker role GetItem/Query/PutItem/UpdateItem/DeleteItem/BatchGetItem/BatchWriteItem/TransactWriteItems on the entire table and all its indexes (arn:...table/propflow-prod, .../index/*) with no condition keys at all — no dynamodb:LeadingKeys, no per-tenant scoping. This is one static, table-wide credential, and — per a separate reader — the Vercel app path uses the same shape: one shared IAM identity for every customer.portfolio-architecture-options.html, no decision taken yet) — a related but different "company" question. X11 covers who is named as running a given building (JP&Co / ConAm), X12 how a company-line conversation gets attributed to a building without moving partitions, X13 whether a "group" tier above company ships now, X15 what "managed by" vs "owned by" grants by default, X16 whether scattered homes get one building-record each. This is portfolio modeling — which company or companies (owner, manager, group) are associated with a building, inside PropFlow's data model — not the security boundary between two different paying customers' data, which is what ADR-0019's Organization/organizationId already claims to be and what this section audits. The two are related (X10-X17 will add more company-shaped relationships to reason about) but distinct: nothing in X10-X17 specifies a database-level isolation mechanism, and the isolation gaps in §1-§4 above exist independently of how X10-X17 resolves.| Option | What blocks it today | Migration size | Effect on shared-staff (platform_admin) view |
|---|---|---|---|
| A. Mandatory tenant context in the data layer + CI | 265 of 292 repository read functions (91%) take no tenant argument and would each need a signature change, plus every one of their ~600+ call sites across routes/loaders/tools updated to pass a context object instead of a raw id. The existing AsyncLocalStorage RequestContext is unused by the data layer today — it's a real starting point, not a green field, but it carries no tenant field yet. | Code change, not data change — ~292 function signatures + hundreds of call sites; the 3 org-envelope-gap-held and 22 unverified-legacy routes already flagged are the known worst cases to start from. | Straightforward — platform_admin becomes an explicit "no tenant filter" context value passed the same way, auditable and loggable, rather than a role check embedded in each helper. |
B. Per-request STS + dynamodb:LeadingKeys | The primary partition key on nearly every entity leads with propertyId, not organizationId (§1) — Property, Unit, Tenant, Conversation, WorkOrder, Turnover, RenewalSaga, Tour, Lease, EscalationMatter, KnowledgeSection, Settings all fail this today. Only Occupancy and Inquiry already have an org-leading GSI (not the primary key — LeadingKeys can be scoped to a GSI, so this is a partial head start, not full compliance). LeadingKeys also excludes Scan entirely (per AWS's own docs, cited above) — and this table is scanned today (this audit's own sampling used Scan, as does at least the dashboard's "all properties" fallback path). | A full re-key of every PK/SK/GSI to lead with organizationId, across ~1.04M rows spanning the entity list in §1 — the two entities that already have an org-leading GSI (Occupancy, Inquiry — under 1,000 rows combined) show the pattern is at least provably workable at small scale here. | Needs an explicit token-vending path for staff (a Lambda minting a session tagged for a chosen org, or an un-scoped break-glass role) since the ambient @propflowai.co-domain override used today has no equivalent under LeadingKeys. |
| C. Table per company | Nothing structural blocks it — the schema is already single-table, so per-tenant splitting is mostly automation + backfill scripting, not a redesign. Only 7 distinct organizationId values exist in prod today (§1), several of them sandbox/test/staff, so the near-term tenant count is small. | Small at today's scale (~1.04M rows across a handful of real orgs) — but 91% of read functions still assume "no tenant needed" and would need the tenant id threaded through to pick the right table, so this doesn't avoid the Option A code change, it adds a table-selection step on top of it. | Cross-org staff views (dashboards, the shared vendor directory) must fan out across every tenant's table or maintain a separate rollup — a new piece of infrastructure that doesn't exist today. |
| D. Account per company | Same starting gaps as C, plus: shared services used across all customers today — one Temporal worker fleet, one ElevenLabs voice integration, one SendGrid/Twilio account — would need cross-account roles or per-account duplication. | Same data-migration size as C at today's ~7-org scale, but the infrastructure lift (new CI/CD per account, cross-account networking, per-account credential provisioning) is much larger and ongoing per new customer. | Every "see all my properties across companies" staff workflow (the current default for platform_admin) becomes a cross-account operation requiring assumed roles per account. |
These are the mechanical starting conditions, not a ranking. Fable writes the decision separately.
Read-only static pass over ~/.claude/propflowai-lookup (8 parallel sub-agents, every file opened and read, no sampling) plus a live probe run as a real, non-staff org_admin — a brand-new test login on the throwaway org org_riverbend, which owns zero properties, so anything it can see came from another company. This section answers "does every customer-reachable screen actually enforce the wall," as a complement to the code-shape findings above (§1-§6), which explain why it doesn't.
Signed in production as clara+isoaudit0907@inbound.propflowai.co, confirmed via GET /api/auth/me as role org_admin on org_riverbend (an org with no properties, no AppFolio connection, no phone numbers — anything it can see is not its own). Every call below is a plain GET; nothing was created, changed, or sent.
| Path called | Live result | Evidence |
|---|---|---|
GET /api/dashboard/stats | Leak | Returned a real portfolio's numbers to a zero-property org — 232 units, 89.6% occupied, $299,100 monthly rent, 22 open work orders |
GET /api/tenants | Leak | 172 real Camellia Apartments tenant rows — full name, phone, unit, property |
GET /api/leasing/prospects | Leak | 394 prospect rows spanning three different companies' data in one response (org_sandbox 340, org_jpco/Camellia 49, org_western_slope 5) |
GET /api/leasing/renewals | Leak | 100 real renewal rows — tenant name, unit, current/market/proposed rent, decision outcome, all Camellia |
GET /api/leasing/availability | Leak | 41 real unit rows for a different company's property (Yale 25 Station), with bed/bath/sqft/asking rent |
GET /api/maintenance-manuals | Leak | Real equipment manuals tied to another company's property id |
GET /api/activity-log | Leak | Real tenant tool-call log lines naming a real tenant id and a real conversation id, not this org's |
GET /api/conversations | Leak | 20 real conversations, incl. a real caller's name and a live voice call summary, none belonging to org_riverbend |
GET /api/cost-savings/summary | Leak | Another company's real financials — trailing-twelve-month revenue ($1.6M), operating expense, and NOI, sourced from that company's AppFolio account |
GET /api/settings | Leak | Another company's real org settings — company name, contact email, contact phone, billing address, AppFolio application link |
GET /api/properties/{known id} | Leak — id-taking, no ownership check | Full detail for a real property — name, street address, 120 units, 579 lifetime work orders, 15 knowledge entries — fetched by a plain numeric id with no ownership check, even though... |
GET /api/properties (the list) | Correctly bounded | ...the list version of the same resource correctly returned [] — the leak is specifically in the by-id path, not the whole resource |
GET /api/team | Leak — confirmed after the static pass flagged it | 18 real user rows — name, email, role, status — spanning two other companies, with zero filtering; the route's own code branches to return every user in the system for org_admin and platform_admin alike |
GET /api/vendors, /api/work-orders, /api/turnovers, /api/turnovers/upcoming, /api/leasing/tours, /api/properties/options | Correctly bounded | All returned an empty list for this zero-property org — a live control group proving these particular reads do apply a real boundary |
GET /api/admin/users, GET /api/admin/waitlist | Correctly rejected | 403 "Admin access required" — confirms the platform_admin-only gate holds against an org_admin caller |
GET /api/search, GET /api/collections, GET /api/dashboard/live (no property chosen) | Empty this time, not provably bounded | Returned no rows for this query/org combination — the static pass separately classifies /api/search and /api/dashboard/live (no propertyId) as property-scope-only/unbounded by code shape, so an empty live result here reflects this particular query, not a verified fix |
Read every file in each list below — not a sample. "GAP" means the only check applied is "is this building assigned to you," which returns "yes, all of them" for an org_admin by design (scope.ts:26-29), so in practice it is not a boundary for that role at all.
| Surface | Total | Company-bounded | Property-only (GAP) | Unbounded | Not reachable / n-a | Id-taking, no ownership check |
|---|---|---|---|---|---|---|
API routes (src/app/api/**/route.ts) | 430 | ~105 | ~66 | ~13 | ~258 (platform_admin-only, cron shared-secret, or webhook-signature-gated) | ~26 |
Workspace pages (src/app/(workspace)/**/page.tsx) | 88 | 25 | 12 | 23 (mostly /admin/dev/* — see below) | 24 (redirects/static) | 6 |
Server actions ('use server') | 2 real | 0 | 0 | 0 | 2 (investor-deck password gate only, no tenant data) | 0 |
Domain loaders (load-*.ts) | 36 | 15 | 8 | 2 | 7 (platform-wide config, non-tenant) | — |
Clara tool handlers (src/lib/tools/handlers/**) | 106 | Not individually tabulated — PR #7164 already deep-audits voice/SMS/email channel isolation. This pass separately confirmed ToolCtx carries no organizationId field, and flagged one adjacent, already-known gap below (not part of #7164's scope). | ~most (thin AppFolio passthrough wrappers take a bare id, forward to L4 with no ownership check) | |||
Grand total, distinct data-reachable paths: 662. A page's middleware route maps every /admin/* path — including the ~20 pages under /admin/dev/* meant for staff debugging (agent traces, email-ingestion logs, a doc-traffic visitor-PII log, raw metrics) — to one coarse "admin" permission that org_admin is explicitly granted at the same level as platform_admin (permissions-source.ts:104-112, commented "operator-side full access"). Only 2 of those ~20 pages re-check for platform_admin specifically inside the page itself; the other ~18 are reachable, unintentionally, by any customer's own company admin.
GET /api/team — live-confirmed. Every user's name, email, and role, across every other company, with a code branch that treats org_admin and platform_admin identically.GET /api/conversations, GET /api/leasing/renewals (no property chosen) — live-confirmed. No org check anywhere in either file; the renewals route's own code comment already says so.GET /api/properties/{id} and its PATCH/DELETE — live-confirmed the GET; PATCH/DELETE were not called (write budget for this audit was GET-only). Same "is this building yours" check that returns "yes" for org_admin.PATCH /api/admin/users and PATCH /api/admin/users/[id] — static only (platform_admin-gated at the route, so not reachable by this test login; the finding is that once inside, the target user's company is never checked against the caller's, unlike the sibling POST in the same file, which does check).DELETE /api/activity-log — static only, most severe by mechanism: no login check of any kind, not just no company check.This is a systemic pattern, not a handful of missed routes. The clearest evidence is in the data-access layer itself (§2 above): of 292 repository read functions, only 27 (9%) take a company id as a parameter at all — and even most of those treat it as optional ("omitted = the whole platform" is a documented, intentional behavior on getProperties()). The other 91% — including the functions behind property, conversation, and work-order lookups — will return a row for any id handed to them, for anyone. The one place this is done right, Person and Tenant identity lookups, proves the pattern is fixable in this codebase: getPerson() takes an organization id as a required parameter and throws if the row belongs to someone else. That is the exception, not the house style. Every other entity relies on each of 430 routes individually remembering to re-check company membership after fetching the row — and the same route family often splits on this: /api/turnovers/[id]/scope checks the company, /api/turnovers/[id]/cancel on the identical turnover doesn't; the POST that creates a user checks the company, the PATCH that edits one doesn't. That inconsistency inside single files is the fingerprint of "each author has to remember," not a boundary the platform enforces — which is exactly the "hard floor" gap the architecture options above (§6) are weighing how to close.
This section is written as a company-isolation correctness audit of PropFlow's own product, in the spirit of ADR-0019's stated goal — not as an attack writeup. No customer data was altered, exported, or shared outside this session; the test account is a throwaway internal login with no real mailbox, and every finding above is described by row count and company name only, never by the underlying person's name, phone, or email. Test-login and rollback detail: ~/agents/006/onboarding-walkthrough-rollback-2026-09-07.md.