PropFlow · Read-only audit · Aug 13, 2026
Cross-Property Leak Audit
Can one property's facts reach another property's conversations? Yes — three live paths, two of them crossing between customer organizations. Audit run against origin/main at cb0fdb8c, night of Aug 13, ordered by Fede. No code was changed by the audit itself.
Status
Held fix PR in flight for the three leak paths — pending Fede's morning review. Trello 4XfnI8gO.
The proposed CI fences are deliberately not in that PR: they would land red against today's main. Fences ship after the fixes.
The verdict, up top
- Property A's facts can reach property B's conversations — three live paths. Two of them cross organizations, meaning one customer's data can surface in another customer's conversation. The third stays inside JP&Co but is live today.
- The prompt contamination is real but cosmetic by comparison. Camellia's name, address, a buzz code and a Denver-shaped worldview are baked into shared prompts. That's an expansion problem and a hallucination vector — it is not how data escapes. Cleaning every prompt would close none of the three leaks.
- The codebase is not sloppy; the principle just isn't enforced. The rule is written down in
CLAUDE.md — with the literal anti-example — and the property knowledge store is safe by construction. But nothing in CI checks the rule, so it erodes at the edges. That is the whole shape of this finding.
The worst scenario, told plainly
Someone calls Camellia's phone number. Clara doesn't recognise the number, so she asks who's calling. The caller says a name. That name happens to partially match a resident at Yale 25 — a different property. Clara then reads that stranger's email address, phone number, unit and lease dates back down the line, and the conversation quietly reassigns itself to Yale 25 for everything that follows.
Nothing here is a bug in the ordinary sense. Every step does what it was written to do; the chain is the problem:
- Soft verification is on for voice caller identification.handle-identify-caller.ts:135
- To find the caller, it loads every tenant at every property — the roster read takes no property or organization argument at all.handle-identify-caller.ts:140
- The last-resort match is on a substring of the caller's name. Nothing else. No phone, no property, no organization.handle-identify-caller.ts:172-175
- The match returns contact details: email, phone, unit, lease dates.handle-identify-caller.ts:66-91
- The dispatcher then overwrites the conversation's property with the matched tenant's property. There is no check that the two were ever the same place.api/voice/tools/[tool]/route.ts:665-667
- And the triage prompt actively steers unrecognised callers into exactly this path.
This one is verified end to end, it involves personal contact information, and it can cross between customer organizations. It is the reason the fix PR is held for review rather than queued behind the prompt cleanup.
Runner-up — property search, fleet-wide, across every customer
The property-search tool scans the entire fleet across all organizations on a query string. A confident match hands back another customer's address, website and office hours, with instructions not to ask for confirmation. An ambiguous match returns every property in the fleet, full addresses included.
The same unscoped roster backs the property_ref argument, which by documented precedence outranks the conversation's own property across twelve leasing tools — a TODO(scope) that has been open since PR #1116. The root cause is small and structural: there is no organization-scoped properties reader anywhere in the repo. The interface method takes no argument, so no caller can scope it even if they wanted to.
Live inside JP&Co today — the unknown-caller replay
When an unrecognised number calls, Clara builds context from that person's recent messages — filtered by person and date, but not by property. So a vendor who left a message on Camellia's line gets it replayed back to them on their next Yale 25 call. The person lookup itself is organization-scoped, so this one cannot cross between customers; it is nonetheless wrong every day inside the portfolio.
And one asymmetry worth its own line
Voice on an unmapped number fails open: the property comes out empty, which skips the tenant-mismatch guard entirely, and the property is then taken from whatever the caller says. SMS in the same situation fails closed. Two channels, opposite defaults, on the same safety question.
Leak-path verdicts
| Path | Verdict | Why |
| Property knowledge store | safe by construction | Keyed by property; cross-property reads are impossible, not merely discouraged. |
| Voice property resolution at ring | safe by construction | The dialled number beats caller identity. |
| Announcements · voice context caches · person-claim cache | safe by construction | Scoped correctly at the read. |
| SMS on an unmapped number | safe by construction | Fails closed. |
| Voice agent dynamic variables (~60) | safe by convention | Correct today, but only the vendor-outbound agent has a drift test, and the drift workflow is report-only. |
| Caller identification | leak possible | The worst path — unscoped tenant roster, name-substring match, property overwritten. Crosses orgs. |
Property search / property_ref | leak possible | Fleet-wide scan across all organizations; outranks the conversation's own property. |
| Unknown-caller episode context | leak possible | No property filter; org-wide. Live inside JP&Co today. |
| Voice on an unmapped number | leak possible | Fails open where SMS fails closed. |
| Empty concessions rendering | leak-adjacent | An empty specials field renders as a confident "no specials" rather than "I don't know." |
Violation inventory — the migration checklist
P0 · wrong facts the moment we add a property
Four things that break on the next building we onboard
- The phone-to-property routing spine is a hardcoded map. It belongs on the property record's inbound numbers. large
- A Denver timezone default, 53 times over — tour dates, availability, calendar, voice, the output gate. The property's timezone should be required, with no fallback at all. large
- Quiet hours default to a second, conflicting fleet timezone, and the comment next to it wrongly claims it matches Camellia. medium
- A literal door buzz code and specific concession unit numbers sit in the voice leasing prompt. Both belong in property policies and concessions. small
P1 · one property's identity inside shared prompts
Camellia is written into prompts every property uses
- "Apartment communities in Denver, CO" is in the core personality across all five entry points, and duplicated in three voice prompts.
- Camellia's name and address appear in the tour few-shot examples.
- A tool schema teaches "Camellia" as the example property reference — which feeds the property-search leak above.
- A Camellia incident note sits in the live triage prompt.
- The insights route hardcodes Camellia's name and mailbox into a prompt used for any property.
- A known-properties customer-name list is compiled into the classifier.
- Per-property truths are asserted as universal (a purchase order counting as authorization).
- Camellia's amenity cue-map — bike shop, rooftop, fire pit, cyber cafe — is a hallucination vector anywhere else.
P2 · config-as-code that blocks onboarding
Lists that require a deploy to add a customer
- Which properties use the data API, the listings config, and the property-to-Clara-number map are all source files.
- The voice tool definitions ship real example IDs and a real person's name.
Verified clean, and worth saying out loud: all ~60 voice variables, office hours, fees, concessions and lease policy already come from the property knowledge base and were confirmed correct. The free-month renderer even refuses to supply a default on purpose. The knowledge base is doing its job — the leaks are all in code paths that go around it.
Two proposed CI fences
Both ship after the fixes. Landed today they would simply be red, and a red fence teaches people to skip fences.
Fence A — no property identity in prompts
A forbidden-literal scan over prompt sources only, extracting string and template-literal contents first — comments carrying incident history are legitimate and must survive. Modelled on the existing org-default-elimination drift test.
Fence B — no unscoped fleet reads
Fail any fleet-wide properties or tenants read outside an allowlist that has to carry a written rationale, and make the organization argument required on the properties reader. This fence alone would have caught all three leaks.
What fences cannot cover: the vendor-outbound and emergency-relay voice agents have no automated sync path at all — the drift workflow only reports. That gap needs its own decision, separately from this audit.
Fix order
- The three leak paths — live, and they involve personal contact information. This is the held PR.
- An organization-scoped properties reader, with the argument required. This is the structural root of two of the three.
- P0: the timezone and routing config — the things that produce wrong answers the day we add a property.
- Prompt cleanup — the Camellia contamination.
- The fences, last, so they land green and stay meaningful.
Where this came from
This audit was ordered on the night of Aug 13, out of the ruling in Escalation & the Applicant Journey — Diagnosis and Rethink that property-specific content lives in the property knowledge base and not in prompts. That ruling also fixes a hard wall into the escalation design: what Clara learns at one property can never surface at another. This audit is the check on whether that wall stands today in the code we already have — and the answer is that the wall is real where the knowledge base is involved, and missing wherever a code path reads the fleet directly.