A customer with no properties is shown another customer's dashboard numbers. Here is the path, why our September isolation work missed it, the two fix shapes, and how to prove the fix.
Sep 8, 2026 · author: session 007 for Fede · status: Handoff, fix not started, shape decision pending · for Gera and Sean
Related. The wider isolation picture lives on Multi-tenant isolation: architecture decision, which lists this as an open item. The onboarding walkthrough carries the full raw investigation under Epic: every dashboard number belongs to the company looking at it. This page is the handoff: what to build, and how to know it worked.
A brand-new customer with no properties yet opens their dashboard and sees real numbers instead of zeros. Those numbers belong to a different customer, Customer A, because a nightly job writes one shared summary row for the whole deployment and nobody's name is attached to it. The dashboard asks for that shared row whenever no property is selected, which is always true for a customer with no properties, and the code skips the ownership check on purpose because there is no company id on the row to check. It is wide: about sixty numbers on the dashboard plus the live gauges, covering work orders, rent, occupancy, past due, conversations and renewals. It is counts and charts only, so no tenant names, addresses, phone numbers or dollar-level records reached the screen, and the Turnovers, Collections and Leasing pages are all correctly scoped. Next: Fede picks the fix shape, we build it dark, test it on a test company, and Fede decides separately whether it goes on at a customer.
flowchart TD A["Nightly snapshot job
lambda/metric-snapshot/handler.ts:325-343"] -->|"fetchSnapshotInput(null) = whole table,
no org argument"| B["One shared summary row
PROP#PORTFOLIO / METRIC#date
~140 keys, no organizationId"] B --> C["Dashboard summary feed
/api/dashboard/metric-history"] B2["Live table reads
conversations, renewals, properties"] --> D["Live gauges feed
/api/dashboard/live"] C --> E{{"propertyId === PORTFOLIO ?"}} E -->|"yes: ownership check SKIPPED"| F["Any company with
no property selected"] E -->|"no: canViewProperty runs"| G["Scoped, correct"] D --> H{{"propertyId named ?"}} H -->|"no: whole deployment read"| F H -->|"yes: canViewProperty runs"| G F --> I["~60 dashboard numbers +
agent-ops gauges, polled every 10s"] style E fill:#B3261E,color:#fff,stroke:#B3261E style H fill:#B3261E,color:#fff,stroke:#B3261E style F fill:#B45309,color:#fff,stroke:#B45309
The two red diamonds are where the ownership check is skipped. Both are the "no property id was named" branch, which the September work never examined.
| Feed | Route | What it carries | Who sees it | Evidence |
|---|---|---|---|---|
| Daily summary series | /api/dashboard/metric-history with no property selected | ~60 metric keys from METRIC_METRIC_KEYS; on stage today 22 open work orders, $299,100 monthly rent, 89.66% occupancy, $48,196.84 past due, 78 delinquent tenants, 328 Clara escalations | Every login with no property selected | route.ts:207-248; analytics.ts:711-719 |
| Live gauges | /api/dashboard/live with no property named | Clara conversations active, calls in progress, escalations pending, renewal runs in flight, AppFolio last-sync across every property | Same, polled every 10 seconds | live/route.ts:52-115, esp. :82-90, :101-111, :116 |
Tiles on the shared row: five consumer groups. (1) Maintenance Created (30d) and Resolved (30d) numbers, DashboardHome.tsx:542,546, the reported defect. (2) Occupancy, Rents and NOI sparklines, :1463,1483,1510; the org-scoped monthly series normally replaces these via stripMonthlyServedKeys, but a company with zero properties has zero monthly rows, so nothing is stripped and all three fall back to the unscoped daily series. (3) The Metric Summary Table trend column, :1632, one unscoped trend line per row. (4) Every expanded metric inspector and chart hero, :1106, useInspectorHistory.ts:83, use-property-override-series.ts:41, all defaulting to PORTFOLIO_METRIC_ID. (5) Agent-ops cards, agent-ops/data.ts:6,38-62, about 21 keys.
Structural. Any future route that reads a pre-rolled aggregate, or treats "no property id supplied" as "unrestricted", inherits this.
| Surface | Verdict | Why | Caveat |
|---|---|---|---|
| Maintenance → Turnovers | clean | One feed. loadScopedTurnovers intersects property scope with org scope before returning (load-turnovers-list.ts:55-91); all four tiles and the tab counts are pure functions of that scoped set. Empty scope means show nothing (scope.ts:63). | The shared loader fails open on an unresolvable caller. What holds it shut is a check in api/turnovers/route.ts:25-29. Any new caller of that loader must repeat it. |
| Collections | clean | Scopes org ∩ property before any ledger read, then narrows to the requested property inside that set (load-collections-list.ts:451-495). Fails closed on an unresolvable caller. Per-tenant screens take the property from the occupancy, never the request, and 404 rather than 403. | Not covered by the sweep test at all; protected by its own route tests. |
| Leasing overview | clean numbers | Both cards and the funnel drill-downs are org-scoped (leasing/stats/route.ts:68-69, load-leasing-stats.ts:186, pipeline-members/route.ts:106). Empty company sees zeros. | The register still files both leasing routes as unverified-legacy. Sweep test covers neither. The Renewals card renders for a company that never bought the module, display bug, correctly scoped zeros. |
| Work-orders list API | not involved | The tile never touches it. The number comes from the summary feed. | None |
Structural note across all four: /api/dashboard/stats, /api/leasing/stats and the turnovers loader each write user ? getUserPropertyScope(user) : null, and null means unrestricted. Each is closed today by something outside itself. Collections is the only one that refuses on its own, and is the pattern the others should follow.
src/__tests__/property-scope-surface-registry.drift.test.ts:67-68 lists both dashboard/metric-history/route.ts and dashboard/live/route.ts as guarded-in-route. That verdict came from an earlier IDOR sweep asking one question: can a caller name someone else's property id? For these routes the answer is no. Nobody asked what happens when no id is named.getUserPropertyScope() was found returning null for org_admin. Files touched: auth/scope.ts, auth/helpers.ts, api/activity-log, api/leasing/renewals, api/maintenance-manuals, api/settings, api/team, api/dashboard/stats, middleware.ts, nav. api/dashboard/metric-history/route.ts and the snapshot Lambda appear in none of them. Renewals and manuals did get their omitted-id branch fixed; metric-history's omitted-id branch resolves to PORTFOLIO, which is not a property partition, so no equivalent filter was added./api/dashboard/metric-history was never exercised directly. The RCA files the dashboard leak under /api/dashboard/stats and never names metric-history.src/__tests__/api-routes-property-visibility-sweep.test.ts:253 asserts the current behaviour as correct, "leaves the pre-rolled PORTFOLIO row alone", and :262-272 pin that an explicit ?propertyId=PORTFOLIO returns the same row.requireOrg, assertOrgScope, withOrgScope or orgGuard exists in the repo. What exists is per-route, in-memory filtering after the read: auth/scope.ts, properties/visibility.ts, visible-property.ts. Org filtering in the repository layer is an optional argument, dynamo/property.ts:154-165, getProperties(organizationId?). The edge-binding POC, PR #7160 on fede/poc-org-at-edge, was closed unmerged Sep 6. getMetricHistory (analytics.ts:711) takes a raw property id, no user, no org, no check. And the stored row has no organizationId attribute, so a database-layer guard would have nothing to compare against until the writer changes.The nightly job writes one summary row per org instead of one per deployment. Both feeds read the caller's own row. The deliberate skip of the ownership check goes away, because there is now something to check against.
Touched: lambda/metric-snapshot/handler.ts:325-343 and metrics/snapshots/compute.ts:214-266 (write per org, pass a scope); api/dashboard/metric-history/route.ts:207-248 (resolve the caller's org row, remove the skip); api/dashboard/live/route.ts:52-115 (scope the three no-property reads); dynamo/analytics.ts:711 (org-keyed read); DashboardHome.tsx:1306-1316 and the inspector hooks if the metric id changes shape; the sweep test and the register. Keep the old row written until the reads are switched, then delete it.
Why this one: it is the root cause. Option B leaves a deployment-wide row one query away from any future reader, and it does nothing for a customer who has properties but is looking at the portfolio view, who is served the same shared row today.
Keep the shared row. In both routes, if the caller's property scope is empty, return an empty series. Smaller and faster, but it fixes only the empty-company case and leaves the unscoped aggregate in place.
property-scope-surface-registry.drift.test.ts and record what guarded-in-route actually covers, so the verdict says whether the omitted-id branch was checked. While in there, refile the two leasing routes currently stuck at unverified-legacy.api-routes-property-visibility-sweep.test.ts covers eight routes and none of collections, dashboard/stats, leasing/stats, leasing/pipeline-members, turnovers. Its metric-history case must be inverted: line 253 currently asserts the bug.The name staff type when creating a company is written to the organization record (ORG#<id> / PROFILE, field Organization.name), and the onboarding wizard reads exactly that via api/onboarding/prefill/route.ts:37, which is why the wizard shows the right name. Settings → Company reads and writes a different, per-user field, User.companyName, through GET/PATCH /api/auth/me (OrganizationSettings.tsx:140,149,226), and fires its required-field error at :220. Nobody has ever PATCHed that user, so the field is empty and Settings nags for a name the company already has. The file's own header comment (lines 14-17) admits it: company name is org identity but is stored per user because the organization row has no writer yet, and dynamo/organization.ts:2 confirms the organization repository is read-only. The fix is a writer on the organization record plus pointing Settings at it. Separate from the leak, same onboarding epic.
1. Fix shape. (A) per-company summary row, both feeds read the caller's (recommended, it is the root cause). (B) zeros for empty companies, shared row stays.
2. The shared row today serves customers who do have properties. A customer viewing the all-properties portfolio view is served the same deployment-wide row. (A) treat as part of this fix (recommended). (B) ship the empty-company case first and file this separately.
3. Nightly job disagreement. Per-property rows read workOrdersCreated = 0 on the two dates where the portfolio row reads 1. The two passes disagree about the same day's tickets. (A) fix inside this work, since we are touching the writer (recommended). (B) separate ticket.
4. Fail-open loaders. Three routes read "cannot identify the caller" as "show everything", each closed today by something else. (A) make them fail closed like Collections, in this work. (B) own item on the isolation architecture page (recommended, it is broader than the dashboard).
5. Renewals card for a company without the module. (A) make the dashboard grid consult enabledModules (recommended, small). (B) leave it, it reads zeros.
Repo ~/.claude/propflowai @ bfa841b5 (main), read-only. Live reads on propflow-stage, us-east-1, no writes.
DashboardHome.tsx:542,546,566,1106,1306-1316,1463,1483,1510,1632 · catalog/card-windows.ts:191 · api/dashboard/metric-history/route.ts:110,207-248,325-330 · api/dashboard/live/route.ts:52-115,64,82-90,101-111,116 · dynamo/analytics.ts:711-719 · data/types.ts:14346-14440 · agent-ops/data.ts:6,38-62 · useInspectorHistory.ts:83 · use-property-override-series.ts:41 · lib/data/source-filter.ts:31lambda/metric-snapshot/handler.ts:325-343 · metrics/snapshots/compute.ts:214-266 · dashboard/stats/compute.ts:265,340-349 · metrics/snapshots/agent-metrics.ts:514platform/auth/scope.ts:47-52,63 · properties/visibility.ts · properties/visible-property.ts · dynamo/property.ts:154-165,163 · __tests__/property-scope-surface-registry.drift.test.ts:67-68,69-84,118,120,137 · __tests__/api-routes-property-visibility-sweep.test.ts:253,262,272 · traffic/route-manifest.generated.tsload-turnovers-list.ts:55-91 · api/turnovers/route.ts:25-29 · domain/turnover/list-stats.ts:4-11,14-19 · dynamo/turnover.ts:40-63 · TurnoversClient.tsx:152,222,262,263,264,493 · load-collections-list.ts:451-495,530-560 · api/collections/route.ts:28,38,56 · collections/resolve-account.ts:10-19,64-80 · collections/actions/route.ts:118 · CollectionsClient.tsx:428-446,518 · leasing/stats/route.ts:68-69 · load-leasing-stats.ts:186 · pipeline-members/route.ts:106,112,143,209,216 · dashboard/stats/route.ts:39-40,56-57api/onboarding/prefill/route.ts:37 · settings/_components/OrganizationSettings.tsx:14-17,140,149,220,226 · dynamo/organization.ts:2PK = PROP#PORTFOLIO, SK begins_with METRIC# on propflow-stage: 193 rows. Over the trailing 30 days (2026-08-10 to 2026-09-08) metrics.workOrdersCreated is 1 on METRIC#2026-08-24 and 1 on METRIC#2026-09-04, zero on every other day, sum 2, exactly the tile. Both are genuine Customer A maintenance tickets, created 2026-08-24T04:12Z and 2026-09-04T17:21Z. Positive control: on METRIC#2026-09-08 the portfolio row and Customer A's own property row both read openWorkOrders = 22. Stage holds 5 properties across 3 orgs; the 3 test-flagged ones are excluded from the rollup, so the shared row is in practice Customer A's numbers. Turnovers on stage: 70 rows across two properties, none reachable by a new company's scope. Prospects: 129 rows, Renewals: 0.