ADR-0020 — Person as the universal human spine
- Status: Accepted
- Authors: Gera (driver) + Claude
- Deciders: Gera (Jose) — author; sane (Sean) and feed (Fede) lenses applied via solo persona pass per
feedback_personas_replace_workshops. - Acceptance note: Status flipped to
Acceptedon 2026-05-17 via the persona-pass decisions memo atdocs/data-model-migration/decisions/2026-05-17-adr-0020-persona-pass.md. All 11 open questions in §7 are either resolved (Q1, Q2, Q3, Q4, Q9, Q10, Q11) or explicitly deferred to a later sub-phase with no impact on Phase 6a launch (Q5 → 6d SOC2 gate, Q6 → 6d SOC2 gate, Q7 → Phase 7, Q8 → Phase 7). The two BLOCKER questions got concrete decisions: (Q1) link to existing Person across the org boundary; PersonRole stays org-scoped — ADR-0019 §2.7 amendment paragraph lands in the Phase 6a code PR. (Q2) syntheticorg_propflow_staffOrganization holds all platform_admin Persons;Person.organizationId: stringstays required everywhere. - Date: 2026-05-06 (drafted), 2026-05-17 (accepted)
- Supersedes: ADR-0018 §Authentication boundary
- Related: ADR-0018 (Person identity model), ADR-0019 (Organization model)
Superseding note. ADR-0018 §Authentication boundary established that Person (property-side identity, in propflow-prod) and Better Auth User (auth-side identity, in propflow-auth-prod) live in different repositories with no cross-references. ADR-0020 evolves that decision in light of the failure modes documented in §1 Context. The boundary still holds at the auth-tokens-and-sessions level — Better Auth still owns sessions, password hashes, OAuth tokens, and provider IDs — and User.id remains the immutable auth subject. What changes is that the human behind a User is recognized as the same Person as the human behind a Tenant when their phone/email match. The auth domain keeps its boundary; the identity domain stops being two parallel universes.
1. Context
ADR-0018 introduced the Person model as the canonical identity for the property-side humans the platform automates against — tenants, prospects, vendor contacts. ADR-0019 introduced the Organization model and split the user role surface (User.role → platform_admin | org_admin | pm).
The two identity domains are deliberately separate today:
| Domain | Entity | Records | Lives in |
|---|---|---|---|
| Auth-side (people who log into the dashboard) | User |
PMs, platform admins | propflow-auth-prod |
| Property-side (people the system acts upon) | Person |
Tenants, prospects, vendor contacts | propflow-prod (PERSON#…) |
This separation has held cleanly through Phases 0.5 / 1 / 2 / 3a / 3b / 3c. It also has a known cost: PropFlow's automation cannot recognize a single human across the two domains.
Concrete failure modes that motivate this ADR
- PM-as-tenant collision. A property manager at PropFlow customer A rents an apartment at PropFlow customer B. Today: two completely unlinked records. Clara may address her by the wrong name depending on which lookup path resolves the inbound phone first.
- Cross-org B2B identity. As PropFlow grows from single-property landlords toward multi-property + multi-org PMCs, a person who's an
org_adminat one customer and atenantat another becomes a routine occurrence. The data model has no shape for it. - Clara's reach is naturally cross-role. Clara texts tenants (renewals), prospects (tour follow-ups), vendors (WO scheduling), and increasingly PMs (dashboard alerts, anomaly notifications). Today there are three identification paths (
getTenantByPhone,getProspectByPhone, no path for User-by-phone) and they don't reconcile. - Onboarding overlap. A PM signs up with email X. Months later she rents a unit at her own portfolio (perhaps after relocating). The ingestion pipeline creates a separate Person record because it has no path to discover she's already in the system as a User.
- Future identity-from-claim. A vendor contact who later becomes a PM (e.g. a maintenance tech promoted to assistant manager). Today: orphan VendorContact + new User. No through-line.
The Person model already proved (Phase 1) that phone- and email-based claim resolution is feasible across multiple property-side roles. The natural next step is to extend the same primitive across all human surfaces, including Users.
2. Decision
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.
Target shape
Person // canonical human record
id: pers_xxx
primaryName, alternateNames[]
emails[], phones[] // claim-resolved (Phase 1 primitive)
preferredLanguage, timeZone // optional human attributes
deceased: bool // hard lifecycle, not role-scoped
createdAt, updatedAt
PersonRole // role-relationship records
personId
role: 'tenant' | 'prospect' // property-side
| 'pm' | 'org_admin' | 'leasing_agent' | 'maintenance' | 'viewer' // auth-side (per ADR-0019 §2.4)
| 'platform_admin' // PropFlow staff
| 'vendor_contact' // vendor-side
scope: { propertyId? | organizationId? | vendorId? }
active: bool
startedAt, endedAt?
metadata: { ...role-specific fields }
User // auth wrapper, thin
personId
authProvider: 'google' | 'magic-link' | 'password'
providerId, passwordHash, sessionState
// role/orgId moves to PersonRole
Tenant → personId + unitId + leaseStart + leaseEnd + ... (role wrapper)
Prospect → personId + propertyOfInterest + stage + ... (role wrapper)
VendorContact → personId + vendorId + position + ... (role wrapper)
The role wrappers (Tenant, Prospect, VendorContact, User) become thin relationship records that own role-specific state. Identity attributes (name, email, phone) live on Person.
Invariants
- Every
User,Tenant,Prospect,VendorContacthas exactly onepersonId. No anonymous records. User.idis immutable after first sign-in. The pre-invite-to-active transition is a one-time exception where the invited record's User.id resolves to the Better Auth-generated ID at sign-in (persrc/lib/platform/auth/server.ts:141–155's delete-and-recreate flow). Phase 6a backfill must carrypersonIdthrough this mutation by attaching it after the sign-in completes, not before.User.providerIdis stable across role changes.PersonRole.scopeis required and non-overlapping per(role, scope)tuple — a person can have onepmrole at Org A and onepmrole at Org B; they cannot have two simultaneous activetenantroles for the sameunitId.- Identity attributes (name, email, phone) live ONLY on
Person. Role wrappers do not carry their own copies. - Auth identifiers (passwords, OAuth tokens, sessions) live ONLY in the auth domain and reference
Person.idviaUser.personId. - Cross-org claim resolution: same phone/email across organizations → same Person (post-2026-05-17 acceptance). PersonRole rows stay org-scoped (a single Person can hold a
pmrole at Org A and atenantrole at Org B simultaneously). Access-layer org isolation is preserved by PersonRole filtering; identity-layer collapse happens at the Person row. (Per Q1 acceptance decision — see Acceptance note and the persona-pass memo. ADR-0019 §2.7's pre-spine "two Persons" rule is amended in the Phase 6a code PR.) PersonRole.scopemust match the role's domain:pm | org_admin | leasing_agent | maintenance | viewerare always org-scoped (scope.organizationIdrequired);platform_adminscope is governed by Open Question 2 (one of: required org, allow null, or syntheticorg_propflow_staff);tenant | prospectare always property-scoped (scope.propertyIdrequired);vendor_contactis always vendor-scoped (scope.vendorIdrequired). Mismatched scope is a TS error caught at write-time.
What this enables
- Clara recognizes the human. "Sarah is the PM at Property A AND a tenant at Property B" — Clara can disambiguate context per conversation rather than picking whichever record the lookup resolved first.
- Universal contact card. One profile per human, with their roles attached. Customer support, audit, and merge UX all become simpler.
- Role-transition lifecycle. A vendor contact who's promoted to PM gets a new
PersonRolerow, not a new identity. History is preserved. - Multi-org B2B identity. A platform_admin at PropFlow's network can also be a tenant somewhere; we know this is the same person.
- Cleaner authz. RBAC reads iterate
PersonRolerows for(personId, scope). Permission checks become per-(person, role, scope) instead of per-User-with-conflated-fields.
3. Alternatives considered
Alternative 1 — keep the two-domain split forever
Pros: Zero migration cost. Auth surface stays trivially scoped to PMs. SOC2/PII boundaries are bright-line.
Cons:
- The PM-as-tenant collision and cross-org B2B identity gaps remain permanent. As the customer base scales beyond single-property landlords, both will become routine production issues.
- Clara's identification logic stays fragmented (3 lookup paths today, plus whatever path is needed for PM-by-phone in the future) instead of one canonical phone-match.
- Every future human-surface (e.g. external brokers, building-owner contacts, applicant cosigners) becomes another fragmented identity store.
Verdict: Acceptable for today's scale, but the failure modes accumulate as the network grows. Rejected as the long-term shape.
Alternative 2 — promote User.email to a phone-claim equivalent and call it done
Pros: Smaller change. No migration. Just teach the ingestion pipeline to check User table when matching by email/phone.
Cons:
- Doesn't solve the multi-role-per-human case. A PM-tenant still has a User record AND a Tenant record with no link.
- The "scattered phone-match" problem persists (now there are 4+ tables to check).
- Not a structural fix; it's a band-aid that adds query fan-out.
Verdict: Saves migration cost but doesn't actually solve the underlying problem. Rejected.
Alternative 3 — make Person an optional pointer from Tenant/Prospect/User (current Phase 1 shape, extended to User)
Pros: Already partially there. Tenant.personId exists. Just add User.personId and stop.
Cons:
- Doesn't address the role wrappers carrying duplicate identity attributes (name, email, phone) which immediately drift as a person updates one surface but not the other.
- Doesn't enable multi-role-per-human — Tenant still owns the lease relationship; User still owns the auth relationship; a person who's both has duplicated identity fields that need to stay in sync forever.
- Pushes the integrity concerns into operational forever-land.
Verdict: Half-measure. The cleaner shape is to make Person the canonical owner of identity attributes and make role wrappers thin relationships. Rejected as too leaky.
4. Consequences
Positive
- One canonical "human" in the system. Every role is a relationship to that human.
- Cross-role queries (
is this PM also a tenant?) become a single PersonRole join. - Clara's identification logic collapses to one phone/email match path.
- Future role surfaces (external brokers, ICE contacts, applicant cosigners) extend the same PersonRole primitive without new identity tables.
- The retention / privacy / SOC2 model becomes per-role rather than per-table — finer-grained and clearer to audit.
Negative
- Significant migration cost. Estimated 2–3 weeks of swarm work + 7-day production bake (mirrors the current Phase 3c-shadow pattern).
- Auth coupling needs careful sequencing. Better Auth depends on stable subject IDs;
User.personIdmust be immutable post-creation. - Authorization model touches every gate in the codebase. RBAC reads currently check
User.roledirectly; under the new shape they iteratePersonRolerows. - SOC2 / privacy review needed. Per-role retention policies must be documented in the unified model.
- Adds a layer of indirection.
getTenant().namebecomesgetTenant().person.name(or projected via a denormalized read). - GDPR cascade across the unified spine. A tenant Person sending an erasure request must be deleted, but if that Person is also a
pmUser at another org with active session/role, cascading the delete terminates dashboard access; not cascading leaves PII on a live auth record. Phase 6d (PersonRole rollout) must land with an explicit cascade policy resolved (Open Question 5 below). Soft-archive on auth-side roles + hard-delete on property-side identity attributes is the most likely shape but needs Sean SOC2 sign-off.
Neutral / requires discipline
- Role enum extensibility. Adding new roles (e.g.
ice_contact,cosigner,external_broker) becomes a schema change to the role enum + a new wrapper type. Manageable but needs a process. - Wrapper denormalization for read perf. The Tenant page reads
tenant.person.name. To avoid the join cost, projections may denormalize identity fields into wrappers, with a strict policy that Person is the source of truth and writes go there first.
5. Scope boundary — not in this ADR
- Children / dependents on a lease (minors are mentioned in notes; not modeled). Stays out.
- Renters' insurance agents — adjacent humans, not in PropFlow's automation surface. Stays out.
- Building owners / investors — modeled by AppFolio, not by PropFlow. Stays out.
- Wrong-number senders / one-off humans — Clara's identify-caller already gracefully handles unidentified callers without minting Person rows. Stays out.
The boundary stays: Person = humans PropFlow can take action toward. The expansion is to recognize that PMs, platform admins, and any future automated-touch role belong inside that boundary alongside tenants/prospects/vendor-contacts.
(These adjacent humans — ICE contacts, cosigners, brokers, building owners — are out of Phase 6 scope but reuse the same PersonRole primitive when they enter scope, per §Consequences/Positive. No separate identity tables for any of them.)
6. Implementation plan (high level)
Sequencing: ADR-0020 implementation does not begin until Phase 5 of ADR-0018 closes. The current 0.5/1/2/3a/3b/3c/4/5 migration is property-side identity and must complete before the auth-side migration starts. Adding User-domain work to the in-flight migration would explode scope and slip the calendar.
Estimated phases (to be detailed in a separate planning doc):
- Phase 6a — additive:
User.personIdnullable column. Backfill: for each User, create or link a Person matching by primary email + phone. Same protection-bar shape as Phase 0.5 / Phase 1 (DDB backup, audit log, exit codes 0/1/2/3, stage primary preflight). - Phase 6b — shadow read. Each User read also reads the Person; field-by-field diff logged via the same
phase3c.shim_*pattern that Phase 3c-shadow proved out. 7-day calendar bake. - Phase 6c — flip per-route. Route-by-route migrate User reads to use Person as identity source (name, email, phone). Same risk-ladder as Phase 3c-flip.
- Phase 6d — PersonRole rollout. Backfill existing User.role values into PersonRole rows. Add scope discriminator. Stage drill on RBAC behavior.
- Phase 6e — drop User identity fields. After all readers point at Person for identity, drop
User.name,User.email,User.phone(or whatever is duplicated). - Phase 6f — destructive cleanup (HARD STOP). Same protection-bar as Phase 5 — split into 6f-a (code cleanup, reversible) and 6f-b (DDB row deletes, with PITR export pre-flight).
A full Phase 6 master plan will be drafted before Phase 6a launches.
7. Open questions
(RESOLVED 2026-05-17 — see Acceptance note + persona-pass memo) Cross-organization Person-merge policy. ADR-0019 §2.7 states a tenant moving from Org X to Org Y becomes two Persons (cross-org isolation). Under Phase 6a backfill, when a User in Org A has the same email/phone as an existing Person in Org B, do we (a) link to the existing Person across the org boundary (violates ADR-0019 §2.7), (b) create a new org-scoped Person and accept the recognition gap, (c) introduce a higher-level "global identity layer" above Person whose semantics still need design? Pick one before Phase 6a backfill writes any rows. Workshop required.
(RESOLVED 2026-05-17 — see Acceptance note + persona-pass memo)
platform_adminPerson organizationId. ADR-0019 §2.2 locksPerson.organizationId: string(required). ADR-0019 §2.4 locksplatform_adminUser'sorganizationId: null. Under Phase 6a backfill, what org does a platform_admin's Person belong to? Three options: (a) ADR-0019 schema amendment to allowPerson.organizationId: string | null, (b)platform_adminstays outside the Person spine (defeats part of the purpose), (c) syntheticorg_propflow_stafforg that all platform_admin Persons belong to. Pick one before Phase 6a launches.(Phasing question) Phase 6d sub-split. The current ADR phases Phase 6d as a single PersonRole-rollout-plus-RBAC-migration step. The execution log's Phase 3c-shadow → 3c-flip pattern argues for splitting into 6d-a (PersonRole backfill, dark writes), 6d-b (shadow RBAC, 7-day bake comparing User.role vs PersonRole reads), 6d-c (flip RBAC primary to PersonRole). Decision: bundle (faster, less calendar) vs split (matches established pattern, more bakes). Default to split unless workshop says otherwise.
Person merge during Phase 6a backfill — auto-merge vs operator-review queue. Phase 1's
mergedIntoPersonIdmachinery handles existing Person merges, but Phase 6a backfill encounters new merge candidates: a PM whose User row matches an existing Tenant Person on phone. Do we auto-merge during backfill or fail-closed and surface to an operator review queue? Recommend fail-closed (operator review) — backfill is a one-time event and the conservative posture matches Phase 1 precedent.Auth-side lifecycle on Person death / GDPR delete. When a Person dies or is GDPR-deleted, does the auth record also die? Today Better Auth has its own lifecycle. The unified model needs an explicit policy. See §Consequences/Negative for the cascade framing; this question becomes "what's the exact code path that fires when the property-side Person is hard-deleted while an auth-side User still has active sessions/role."
Per-role retention policy ownership. Does PersonRole carry per-role retention metadata (
PersonRole.metadata.retentionDays), or does retention live in a centralized service that consultsPersonRole.role? SOC2 review answers this; decision blocks Phase 6d schema finalization.SAML / SSO subject ID future. What's the stable subject ID for SAML / SSO if we add it? Probably
Person.id, but Better Auth may need a wrapper. Out of Phase 6 scope; flag for Phase 7 design.External brokers / cosigners — Phase 6 or Phase 7? They'd extend the same PersonRole primitive. Probably defer to a Phase 7 follow-up to avoid scope creep.
Cross-org Person collision during Phase 6a backfill — when User-A@Org-1 has the same email as an existing Person-B@Org-2, link or create-new? Resolution depends on Q1.
ADMIN_EMAILSenv var + Phase 6a — theADMIN_EMAILSflow insrc/lib/platform/auth/server.tsauto-promotes emails to platform_admin on first sign-in. After Phase 6a, does this flow create a skeleton Person? Of what org (depends on Q2)?Stable subject for impersonation audit log — ADR-0019 §2.4 introduced an audit trail tagging
realUser: { userId, role }andactingAs: { userId, role, orgId }. Under Phase 6, is the audit trail keyUser.idorPerson.id? OnceUser.idbecomes a thin wrapper, historical audit log entries (keyed by User.id) need a migration path.
8. References
- ADR-0018 — Person identity model (Phase 1)
- ADR-0019 — Organization model (Phase 0.5)
docs/data-model-migration/master-plan.md— current 0.5/1/2/3/4/5 plandocs/data-model-migration/execution-log.md— Phase 1 retrospect, Phase 3b W1+W2 retrospect, Phase 3c-shadow retrospect (precedent for shadow→flip→cleanup pattern)