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.
“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.
src/lib/platform/auth/scope.ts:105-110 getUserPropertyScope returns null (every building, every company) for platform_admin only. Every other role, org_admin included, gets a real Set. An org admin’s Set is filled once per request with their own company’s roster at src/lib/platform/auth/helpers.ts:530-554 (getProperties(organizationId)). The file’s own header records the 2026-09-07 bug this closed and warns that an all-buildings grant expressed as null would be cross-tenant.src/lib/domain/scope/selection.ts:470 deriveSelection intersects any proposed org or building with the caller’s grants; ids outside the grant are dropped; an empty result falls to the caller’s default org, never to “everything”. Fleet (org=*) is reachable only by staff.src/lib/domain/dashboard/scope/route-scope.ts: computeScopeOf (:257, staff → null), businessDataScopeOf (:315, staff with nothing picked → empty set, no read), calendarReadScopeOf (:352, staff → null). Which one a page calls decides what a platform admin with nothing selected reads. Only work orders, routine (seed) and tenant-confirmations use the empty-set reading; every other page hands staff the whole estate.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.
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.
| page | request path | scoped at the database? | reads wider than the answer | can org 1 see org 2? enforced at | test that pins it | window at DB | paginated at DB | order | freshness |
|---|---|---|---|---|---|---|---|---|---|
| prospects | SSR leasing/prospects/(list)/page.tsx → loadScopedProspectsPage; poll GET /api/leasing/prospects 5s | yes 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 partition | every 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 :566 | no assigned ∩ org ∩ selection at load-prospects.ts:675-681; foreign-org rows inside a partition dropped at inquiries.ts:1200; unresolvable caller → empty set :675 | A 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 bound | no — 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-1071 | index (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 |
| tenants | SSR tenants/(list)/page.tsx → loadTenantsList; island fetches GET /api/tenants once on mount, same loader, no poll | yes, by building GSI1PK=PROPERTY#<id>, occupancies.ts:211-216, one query per assigned building | every 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 ship | no 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 control | none | no — full walk, bare array | none in the loader; the client table sorts | live; refetch on scope change |
| properties | SSR → loadPropertiesList; poll GET /api/properties 5s | yes roster PROPORG#<org> (property.ts:364) then PROP#<id> child reads (helpers.ts:1704-1706). The only page that uses the roster partition | none for an org caller; staff → whole estate | no 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) | none | no | none | live; 5s |
| turnovers | SSR → loadScopedTurnovers; poll GET /api/turnovers?view=meta 5s | yes PROP#<id>/TURNOVER#, turnover.ts:56 | every 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) | none | no | .sort() :127 (payload stability; the table re-sorts) | live; 5s |
| tours | SSR leasing/tours/page.tsx → loadTourCalendar; no poll — the client has zero fetches | yes 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-172 | A 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 tree | none — all history | no | .sort() tour-day-view.ts:173 | live; navigation only |
| calendar | SSR → 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 tours | every 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-164 | A twice: calendar-pages-read-only-the-selection.test.ts:251 and load-building-calendar.test.ts:129-133. Control: PR-body claim only | none (a day-ranged index exists for vendor events and is deliberately unused, :142-149) | no | .sort() building-calendar.ts:515, 532 | live; refetch on switch |
| review queues | client; five routes in one Promise.all (review/page.tsx:512-534), polled; no server seed | yes 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,106 | A 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 any | yes at DB for dun / renewal-change / new-lease (collections-dun-review.ts:87 filter; staff branch key condition); none for the other two | no (browser table) | .sort() in each route | live; poll |
| conversations | client ConversationsLog → GET /api/conversations?limit=100 (+ cursor on load-more); no server seed | yes 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 callers | no assigned ∩ org ∩ selection route.ts:188-194; post-read :428-429 | A 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 check | none (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-543 | live; poll |
| work orders | SSR → 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 rule | no businessDataScopeOf route-scope.ts:315-326; scopeByProperty :143 | A 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:139 | none | no | .sort() :146 | live; 5s |
| collections | SSR → loadScopedCollections; poll GET /api/collections 30s | yes 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 building | every Property row (:507). No selection leg at all: the bound is grant ∩ org + the TopBar cookie. Staff → whole estate, four reads per building per poll | no 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 test | JS after the read | no — BALANCE_DESC_WHOLE, nextCursor: null (:598) | .sort() :560-565, merged in memory | live; 30s; signal read cached |
| costs | /costs redirects to /maintenance/costs; SSR → loadCostData; poll GET /api/costs 30s | yes 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 poll | no 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 unpinned | JS after the read (:148-160) although the sort key carries extractedAt | no | .sort() :194 | live; 30s |
| routine | SSR → loadScopedMaintenanceManuals; poll GET /api/maintenance-manuals | yes — 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 wire | every 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 intended | no seed businessDataScopeOf; route three-leg intersect route.ts:176-183 + canViewProperty :104-106 | A 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-386 | n/a | no | none — incidental index order (manual id ascending) | live; poll |
| mass sends | client; GET /api/mass-sends[?propertyId] once, then 3s only while a row is in flight; no server seed | yes 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 roster | A 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) | none | no — LIVE_FIRST_ACTIVITY_DESC_WHOLE, nextCursor: null (:296) | .sort() ledger-rows.ts:230-237 | live; 3s while sending |
| dashboard | client; provider polls GET /api/dashboard/stats + GET /api/leasing/stats 30s; DashboardHome adds metric-history (one per split property), three more leasing recounts, report-data | mixed 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 three | no 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 unpinned | JS; the NOI leg at DB (load-inputs.ts:526-531) | n/a (aggregate) | n/a | 30s server cache, scope-keyed (route.ts:251) + browser max-age=5, swr=30: a tile can be ~35s old; leasing tiles ~94s |
| leasing home | client; 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 set | none for an org caller; staff → estate | no leasing/stats/route.ts:334-335 = assignedPropertyIds ∩ selection — no org leg (:329-333); separation rests on the org admin’s Set being their own roster | A 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 absence | JS except renewalHorizonDays, which reaches the read | n/a | n/a | two stacked 30s TTLs + browser swr: up to ~94s (route.ts:62-69) |
| maintenance home | client; zero fetches of its own — reads the provider context (maintenance/page.tsx:30); exact-match consumer at poll-gate.ts:50 | inherits the dashboard and leasing-home rows | inherits | no inherits both routes’ chains | no test of its own (dashboard-poll-gate.test.ts is a drift guard on who polls); separation is pinned at the two routes it reads | inherits | n/a | n/a | sessionStorage hydrate + 30s provider |
| settings | client OrganizationSettings: GET /api/settings (prefill), GET /api/team, /api/auth/me, /api/properties, billing, integrations; no poll | no — 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 left | no 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 separation | n/a | no (team is a full walk) | none | live; no poll |
| vendors | SSR → 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 guards | every 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 middleware | A vendors-engagement-is-the-boundary.test.ts:161-162 on partition keys. Control: scripts/mutation-controls/vendors-engagement-bound.control.ts | n/a | no | none | live; 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 propertyIds | as left, for every caller once a building is named | no 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 own | A (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 |
| clara | client; GET /api/cost-savings/summary[?propertyId] (+ /api/properties/options); no poll | mixed 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 memory | every company’s renewal sagas; staff → getProperties() (:894) | no callerPropertyScope (null caller → empty set) route.ts:51 ∩ selection :80; named id 403 :92-97 | A 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/a | 5-minute cache |
On these twenty pages, no — and here is the shape of the guarantee, so you know what it rests on.
getUserPropertyScope gives null to platform_admin only; an org admin’s Set is their own company’s roster, resolved from the Organization’s own building rows (helpers.ts:530-554). Every page above either keys its reads on that Set or filters against it before answering./api/leasing/stats (serving the leasing home, dashboard and maintenance home tiles) and the named-id arm of /api/mass-sends. They are separated because the Set is the roster; a corrupted assignedPropertyIds entry naming another company’s building would be filtered by the dashboard’s chain (stats/route.ts:117-118, pinned at route.propertyid-scope.test.ts:166-181) and not by those two. Defence in depth, not exposure.settings/route.ts:112-120) plus an org filter (team/route.ts:63) decide what leaves. That is the class of guard that stays correct only while someone re-reads it; no mutation control proves it can fail.getProperties()): another company’s rows are held in the request process and dropped before the response. Correct today; each is one deleted filter line from a leak, and only conversations has an executed control that would notice.null scope, not a customer-separation finding.Every place this verification disagreed with the prior night’s table. The two that change its headline come first.
Limit and ExclusiveStartKey are all real and correct (inquiries.ts:1174-1183). Nothing on the request path asks for a chunk: seed page: null (page.tsx:143), route opt-in by name (route.ts:88-89), island sends neither param (ProspectsClient.tsx:683-686). The real read is the walk at load-prospects.ts:1054-1071. Correct cell: capability built, exercised by nothing./api/vendor-outreach has no separation test (its only not.toHaveBeenCalled is the 403 case). Settings: /api/team has none, and /api/settings’ tests are output-shape (class B). Nothing was found not separated.prospect-list-page.ts:300); the date window is not a database bound; the AppFolio-mirror arm still runs the retired every-building fan-out on the all-properties view (:563)./api/leasing/tours is not on this page’s path.readOverScope call sites; the page reads seven things and vendor events are keyed by a hand-written intersection, not readOverScope.getTenantConfirmations (tenant-confirmation.ts:42, unreachable from its route) and getVendorCallSessions (vendor-coordination-session.ts:53, reachable for staff with nothing picked). Also: the page’s comment at review/page.tsx:494-499 says the confirmations fetch is unscoped; the code at :514-516 sends the scope.conversation.ts:533) with zero request-path callers. Cell: reader shipped, unwired.:398-402).FilterExpression on a constant partition) for every caller with an org bound; the staff poll is completely unfiltered while the staff seed reads nothing, and a test pins the unfiltered poll as intended. Nothing sorts the manuals at any layer.page.one-read.test.tsx:142-149); at the database it is still one keyed query per building. The named-id arm has no org leg when nothing is proposed.route.propertyid-scope.test.ts:166-181 is an org-admin case. It is partial because the compute is mocked at the route and no test renders a count. The estate-wide roster read is two reads, and tenants and renewals are estate-wide too./maintenance/work-orders, /turnovers, /costs are separate rows).companyName refilled from the caller’s own Organization. “Keyed point reads” are to a single global row, not a per-org partition. The page’s biggest read (/api/team, every user) was not in the cell.loadRenewalPolicyView, live.ts:213); four estate-wide queries, not one, because getRenewals is called positionally with no propertyIds. The mutation control is real and covers the URL gate only. No GET poll exists on this page.route.ts:51/80, BatchGet on own ids, one estate-wide saga read, class-A test with a registered control.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.
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./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./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./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.?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.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.route.ts:122→192; route.org-isolation.test.ts:159-170, 309-325. Staff-only; the two doors implement two rules.scripts/mutation-controls/*).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.Limit means anything.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.