Page reads and company separation

Every workspace page, one row each: does it read live from the database, is that read bounded to the caller’s own company and buildings, and can an admin at company 1 ever be handed company 2’s rows? With the file and line that enforces it, and the test that pins it.

2026-09-22 · read at origin/main 9146303fc8 · read-only audit of code and tests, no production reads · 20 pages · every cited assertion was re-opened by hand before it entered this table

No page serves another company’s rows to a company admin, and 18 of 20 have a test that pins it. The two that don’t (review queues, settings) are separated in code but one read on each path has no test. On the performance half the picture is worse than the working notes said: zero pages paginate at the database on any real request — the one that was said to (prospects) has the machinery built and switched off — and 13 pages read every company’s building rows on every request before narrowing in memory.

The honest count

18separated, and a test pins it
2separated, but a read on the path has no test (review queues, settings)
0could not determine
0not separated

“Separated” here means: an org admin’s request never receives a row that belongs to another organization. It is a statement about the answer. “At the database” is a different statement, about the read: is the boundary in the partition key the query addresses, or applied in memory after a wide read? The two must stay in separate columns — a page can read widely and still be perfectly separated (vendors is the canonical case), and the table is worthless if they blur.

Where the boundary sits, for each page’s business rows: 15 pages key every business read on the caller’s own scope (or, for routine, a database-side filter); 4 are mixed — at least one business read pulls every company’s rows into the process for an org caller and narrows there (prospects’ AppFolio-mirror arm, dashboard, renewals board, clara); 1 is separated in memory only — settings, which reads one shared global row and every user on the platform.

Where separation is enforced — the three primitives every row depends on

Consequence for Half 2: an org admin cannot receive null from any of these, so the only way another company’s row could reach them is a read that ignores the scope entirely, or an in-memory narrowing that is skipped. Each row below names the line that applies the bound and the test that would go red if it were removed.

The matrix — 20 pages

Path = the request path (server seed and the route the browser polls, with cadence). Test class: A = asserts the database was never asked for the other company’s partition (strongest); B = asserts the other company’s rows are absent from the output (the read may still have happened). Control = a test that breaks the scope and shows the assertion going red, so it cannot be passing vacuously. “Every Property row” = a no-argument getProperties() → property.ts:202, a query on the constant key GSI3PK='Property': every building of every company, narrowed in memory.

pagerequest pathscoped at the database?reads wider than the answercan org 1 see org 2? enforced attest that pins itwindow at DBpaginated at DBorderfreshness
prospectsSSR leasing/prospects/(list)/page.tsx → loadScopedProspectsPage; poll GET /api/leasing/prospects 5syes by company; mirror arm mixed partition PLIST#<org>, inquiries.ts:1174-1183. Property narrowing is in memory (prospect-list-page.ts:300): one building picked still returns the company’s whole partitionevery Property row (load-prospects.ts:673). For a company whose records live in AppFolio, on the all-properties view, the retired every-building prospect fan-out still runs (:563 → leasing.ts:183), narrowed at :566no assigned ∩ org ∩ selection at load-prospects.ts:675-681; foreign-org rows inside a partition dropped at inquiries.ts:1200; unresolvable caller → empty set :675A load-prospects.projection-flip.test.ts:161 (only org_a queried); load-prospects.org-isolation.test.ts:267 (empty selection issues no read). Control: executed (:221-248) but on walk completeness, not the org boundno — no key condition, no filter (inquiries.ts:1131-1138); per-row in JS prospect-list-page.ts:301, and the walk keeps reading below the edge (:316-323)no, on any request path Limit+ExclusiveStartKey exist (inquiries.ts:1181-1182) but the seed passes page: null (page.tsx:143), the route chunks only when ?limit/?cursor is named (route.ts:88-89), and the island never names either (ProspectsClient.tsx:683-686). Real read = do…while walk to exhaustion load-prospects.ts:1054-1071index (ScanIndexForward:false inquiries.ts:1180 + k-way merge) on the projection arm; .sort() on the mirror/mixed arms (:910, :963)live; 5s poll; stats tile 30s cache
tenantsSSR tenants/(list)/page.tsx → loadTenantsList; island fetches GET /api/tenants once on mount, same loader, no pollyes, by building GSI1PK=PROPERTY#<id>, occupancies.ts:211-216, one query per assigned buildingevery Property row (load-tenants-list.ts:43); one extra GetItem per building (property.ts:994, org hint not passed). TopBar pick is client-side only (TenantsClient.tsx:300): every assigned building’s tenant rows shipno assigned ∩ org at load-tenants-list.ts:52-55; null caller → empty set (read-over-scope.ts:163)A load-tenants-list.org-isolation.test.ts:152-156 — exact equality on the partitions read. No executed controlnoneno — full walk, bare arraynone in the loader; the client table sortslive; refetch on scope change
propertiesSSR → loadPropertiesList; poll GET /api/properties 5syes roster PROPORG#<org> (property.ts:364) then PROP#<id> child reads (helpers.ts:1704-1706). The only page that uses the roster partitionnone for an org caller; staff → whole estateno load-properties-list.ts:69-71, 124-125; null caller → [] with no read (:51-54); the route answers 503 instead (route.ts:47-53)A load-properties-list.org-isolation.test.ts:155-159 over a recording Dynamo client; :167 pins the entity index is never touched. Control :187-191 (proves the instrument sees the other company when unbounded)nonenononelive; 5s
turnoversSSR → loadScopedTurnovers; poll GET /api/turnovers?view=meta 5syes PROP#<id>/TURNOVER#, turnover.ts:56every Property row (load-turnovers-list.ts:81)no assigned ∩ org :94-99; named id fenced read-over-scope.ts:104. A null caller gets null = estate (:94), reachable only behind the ADMIN_API_SECRET bearer (middleware.ts:766, route.ts:25-29)A load-turnovers-list.org-isolation.test.ts:134, 158 — exact key list. Control: by hand only (:26-28)noneno.sort() :127 (payload stability; the table re-sorts)live; 5s
toursSSR leasing/tours/page.tsx → loadTourCalendar; no poll — the client has zero fetchesyes PROP#<id>/TOUR# (leasing.ts:882) + inquiries GSI1PK=PROPERTY#<id> (leasing.ts:177-195)every Property row (load-tour-calendar.ts:136, 230)no calendarReadScopeOf (route-scope.ts:352-363) then the visibility envelope load-tour-calendar.ts:170-172A calendar-pages-read-only-the-selection.test.ts:251 — renders the real server component over a recording client; keys naming the other company = []. Control: claimed in a PR body, not in the treenone — all historyno.sort() tour-day-view.ts:173live; navigation only
calendarSSR → loadBuildingCalendar; refetch GET /api/calendar on property switch (not an interval)yes six sources keyed PROP#<id>: work orders maintenance.ts:103, occupancies occupancies.ts:211, leases lease.ts:33, turnovers turnover.ts:56, key pickups lease.ts:141, vendor events vendor-scheduled-event.ts:148; plus toursevery Property row (via load-tour-calendar.ts:136)no calendarReadScopeOf at both doors (page.tsx:46-48, route.ts:105); named id canViewProperty route.ts:87-99; vendor events hand-keyed visible ∩ selection load-building-calendar.ts:160-164A twice: calendar-pages-read-only-the-selection.test.ts:251 and load-building-calendar.test.ts:129-133. Control: PR-body claim onlynone (a day-ranged index exists for vendor events and is deliberately unused, :142-149)no.sort() building-calendar.ts:515, 532live; refetch on switch
review queuesclient; five routes in one Promise.all (review/page.tsx:512-534), polled; no server seedyes PROP#<id> per building on all five (tenant-confirmation.ts:41, vendor-coordination-session.ts:52, collections-dun-review.ts:84-87, renewal-change-review.ts:94, new-lease-package-review.ts:65)3 of 5 read every Property row unconditionally (collections-dun-reviews/route.ts:145, renewal-change-reviews/route.ts:99, new-lease-package-reviews/route.ts:96). Staff with nothing picked: 4 of 5 read across companies; only tenant-confirmations refuses (:140-143); vendor-outreach reaches an unguarded estate fan-out (vendor-coordination-session.ts:53)no org re-key on all five: tenant-confirmations:221,227 · vendor-outreach:88,90 · collections-dun-reviews:158,162 · renewal-change-reviews:109,113 · new-lease-package-reviews:102,106A for three — review-feeds-read-only-the-callers-buildings.test.ts:227-230 (real repos, partition keys, filter expression). Tenant-confirmations: A on a mocked reader (route.selection.test.ts:235). vendor-outreach: no test pins separation (route.test.ts:127 is the 403 auth case). No control on anyyes at DB for dun / renewal-change / new-lease (collections-dun-review.ts:87 filter; staff branch key condition); none for the other twono (browser table).sort() in each routelive; poll
conversationsclient ConversationsLog → GET /api/conversations?limit=100 (+ cursor on load-more); no server seedyes for org callers PROP#<id> per building conversation.ts:476 plus PROP#UNASSIGNED (route.ts:201); staff with nothing picked: one global recency partition, newest 2,500 (conversation.ts:497)every Property row per poll (route.ts:188). The per-company index conversation-list-index is still dark: written by every meta writer, reader shipped at conversation.ts:533, zero request-path callersno assigned ∩ org ∩ selection route.ts:188-194; post-read :428-429A route.reads.test.ts:365, 372-374 (real readers, a DynamoDB meter). Executed differential control :390-408: the other company’s data quadruples and the caller’s read ledger must not move — the only differential control among the twenty; every other control is a positive or vacuity checknone (staff cap trims the tail)no findIndex/slice over a re-read set (route.ts:554-560); every load-more re-reads every partition.sort() :538-543live; poll
work ordersSSR → loadScopedWorkOrders; poll GET /api/work-orders?view=meta 5s (+ /api/dashboard/stats once)yes PROP#<id>/WO# maintenance.ts:103 via businessDataScopeOf: staff with nothing picked reads nothing; the poll refuses needs_a_selection (route.ts:104-114)every Property row (load-work-orders-list.ts:119) — its own test declines to pin that read (:398-402). The stats tile on the same page uses computeScopeOf, the opposite staff ruleno businessDataScopeOf route-scope.ts:315-326; scopeByProperty :143A work-orders-ssr-seed-partitions.test.ts:379-381 on raw command inputs, with an attribution guard that fails on any read the extractor cannot classify. Positive control :412-428. Route: route.selection.test.ts:139noneno.sort() :146live; 5s
collectionsSSR → loadScopedCollections; poll GET /api/collections 30syes four keyed reads per building — occupancies occupancies.ts:211, units property.ts:950, balances property.ts:2014, cases collections-case.ts:450 — plus a conditional bounded signal read; plus one live PMS HTTP call per buildingevery Property row (:507). No selection leg at all: the bound is grant ∩ org + the TopBar cookie. Staff → whole estate, four reads per building per pollno load-collections-list.ts:512-516; a named id outside scope answers empty (:528-529); no caller → empty (:495-505)A collections-list-read-fanout.test.ts:354-358, 370 — the other company’s partitions never touched. No control; the SSR page itself has no testJS after the readno — BALANCE_DESC_WHOLE, nextCursor: null (:598).sort() :560-565, merged in memorylive; 30s; signal read cached
costs/costs redirects to /maintenance/costs; SSR → loadCostData; poll GET /api/costs 30syes PROP#<id>/SIGNAL# per visible building (analytics.ts:668); a named id is checked before any read (load-cost-data-core.ts:141)every Property row (:135); staff → every building’s whole signal partition each pollno filterVisibleProperties visibility.ts:87-94 (org + assigned)A api-routes-costs.test.ts:543, 556 with control :577-583. Route only; the SSR seed is unpinnedJS after the read (:148-160) although the sort key carries extractedAtno.sort() :194live; 30s
routineSSR → loadScopedMaintenanceManuals; poll GET /api/maintenance-manualsyes — as a DB filter, not a key constant partition GSI3PK='MaintenanceManual' with FilterExpression #faf IN (…) of the caller’s bound (maintenance.ts:872-878, helpers.ts:2308-2324): capacity is charged on the whole partition, only in-bound rows cross the wireevery Property row on both legs (load-maintenance-manuals.ts:131, route.ts:176). Seed/poll asymmetry for staff: seed reads nothing (:126), the poll reads the whole index unfiltered (route.ts:122→131→188→192), and route.org-isolation.test.ts:159-170, 309-325 pins that as intendedno seed businessDataScopeOf; route three-leg intersect route.ts:176-183 + canViewProperty :104-106A routine-manuals-ssr-seed-bound.test.ts:213-217, 253-255 (refuses an unfiltered query outright). Controls: :323-333, route.selection.test.ts:214-218, api-routes-property-visibility-sweep.test.ts:377-386n/anonone — incidental index order (manual id ascending)live; poll
mass sendsclient; GET /api/mass-sends[?propertyId] once, then 3s only while a row is in flight; no server seedyes PROP#<id> per building mass-comms-draft.ts:85; roster by BatchGet of the caller’s own ids (route.ts:143)staff with nothing picked → the picker roster (every Property row via loadPropertyOptions). “40 requests → 1” was HTTP: at the database it is still one keyed query per building (route.ts:274-278)no route.ts:93-96 (named id must be in the grant), :112 (∩ selection). The named-id arm has no org leg when nothing is proposed — the bound is assignedPropertyIds alone, which for an org admin is their own rosterA route.selection.test.ts:260, 341, with two lines named in the source as the ones that red on the regression (:337-341, :385-387) and a vacuity control (:184)noneno — LIVE_FIRST_ACTIVITY_DESC_WHOLE, nextCursor: null (:296).sort() ledger-rows.ts:230-237live; 3s while sending
dashboardclient; provider polls GET /api/dashboard/stats + GET /api/leasing/stats 30s; DashboardHome adds metric-history (one per split property), three more leasing recounts, report-datamixed six entity readers keyed PROP#<id> via readOverScope (load-inputs.ts:292-333); tenants and renewals are estate-wide (:326, :341-343 → property.ts:998, leasing.ts:1244-1279) and narrowed in memory (:418-421)every Property row twice (stats/route.ts:115, load-inputs.ts:298); every occupancy and renewal row of every company per portfolio request. The sibling leasing loader bounds all threeno three-leg chain stats/route.ts:115-128 (assigned ∩ org-resolved ∩ selection); named id :192-196. /api/leasing/stats on the same page has no org leg (leasing/stats/route.ts:329-335, self-declared unverified-legacy)A at the scope boundary route.propertyid-scope.test.ts:181 (an org-admin caller); A at the loader load-inputs-scope-fanout.test.ts:210, 233-243; positive controls route.tab-scope-wins.test.ts:176, 185, 202. No end-to-end test that a rendered count excludes the other company; the estate reads are unpinnedJS; the NOI leg at DB (load-inputs.ts:526-531)n/a (aggregate)n/a30s server cache, scope-keyed (route.ts:251) + browser max-age=5, swr=30: a tile can be ~35s old; leasing tiles ~94s
leasing homeclient; the provider’s two 30s polls + its own windowed /api/leasing/stats recount (leasing/page.tsx:119, cached in useState)yes every read keyed per building, roster included (load-leasing-stats.ts:147-200; roster = one GetItem per building :195-200). The best-bounded loader in the setnone for an org caller; staff → estateno leasing/stats/route.ts:334-335 = assignedPropertyIds ∩ selection — no org leg (:329-333); separation rests on the org admin’s Set being their own rosterA at the partition level load-leasing-stats.query-partitions.test.ts:304, 334 with three named positive controls (:348, 415, 452). Nothing pins the org-leg absenceJS except renewalHorizonDays, which reaches the readn/an/atwo stacked 30s TTLs + browser swr: up to ~94s (route.ts:62-69)
maintenance homeclient; zero fetches of its own — reads the provider context (maintenance/page.tsx:30); exact-match consumer at poll-gate.ts:50inherits the dashboard and leasing-home rowsinheritsno inherits both routes’ chainsno test of its own (dashboard-poll-gate.test.ts is a drift guard on who polls); separation is pinned at the two routes it readsinheritsn/an/asessionStorage hydrate + 30s provider
settingsclient OrganizationSettings: GET /api/settings (prefill), GET /api/team, /api/auth/me, /api/properties, billing, integrations; no pollno — in memory only /api/settings reads one shared global row getItem(CONFIG, SETTINGS) (settings.ts:39) and rebuilds an allowlist for an org caller (route.ts:112-120, 152-158, 180); /api/team reads every user on the platform (user.ts:24) and filters (team/route.ts:63)as leftno settings/route.ts:180 (staff fork) + :189 (own Organization, resolved by spine id); team/route.ts:60-63/api/settings: B settings-own-company.test.ts:154-161, route.org-isolation.test.ts:99-100; no control. /api/team: no test pins separationn/ano (team is a full walk)nonelive; no poll
vendorsSSR → loadVendorsList; island GET /api/vendors on scope change (same loader via read-vendors-list.ts:59-65)yes membership VMEM#<org> per selected org (memberships.ts:393-404). The card read is every vendor card in the table (property.ts:1645-1648) by design: under ADR-0033 a card carries no per-company data, held by two drift guardsevery card (design); staff → every org’s memberships (:214-218)no load-vendors-list.ts:178 → :186 (scopeVendors). A null caller becomes scopeOrgs = null, the staff path (page.tsx:32, read-vendors-list.ts:49-53) — behind session middlewareA vendors-engagement-is-the-boundary.test.ts:161-162 on partition keys. Control: scripts/mutation-controls/vendors-engagement-bound.control.tsn/anononelive; refetch on scope change
renewals board/leasing/renewals/board redirects; SSR renewals/(list)/page.tsx → decideBoardScope → loadBoardData(propertyId); no GET poll (POSTs + router.refresh() re-enter the gate)mixed 8 of 9 reads keyed on the one gated building. Four estate-wide queries narrowed in memory: sagas twice (live.ts:71 → renewal-saga.ts:317; leasing.ts:2043), every Property row (leasing.ts:1279), every renewal projection (leasing.ts:1457) — because load-renewals.ts:49 passes no propertyIdsas left, for every caller once a building is namedno one gate: page.tsx:66-68 decideBoardScope + canCallerViewProperty (visible-property.ts:103-110). Staff with nothing picked → the pick-a-property prompt, zero reads. loadBoardData applies no scope of its ownA (list)/page.test.tsx:161-167 — the loader is never called for the other company’s building, and the gate is named as what refused. Control: scripts/mutation-controls/renewals-board-url-property-gate.control.ts (the URL gate only; the estate reads’ narrowing is unpinned)decisions at DB (sort-key range + cap, renewal-eligibility-decision.ts:329-335); renewals in JS (leasing.ts:1618)no.sort() (board/_components/data.ts:372, 443…)live; re-render on action
claraclient; GET /api/cost-savings/summary[?propertyId] (+ /api/properties/options); no pollmixed roster by BatchGet on the caller’s own ids (cost-savings/index.ts:897) and per-building reads keyed; but getAllSagas() is estate-wide on every portfolio request (:945), narrowed per building in memoryevery company’s renewal sagas; staff → getProperties() (:894)no callerPropertyScope (null caller → empty set) route.ts:51 ∩ selection :80; named id 403 :92-97A on the bound set route.selection.test.ts:210-211, 228-229 with a vacuity control (:109-115). Control: scripts/mutation-controls/clara-cost-savings-caller-bound.control.ts (caller bound only; the saga narrowing is unpinned)JS (:343)no; a 5-minute in-process cache instead (route.ts:15-22, 104-114)n/a5-minute cache

Half 2, answered directly: can an admin at company 1 ever see company 2’s rows?

On these twenty pages, no — and here is the shape of the guarantee, so you know what it rests on.

Corrections to the working matrix of 2026-09-21

Every place this verification disagreed with the prior night’s table. The two that change its headline come first.

What should become board rows

None of these is a customer seeing another customer’s data; nothing here met the interrupt bar. Ordered by how close each sits to that line.

  1. Renewals board: same-company cross-building saga misattribution, and the test for it exercises the other path. leasing.ts:2066 filters sagas by scopeIds, which is undefined on the board’s positional call (load-renewals.ts:49), so the filter is inert; the file’s own header (:1957-1967) describes the resulting defect: a resident at two of the same company’s buildings can have the unselected building’s renewal state rendered on the selected one. renewals-saga-attribution.test.ts covers only the opts.propertyIds path (:493, 554, 663, 731). A Person carries one organizationId (persons.ts:10), so this is within one company — a correctness bug, not a separation one.
  2. /api/vendor-outreach: no separation test, and a live unguarded estate fan-out for staff. Org re-key is present (:88-90); nothing pins it; vendor-coordination-session.ts:53 is reachable when effectiveQueryScope is null.
  3. /api/team: every user on the platform read and filtered in memory, no test. user.ts:24 → team/route.ts:63. And /api/settings’ allowlist (route.ts:112-120) has no mutation control.
  4. /api/leasing/stats has no org leg (:329-335, self-declared) and the named-id arm of /api/mass-sends has none when nothing is proposed (:93-96, 112). Both rest on the org admin’s Set being their roster. Defence in depth owed.
  5. Prospects: an un-narrowed ?propertyId= reaches two reads before the scope intersect when no selection is proposed (route.ts:161-163): a point read of the foreign building (prospect-record-owner-fetch.ts:78, discarded at :86-92 with a warning log naming the id) and, for an AppFolio-owned caller, a GSI query on the foreign building’s partition (load-prospects.ts:563, narrowed at :566). No foreign row is returned.
  6. Vendors and turnovers hand a null caller the wide path (vendors/(list)/page.tsx:32 + read-vendors-list.ts:49-53; load-turnovers-list.ts:94). Both are behind a gate (session middleware; the admin bearer), both documented; both are the shape read-over-scope.ts:120-141 names as the cause of a measured incident on /properties.
  7. Routine: seed reads nothing for staff, poll reads everything, and a test pins the poll. route.ts:122→192; route.org-isolation.test.ts:159-170, 309-325. Staff-only; the two doors implement two rules.
  8. Three suites claim a mutation control “in the PR body” (tours, calendar, the three review feeds). That is a claim about a PR, not a test in the tree. Executed controls exist for: conversations (in-suite), work orders, routine, costs, mass sends, properties (visibility), dashboard, leasing home, vendors, renewals board and clara (the last three as registered scripts/mutation-controls/*).
  9. Thirteen pages read every company’s Property rows on every request, for every caller (property.ts:202): prospects, tenants, turnovers, work orders, collections, costs, routine, tours, calendar, conversations, dashboard (twice), three review feeds, renewals board. Four more do it for staff only (mass sends, leasing home, clara, vendor-outreach). Only properties uses the roster partition. One change, thirteen wins.
  10. Zero pages paginate at the database. Prospects is the copy to make (adopt the chunk the envelope already carries); conversations needs its dark index wired first; everything sorted in memory needs a clock-keyed projection before a Limit means anything.

Not measured

Absence of a cell is not a pass. These were on or near the request path and were not traced to their database call: the name-heal spine reads on tours and calendar; the calendar move-in BatchGet of profile rows; EventRow’s per-row messages fetch; the settings child cards (email, calendar, checklist, BCC, on-call); routine’s modal reads of /api/properties and /api/vendors; prospects’ per-row buildRow sub-reads; collections’ tenant-name and occupancy maps; clara’s /api/properties/options; the dashboard’s per-property metric-history fan-out (traced only briefly). The browser-side questions (skeletons, re-renders, debounce, lazy loading) were not audited on any page. No production latency was measured; every number above is a count of reads, not a timing.

Method: the request path was read, not the loader symbol — every route the browser fetches was enumerated per page and traced to its DynamoDB command. Every test cited was opened and its assertion read; a test was classed A only where it asserts on the partition key or the arguments the reader received, never on the response alone. git grep -P throughout. Nothing was written to any environment.

PropFlow Docs