Proposed — pending Fede's review. Six decisions in §5 are multiple-choice with recommendations. Produced by the 15-agent fleet audit of 2026-08-14; every claim adversarially re-verified.

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 Prospect and signs a lease at Property B becomes a Tenant with 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_applications poller creates a Prospect and the tenant_directory poller creates a Tenant for the same human, the two records are not linked. — :21

The decision that followed:

Introduce a stable Person entity. Phone, email, and PMS external IDs are claims on a Person, not identity. — :29

Person.id is 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:

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-mergeddocs/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) → applyEntitySyncsaveTenantsspineStampTenantBatchOrThrow (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) email keep mine, warn :790
4 :866 (phone-reuse path) email keep mine, warn :872live 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) email :1049 no phone → follow winner; :1060 phone present → keep mine, warn :1071live 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

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.

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):

Related Camellia counts, all per-row verified:

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)

  1. 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.
  2. The same unit-418 lease exists twice under two different people (OCCUPANCY#1773625953462… and OCCUPANCY#pers_f9c6385b…). Occupancy keys use three incompatible schemes across the 170 rows: 101 PMS-numeric, 37 tenant_…, 32 pers_….
  3. A placeholder address squats the dedup partition: two people both claim temp@gmail.com.
  4. 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:

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

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 logWarnconsole.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:

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

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:

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_verifiedmanual_pmemail_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?

Decision 2 — Where does the decision fire?

Decision 3 — How strong must the evidence be to re-point automatically?

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.

Decision 5 — What happens to the eval that currently forbids the fix?

Decision 6 — ADR paperwork


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

Order of operations

  1. Free the claim sentinel on deprecation (ADR-0054 §1.4) — nothing else can run first
  2. Dedupe the amplified claim rows (one record has 244 copies of one address)
  3. Backfill AppFolio-id claims onto the 37 Camellia anchors that lack them
  4. Land the chokepoint fix + rewrite the two pinning tests and the P7 eval
  5. Ship the nightly claim-coherence invariant — it should print 88, then count down
  6. Run the sweeper against the corroborated cohort; queue the rest for PM adjudication
  7. Resolve the unit-418 duplicate occupancy and the temp@gmail.com squatter 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-5251email-lease-answer-identity.ts:379, resolve at :402, occupancy read at :472-486 Shell has zero occupancies → verdict 'none' at :482isCurrentResident=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-5475incoming-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_INCOMINGbook_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=falsenew-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.todothat 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.

PropFlow Docs