ADR-0054 — One identity chokepoint + engagement threads for all channels: every hat, one precedence, messages bind to the work that prompted them
- Status: Accepted
- Date: 2026-06-10 (§4 spine-hygiene state corrected 2026-08-14 — see §2 slice 4)
- Deciders: Fede (CTO)
- Evidence base: 2026-06-10 incident forensics (
/tmp/identity-deep-dive/IDENTITY-REPORT.md), the Erika/Joanna walk case study (erika-case.md), and the context-injection history audit (committed with this ADR atdocs/research/context-injection-history.md). Key incident (2026-06-10, prod tracetrace_4493c1d7): a PM answered Clara's own who-pays questions by SMS and was answered as a tenant; the turn's only action wasforward_to_property_manager, so his who-pays decisions were never applied.
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:
- The reply was treated as a cold inbound — global identity ran from scratch; the thread we initiated played no part in routing.
resolveIdentitylet 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 wasforward_to_property_manager(tracetrace_4493c1d7) — and the PM's money decisions were never applied.- Even composed correctly, the writes would have bounced: the dispatch gate derives
permissions from
identity.typeplus 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'sstart_inspection/append_notes/send_summaryover SMS all returnedNOT_AUTHORIZED— her walk only succeeded because voice created the report and the inline photo-attach path is identity-agnostic. - 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 incident —deprecateClaimnever 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'sSH-1invariant.DynamoPersonRepository.deprecateClaimnow callsreleaseSentinelsForDeadClaim— and does so even on an already-deprecated row, idempotent on the row but not on the sentinel — pinned bySH-1insrc/__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:
resolveActor()chokepoint (new module insrc/lib/domain/identity/): wraps today'sresolveIdentity+resolveInboundStaffMembership+resolveInboundVendorMembershipinto 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.Gate unification:
effectiveRoleTiersreads the ActorContext (staff tiers included), notidentity.type. Compose and dispatch can never disagree again — this is the one-line root cause of the Erika NOT_AUTHORIZED failures.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).
Spine hygiene invariants:
deprecateClaimfrees its sentinel in the same transaction; person-merge deprecates the merged person's claims and deactivates its roles;resolveActorrefuses 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 release —
DynamoPersonRepository.deprecateClaimcallsreleaseSentinelsForDeadClaimon both the fresh-deprecation and the already-deprecated branch (idempotent on the row, not on the sentinel). Pinned bySH-1insrc/__tests__/spine-hygiene-invariants.test.ts. The JSON dev backend mirrors it via thededupReleasedflag insrc/lib/data/store.ts, pinned bysrc/__tests__/merge-never-orphans-a-value.test.tsagainst a real on-disk store. This is what makes claim migration possible at all:mergePersonsfrees 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 roles —
mergePersons(src/lib/domain/identity/merge-persons.ts). Still open:resolveActor's archived-person refusal and the one-off backfill audit.
- Sentinel release —
Claim lifecycle surface: an ops/admin path for asserting and retracting
manual_pmclaims (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.