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).
| Piece | Remove | Re-add by email only |
|---|---|---|
| Sign-in | Deleted, sessions ended | New sign-in, invite sent |
| Company membership | Ended | New one, pointing at a new record |
| Job (maintenance) | Ended on the old record | Started on the new record |
| Work email | Released so the address stops pointing at anyone | Lookup skips released addresses, so no match, so a new record |
| Mobile | Kept, because the record is a resident's | Never checked: the join-by-phone step needs a typed number |
| Personal email, home, AppFolio link | Kept | Never checked. Even a match would be refused: staff are never joined by email to a resident record |
| On-call list | Untouched | Untouched, still names the old record |
| Teammate | Remove keeps | Re-add by email | Re-add with phone |
|---|---|---|---|
| Also a resident (this case) | Phone, home | Splits | Likely joins if the names match |
| Ordinary teammate, any job (admin, leasing, maintenance) | Nothing: email and phone both released | Splits | Splits: the old phone was released too |
| Works for two companies, removed from one | Everything; only the job and membership end | No 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.
| Feature | Today | After fix + repair |
|---|---|---|
| Tech calls or texts Clara | Broken not seen as staff (only as a resident) | Recognized as maintenance |
| Tech emails from work address | Works | Works |
| Tech emails from personal address | Not staff | Recognized |
| Team page | Degraded listed with no phone | Phone shows |
| On-call chain ringing | Works by luck old record still holds the phone | Works by design |
| Editing the on-call list | Broken every save refused; the new record can't be added (no phone) or given the phone (old record holds it) | Saves |
| Emergency alert emails | Degraded go to personal Gmail / iCloud | Work address |
| Staff texts (tour alerts, team notices) | Broken old has no job, new has no phone | Delivered |
| In-house tech dispatch texts | Fine go to the building's tech number | Fine |
| Work-order assignment | Fine assigned to the vendor company | Fine |
| Their own tenancy and AppFolio sync | Fine stays on the old record | Fine |
| Calendars, tour hosts | Unverified stranded if set on the old record | Kept |
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 behavior | Fit |
|---|---|
| Removal ends the role | Fits |
| 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 company | Violates one person per company |
| On-call list keeps naming a removed person | Violates 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.
| Option | What changes | Effort / risk | Fits Gera |
|---|---|---|---|
| B. Removal ends the job only Recommended | Removal 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-add | Keep 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 check | B, 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.
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.
| Kind of tool | What remove does | How re-add matches | What 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 months | History stays put. HubSpot: unreassigned booking pages keep booking |
| Identity standards SCIM, Okta, Entra 4 5 6 | Marks inactive; Okta never deletes | Look up by a unique attribute, reactivate if found; Entra keeps the id | Groups and apps not restored automatically |
| HR systems Workday, BambooHR 7 8 | Ends employment; the person id is kept | Workday 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 attached | Contacts 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 schedules | Lists point at the user id; the user owns the phones | Visible stub or "No-One" slot; a page already ringing keeps its list |
* Wording read from a search summary, not the vendor page.
| # | Decision (plain English) | Industry precedent |
|---|---|---|
| 1 | Repair Western Slope first: fold each new record into the original (section 8). Done 2026-10-16 | Workday rehire relinks to the existing account |
| 2 | Remove ends the job, membership and sign-in; every phone and email stays on the person. Fix to live behavior, no switch | Deactivate, don't delete (SCIM, Okta, Salesforce, HubSpot, Housecall Pro) |
| 3 | Re-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 switch | Find, then reactivate (Okta, Entra, BambooHR, GitHub); Segment refuses merges that conflict |
| 4 | Merges also move the sign-in, the membership and every on-call or hand-off entry. Fix to live behavior, no switch | Entra reuses the target id; PagerDuty lists point at ids |
| 5 | On-call lists point at a person, who owns the phone. Already true. Fix to live behavior, no switch | PagerDuty, Opsgenie |
| 6 | Listed 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 switch | PagerDuty stub plus notice; Gera's own test marks rows out |
| 7 | Resident 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 |
| 8 | Later: a nightly job finds two records for one human and merges them (option C). | Segment's deterministic identity graph |
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.
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) | Touches | Undo |
|---|---|---|---|
| 1 | Release the work email on the new record | New record's email | Revive it |
| 2 | Revive the work email on the old record, as a work address beside the personal one | Old record's email | Release it |
| 3 | Start the maintenance job on the old record | Old record's roles | End it |
| 4 | Point the pending sign-in and the company membership at the old record | Login, membership | Point back |
| 5 | End the new record's job and mark it merged into the old one | New record | Unmark, restart |
| 6 | Check: call lookup by …8290 / …4441 says maintenance; on-call list saves; Team page shows the phone | Reads only | n/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.
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.
| Who | OLD person | NEW person |
|---|---|---|
| Ryan | pers_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. |
| Jonathan | pers_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.
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.POST /api/admin/users → linkUserToSpinesrc/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:
findTeammatePhoneMatch returns null when there is no phone. This is the only branch that joins a staff invite to a PMS-anchored person.findPersonByClaim('email') misses, because the claim is deprecated.pmsImported = isStaffRole && carriesPmsLink(existing) makes it mint a fresh person and skip the email claim (pms_imported_skipped).buildPersonRole uses scope: { organizationId } and source: 'auth-hook'. recordOrgMembershipOnLink writes the new membership.staffContact claim for this email (a prior deliberate join) is linked, not refused.src/lib/data/dynamo/persons.ts. The sentinel is gone, so it would have to be a GSI read.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.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: ''.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.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.src/lib/data/claim-selection.ts staffEmailFromClaims uses the live staffContact email, else the primary. Today that is the personal address.src/lib/domain/leasing/staff-tour-alert-subscribers.ts, team-notify/sms-sender.ts (active role + phone).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.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.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.mergePersons repoints embedded personId settings, moves the login and membership pointers, and runs the primary repair.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….
deprecateClaim(NEW, workEmailClaim).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.savePersonRole on OLD: maintenance, {organizationId}, ledgered source.User.personId to OLD and write recordOrgMembershipOnLink or the equivalent repoint.endPersonRole(NEW), then softRedirectPerson(NEW→OLD).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.