Teammate identity: why remove + re-add made two people

Western Slope removed two maintenance techs and re-added them a minute later. Each is now two records, and Clara no longer knows them as staff by phone.

Proposed — pending review  Read-only inspection, 2026-10-16 · issue #20464 · industry comparison added 2026-10-16 · for Fede and Gera's lane

Finding. Three rules collide. Removing a teammate releases their work email. Adding one only looks at live emails. And joining a teammate to their existing resident record only happens when a phone is typed. Both techs live in Western Slope buildings; the re-add typed only email, so each got a fresh record with the job but no phone. The old record keeps the phone, the home and the on-call slot, but no job.

Recommendation: removal ends the job and nothing else (option B), plus an "out" marker on on-call slots: a removed person keeps their slot but is skipped when ringing, and re-adding them clears it. That meets both Gera's rule and lists staying valid across remove and re-add. First, fold each new record back into the old one (repair, done 2026-10-16, section 8).

BEFORE · OCT 13 Ryan Dyer Mobile …8290 Personal email Resident (AppFolio link) Work email Job: maintenance One record. Clara knows him. AFTER · OCT 14, 21:27 Old record Mobile …8290 Personal email Resident (AppFolio link) Work email (released) Job: maintenance (ended) New record No mobile Work email Job: maintenance Sign-in invite pending On-call list points here (still rings …8290) Team page shows this one (no phone) His call from …8290 lands on the old record: no job, so Clara does not see him as staff.
Same for Jonathan Balding (…4441). Jay Taylor, also on call, was never removed and is fine.
2. What remove and re-add do, piece by piece
PieceRemoveRe-add by email only
Sign-inDeleted, sessions endedNew sign-in, invite sent
Company membershipEndedNew one, pointing at a new record
Job (maintenance)Ended on the old recordStarted on the new record
Work emailReleased so the address stops pointing at anyoneLookup skips released addresses, so no match, so a new record
MobileKept, because the record is a resident'sNever checked: the join-by-phone step needs a typed number
Personal email, home, AppFolio linkKeptNever checked. Even a match would be refused: staff are never joined by email to a resident record
On-call listUntouchedUntouched, still names the old record
3. Who else can split
TeammateRemove keepsRe-add by emailRe-add with phone
Also a resident (this case)Phone, homeSplitsLikely joins if the names match
Ordinary teammate, any job (admin, leasing, maintenance)Nothing: email and phone both releasedSplitsSplits: the old phone was released too
Works for two companies, removed from oneEverything; only the job and membership endNo split from removal (re-add path not traced)

A teammate limited to some buildings comes back company-wide; the building list has to be picked again.

4. What breaks
FeatureTodayAfter fix + repair
Tech calls or texts ClaraBroken not seen as staff (only as a resident)Recognized as maintenance
Tech emails from work addressWorksWorks
Tech emails from personal addressNot staffRecognized
Team pageDegraded listed with no phonePhone shows
On-call chain ringingWorks by luck old record still holds the phoneWorks by design
Editing the on-call listBroken every save refused; the new record can't be added (no phone) or given the phone (old record holds it)Saves
Emergency alert emailsDegraded go to personal Gmail / iCloudWork address
Staff texts (tour alerts, team notices)Broken old has no job, new has no phoneDelivered
In-house tech dispatch textsFine go to the building's tech numberFine
Work-order assignmentFine assigned to the vendor companyFine
Their own tenancy and AppFolio syncFine stays on the old recordFine
Calendars, tour hostsUnverified stranded if set on the old recordKept
5. Does this fit Gera's design?

His model, in plain words: one person record per human per company; their phone and emails belong to that record and are unique inside the company; a job is a role that starts and ends; and leaving a company means ending the role. Lists like on-call point at the person.

Today's behaviorFit
Removal ends the roleFits
Removal also releases the work email (and, for ordinary teammates, the phone)Violates his model never touches contacts on departure
Re-add mints a second record for the same human in the same companyViolates one person per company
On-call list keeps naming a removed personViolates he ends a person's assignments with the role, "so the on-call list never pages a departed employee"

He says nothing about re-adding someone. Every option below fits his model; B is the closest, because it makes removal do only what he says.

6. Options
OptionWhat changesEffort / riskFits Gera
B. Removal ends the job only RecommendedRemoval keeps every phone and email on the record. Re-add by email or phone finds the same record, and a record already joined as a teammate is allowed back even though it is a resident's. Merges also repoint on-call and hand-off lists.Two small changes, ~2 days. Risk: a work mailbox reused for a new hire would join the old person; the name check guards it.Yes, exactly
A. Revive on re-addKeep releasing, but re-add also searches released contacts of former teammates and revives that record.~3 days; needs a new lookup over released contacts. Leaves two meanings of "released".Yes, but keeps the contact release he does not have
C. B plus a nightly split checkB, plus a nightly job that finds two records for one human in a company and merges them, no button.~4 days. Catches splits from paths we have not found.Yes

Without the "already a teammate" exception, B alone would make this case worse: the re-add would find the resident record, refuse it, and mint a record with no contacts at all. That exception is the core of the fix. None of the options repairs the existing pair by itself; that is section 8.

7. How others do it, and what we recommend

Everyone does the same thing. Removing a teammate ends their job and their sign-in, never the person or their phone and email. Re-adding them finds the old record before it creates a new one. On-call lists point at the person, and when a listed person leaves, the tool either refuses the removal or leaves a visible gap and tells someone. Never a silent hole.

Who does what

Kind of toolWhat remove doesHow re-add matchesWhat happens to lists / assignments
Horizontal SaaS
Slack, GitHub, Salesforce, HubSpot 1 2 3
Ends the membership; the account stays. Salesforce can't delete, only deactivate*Reactivate the same account; GitHub reinstates within 3 monthsHistory stays put. HubSpot: unreassigned booking pages keep booking
Identity standards
SCIM, Okta, Entra 4 5 6
Marks inactive; Okta never deletesLook up by a unique attribute, reactivate if found; Entra keeps the idGroups and apps not restored automatically
HR systems
Workday, BambooHR 7 8
Ends employment; the person id is keptWorkday rehire keeps the same person id. BambooHR blocks a new hire with an inactive email: rehire the old profile*Old directory account relinked to the rehire (Entra)
Field service
Housecall Pro, ServiceTitan 9 10
Archive / deactivate; phone and email stay attachedContacts stay bound to the old tech (reuse needs a manual rename)Tech stays on past and future jobs
On-call
PagerDuty, Opsgenie 11 12
PagerDuty removes and emails managers; Opsgenie refuses and names the schedulesLists point at the user id; the user owns the phonesVisible stub or "No-One" slot; a page already ringing keeps its list

* Wording read from a search summary, not the vendor page.

Eight decisions, in ship order

#Decision (plain English)Industry precedent
1Repair Western Slope first: fold each new record into the original (section 8). Done 2026-10-16Workday rehire relinks to the existing account
2Remove ends the job, membership and sign-in; every phone and email stays on the person. Fix to live behavior, no switchDeactivate, don't delete (SCIM, Okta, Salesforce, HubSpot, Housecall Pro)
3Re-add finds the same person by email or phone; same name, restart their job; different name on a work email, the email moves to the new hire. Buildings default to what they had. Fix to live behavior, no switchFind, then reactivate (Okta, Entra, BambooHR, GitHub); Segment refuses merges that conflict
4Merges also move the sign-in, the membership and every on-call or hand-off entry. Fix to live behavior, no switchEntra reuses the target id; PagerDuty lists point at ids
5On-call lists point at a person, who owns the phone. Already true. Fix to live behavior, no switchPagerDuty, Opsgenie
6Listed person removed: keep the slot, mark it "out", skip it when ringing; re-add clears it. A list with nobody live alerts the alerts channel and the property page, no button. Fix to live behavior, no switchPagerDuty stub plus notice; Gera's own test marks rows out
7Resident who is also staff: one person, two roles; the staff role wins on the company's line.ServiceTitan one user plus one tech; HOA portals link accounts
8Later: a nightly job finds two records for one human and merges them (option C).Segment's deterministic identity graph

Where we are on our own

Nobody studied recognizes an inbound caller as staff by phone, and nobody holds resident records from a PMS. So two things need a real end-to-end call as proof: a removed teammate's number comes back as not staff, and a resident who is also active staff comes back as staff.

Full research report, every claim cited, for engineers: agents/005/reports/Staff invite and removal design.md on Fede's machine. It is not published.

8. Western Slope repair Done 2026-10-16

Direction: fold the new record into the old one. The old record already holds the phone, the home, the AppFolio link, and the on-call slot, so nothing in settings needs repointing.

#Write, per tech (Ryan, Jonathan)TouchesUndo
1Release the work email on the new recordNew record's emailRevive it
2Revive the work email on the old record, as a work address beside the personal oneOld record's emailRelease it
3Start the maintenance job on the old recordOld record's rolesEnd it
4Point the pending sign-in and the company membership at the old recordLogin, membershipPoint back
5End the new record's job and mark it merged into the old oneNew recordUnmark, restart
6Check: call lookup by …8290 / …4441 says maintenance; on-call list saves; Team page shows the phoneReads onlyn/a

Nothing is deleted; every write is a flag or pointer and can be reversed. The other direction (old into new) would move the home and AppFolio link onto the new record, leave two main emails, and need two on-call entries repointed. Not recommended.

9. Not verified
  • Whether re-adding with a phone would have passed the name check ("Ryan Dyer" against "Ryan M. Dyer").
  • Calendars and tour-host lists; whether either tech has one set.
  • Exactly how Clara handles their calls now beyond "not staff" (the resident path was not traced end to end).
  • Whether the tech's pending invite, once accepted, keeps the repointed login. Code says yes (accept and first sign-in find the login by the work email, which now lives on the original record; the link step is skipped because the login already has a record); not exercised by a real sign-in.
  • The re-add path for someone who works for two companies.
Engineering appendix (for Gera's lane)

All paths are in the main app repo at origin/main 3ea3da5d9f (2026-10-16). Prod reads ran read-only against propflow-prod / propflow-auth-prod. Numbers are masked to the last 4 digits.

A1. Prod state (org_c42d9150-b9ad-46f8-8f5f-b0fe56bacf38)

WhoOLD personNEW person
Ryanpers_16e27b3d-79ca-4f06-8458-dcedaca7d427. Source backfill 09-13. Live claims: phone …8290 (primary), gmail (primary), pms_external_id appfolio:2024. Work email staffContact:true, primary:false, created 10-10, now deprecated. No live role. Active tenancy at a WS building. No User.pers_183a6c3b-17f1-4aeb-9f0d-66b76e73db27, created 10-14T21:27:05Z. Work email primary. maintenance org-scoped, source auth-hook. User 8f886390-… status invited.
Jonathanpers_593c4543-ab5d-4ae1-9294-97b7b7518e06. Same shape: phone …4441, icloud, appfolio:1389, deprecated staffContact work email (10-10). Active tenancy.pers_f3f391e8-fb4b-44c2-93d2-9541784bbc6a, created 10-14T21:26:34Z. User 2014c19a-… invited.
Jay (control)pers_224f0528-…. Single person, org_admin. Lookup by phone and by email both resolve.

readEmergencyOnCall(org).residential.people = [Ryan OLD …8290, Jay …3326, Jonathan OLD …4441]. resolveInboundStaffMembership(…8290|…4441, org) = null. resolveInboundStaffMembershipByEmail(work email, org) = the NEW person with maintenance. By personal email = null.

A2. Remove: DELETE /api/admin/users/[id]

  • src/app/api/admin/users/[id]/route.ts DELETE. A single-company login takes the whole-login branch: revokeAllUserSessions → revokeInvite → purgeAuthRowsForUser → closeOutWholeLogin → deleteUser. A login with another live company takes endTeammateMembership only, with no claim writes.
  • src/lib/domain/team/teammate-membership.ts closeOutWholeLogin. For each org: always deprecateClaim on a staffContact email or phone. It also deprecates the primary email and phone when primariesAreTheLogins, meaning no live pms_external_id and no non-workspace role. Then endTeammateMembership (ends WORKSPACE_TIERS roles) and endOrgMembership.
  • src/lib/data/dynamo/persons.ts deprecateClaim frees the dedup sentinel in the same transaction (releaseSentinelsForDeadClaim). findPersonByClaim filters deprecated rows. After removal the value resolves to nobody.

A3. Re-add: POST /api/admin/users → linkUserToSpine

  • src/app/api/admin/users/route.ts: getUserByEmail finds nothing (User deleted). matchPhone is passed only when the admin typed a phone.
  • src/lib/platform/auth/link-user-to-spine.ts linkUserToSpine:
    1. findTeammatePhoneMatch returns null when there is no phone. This is the only branch that joins a staff invite to a PMS-anchored person.
    2. findPersonByClaim('email') misses, because the claim is deprecated.
    3. Even on a hit, pmsImported = isStaffRole && carriesPmsLink(existing) makes it mint a fresh person and skip the email claim (pms_imported_skipped).
    4. The fresh buildPersonRole uses scope: { organizationId } and source: 'auth-hook'. recordOrgMembershipOnLink writes the new membership.
  • Consequence for option B: once claims stop being deprecated, step 2 hits OLD and step 3 mints a contactless person. B must change step 3 so that a person already holding a staffContact claim for this email (a prior deliberate join) is linked, not refused.
  • Consequence for option A: a revive needs a new reader, "claims by (org, type, normalizedValue) including deprecated", in src/lib/data/dynamo/persons.ts. The sentinel is gone, so it would have to be a GSI read.

A4. Blast-radius code paths

  • Inbound staff: src/lib/domain/staff/inbound-resolution.ts resolveInboundStaffMembershipInternal, staffCandidatesInLineCompany. The phone resolves to OLD (no active role). The active-role fallback finds NEW but drops it, because NEW has no live claim on the phone.
  • Team page: src/app/api/team/route.ts listCompanyTeam (logins → enrichUserRows). Picker: src/lib/domain/team/company-people.ts listCompanyPeople (active roles → resolvePersonContactsByIds), which gives NEW with phone: ''.
  • Ring time: src/lib/domain/identity/resolve-handoff-phone.ts resolveOnePhone. It reads the person's live primary phone claim, else the snapshot. No role check, so removed people keep ringing.
  • On-call save: src/lib/domain/escalation/transfer-numbers-settings.ts onCallPeopleOf refuses any pick not in the active staff list ("isn't on this company's Team"), and refuses a pick with no phone. src/lib/domain/settings/ring-list-people.ts setRingPersonPhone → upsert-phone-claim.ts returns 409 while OLD holds the number.
  • Alert email: src/lib/data/claim-selection.ts staffEmailFromClaims uses the live staffContact email, else the primary. Today that is the personal address.
  • Staff texts: src/lib/domain/leasing/staff-tour-alert-subscribers.ts, team-notify/sms-sender.ts (active role + phone).
  • Settings that embed a personId: human_handoff.people[], emergency.route.residential|commercial.people[], Organization.tourShowers[], and per-person staff.tourAlerts and calendar. ring_list.*, catch_all and leasing_digest.recipients embed none.

A5. Merge primitive

  • src/lib/domain/identity/merge-persons.ts mergePersons. It refuses only self-merges, person_link_exists, and cross-org or already-merged pairs (in softRedirectPerson). It ends the source's active roles and never moves them. It moves live claims (transferClaim, which keeps primary, so two primaries are possible) and references (merge-move-references.ts moveReferencesToSurvivor). It does not touch User.personId, OrgMembership.personId, or any settings that embed a personId. repairPrimaryAfterMerge runs only in identity-merge-heal.ts.
  • The auto-heal (identity-merge-decision.ts decideIdentityMerge) would answer both_lease_anchored (OLD has the AppFolio anchor, NEW has the role). It would also never fire, because the two persons share no live claim value.
  • Proposed change, in every option: mergePersons repoints embedded personId settings, moves the login and membership pointers, and runs the primary repair.

A6. Repair shape (executed 2026-10-16, issue #20464)

As run: savePersonRole(OLD, maintenance, manual_pm) → deprecateClaim(NEW work email) → addClaim(OLD, primary:false, staffContact:true; status created, not a revive) → saveUser personId→OLD → recordOrgMembership personId→OLD → mergePersons(NEW→OLD, manual_merge): 0 claims, 1 role ended, 0 references moved. New roles: Ryan role_1348ffc7…, Jonathan role_a415341b….

  1. For each NEW→OLD pair: deprecateClaim(NEW, workEmailClaim).
  2. addClaim on OLD with the same value and {primary:false, staffContact:true, deprecated:false}. This takes the same-person revive path (_reviveDeprecatedClaim). Check that staffContact survives the revive.
  3. savePersonRole on OLD: maintenance, {organizationId}, ledgered source.
  4. Set User.personId to OLD and write recordOrgMembershipOnLink or the equivalent repoint.
  5. endPersonRole(NEW), then softRedirectPerson(NEW→OLD).
  6. Verify: resolveInboundStaffMembership by phone returns maintenance, listCompanyPeople shows the phone, and a dry-run onCallPeopleOf accepts the current list.

Resume and suspend (resumeTeammateMembership) already preserve the person. It is the existing "turn off / turn on" shape that removal could reuse.

PropFlow Docs