ADR-0020 — Person as the universal human spine

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.roleplatform_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

  1. 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.
  2. Cross-org B2B identity. As PropFlow grows from single-property landlords toward multi-property + multi-org PMCs, a person who's an org_admin at one customer and a tenant at another becomes a routine occurrence. The data model has no shape for it.
  3. 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.
  4. 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.
  5. 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

  1. Every User, Tenant, Prospect, VendorContact has exactly one personId. No anonymous records.
  2. User.id is 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 (per src/lib/platform/auth/server.ts:141–155's delete-and-recreate flow). Phase 6a backfill must carry personId through this mutation by attaching it after the sign-in completes, not before. User.providerId is stable across role changes.
  3. PersonRole.scope is required and non-overlapping per (role, scope) tuple — a person can have one pm role at Org A and one pm role at Org B; they cannot have two simultaneous active tenant roles for the same unitId.
  4. Identity attributes (name, email, phone) live ONLY on Person. Role wrappers do not carry their own copies.
  5. Auth identifiers (passwords, OAuth tokens, sessions) live ONLY in the auth domain and reference Person.id via User.personId.
  6. 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 pm role at Org A and a tenant role 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.)
  7. PersonRole.scope must match the role's domain: pm | org_admin | leasing_agent | maintenance | viewer are always org-scoped (scope.organizationId required); platform_admin scope is governed by Open Question 2 (one of: required org, allow null, or synthetic org_propflow_staff); tenant | prospect are always property-scoped (scope.propertyId required); vendor_contact is always vendor-scoped (scope.vendorId required). Mismatched scope is a TS error caught at write-time.

What this enables

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:

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:

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:

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

Negative

Neutral / requires discipline

5. Scope boundary — not in this ADR

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

  1. Phase 6a — additive: User.personId nullable 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).
  2. 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.
  3. 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.
  4. Phase 6d — PersonRole rollout. Backfill existing User.role values into PersonRole rows. Add scope discriminator. Stage drill on RBAC behavior.
  5. 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).
  6. 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

  1. (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.

  2. (RESOLVED 2026-05-17 — see Acceptance note + persona-pass memo) platform_admin Person organizationId. ADR-0019 §2.2 locks Person.organizationId: string (required). ADR-0019 §2.4 locks platform_admin User's organizationId: null. Under Phase 6a backfill, what org does a platform_admin's Person belong to? Three options: (a) ADR-0019 schema amendment to allow Person.organizationId: string | null, (b) platform_admin stays outside the Person spine (defeats part of the purpose), (c) synthetic org_propflow_staff org that all platform_admin Persons belong to. Pick one before Phase 6a launches.

  3. (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.

  4. Person merge during Phase 6a backfill — auto-merge vs operator-review queue. Phase 1's mergedIntoPersonId machinery 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.

  5. 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."

  6. Per-role retention policy ownership. Does PersonRole carry per-role retention metadata (PersonRole.metadata.retentionDays), or does retention live in a centralized service that consults PersonRole.role? SOC2 review answers this; decision blocks Phase 6d schema finalization.

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

  8. 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.

  9. 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.

  10. ADMIN_EMAILS env var + Phase 6a — the ADMIN_EMAILS flow in src/lib/platform/auth/server.ts auto-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)?

  11. Stable subject for impersonation audit log — ADR-0019 §2.4 introduced an audit trail tagging realUser: { userId, role } and actingAs: { userId, role, orgId }. Under Phase 6, is the audit trail key User.id or Person.id? Once User.id becomes a thin wrapper, historical audit log entries (keyed by User.id) need a migration path.

8. References