0018 — Person identity model: claims and roles

Context

PropFlow's data model treats Tenant and Prospect as sibling root records, both keyed primarily by phone number when joining across other entities (Conversation, WorkOrder, Tour). This was fine when the product was one property and one channel. It is now actively breaking as we grow:

The deeper question: what is the identity of a contact in PropFlow?

The current implicit answer — "a phone number plus a role" — is a natural-key identity model, and natural-key identity is one of the most well-documented anti-patterns in CRM and contact-management systems. The well-trodden alternative is stable opaque ID + identity claims + roles (Stripe's Customer / PaymentMethod, HubSpot's Contact + identity profiles, Auth0's User + Identity providers, Salesforce's LeadContact conversion).

Decision

Introduce a stable Person entity. Phone, email, and PMS external IDs are claims on a Person, not identity. Roles like "tenant of this lease" or "prospect at this property" become role records that reference a Person.

Three new entities:

1. Person — the stable identity

interface Person {
  id: string;                          // pers_<uuid>, never reused, never reassigned
  displayName: string;                 // best-known name; computed from claims/roles
  preferences: {
    languagePreference: LanguageCode | null;
    languageAskedAt: string | null;
    timezone?: string;
    smsOptOut?: boolean;
    emailOptOut?: boolean;
  };
  notes?: string;                      // PM-authored, person-level
  createdAt: string;
  updatedAt: string;
  // Soft-merge support
  mergedIntoPersonId?: string;
  mergedAt?: string;
}

Person.id is the only thing we join on across the system. It never changes for the lifetime of a human.

2. IdentityClaim — every way a Person can be looked up

interface IdentityClaim {
  id: string;                          // claim_<uuid>
  personId: string;                    // → Person.id
  type: 'phone' | 'email' | 'pms_external_id';
  value: string;                       // E.164 phone, lowercase email, or "appfolio:12345"
  source: 'sms_verified' | 'email_verified' | 'appfolio_sync' | 'manual_pm' | 'self_declared';
  verifiedAt: string | null;
  deprecatedAt: string | null;         // set when value changes; old still resolves history
  primary: boolean;                    // one per type used for outbound
  createdAt: string;
}

Claims are append-only. Phone numbers, email addresses, and AppFolio IDs are not deleted when they change — they are deprecated. Historical lookups continue to resolve to the correct Person.

Claim trust hierarchy (per workshop V5 override 2026-05-04):

Tier 1 (top — both empirical human acts):
  sms_verified  ≡  manual_pm        (tie — primary flag decides)
                                    email_verified is the email-channel equivalent

Tier 2:
  appfolio_sync                     (system-of-record but sometimes stale)

Tier 3:
  voice_inbound  ≡  sms_inbound  ≡  email_inbound   (channel signal, spoofable)

Tier 4:
  self_declared                     (caller stated the value, not yet proven)

When two Tier-1 claims conflict, neither auto-overrides the other — both stay as separate claims and the primary flag decides outbound. PMs explicitly mark which claim is primary; the system respects that judgment. This preserves PM authority on the "I just verified this phone via a 5-minute call" case without auto-overriding fresh PM verification with stale SMS round-trips.

3. Roles — what a Person is to us

Tenant is replaced by TenantOccupancy (a Person occupying a unit on a lease). Prospect is replaced by ProspectInquiry (a Person inquiring at a property). VendorContact is added explicitly. A Person can have many active roles, including multiple of the same type at different properties.

Amendment (2026-06-02, ADR-0035 field-audit F1/F4): the leaseId FK below was dropped before it was ever populated. The occupancy IS the canonical occupancy→lease locator: the (personId, unitId, startDate/leaseStart) triple uniquely points at the current Lease instance (disambiguating across turnovers via leaseStart). A stored leaseId was redundant with that triple, had zero readers, and would have to be a cross-person FK for co-tenants (who share the primary's lease). Navigate occupancy→Lease via the triple (getCurrentLease(propertyId, unitId) + match by personId/startDate), never a stored FK. See ADR-0035 §F1/F4 + spine-field-placement-audit.md.

interface TenantOccupancy {
  id: string;
  personId: string;
  // leaseId — DROPPED (ADR-0035 F1/F4, see amendment above). The lease window
  // (startDate/endDate) doubles as the canonical occupancy→lease locator.
  propertyId: string;
  unitId: string;
  unitNumber: string;
  role: 'primary' | 'co_tenant' | 'guarantor';
  startDate: string;
  endDate: string;
  archivedAt?: string;
  af?: { propertyId?: number; tenantId?: number; occupancyId?: number };
}

interface ProspectInquiry {
  id: string;
  personId: string;
  propertyId: string;
  stage: ProspectStage;
  source: ProspectSource;
  desiredBedrooms: number;
  desiredMoveIn: string;
  tourId: string | null;
  closedAt?: string;
  closedReason?: string;
  af?: { rentalApplicationId?: number; inquiryId?: number };
}

interface VendorContact {
  id: string;
  personId: string;
  vendorId: string;
  role: 'owner' | 'dispatcher' | 'tech';
}

4. Persons without role rows — category for inbound classification

The role-via-relationship-row design (TenantOccupancy / ProspectInquiry / VendorContact) covers humans who interact with us through a known property-management relationship. It does NOT cover humans who:

For these, Person carries an optional category field that classifies the Person ITSELF (orthogonal to relationship rows):

type PersonCategory =
  | 'tenant'        // has active TenantOccupancy
  | 'prospect'      // has active ProspectInquiry
  | 'staff'         // PM-side human (Phase 6a bridges to User domain via personId + PersonRole)
  | 'vendor_contact'// has VendorContact relation
  | 'spam'          // confirmed spam — blacklist
  | 'wrong_number'  // confirmed not-our-tenant
  | 'unclassified'; // first contact, not yet triaged

Key invariants:

Why this is additive, not a redesign:

See also: docs/data-model-migration/planning/2026-05-10-wave-2-and-beyond-scoping.md §Phase 4-cleanup spam-Person expansion (Phase 4-cleanup or Phase 5a-fast-follow).

DynamoDB key shape

Single-table layout (matching existing convention):

PK                       SK                       GSI1PK
─────────────────────    ─────────────────        ────────────────────
PERSON#pers_abc          PROFILE                  —
PERSON#pers_abc          CLAIM#claim_xyz          PHONE#+15551234567
PERSON#pers_abc          CLAIM#claim_eml          EMAIL#jane@example.com
PERSON#pers_abc          CLAIM#claim_pms          PMS_EXTERNAL_ID#appfolio:12345
PERSON#pers_abc          OCCUPANCY#occ_abc        PROPERTY#1234
PERSON#pers_abc          PROSPECT#prosp_abc       PROPERTY#5678
PERSON#pers_abc          VENDOR_CONTACT#vc_abc    VENDOR#vend_abc

GSI1 inverts claim values back to personId for inbound lookups (the only GSI access pattern that needs this shape). All other access patterns query by PK.

Person lifecycle — create-on-first-touch

A Person is born the first time PropFlow sees a contactable identity, even if we know nothing else about them. Every inbound channel (voice, SMS, email, Telegram, PMS sync) calls findOrCreatePersonByClaim(claim), which atomically resolves an existing Person via GSI1 or creates a new "skeleton" Person + Claim under a DynamoDB conditional write. Skeleton Persons start with no roles, no preferences, and a placeholder displayName; they evolve as we learn more (Clara's conversation tools update fields, role records attach, AppFolio sync upgrades claim source). There are no status enums to maintain — the shape of the Person carries the meaning. Verification is per-claim, not per-Person, with implicit trust by source (appfolio_sync is verified-by-default; voice_inbound / sms_inbound start unverified). Skeleton Persons that never engage are tolerated — Persons are immutable identity, never deleted; a Phase 5 admin view surfaces "low-signal contacts" for soft-archive if PMs want it.

Implementation status (2026-05-20). Implemented as ensurePersonByClaim(claimType, rawValue, orgId, {channel}) in src/lib/data/dynamo/persons.ts (and mirrored in the JSON backend at src/lib/data/store.ts). The function name in source diverges from this ADR's prose name findOrCreatePersonByClaim for symmetry with the existing ensurePersonForSignals orchestrator family — semantically equivalent: atomic find-or-create via the sentinel-transaction dedup partition, race-loss winner-follow via consistent-read. The resolvePerson contract (ADR-0022 §5) now wraps it via buildResolvePerson(orgId), so every inbound channel adapter (Twilio SMS, voice personalization observer, email ingest, conversations PM-query, Telegram with known persona) mints or reuses a Person on first touch — envelope.personId carries the Person.id downstream for conversation persistence + spine-aware code. Telegram-only unknowns (no phone via persona) stay anonymous until they identify on another channel. See docs/planning/messaging-canonical-flow-plan.md Item 1 for the landed scope.

Full mechanics (lookup-or-create flow, race handling, promotion paths, verification table, backfill of pre-existing Conversations) live in docs/data-model-migration/archive/kickoff.md §4b.

Cross-channel identity stitching

Stitching — recognizing that a phone caller, an SMS sender, an emailer, and a web-form submission are the same human — happens through four complementary mechanisms, in increasing fuzziness:

  1. Exact claim match (synchronous): GSI1 lookup on the inbound claim value. Hit → attach to existing Person. Cheap, no false positives, no fuzzy work in the hot path.
  2. Bundle intake (synchronous): when a single inbound event carries multiple claims atomically (web form with phone+email, PMS sync row with phone+email+PMS-id, PM-typed contact), all claims attach to one Person. Strongest cross-channel link source we have.
  3. User-told-us (conversational): Clara has explicit tools (add_phone_claim, add_email_claim, link_to_prior_contact) that attach claims as a byproduct of normal conversation. Highest leverage because the user themselves names the link.
  4. Async stitch pipeline (background): nightly Lambda walks recent skeleton Persons and unattached Conversations, finds candidates via property + name + time-window proximity and content references, writes IdentitySuggestion records to a PM review queue. Never auto-merges from fuzzy signal — PM confirms.

A separate inline-LLM stitch check runs at first Clara turn for skeleton Persons (Phase 3, behind a flag; Phase 4 GA), proposing same-property + same-name + close-in-time matches as suggestions, never as auto-merges.

Confidence tiers, edge-case handling (family sharing a phone, phone reassignment, name collisions, privacy scoping), and the IdentitySuggestion schema live in docs/data-model-migration/archive/kickoff.md §4c.

Identity merge strategy

Manual merge first. Detection later, only when justified by data.

Concretely:

Rationale: every other merge UX (suggested queue, auto-merge on strong signal) sits on top of manual merge. Build the floor; add ceilings later.

AppFolio sync semantics

AppFolio is the system of record for leases, signed documents, and PMS-canonical tenant records. AppFolio's own model is Tenant and ProspectiveTenant as separate root entities — no Person concept. The sync layer translates:

Authentication boundary

Person is for contacts: tenants, prospects, co-tenants, vendor staff. It is not the same as Better Auth's User, which is for property-management staff (PMs, admins) authenticating into the dashboard. The two models stay separate. A PM is not a Person; a tenant is not a User. This boundary is enforced in code by keeping them in different repositories and not cross-referencing IDs.

If a PM is also a tenant of one of their own properties (rare, possible at small operators), they will have both a User row (for auth) and a Person row (as a tenant). The two are linked by convention via shared email, not by foreign key.

Consequences

Becomes easier:

Becomes harder:

Follow-up work this commits us to:

Alternatives considered

A. Status quo — languagePreference on both Tenant and Prospect

Cheapest by far. Ships the language feature in days. But it codifies the multi-truth model as the way things work. Future cross-record attributes (smsOptOut, timezone, pmAuthoredNotes) all face the same problem and the same workaround. The friction compounds.

Rejected because the schema fragility is no longer hypothetical — phone-number changes and cross-property duplicates are happening in production today. Continuing to layer features on this model trades short-term velocity for compounding long-term cost.

B. Contact table for cross-record preferences only

Introduce a single new Contact record keyed by phone, holding only languagePreference and languageAskedAt. Tenant and Prospect look up the Contact by phone. Nothing else changes.

Solves the language-preference single-source-of-truth problem in ~3 days. But it does not fix the underlying issues: phone-number changes still orphan history, cross-property duplicates still exist, AppFolio sync still creates duplicates, identity merge has no home. It buys time while preserving the fragility.

Rejected as a destination, but the spirit lives on in the phased rollout: Phase 1 of this ADR is essentially Option B with a Person table instead of a Contact table — a stepping stone toward the full model.

C. Full Person + Claims + Roles (this proposal)

The model described above. Higher upfront cost (estimated 6–8 weeks of focused work to land all four phases). Lower marginal cost for every future feature that touches a contact. The right shape for a CRM-shaped product.

Selected. The cost is real but the product is heading toward more channels (email, Telegram already shipped; SMS chatbot, dashboard chat, and possibly WhatsApp are on the horizon), more PMS integrations (Yardi, OneSite, RealPage per PMS_INTEGRATION.md), and more cross-property workflows (multi-property prospects, transfer tenants, etc.). All of these get materially harder under the natural-key model and materially easier under the identity model.

D. Adopt an external identity service (Auth0, Clerk, Stytch)

Considered briefly. These products solve PM authentication, not contact identity. None of them model "this human has tenant role at property A, prospect role at property B, vendor-contact role at vendor C." The shape is wrong for our domain.

Rejected — this is an internal data-modeling problem, not an auth problem.

References