0018 — Person identity model: claims and roles
- Status: Accepted
- Date: 2026-05-04
- Deciders: Gera (Jose) — direct endorsement; sane (Sean) and feed (Fede) lenses via persona pass on 2026-05-04 (workshop output)
- Acceptance note: Workshop run via personas surfaced 4 overrides; the decision-relevant one for this ADR is V5 — claim-trust hierarchy ties
sms_verifiedandmanual_pmat the top tier (was: strict ranking). Override applied in §10b decision 11 below. - Subsequent ADRs that extend this one:
- ADR-0020 (Person as universal human spine) — generalizes the spine to absorb the auth-side
Userdomain. AddsUser.personIdbridge +PersonRolefirst-class entity replacing implicitTenant.role/Prospect.*/User.rolewrappers. Cross-org Person collapse permitted viaPersonRolemembership. - ADR-0021 (Collapse parallel dynamo data layers) — eliminates the duplicated
agents/clara/lib/data/dynamo/tree so identity changes propagate to the Lambda automatically.
- ADR-0020 (Person as universal human spine) — generalizes the spine to absorb the auth-side
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:
- Phone numbers change. When a tenant updates their cell number, every Conversation, Tour, and Prospect record linked by the old number is silently orphaned. There is no way to walk forward or backward across the change.
- Same human, multiple records. A person who tours Property A as a
Prospectand signs a lease at Property B becomes aTenantwith no relationship to their prospect history. We see them as two separate humans. - Same human, multiple roles simultaneously. A current tenant at Property A can also be a prospect inquiring at Property B. Today this requires a
Prospectrow whose phone happens to match aTenantrow, with no enforced linkage. - Cross-record attributes have no home. The motivating case is
languagePreference(seedocs/architecture/voice-funnel-architecture.md§9). Putting it onTenantandProspectseparately creates a drift trap — the same human's preference is stored in two places, kept in sync by convention. The same problem will repeat forcommunicationPreferences,optOutOfMarketing,timezone,pmAuthoredNotes, etc. - Co-tenants are inline strings.
Tenant.additionalTenants?: CoTenant[]carries names but not records. A co-tenant who calls in is an "Unknown Caller" even though we know their name from the lease. - Phone collisions are unhandled. A vendor contact and a tenant could share a phone (rare but not impossible). Today's
getTenantByPhonereturns one or the other depending on lookup order. - AppFolio sync compounds duplicates. When AppFolio's
rental_applicationspoller creates aProspectand thetenant_directorypoller creates aTenantfor the same human, the two records are not linked.
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 Lead → Contact 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
leaseIdFK 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 currentLeaseinstance (disambiguating across turnovers vialeaseStart). A storedleaseIdwas 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:
- Spam / wrong-numbers — bots and misdial callers who text or call our toll-free numbers. Need to be tracked for volume metrics ("Clara deflected 247 spam contacts this month") and blacklisting (don't auto-respond to known-bad numbers).
- Internal staff — PM staff using their personal phone to email Clara about an internal coordination question. Not a tenant/prospect; staff identity is now unified via the Phase 6a
User.personIdbridge +PersonRolerows (per ADR-0020). Platform-admin staff resolve to the syntheticorg_propflow_stafforg withplatform_adminPersonRole. - First-contact prospects pre-Prospect-creation — someone texts a property's TFN before Clara captures their info into a Prospect row. Brief window between first SMS arrival and Prospect creation.
- Other humans-we-track-but-don't-classify-yet — admin-flagged but unclassified contacts.
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:
categoryis derived by default: a Person with an active TenantOccupancy iscategory: 'tenant'regardless of what the field says. The field is the source of truth ONLY for Persons with no relationship rows (spam, wrong_number, staff, unclassified). For Persons with relationship rows, the field is informational / cached.- Going-forward writer policy: every inbound from an unknown sender (no IdentityClaim match) creates a Person with
category: 'unclassified'. Auto-classification rules + admin triage UI move them to spam / wrong_number / known-tenant / known-prospect. - Storage: Persons table grows with spam volume; storage cost is rounding error (~200 bytes/row × low-thousands of spam Persons/year = a few MB).
- Multi-org collision policy: spam phones may hit multiple orgs. The
IdentityClaimcross-org partition (per Foundation Fix G) handles this — one Person per phone, classified at first encounter, reclassifiable across the org's view via admin merge.
Why this is additive, not a redesign:
- Phase 1-4 ships on the role-via-relationship-row architecture as originally specified.
categoryas an optional field can be added later (Phase 4-cleanup) without disturbing Phase 4a'sConversation.personIdwriter or backfill.- Inbound conversations from spam senders today produce
personId: nullrows — that's pre-Phase-4-cleanup behavior, correctly representing "no Person spine row exists yet." Post-Phase-4-cleanup, the spam-Person hook creates the Person row + classifies it, and a one-time second-pass backfill stamps the historical rows.
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})insrc/lib/data/dynamo/persons.ts(and mirrored in the JSON backend atsrc/lib/data/store.ts). The function name in source diverges from this ADR's prose namefindOrCreatePersonByClaimfor symmetry with the existingensurePersonForSignalsorchestrator family — semantically equivalent: atomic find-or-create via the sentinel-transaction dedup partition, race-loss winner-follow via consistent-read. TheresolvePersoncontract (ADR-0022 §5) now wraps it viabuildResolvePerson(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.personIdcarries thePerson.iddownstream for conversation persistence + spine-aware code. Telegram-only unknowns (no phone via persona) stay anonymous until they identify on another channel. Seedocs/planning/messaging-canonical-flow-plan.mdItem 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:
- 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.
- 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.
- 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. - 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
IdentitySuggestionrecords 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:
- Every Person profile in the admin dashboard exposes a "Merge with…" action. PM picks the duplicate, sees a side-by-side diff of what will change, confirms.
mergePersons(winnerId, loserId, conflictResolution)lives insrc/lib/domain/identity/. It:- Reassigns all claims and roles from loser to winner.
- Stamps
loser.mergedIntoPersonId = winner.idandmergedAt. - Updates
Person.preferencesper a documented conflict policy: more recent verified value wins; if neither is verified, more recentupdatedAtwins. - Writes an audit-log row (winner, loser, who, when, conflict resolutions chosen).
getPerson(id)transparently followsmergedIntoPersonIdfor soft-redirect, so historical references to the loser ID continue to work.- No automatic detection in the initial rollout. PMs spot duplicates as they work the dashboard. We will add suggestion + auto-merge only after observing real duplicate patterns in production for at least one quarter.
- No split / un-merge operation. Wrong merges will be rebuilt from the audit log manually if they happen. Building reversible-merge correctly is a project on its own and is deferred until evidence shows it is needed.
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:
- AppFolio entities are pulled and matched against
IdentityClaim(pms_external_id→ personId). - A new AppFolio tenant we have not seen creates a new Person plus an
appfolio:<tenantId>claim plus aTenantOccupancy. - An AppFolio tenant whose claim already resolves to a Person creates / updates a
TenantOccupancyon the existing Person. - PropFlow merges do not propagate to AppFolio. When two Persons in PropFlow are merged, the underlying AppFolio records remain separate. The
pms_external_idclaims for both AppFolio records hang off the merged Person, and outbound AppFolio writes pick the right external ID per role context. This is consistent with PMS_INTEGRATION.md's "PropFlow is the action layer; the PMS is the system of record" principle. - Conflict resolution on field-level data (name, phone, email) defers to the PMS for fields the PMS owns (lease term, balance) and to PropFlow for fields PropFlow owns (
languagePreference, conversation history, AI notes). Documented field-by-field in the migration plan.
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:
- Phone-number changes are non-events. New claim, deprecate old. Conversation history continues to resolve.
- Cross-channel context. Voice + SMS + email history for the same person rolls up to one Person regardless of which channel was used or how many phone numbers they have used.
- Cross-property context. A tenant at Property A inquiring at Property B is a single Person with two roles. Clara can see both contexts when handling either interaction.
- Single source of truth for person-level preferences.
languagePreference,smsOptOut,timezone,pmAuthoredNotesall live on Person and are read once per request. - AppFolio duplicate tolerance. AppFolio's own duplicate records (the same human as two ProspectiveTenants from two inquiries) are merged in PropFlow and presented as one contact, even though sync writes to the right AppFolio record per role.
- Identity merge is a documented operation, not a guess. Wrong assignments get fixed instead of accumulating.
Becomes harder:
- Read amplification. Today's
getTenantByPhoneis one DynamoDB read. New flow is GSI query → PK query (multiple rows) → optionally followmergedIntoPersonId. More latency, more RCU. Budget: ~2x read cost on hot inbound paths. Mitigation: batched reads where possible; aggressive request-context caching. - Eventual consistency on claim lookups. GSIs are eventually consistent. A phone added in request A may not be visible to request B for tens of milliseconds. The same problem exists today at smaller surface; we make it explicit in the data layer.
- Conversations / WorkOrders / Tours need rewiring.
Conversation.tenantIdbecomesConversation.personId(with a one-time backfill). Same for Tour, WorkOrder, ActivityLogEntry. Denormalized fields likeConversation.tenantNameeither stay (with fanout-on-Person-update) or move to read-time joins. Decision: keep denormalized for cheap UI reads; fan out on Person update via a small worker. - Co-tenant ghost Persons. Each co-tenant becomes a separate Person, often with zero claims (we know their name from the lease but have no contact info). Searchable only via the occupancy. Acceptable trade-off — better than today's inline-string approach.
- Onboarding cost. New engineers must learn Person + Claims + Roles before they can read a tenant's name. Mitigated by good documentation and by the
findPersonByPhone/loadPersonContexthelpers that hide most complexity. - Backfill is a real project. Existing tenants and prospects must be deduplicated into Persons. Phone is a heuristic. Email helps. Names help. Ambiguous cases need a PM-review UI. Estimated 1 week of dedicated work.
- AppFolio sync writers must be re-thought. Every writer in
lambda/appfolio-sync/andsrc/lib/pms/that resolves atenantIdorprospectIdneeds to resolve apersonIdfirst and then translate to the correct PMS external ID per role. Documented in the migration plan.
Follow-up work this commits us to:
- ADR-0019 (or this one's appendix): merge conflict resolution policy field-by-field.
- An identity-rot dashboard for PMs (suggested merges, deprecated claims, recent merges).
- Re-validating
evals/— every Clara eval that mentionstenantIdorprospectIdin fixtures. - Updating
docs/architecture/PMS_INTEGRATION.mdto reflect the claims-as-sync-boundary semantics. - Updating
docs/architecture/multi-channel-architecture.mdto reflect Person-rooted conversations.
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
docs/architecture/voice-funnel-architecture.md— research that surfaced the multi-truth problem (originally a language-preference scoping doc; identity discussion in §9).docs/architecture/PMS_INTEGRATION.md— claims-as-sync-boundary keeps PropFlow as the action layer.docs/data-model-migration/architecture/rollout.md— phased migration plan (companion document to this ADR).- Designing Data-Intensive Applications, Kleppmann — Chapters 2 and 5.
- Stripe API design —
Customer/PaymentMethodseparation. - Segment Identity Resolution — anonymous-to-identified user stitching at scale.
- Auth0 Account Linking — merge-two-identities-into-one operation semantics.