ADR-0054 — One identity chokepoint + engagement threads for all channels: every hat, one precedence, messages bind to the work that prompted them


1. Context — how identity actually works today

Three actor types resolve through three unrelated mechanisms, in a fixed router ladder (agents/clara/lib/messaging/inbound-router.ts:137):

flowchart TD
    A[Inbound message
SMS / MMS / email / voice] --> V{1. Vendor membership?
resolveInboundVendorMembership} V -- external vendor --> VL[Deterministic completion lane] V -- in-house handyman --> HL[Comms divert or Clara lane] V -- no --> S{1b. Staff tiers?
resolveInboundStaffMembership
phone → Person → PersonRoles} S -- "pm/maintenance/admin" --> O[Persona OVERLAY only
never identityKind,
never the dispatch gate] S --> I[2. resolveIdentity
tenant-by-phone → tenant-by-email
→ prospect → unknown] O --> I I --> K[identityKind:
verified_tenant / verified_prospect / unknown_caller] K --> G[Tool dispatch gate
role-matrix: effectiveRoleTiers from identity.type ONLY]

Meanwhile voice has its own fourth mechanism: the personalization webhook (src/app/api/voice/personalization/route.ts:384-415) consults PersonRoles via pm-call-context.ts and applies an explicit "PM outranks stale tenant match" precedence that no other channel has.

Underneath all of it sits the Person spine (ADR-0018): Persons, IdentityClaims (with a Tier-1/Tier-2 trust cascade — manual_pm is a legitimate Tier-1 provenance, claim-selection.ts:30), and PersonRoles. The spine is sound; the consumers are fragmented.

What the 2026-06-10 incident proved

A PM (whose phone also carried a tenant claim on the test property) replied to the turnover recap we sent him, answering three who-pays questions:

  1. The reply was treated as a cold inbound — global identity ran from scratch; the thread we initiated played no part in routing.
  2. resolveIdentity let tenant-by-phone win unconditionally; the pm PersonRole was consulted only for the persona overlay. The prompt contained the verified-tenant block and the PM overlay and the bound turnover context simultaneously; the model took the tenant escape hatch — the turn's sole tool call was forward_to_property_manager (trace trace_4493c1d7) — and the PM's money decisions were never applied.
  3. Even composed correctly, the writes would have bounced: the dispatch gate derives permissions from identity.type plus two thread-shape flags (leasingThread, inHouseHandyman) — never from staff PersonRoles (role-matrix.ts:434), so staff tiers that compose the prompt do not authorize the tools it offers. The Erika/Joanna walk (2026-06-05, conv_voice_f2897a23) proved this independently: a pure PM's start_inspection / append_notes / send_summary over SMS all returned NOT_AUTHORIZED — her walk only succeeded because voice created the report and the inline photo-attach path is identity-agnostic.
  4. The stale tenant binding was a PropFlow IdentityClaim no PMS edit can retract: the sync's claim write-back is a documented no-op on emptied values (tenant-spine-stamp.ts:240), and — at the time of the incidentdeprecateClaim never freed the uniqueness sentinel, so a phone could not be re-homed without manual surgery. An incomplete person-merge had also left a live duplicate claim. (Historical: the sentinel release shipped separately as §4's SH-1 invariant. DynamoPersonRepository.deprecateClaim now calls releaseSentinelsForDeadClaim — and does so even on an already-deprecated row, idempotent on the row but not on the sentinel — pinned by SH-1 in src/__tests__/spine-hygiene-invariants.test.ts. See §4 in the decision below for the current contract.)

The pattern behind five incidents

Walk-call misroute (Jun 9, phone-map drift), MMS-photo misroute (May 29, ADR-0041), YES-to-renewal hijack (Jun 9, Langfuse trace 341de6c1, patched by PR #2124's precedence probe), tenant-persona deflection (Jun 10, trace trace_4493c1d7 — distinct from ADR-0042's May 27 photo-reply incident), and the NOT_AUTHORIZED walk (Jun 5, conv_voice_f2897a23) are all the same defect: each new workflow added another takeover, probe, gate, or overlay instead of (a) messages binding to the workflow that initiated them and (b) one resolver deciding which hat a person wears. Point fixes fight each other; the next workflow adds the next probe.

2. Decision

Build one identity chokepoint and thread-first routing, reusing the existing spine, vendor lane, staff resolution, and thread-log — no new infrastructure.

flowchart TD
    A[Inbound message, any channel] --> T{1. Thread binding FIRST
OutboundThreadLog + warm bindings:
did WE initiate this exchange?} T -- bound --> W[Adopt the bound workflow's context:
workflow kind, entity, expected responder role] T -- unbound --> C W --> C[2. resolveActor — THE chokepoint
phone/email → Person spine →
ALL hats: tenant occupancies, prospect,
staff roles, vendor membership] C --> P{3. Precedence — decided ONCE:
bound workflow's expected role ≻
operator/staff ≻ vendor ≻ tenant ≻ prospect} P --> X[ActorContext: one identity, one active hat,
other hats listed for the prompt] X --> M[4. Compose: persona + tools FROM ActorContext] X --> G[5. Dispatch gate: effectiveRoleTiers FROM ActorContext
compose and gate can never disagree]

Concretely, five slices — all extensions of code that exists:

  1. resolveActor() chokepoint (new module in src/lib/domain/identity/): wraps today's resolveIdentity + resolveInboundStaffMembership + resolveInboundVendorMembership into one call returning every hat with one precedence rule. SMS, voice, and email all call it; the voice route's bespoke "PM outranks stale tenant" moves inside it and stops being channel-local.

  2. Gate unification: effectiveRoleTiers reads the ActorContext (staff tiers included), not identity.type. Compose and dispatch can never disagree again — this is the one-line root cause of the Erika NOT_AUTHORIZED failures.

  3. Thread-first routing: the OutboundThreadLog/warm-binding probe (shipped for turnover in #2124, generalized by the in-flight recap-reply PR) runs before global identity for every workflow that sends outbound asks. A reply to a question we asked binds to that workflow and its expected responder role. Renewal's whole-prompt takeover migrates onto the same primitive (one mechanism, not N).

  4. Spine hygiene invariants: deprecateClaim frees its sentinel in the same transaction; person-merge deprecates the merged person's claims and deactivates its roles; resolveActor refuses archived persons. One backfill audit sweeps existing rot (duplicate claims, orphan sentinels, cross-linked occupancies).

    Shipped. This is the load-bearing half of the ADR today, so its current state is recorded here rather than left to be inferred:

    • Sentinel releaseDynamoPersonRepository.deprecateClaim calls releaseSentinelsForDeadClaim on both the fresh-deprecation and the already-deprecated branch (idempotent on the row, not on the sentinel). Pinned by SH-1 in src/__tests__/spine-hygiene-invariants.test.ts. The JSON dev backend mirrors it via the dedupReleased flag in src/lib/data/store.ts, pinned by src/__tests__/merge-never-orphans-a-value.test.ts against a real on-disk store. This is what makes claim migration possible at all: mergePersons frees each source claim's sentinel and re-mints the value on the survivor, which the identity chokepoint (identity-merge-heal.ts) depends on.
    • Person-merge deprecates claims and deactivates rolesmergePersons (src/lib/domain/identity/merge-persons.ts). Still open: resolveActor's archived-person refusal and the one-off backfill audit.
  5. Claim lifecycle surface: an ops/admin path for asserting and retracting manual_pm claims (today only ad-hoc scripts write them), plus an explicit tombstone semantic so a PMS-side contact deletion can propagate instead of the current keep-on-empty no-op.

2b. The engagement layer — entity-keyed threads over transport conversations

Identity answers who; it cannot answer what this message is about. A phone number is one transport thread per person, forever — but the PM's mental model is N work threads (this turnover, that work order, this renewal), and they assume the obvious: "I'm replying to the thing you just asked me about." Treating the conversation row (a transport artifact) as the unit of context is the root of the remaining incidents: post-identity-fix, Clara correctly knew the sender was a PM and that a TEST-101 turnover was open, yet replied "this is Federico's tenant thread (Unit TEST-102)… jump into that thread" — there is no other thread to jump into. Meaning must live on the work, not the transport.

The pattern already exists in production. ADR-0053 (Maestro, Phases 0–1 shipped) keys maintenance comms to the work order: one durable workflow per WO, inbound handyman/resident messages classified at the edge and signaled into the WO-keyed workflow (signalTechReplied / signalResidentReplied), with a deterministic prior (resolveResidentAwaitingWO) choosing which WO a reply belongs to. The WO-keyed handyman lane has produced no misroute incidents to date — though Maestro Phases 0–1 only shipped 2026-06-10, so the stronger evidence is structural: replies signal a WO-keyed workflow instead of re-deriving context from the transport thread. The context-injection history audit (docs/research/context-injection-history.md: ~25 attempts and events since March 2026, five classes: prompt-prose, whole-prompt takeover — reverted same-day, structured hints/probes, role gates, mode routing) shows every other class failing for the same reason: context injected into a transport conversation instead of messages binding to the work that prompted them.

Decision: generalize Maestro's pattern to every human-facing workflow.

flowchart TD
    OW[Any workflow that messages a human
turnover, renewal, WO comms, intake] -->|registers on send| R[(Engagement registry
entity + person + what we asked +
expected answer shape + recency
— OutboundThreadLog, grown up)] A[Inbound message] --> D1{a. Explicit reply binding?
warm binding / thread-log} D1 -- yes --> B[Bind to that engagement] D1 -- no --> D2{b. Sender's OPEN engagements} D2 -- exactly one --> B D2 -- several --> D3{c. Deterministic signals:
expected-answer shape, entity mention,
attachment type, recency} D3 -- match --> B D3 -- ambiguous --> D4[d. Score message against engagement
summaries — small classifier,
ranked AFTER rules, never instead] D4 -- confident --> B D4 -- low confidence --> Q["e. Ask ONE targeted question:
'TEST-101 turnover or the faucet WO?'"] D2 -- none --> N[f. New topic → intake engagement] B --> S[Signal the bound workflow
persona + tools composed from
actor + engagement]

Rules that make it the smartest-assistant behavior rather than another guess: the ladder is deterministic before intelligent (the classifier only ranks genuinely ambiguous cases); low confidence asks one targeted question instead of guessing (a money-path decision routed to the wrong engagement is a wrong-charge-class defect); persona AND the dispatch gate compose from the (actor, engagement) pair, so a PM answering a turnover question holds apply_charge_decision and a tenant never does.

Copilot consequence: once the registry exists, the PM's single SMS thread becomes a multiplexed command line over all their open work — "what's waiting on me?" is a registry read; Clara can initiate ("two things need you: a who-pays call on TEST-101 and an invoice on WO-861"); interleaved topics in one thread stop being a bug because each message routes to its own engagement.

3. Consequences

Positive: one place to reason about "who is this and which hat applies"; channel parity by construction; the persona/gate split (two sources of truth) is eliminated; new workflows get reply-binding by registering their outbound sends — no new takeovers; dual-hat people (owner-operators who are also residents, PMs on tenant records) become a first-class case instead of a collision.

Negative / risks: resolveActor touches the hottest path of every channel — it ships behind per-channel adoption (voice first, since it already has the strictest semantics, then SMS, then email); the precedence rule is product policy and must be eval-gated (wrong-hat selection is a money-path defect, same severity as a wrong charge); spine backfill on prod requires the same fail-closed discipline as any data migration.

Deliberately not doing: new identity stores, per-channel resolvers, keyword/LLM identity guessing (locked rule: identity is structural), or migrating the vendor completion lane (it already conforms — it just becomes a hat inside resolveActor).

4. Rollout

Slice Surface Verification
1. resolveActor + precedence shared module, voice adopts unit + replay of all five incident transcripts as fixtures
2. gate unification role-matrix regression: Erika's three denied tool calls pass as PM; tenant attempts still denied
3. thread-first routing inbound-router + workflow senders replay 2026-06-10 reply: binds to recap, answers applied
4. spine invariants + backfill data layer + audit script sentinel-freed property test; prod audit report before/after
5. claim ops surface admin API manual claim assert/retract round-trip on test property
6. engagement registry OutboundThreadLog generalized; turnover + renewal workflows register sends registry read shows every open ask for a person
7. inbound demux ladder inbound-router, per-workflow adoption (turnover → renewal → WO comms converges with Maestro) replay corpus: who-pays answers, mid-walk photos, interleaved topics, ambiguous → one targeted question

Slices 1–3 subsume the in-flight recap-reply binding PR's scope; it lands first as the narrow fix, this ADR generalizes it. Slices 6–7 are the engagement layer (§2b) — they ride on the same Temporal spines that already exist (turnover workflow, renewal saga, Maestro WO workflows); the registry is an index, not a new store of record.