The Identity Split: One Human, Two Records
Audit synthesis — 2026-08-13 · PropFlowAI identity spine · Camellia + Willows bench
Status of every number below: prod DDB reads (propflow-prod, us-east-1) and repo reads at HEAD 1f9ebe7ec. Every claim was independently re-verified by an adversarial pass; corrections are applied inline and the material ones are flagged.
The short version
PropFlow is supposed to know a person by one permanent id, no matter how they show up. A renter who emails about a listing, applies, signs, and moves in should be the same record the whole way through. That is what we wrote down and approved a year's worth of design decisions around.
It doesn't work that way. When someone emails us first, we create a record that holds only their email address. When their lease later arrives from AppFolio, we create a second record holding their phone number and their AppFolio id — and we hang the lease, the unit, and the move-in on that second one. The email address stays stranded on the first.
The consequence is one-sided and that is why it hid: texting and phone calls work, email does not. Clara answers a signed resident's email as if they were a stranger off Zillow — quoting application fees, deposit tiers, and new-lease specials to someone who already lives there.
The code knows this is happening. On every collision it writes a log line that says, in plain words, "these are probably the same person, someone should merge them." Nobody reads that log. There is no queue, no alert, no dashboard. The line has been printing for three months.
Where it stands today: the damage that has already reached a customer is small — three email threads in Camellia's whole history landed on the wrong record, none in the last 30 days, because Camellia residents currently talk to us by text and phone, not email. The damage that is loaded and waiting is large: 86 of Camellia's 157 lease-holding people already have a duplicate record holding an email address. The moment resident email goes live there, more than half of that building resolves to a stranger.
The fix is not a patch. It is: stop the split at the one place identity is decided, make the "which record is this" decision follow the trust rules we already approved, build the merge queue that three ADRs assumed existed, and then repair the ~88 pairs already in the data. Six decisions need Fede's ruling before code is written; they're in section 5 as multiple choice with recommendations.
1. What we promised
The one-record-per-human rule isn't a new idea from this week. It is the reason the identity system exists at all, and the exact failure we're seeing is quoted in the founding document as the thing it was built to kill.
ADR-0018 (Accepted 2026-05-04), the Context section — the two bullets that name this bug:
Same human, multiple records. A person who tours Property A as a
Prospectand signs a lease at Property B becomes aTenantwith no relationship to their prospect history. We see them as two separate humans. —docs/adr/0018-person-identity-model.md:16
AppFolio sync compounds duplicates. When AppFolio's
rental_applicationspoller creates aProspectand thetenant_directorypoller creates aTenantfor the same human, the two records are not linked. —:21
The decision that followed:
Introduce a stable
Personentity. Phone, email, and PMS external IDs are claims on a Person, not identity. —:29
Person.idis the only thing we join on across the system. It never changes for the lifetime of a human. —:55
And the specific promise about a PMS sync row — the exact event that breaks us:
Bundle intake. When a single inbound event carries multiple claims atomically (a PMS sync row with phone+email+PMS-id), all claims attach to one Person. Strongest cross-channel link source we have. —
:214
That sentence is the whole ballgame. The AppFolio lease row is a bundle: it carries phone, email, and the AppFolio id together. The design says all three attach to one Person. The code attaches two to one Person and leaves the third where it was.
ADR-0020 (Accepted 2026-05-17) made it structural: "Person becomes the canonical record for any human the platform recognizes. Every role (tenant, prospect, vendor contact, PM, platform admin) is a relationship that points at a Person, not a separate identity table." (:40) with invariant #4: "Identity attributes (name, email, phone) live ONLY on Person." (:82)
ADR-0035 (Accepted, frozen 2026-06-03), §Decision :18 spells out Fede's ruling verbatim, a year early: "Tenant = Person + TenantOccupancy, Prospect = Person + ProspectInquiry." Same id, two role rows.
Fede's ruling is not new policy. It is the accepted design, restated. The code is out of compliance with three accepted ADRs.
The one thing the ADRs got wrong, and where
ADR-0018 also said, in 2026-05: *"Manual merge first. Detection later, only when justified by data."* (:224) and "No automatic detection in the initial rollout. PMs spot duplicates as they work the dashboard." (:235)
That was a defensible call at the time — but it rested on two things that never shipped:
- A PM-usable merge button. ADR-0018:227 promised "Every Person profile in the admin dashboard exposes a 'Merge with…' action." What exists is
PersonMergeAction.tsxinside/admin/dev/atlas, gated to platform admins only (atlas/page.tsx:94,isActivePlatformAdmin). No PM can reach it. (Correction applied: an earlier draft of this audit said no merge UI exists at all. It exists; it is just invisible to the operators the ADR relied on.) - Anything that tells anyone a merge is needed. No queue, no detector, no alert. See section 4.
And then a planning document, never ratified by any ADR, decided the opposite of ADR-0018:214 and that decision is what shipped:
P7 cross-signal split: phone → Person A, email → Person B | phone-winner authoritative; email collision surfaces merge-candidate warn; spine NOT auto-merged —
docs/planning/spine-redesign/spine-funnel-eval-gauntlet.md:70
That line is now enforced by a passing test suite and by an eval that fails if the two records are ever merged (evals/spine-gauntlet/harness.ts:163-175: "expected distinct persons but both resolve to … (silent merge)"). Overturning P7 is a required deliverable of the fix, not a side effect.
2. Where the code breaks the promise
The path a lease takes
AppFolio lease sync → syncPropertyOccupancies (src/lib/integrations/appfolio/writers/occupancies.ts:120) → contacts pulled from tenant_directory only (:476-483) → applyEntitySync → saveTenants → spineStampTenantBatchOrThrow (src/lib/data/dynamo/property.ts:343, per-tenant call at :437) → ensurePersonForTenant (src/lib/domain/identity/tenant-spine-stamp.ts:393) → ensurePersonForSignals (src/lib/domain/identity/person-stamp.ts:631).
The sync itself does no identity lookup — that was deliberately retired (occupancies.ts:172, "ADR-0032 PR-A.2: personId pre-resolution retired… The writer is the only place that mints/resolves personId"). So ensurePersonForSignals is the single chokepoint, and it is where the divergence lives.
The mechanism, in one sentence
ensurePersonForSignals resolves a person by AppFolio id, then phone, then email — in that order — and once it has a winner, every other claim is only "topped up." If a top-up collides with a claim someone else already holds, the shared primitive addClaimAndFollow (person-stamp.ts:571-607) reports the collision and returns; it never moves the claim. The caller then logs a warning and keeps its own winner.
Count of collision sites and what each decides:
| # | Site | Claim being topped up | Decision |
|---|---|---|---|
| 1 | person-stamp.ts:535 (helper topUpPmsExternalIdClaim) |
AppFolio id | keep mine (:538-545) |
| 2 | :756 (Tier-0 anchor path) |
phone | keep mine, warn :763 |
| 3 | :787 (Tier-0 anchor path) |
keep mine, warn :790 |
|
| 4 | :866 (phone-reuse path) |
keep mine, warn :872 ← live bug |
|
| 5 | :948 (email-reuse path) |
phone | keep mine, no check at all — the winner is discarded without inspection |
| 6 | :1010 (mint path) |
phone | follow winner :1021 |
| 7 | :1046 (mint path) |
:1049 no phone → follow winner; :1060 phone present → keep mine, warn :1071 ← live bug |
Six of seven collision sites keep the caller's record. The only two that follow the winner are both mint-path race-losses. (Correction applied: an earlier draft said "four of five.")
The two live divergence points
A. Mint path — person-stamp.ts:1048-1078. The exact predicate:
if (result.winnerPersonId !== winnerPersonId) {
if (!normalizedPhone) { … follow winner … } // :1049-1059 correct
else { emitMergeCandidateWarn('mint path', …) } // :1060-1078 THE BUG
}
At first lease sync for a never-seen tenant: the AppFolio-id lookup misses (nobody owns it yet), the phone lookup misses (directory phone is new to us), so we mint Person B, attach the phone, then discover the email already belongs to shell Person A — and because a phone signal exists, we throw that discovery away. B then gets the AppFolio id stamped on it too (:1091). Two records, one human.
B. Phone-reuse path — person-stamp.ts:866-878. Identical outcome whenever the shell also holds the phone (prospect texted too, or any second sync cycle). Worse: it also back-fills the AppFolio id onto the phone-resolved record at :885-897, cementing the wrong anchor.
(Correction applied: an earlier draft named only the mint path. A fix that patches :1060 alone leaves :866 producing the identical split, and leaves :787 re-affirming it on every subsequent sync.)
The intent lost at one predicate: the code treats "I have a phone number" as authority to outrank an existing claim on an existing record. Nothing in ADR-0018/0020/0032/0054 says a freshly-minted record outranks an established claim-holder. A phone number we have never seen before beats a live email bond purely by cascade order.
Why it never heals
Once the occupancy carries the wrong personId, the contact cascade is never run again for that tenant (tenant-spine-stamp.ts:426-448, the 2026-07-21 stamped-tenant contract, PR #4249). Only the AppFolio id can move the bond (:460-490) — and the wrong record already owns it. Attempts to write the email onto the right record hit the same collision and return 'reused_active' with the spine unchanged (tenant-spine-stamp.ts:335-342). The split is permanent by design.
Two adjacent holes in the same chain
prospect-spine-stamp.ts:145-167never passes the AppFolio id — even forappfolio_rental_app_sync. So the applicant leg of "prospect → applicant → lease → occupancy" gets no strong anchor at all. The one place the chain could have been welded shut is unwired.cotenant-spine-stamp.ts:53-63drops it too, so co-signers can never anchor.
This is not the co-signer bug
The known co-signer case (couples sharing a phone or mailbox) is two different humans we deliberately keep apart. Every guard written for it refuses on two or more matches: property.ts:557 (if (persons.length !== 1) return null), application-status-scope.ts:252, pickIncomingResidentPerson (incoming-resident-scope.ts:261).
This bug produces exactly one match — the wrong one. Every one of those guards is a no-op against it. That is also why it cannot be fixed at the gate layer: the resolver believes it succeeded.
3. Who is affected in prod today
The honest headline
Already harmed: very few. Already broken in the data: more than half of Camellia.
Camellia's email lane today is almost entirely listing traffic from Zillow and Apartments.com. Residents use text and voice, which work. So the split has had almost nothing to bite on — yet.
What has actually reached a customer (Camellia, all time)
Of 89 email conversations ever at Camellia, 3 anchored to a record that was not the one holding the person's lease:
| Thread | Date | What happened | Class |
|---|---|---|---|
7f7a4805 |
2026-05-29 | An active resident of unit 502 on a 12-month lease wrote in on a resident question and was answered as an unknown — no unit, no lease dates, no resident scope | Sender-id sentinel class (see below), not the collision bug |
98725947 |
2026-04-06 | Claire Bene, active resident of unit 308, same outcome | Same sentinel class |
02d58564 |
2026-04-14 | The property's own mailbox address (camelliaapts@jp-co.com) is claimed by a Person who also holds an active lease in unit 418 |
Separate spine hazard — flag, don't fold in |
Important correction: the first case was originally read as the claim-collision bug. It is not. Both 7f7a4805 and 98725947 are the email_user: sentinel class — the shell held the same address, namespace-prefixed. That class is closed: 46 such claims exist, all in org_jpco, all created between 2026-05-25 and 2026-06-05, none since; the guard (isMintableEmail, person-stamp.ts:627) landed in PR #5578 on 2026-08-09, well after minting had already stopped.
So: the addClaimAndFollow collision path is confirmed in code and live-proven on the Willows bench, but it has not yet produced an observed customer-facing failure at Camellia. Say that plainly; it is the one place where the code story and the prod story do not yet meet.
Last 30 days at Camellia (2026-07-14 → 2026-08-13)
229 conversations: 151 voice, 48 text, 30 email.
- Email threads anchored to a record that holds a lease: 3
- Email threads anchored to a record with no lease: 27 (26 distinct people)
- Of those 26 — how many actually hold a lease under a different record: 0
All 26 are genuine cold leads. 26 of 27 carry a prospect inquiry sourced from Zillow (17), Apartments.com (5), or the website (4). Not a single false-stranger in the window. Text is healthy too: 44 of 48 in-window texts anchor to the right lease-holder.
The number that matters — loaded and waiting
86 of Camellia's 157 lease-holding people (55%) already have a second, lease-less record that holds an email address. Any mail from those addresses resolves to the stranger.
Signature of the dominant group (82 of the 86, exact name match):
- Shell created 2026-05-05, 82 out of 82,
source=backfill— the same backfill run that created 112 of the 155 lease anchors, minutes apart - Email
verifiedBy=unverified; no session claim; no household row and no inquiry (81 of 82 on both) - Zero contact overlap with the anchor — a genuinely different phone and a different email, even counting deprecated claims. All 82 shells carry a phone. That is precisely why the phone→email ladder minted a second record instead of reusing: the ladder has no name rung and no PMS-id rung.
Related Camellia counts, all per-row verified:
- 32 of 155 lease anchors carry no live email claim at all → invisible to every email-lane reader by construction. 0 of those 32 have their address findable on a shell. (Correction: earlier draft said 1 of 32.)
- 37 of 155 lease anchors carry no live AppFolio-id claim — so a fix that leans on the PMS anchor covers only ¾ of the building. (Correction: this replaces an earlier "≥24" inference with a per-row count.)
- Only 2 conversations in all of history have ever anchored to any of the 182 twin records. The blast radius is small today purely because the traffic isn't there.
Fleet-wide
| Measure | Camellia | Willows bench (appfolio-45) |
Yale sandbox | Fleet |
|---|---|---|---|---|
| Lease-anchored people | 155 | 48 | 10 | 213 |
| …with no live email claim | 32 | 7 | 0 | 39 |
| …with no phone claim at all | 8 | 5 | 0 | 13 |
| Split pairs (both records hold different live emails) | 86 | 2 | 0 | 88 |
| Anchors whose email sits on a shell | 0 | 2 | 0 | 2 |
| Total shell records | 5,105 (org-wide) | 703 | 10 | 5,842 |
| Shells holding a live email — the actionable surface | 345 | 189 | 10 | 537 |
| Co-signer class (shared contact, different humans) | 8 | 8 | 0 | 16 |
Person rows in prod: ~7,006. Identity claims: ~5,918. 109 records already carry a merge redirect and are excluded from every count above.
The bench case (live-proven, the one we can point at)
Person pers_457e2019-… holds two pending occupancies and AppFolio ids 1899/1900 — and no email claim at all. Two separate inbound-email shells (pers_ace5d2c9-…, pers_481ac0d9-…) hold the addresses. The system logged the collision in prod, in the exact words, twice, on 2026-08-13 and 2026-08-14. Nothing read it.
Data hygiene problems found alongside (separate work, but they touch any repair)
- Claim rows are being re-appended, not upserted. One record holds 244 identical copies of the same email claim; four others hold 124 each. Any repair job must dedupe first.
- The same unit-418 lease exists twice under two different people (
OCCUPANCY#1773625953462…andOCCUPANCY#pers_f9c6385b…). Occupancy keys use three incompatible schemes across the 170 rows: 101 PMS-numeric, 37tenant_…, 32pers_…. - A placeholder address squats the dedup partition: two people both claim
temp@gmail.com. - 18 email addresses are claimed by more than one lease-holder — the legitimate shared-mailbox class.
4. How we missed it
Five independent detection layers were in place. Each one was structurally incapable of seeing this. Below each, the fence that would have caught it.
4.1 The behavior was the specification
Two passing unit tests assert the split as the correct outcome:
src/__tests__/person-stamp.test.ts:521— "logs merge-candidate when phone + email collide on different Persons", assertingemailClaim === 'reused_active'and the warn firessrc/__tests__/tenant-spine-stamp.test.ts:257— "logs merge-candidate warn when phone-reuse email top-up resolves to a different Person" — this is literally the prod bug path, asserting the PMS record keeps no email claim
And an eval fails if the records are ever merged: evals/spine-gauntlet/harness.ts:163-175, seeded at run-spine-gauntlet.ts:254-267, catalogued at matrix.ts:45 ('cross-signal-split'). Landed in PR #1442.
Fence that would have caught it: a data invariant instead of a behavior assertion — for any Person with an active or pending occupancy, every email address AppFolio reports for that tenant must resolve to that same Person. Nothing in the repo asserts claim coherence. Adopting it turns the two tests and the eval red on day one; that is the correct signal, and rewriting them is a fix deliverable.
4.2 Every test hands the consumer the right person
There is no claim table in any test fixture. addClaim is mocked to always return created in five suites (person-stamp.test.ts:64-67, identity-resolve.test.ts:153, person-stamp-tier0-pms-anchor.test.ts:78, email-drop-when-phone-present.test.ts:107-110, tenant-spine-stamp.test.ts:114, tenant-spine-stamp-preserve-existing.test.ts:106). Collisions are opt-in, hand-scripted per test.
The email lane's own gauntlet is worse: email-lease-answer-gauntlet.test.ts:322-328 returns PERSON_A for every non-cold case and :284-309 hands the occupancy to that same id. The person the email lookup returns is the lease-holder, by construction. Its sender taxonomy (:228-254) has nine kinds — cold, applicant, resident, co-tenant, transferred, three flavors of ambiguous, unreadable — and no kind for "resolves to a real person who isn't the one with the lease." In prod that state renders as one match with zero occupancies, indistinguishable from cold.
Two of the four collision sites have no test at all: :763 and :790, the Tier-0 anchor path — the branch that owns a synced lease. The one Tier-0 test whose name promises coverage ("uses Tier-0 even when phone matches a different Person") returns null for the top-up lookups, so the warn branch never executes.
Fence: a fixture that owns claim→person state, so "this address already belongs to someone" is a property of the world rather than a per-test script. Plus a taxonomy entry for the split so the gauntlet can express it.
4.3 The coverage guard is a set-union, so the real entry point is exempt
src/__tests__/spine-gauntlet-coverage.test.ts:74-79 requires every branch to appear in at least one entry point. The rentroll-tenant entry point carries cross-signal-split, so the guard is globally satisfied — and the email-prospect entry point (inbound email → save prospect, i.e. the exact way this starts) carries only have-by-email, new-by-email, tier-stamping. The email-then-lease sequence is never a gauntlet case.
Fence: make the coverage guard a cross-product, not a union.
4.4 The health monitors measure the opposite failure
spine-orphans/classifiers.ts:47— orphan meanspersonIdis missing. A split human has a valid id on both rows. Card reads a healthy zero.src/lib/data/invariants.ts:84assertNoDuplicateLiveTenantsByPersonflags too few records.:136assertConversationTenantBindingflags a phone mismatch. Both run nightly vianpm run audit:prod(.github/workflows/nightly.yml:1005) — every night for months, structurally blind to too-many.
Fence: the invariant in 4.1, run nightly against prod. It would have printed 86 rows at Camellia on the first night.
4.5 The one signal that fired went nowhere, and was drowned 196:1
emitMergeCandidateWarn (person-stamp.ts:471-483) calls logWarn → console.warn. logWarn (platform/logging.ts:116-122) has no Sentry path; only logCritical does. There is no CloudWatch metric filter on the AppFolio sync log group ({"metricFilters": []}), and none of the account's 50 alarms touch identity.
Thirty days of the sync log group (2026-07-15 → 2026-08-14, 4.34M records scanned):
| Context | Warns | Distinct addresses |
|---|---|---|
| co-signer sync | 587 | 1 |
lease sync (ensurePersonForTenant) |
3 | 2 |
The 587 are one address re-warned every 15 minutes for seven days — the known, accepted co-signer class. 99.5% of the signal is a case we already decided not to act on, which is exactly what made the three real ones unfindable. Of those three, two are the bench persona announcing this bug in prod in its own words, and one is a real Camellia-org human split on 2026-07-28 and never triaged.
Three compounding problems with that signal:
- It undercounts. The prospect-shell side runs in Next.js on Vercel; those
console.warns never reach CloudWatch at all. We have never seen half the picture. - It mislabels. The Tier-0 phone collision at
:763passes a phone into a parameter namednormalizedEmail, and the message is hard-coded "email-vs-phone person-mismatch … keeping phone-winner." Any substring filter used to size this incident class mis-buckets phone collisions as email collisions. - Nothing consumes it.
person-stamp.ts:1067-1070still carriesTODO(ADR-0018 follow-up): replace emitMergeCandidateWarn with a structured merge-candidate event consumed by the admin merge-queue surface. Opened in PR #1143 on 2026-05-19. Open three months. There is no queue, no detector, no DDB row type.
Fence: promote the warn to a structured event +
logCritical, dedup on a stable key (the pair of person ids), and build the queue. Separate the co-signer class into its own bucket so it can't drown the real one.
4.6 It was flagged three times and misattributed each time
- PR #1139 (2026-05-19), the PR that introduced the behavior. Gera asked directly: "Is 'always pick phone' the right tiebreaker, or could that mask a real merge candidate?" The bot answered: *"'Always pick phone' is the right operational default for Clara's SMS-first architecture… No correctness concern."* Correct for May 2026 — the email lane did not exist as a read consumer yet. The fix applied in-PR was to add a second warn. (Attribution correction: "✅ Clean to merge" comes from the third review pass at 01:52Z, not the second pass, which ended "No blockers.")
- PR #5598 (2026-08-09), the nearest miss. "The applicant seed hangs its approved inquiry on a pre-existing Person that carries a phone claim and no email claim… the email lease-answer gate starts at
findPersonsByEmailAcrossOrgs, so no address on the claim spine can resolve to that Person." That is a verbatim statement of the root cause — dismissed as a harness artifact. The follow-up (#5605) worked around it by minting yet another record with a live email claim. - PR #5612 (2026-08-09). A real Willows resident got nine delivered new-lease replies. Diagnosed as "email identity resolution misses shared and unrecognized mailboxes"; the fix widened the gate rather than fixing the spine.
And the journey harness had already normalized it: scripts/journey-harness/run.ts:186-190 documents "the email identity mints a separate spine Person from the phone identity" — as a teardown convenience.
4.7 Why no name-matching detector found the 86
An earlier pass concluded a name-join finds only 1 pair fleet-wide. Live data found 82 at Camellia. Both are right, and the difference is the finding:
- The 254 "email-only" shells (no phone, no AppFolio id) are 87% nameless — their display name is the email address. Un-joinable by name.
- The 82 Camellia shells have real names and phone numbers — they came from the 2026-05-05 backfill, not from inbound email. They were excluded from the earlier detector because it filtered to email-only shells.
There are two shell populations with opposite signatures, and any detector or backfill must cover both. The inbound-email population needs contact-based or PMS-id matching; the backfill population needs name-based matching with adjudication.
5. The fix, and the decisions Fede must make
The design in five parts
Part 0 — Prerequisite: free the claim sentinel on deprecation.
Today deprecateClaim never releases the uniqueness sentinel (dynamo/persons.ts:495-515 vs helpers.ts:126, documented in ADR-0054 §1.4). A claim can never be re-homed without manual surgery. No re-point is possible until this lands. This is ADR-0054 slice 4, already written and never accepted.
Part 1 — Decide ownership by trust tier at the chokepoint, not by cascade order.
ADR-0018's accepted trust hierarchy (:75-92) already ranks claims: Tier-1 sms_verified ≡ manual_pm ≡ email_verified; Tier-2 appfolio_sync; Tier-3 *_inbound; Tier-4 self_declared. An email-first prospect's claim is Tier-3; a lease sync's is Tier-2. The incoming claim strictly outranks the incumbent. A tier-ordered re-point needs no new policy — ADR-0032 §4b already uses tier comparison for write-back; extending the same comparator to decide claim ownership is the minimal ADR-consistent change.
Carve-out, straight from :92: "When two Tier-1 claims conflict, neither auto-overrides." That is exactly the right refusal for the co-signer class.
Applies at all four collision sites — :763, :790, :872, :1071 — not just the mint path.
Part 2 — Weld the applicant leg. Pass the AppFolio id through prospect-spine-stamp.ts:145-167 and cotenant-spine-stamp.ts:53-63. Without this, "prospect → applicant → lease" has no strong anchor and Part 1 is running on contact values alone.
Part 3 — Build the merge-candidate queue. Replace the log warn with a structured event and a DDB row; surface it where a PM can act; keep the platform-admin merge action as the executor. This is PR #1143's three-month-old TODO and ADR-0018:227's unbuilt promise.
Part 4 — Overturn P7 and re-fence. Rewrite the eval case to assert a merge for the one-human case, add a new case for the genuinely-different-humans case, make the coverage guard a cross-product, and add the nightly claim-coherence invariant.
The single regression assertion the whole fix collapses to:
For any Person P holding an AppFolio-id claim and an active or pending occupancy, and any email address E in that person's AppFolio contact record:
findPersonsByEmailAcrossOrgs(E)must return exactly[P].
Decision 1 — Re-point the claim, or merge the records?
- (a) Re-point the email claim only. Minimal write. The shell survives, still holding the prospect inquiry and the email conversation history.
- (b) Merge the shell into the anchor. One record, as the ADRs intend — but merge only redirects lookups into a person; it does not rewrite the pointers out of one (see Decision 4).
- (c) ✅ Recommended — merge when the shell is inert, re-point otherwise. If the shell holds no occupancy, no active role, no vendor membership → merge it (this is 81 of Camellia's 82). If it holds a prospect inquiry or conversations → re-point the claim now and queue the merge for the FK sweep. Gets the email lane correct immediately without risking history loss.
Decision 2 — Where does the decision fire?
- (a) At the chokepoint only (
ensurePersonForSignals). Stops new splits; does nothing about the 88 already loaded. - (b) A reconciler job only. Repairs history; new splits keep arriving every sync.
- (c) ✅ Recommended — both. Chokepoint stops the bleeding; a sweeper repairs the backlog. ADR-0032 §4a's stated trigger boundary ("
mergePersonsis NOT called from the HOF") assumed the only site with both ids in hand is a voice tool — that assumption is wrong:person-stamp.ts:872demonstrably holds both ids and chooses to warn. That boundary is what needs amending.
Decision 3 — How strong must the evidence be to re-point automatically?
- (a) Strict tier order. Any higher-tier claim takes the value. Simple, matches the accepted cascade, but a manually-entered address (Tier-1) could be taken by a sync (Tier-2) — actually blocked by the Tier-1 rule, but the general shape is aggressive.
- (b) ✅ Recommended — tier-ordered, with two refusals. Refuse Tier-1 ↔ Tier-1 (ADR-0018:92 — this is the co-signer carve-out). Refuse when the value is claimed by two or more lease-anchored records (the 18 shared addresses at Camellia). Everything else re-points.
- (c) Require corroboration (matching name and a second matching contact value) before any re-point. Safest, but the 82 Camellia pairs share zero contact values by construction, so it would repair almost nothing.
This also honors PR #5625's owner ruling — "Merges happen only by explicit PM action or an exact verified-claim match" — because this is an exact verified-claim match, not a heuristic.
Decision 4 — What about pointers that face the other way?
Merging redirects reads into a person (getPerson follows mergedIntoPersonId, persons.ts:444-450). But person→entity reads are raw index queries with no union: getConversationsByPersonId (conversation.ts:321-330), listOccupanciesForPerson, getPersonEvents. Merging A into B leaves the mover's entire pre-lease email history unreachable from B.
- (a) Rewrite every FK offline. ~23 typed foreign keys plus four denormalized name caches. Thorough, heavy, risky.
- (b) Union the reads at query time (follow redirects on the way out).
- (c) ✅ Recommended — (b) now for conversations, occupancies, and events; (a) offline afterward as cleanup. Read-side union is cheap, reversible, and fixes the user-visible half immediately. Note one trap either way: co-tenant occupancy rows key on
personId(tenants-occupancy-dual-write.ts:518-525), so re-pointing one is a delete-and-create, not an update.
Decision 5 — What happens to the eval that currently forbids the fix?
- (a) Delete the P7 case. Loses the genuinely-different-humans coverage.
- (b) ✅ Recommended — rewrite P7 to assert the merge for the one-human case, and add P7b asserting no merge for two humans sharing a contact. Both classes stay fenced, and the fence finally points the right way.
- (c) Leave the eval, exempt the new path. Not viable — the eval is the codified form of the wrong decision.
Decision 6 — ADR paperwork
- (a) Amend ADR-0018 in place.
- (b) ✅ Recommended — accept ADR-0054 (currently Proposed, its slice 4 is exactly this work) and publish a new decision doc that supersedes the P7 planning decision and amends ADR-0032 §4a's trigger boundary. Per the docs rule, that ships as a docs.propflowai.co artifact page, marked Proposed — pending review, published immediately.
- (c) No paperwork, code only. Leaves the next engineer reading a planning doc that says the opposite of the code.
6. Backfill scope
What needs repair
| Cohort | Count | Evidence quality | Recommended action |
|---|---|---|---|
| Camellia split pairs — exact name, 2026-05-05 backfill twins | 82 | Medium. Same name, same-day creation, zero contact overlap | Adjudicate against AppFolio (tenant record vs guest card). Auto-merge the corroborated ones; queue the rest. Never bulk-merge on name — 7 duplicate names exist within the 155 anchors |
| Camellia — ambiguous name (2+ anchors share the name) | 2 | Low | Human adjudication only |
| Camellia — local-part matches (contains full name / lastname) | 2 | High / medium | Auto-merge under Decision 3(b) |
| Willows bench splits | 2 | High (live-proven) | Auto-merge; use as the fix's proof case |
| Total pairs in scope | 88 | ||
| Camellia anchors with no email claim at all | 32 | n/a | Not repairable from PropFlow data — the address exists only in AppFolio. Requires a contact re-sync, not a merge |
| Camellia anchors with no AppFolio-id claim | 37 | n/a | Prerequisite: backfill the Tier-0 anchor, or Part 1 has nothing strong to resolve against for ¼ of the building |
Explicitly out of scope — do not touch with this lever
- Co-signer shared-contact class: 8 at Camellia, 8 at the bench. Two different humans; keeping them apart is correct. Detectable only through deprecated phone claims (live-claim matching returns zero in every group, because the sentinel makes a live value ownable by one record per org).
- The
email_user:sentinel class: 46 claims, all org_jpco, all created 2026-05-25 → 2026-06-05, none since. 13 shadow an address held by a different record; 3 of those shadow a Camellia lease-holder. Closed class — repair the 13 as a one-off, do not build machinery for it. - 109 records already carrying a merge redirect — excluded from every count above; chains were not followed transitively, so re-check before any sweep.
- The 4,997 inert org_jpco shells with no email claim (mostly session-only rows). The actionable surface is 537 fleet-wide / 345 at Camellia, not ~5,800.
Order of operations
- Free the claim sentinel on deprecation (ADR-0054 §1.4) — nothing else can run first
- Dedupe the amplified claim rows (one record has 244 copies of one address)
- Backfill AppFolio-id claims onto the 37 Camellia anchors that lack them
- Land the chokepoint fix + rewrite the two pinning tests and the P7 eval
- Ship the nightly claim-coherence invariant — it should print 88, then count down
- Run the sweeper against the corroborated cohort; queue the rest for PM adjudication
- Resolve the unit-418 duplicate occupancy and the
temp@gmail.comsquatter separately
Technical appendix
A. Exact divergence points
| Location | What it does |
|---|---|
src/lib/domain/identity/person-stamp.ts:571-607 |
addClaimAndFollow — the shared collision primitive. On non-created, re-reads the sentinel owner and returns winnerPersonId. Never re-points; only reports. Only failure it logs is the deleted-winner race (:600) |
:471-483 |
emitMergeCandidateWarn — pure logWarn. Message hard-coded email-vs-phone person-mismatch (…) … keeping phone-winner. Its 5th param is named normalizedEmail; the :763 call site passes a phone into it → Tier-0 phone collisions log as email collisions |
:1067-1070 |
TODO(ADR-0018 follow-up) — the merge-queue that never shipped. Opened PR #1143, 2026-05-19 |
:1048-1078 |
Divergence A (mint path). :1049 no-phone → follow winner (correct). :1060 phone present → keep mine + warn at :1071 (bug) |
:866-878 |
Divergence B (phone-reuse path). Warn at :872. Also back-fills the AppFolio id onto the phone-resolved record at :885-897 |
:787-797 |
Tier-0 email top-up. Warn at :790. Re-affirms the split on every subsequent sync — this is what makes it permanent rather than self-healing |
:948 |
Email-reuse path phone top-up — destructures { outcome } only. No mismatch check exists at all |
:538-545 |
Preserve-existing-bond guard (PR #2038) — same follow+warn shape, second occurrence: "keeping contact-resolved Person authoritative. Admin merge may be needed." |
:607-629 |
PERSON_EMAIL_SHAPE / SENDER_ID_NAMESPACE / isMintableEmail (fn at :627) — the email_user: guard, PR #5578 |
tenant-spine-stamp.ts:426-448 |
Stamped-tenant contract (PR #4249) — cascade never re-runs for a stamped tenant. Why the split is permanent |
tenant-spine-stamp.ts:335-342 |
Tier-1 overwrite hits cross-person collision → warn + return 'reused_active', spine unchanged |
prospect-spine-stamp.ts:145-167 |
pmsExternalId absent from the signals literal — applicant leg has no Tier-0 anchor |
cotenant-spine-stamp.ts:53-63 |
Same omission for co-tenants |
dynamo/persons.ts:495-515 vs helpers.ts:126 |
deprecateClaim never frees the uniqueness sentinel — the prerequisite blocker |
property.ts:444 |
saveTenants throws when no anchor signals exist (ADR-0032 §1) |
occupancies.ts:172 |
"ADR-0032 PR-A.2: personId pre-resolution retired… The writer is the only place that mints/resolves personId" |
B. Consumer blast radius
The read predicate: findPersonsByEmailAcrossOrgs (store.ts:4762, persons.ts:1803) and its singular twin (store.ts:4662, persons.ts:1686) resolve claims, not roles. The shell holds the claim → exactly one match, wrong person. Not zero (cold path), not two (ambiguity refusal). A confident wrong answer that bypasses every fail-safe.
The purpose-built cross-check that it walks through: pickIncomingResidentPerson (incoming-resident-scope.ts:253-263) discards the person only at if (input.emailMatchCount != null && input.emailMatchCount > 1) return null (:261), with the comment "ambiguity is 2+; a zero count … is not a reason to discard a person." The shell scores 1 and passes straight through. This is the single strongest piece of evidence that the split is a confident-wrong answer rather than an ambiguity.
Tier 1 — customer-visible, silent
| Failure | Path | Detail |
|---|---|---|
| Resident-scope gate says "not a resident" | conversation-manager.ts:5247-5251 → email-lease-answer-identity.ts:379, resolve at :402, occupancy read at :472-486 |
Shell has zero occupancies → verdict 'none' at :482 → isCurrentResident=false, residencyUnreadable never set. The gate's fail-closed arm (:5253-5271, returns isCurrentResident: true) only fires on a throw — the split converts a fail-closed gate into a fail-open one by making the read succeed. Downstream consumer: :5416. The gate's own docstring (capabilities/index.ts:91-110) documents the incident it was built to stop (CONV#e1435327, deposit tiers and application fee recited nine times) — and the split defeats it too |
| Incoming-resident (signed mover) scope never fires | conversation-manager.ts:5466-5475 → incoming-resident-scope.ts:429-452 |
resolveIncomingResident uses a person-first ternary at :441-452, so the email fallback door is unreachable whenever a shell resolves. (Correction: an earlier draft claimed two independent paths fail; it is one path, and the email door is dead code here.) Verdict NOT_INCOMING → book_key_pickup never offered, co-signer blockers never surfaced. Named prod casualty in the comment at :5375-5381: a mover told he was "all set" with signatures outstanding, who no-showed |
| Renewal reply lands in the wrong thread with no context | conversation-manager.ts:2050-2062, email/thread-context-bypass.ts:174-206, :4306 |
Tenant lookup by conversation.personId fails → no tenant context → no renewal context → renewalShaped=false → new-lease specials rendered to a renewing tenant |
| Application status is stale, not absent | application-status-scope.ts:248-252 |
matchCount===1 and the shell holds the inquiry → the block renders, telling someone who already signed what stage their application is at |
Tier 2 — hard failures ("Clara can't help me")
All key off conversation.personId = shell, all throw IdentityMismatchError: resolve-tenant-context.ts:91, handle-get-tenant-balance.ts:211, handle-get-lease-terms.ts:150, handle-list-my-work-orders.ts:163, handle-get-unit-appliances.ts:27, handle-get-available-vendors.ts:37, move-in/key-pickup.ts:297-305, handle-identify-caller.ts:65,78, handle-create-work-order.ts:401,414,434.
Composition trap: capabilities are granted off identity.type, which is also wrong, so most of these are never offered — the failure usually presents as an absence, not a refusal.
Tier 3-4 — escalation and cadence
open-matter.ts:150-172 opens the matter on the shell; handle-forward-to-property-manager.ts:289-297 resolves resolvedKind = 'prospect' → the PM escalation email labels a signed resident as a prospect. conversation-manager.ts:2155-2161 — tenant backfill is a permanent no-op because existing.personId is already set to the shell. signals/resolve-signal-tenant.ts:74-84 returns null and logs it as an ordinary no-match.
Tier 5 — unaffected, and why
SMS (twilio/webhook/route.ts:512,528) and voice (voice/personalization/route.ts:373,411) both resolve by phone, which lands on the lease-anchored record. Caveat: conv 57651dec (2026-08-13, SMS) anchored to a record carrying a merge redirect — so the SMS lane is not immune by construction, only by luck of which claim landed where.
Tier 6 — write-side amplifiers
platform/auth/link-user-to-spine.ts:166 binds the dashboard User to the shell — a third divergent view. tenants/cotenant-enrichment.ts:312 can write a co-tenant occupancy onto the shell, inverting the split. vendors/inbound-resolution.ts:183 is the only email-lane consumer that reads the plural set without collapsing it — structurally correct.
C. Lineage
| Date | PR | Effect on the collision path |
|---|---|---|
| 2026-05-18/19 | #1139 (gera-propflow, merge 5cbb6ca5c, branch commit 34c363a8b by jose@) |
Introduced follow-winner + the email-vs-phone person-mismatch warn. Trigger was an SMS identity_mismatch in Pipeline Lab — not co-signers, which did not exist in code yet. Review asked the tiebreaker question and cleared it: "No correctness concern" |
| 2026-05-18 | #1143 | Extracted ensurePersonForSignals into person-stamp.ts; factored the warn into emitMergeCandidateWarn; added the TODO. Behavior unchanged |
| 2026-05-26 | #1366 → #1440 | ADR-0032 construction primitives across all 7 spine entities. mergePersons shipped (merge-persons.ts:127) |
| 2026-05-28 | #1601 | collision_different_person outcome born (sync-tenant-from-pms.ts:170 — "needs merge UI") |
| 2026-06-02 | #1857 | Co-tenant collapse to Person + occupancy |
| 2026-06-08 | #2038 (fede-propflow, 8ca5a04e2) |
Tier-0 AppFolio-id anchor + the email top-up collision branch = today's topology. PR body's test-plan table asserts "New phone/email claims land on the PMS-bonded Person"; its backing test is titled 'top-ups phone + email claims onto the Tier-0-resolved Person **when missing**' — the collision case was never exercised, so the body claim was unfalsified rather than false |
| 2026-06-19 | #2505 | Mint-path email claim primary: true. Explicitly deferred the reuse/top-up paths to an it.todo — that todo no longer exists in the tree; the follow-up was dropped |
| 2026-06-30 / 07-07 | #2719, #3183, #2721, #3187 | 15 real prospects split, fixed at the conversation-claim layer + one-off merge scripts. Nobody generalized to prospect→lease |
| 2026-07-05 | #3060 → #3068 revert (52 min) | Co-signer sync minted a phantom record every 15 min: 24 rows / 24 person ids / 18 units in under 2h. Re-landed #3219; sensor #3496 |
| 2026-07-21 | #4249 | Stamped tenants never re-run the cascade. Cements never-re-point, and by July the behavior is being defended as "the surviving layer of the 2026-06-08 corruption protection" |
| 2026-08-07→10 | #5545, #5573, #5612, #5638 | The email lane is built on findPersonsByEmailAcrossOrgs — on the exact claim the split strands |
| 2026-08-09 | #5625 | Owner ruling: "Merges happen only by explicit PM action or an exact verified-claim match." The second clause authorizes the fix |
| 2026-08-09 | #5578 | isMintableEmail guard, ~2 months after email_user: minting had already stopped |
| 2026-08-13/14 | #5730, #5736 | Incoming-resident lane still patching around identity (matchCount semantics). L6 landmine comments enter the repo |
mergePersons has zero runtime callers in src/, agents/, or lambda/. ADR-0032 §4a designated identify_caller as its trigger; that call site does not exist. conversation-spine-stamp.ts:72 still asserts "today the only mergePersons caller is the voice identify_caller" — that comment is false.
Ten hand-written merge scripts exist in scripts/: merge-split-prospect-persons.ts, merge-conv-claim-dup-persons.ts, merge-contact-dup-persons.ts, _find-split-brain-persons.ts, _realign-split-brain-persons.ts, _realign-willows-split.ts, _resolve-camellia-dups.ts, _merge-org-default-dup-persons.ts, plus per-human scripts. That volume is itself the evidence the automatic path was never built. Note merge-contact-dup-persons.ts auto-merges only strict orphans (no occupancy, no active role) — a shell holding a prospect inquiry does not qualify, so it cannot repair this class.
D. Method and confidence
Prod reads only. propflow-prod, us-east-1, 32-segment parallel scan on begins_with(PK, 'PERSON#'), 16,162 rows: Person 7,006 · IdentityClaim 5,918 · HouseholdMember 1,819 · VendorMembership 833 · ProspectInquiry 329 · TenantOccupancy 235 · PersonRole 18 · PersonEvent 4. Zero writes (verified: no put_item/update_item/delete_item in any script). Camellia conversation set: 780 rows, PK=PROP#1773625953462, 100% GSI6 anchor coverage. Occupancy ground truth: 170 rows / 157 distinct persons. Artifacts in the session scratchpad.
Definitions. Anchored = not merge-redirected, holding a TenantOccupancy row or a live AppFolio-id claim. In practice the second arm never fires — all 213 anchors qualify via an occupancy row. Shell = non-anchored, non-merged, no vendor membership, no role.
Confidence labels.
- Hard, per-row verified: all counts in section 3, all file:line citations in appendices A-C, the 30-day CloudWatch warn census.
- Medium — needs adjudication: the 82 name-exact Camellia pairs. Seven duplicate names exist within the 155 anchors, so a same-name-different-human rate is non-zero.
- Lower bound, not a ceiling: split counts. AppFolio's tenant email is not stored in PropFlow —
TenantPmsSnapshot(types.ts:2966-3007) deliberately excludes identity fields ("Identity fields (name/phone/email) do NOT live here — they write to the Person spine"). An email-first prospect with no phone and a name-free address is unmatchable by any method available. Camellia's 32 email-less anchors mean "no attributable address in PropFlow," not "no split." - Inference, flagged: that
RenewalSaga/WorkOrder.personId/EscalationMatter.personIdland on the PMS-anchored record (stamp sites atworkorder-spine-stamp.ts:123,turnover-spine-stamp.ts:125were not verified this pass). The GSI nameconversation-person-indexwas not confirmed; the writer isconversation-spine-stamp.ts:283. - Open conflict: one verification pass reported no
person-stamptest file (scoped tosrc/lib/domain/identity/__tests__/, which indeed has none); a second pass quoted the pinning tests verbatim fromsrc/__tests__/person-stamp.test.ts:521andsrc/__tests__/tenant-spine-stamp.test.ts:257. The tests exist, insrc/__tests__/. Expect both to go red when the fix lands. - Not measured: message-kind distribution on the 30 Camellia email threads; any out-of-tree identity suites under
agents/clara/**(the "only two of four collision sites are covered" claim was verified oversrc/andevals/only).