0040 — Identity-Anchored Reasoning Router for Operational Users
- Status: Proposed
- Date: 2026-05-29
- Deciders: Fede
- Related: ADR-0029 (role-prompt-tool router — identity→capability selection, multi-role = union load, runtime trust tier not stored
PersonRole), ADR-0034 (turnover orchestration — capture→project→freeze spine, hard PM gates), ADR-0042 (inbound thread routing) (theoutbound-thread-logcontinuity primitive — this ADR is the successor it flagged: "make capability selection fully stateful / persist on the conversation"), ADR-0026 (PII boundary). Incident: 2026-05-29, PM texting turnover photos receives a leasing "you're not yet a resident here" reply.
Context
PropFlow's inbound capability selection (composeCapabilities →
getUnifiedSystemPrompt) is per-message and identity-defensive: it was designed
for anonymous inbound, where the system must guard against an unknown sender and ask
"prospect or tenant?" before acting. Pointed at trusted operational users —
property managers and in-house handymen, who are identity-resolved before a word is
parsed and are doing repetitive operational work — every assumption inverts, and the
behavior turns robotic and occasionally hostile.
The incident
A PM doing a turnover walk texts photos to the property number. The photo attaches
correctly to the turnover's ConditionReport (the inbound attach path fires, so
resolveTurnoverContext returned a real context). But Clara's reply comes back in
the leasing/prospect persona:
"Federico, I want to make sure these photos get to the right place — but I should be upfront with you: this conversation is linked to your tour inquiry, and you're not yet a resident here. If you're currently a tenant at Camellia and need to report a maintenance issue, please reach out through your resident portal…"
The photo routed correctly; the persona did not.
Root cause — two independent defects (verified against source)
1. LEASING co-loads with TURNOVER for any non-tenant sender.
agents/clara/lib/messaging/process-envelope.ts:167— for ANY inbound SMS to a property-mapped Twilio number,leasingContextis set to{ propertyId, propertyName: '' }. It is neverundefinedfor a known property.agents/clara/lib/agent/capabilities/index.ts:72—composeCapabilitiespushes the LEASING capability wheneveridentity.type !== 'tenant' && leasingContext !== undefined. A PM resolves toidentity.type === 'unknown'(not a tenant, not a prospect), so LEASING is added — independently of the TURNOVER capability pushed at:83. Both land incaps.agents/clara/lib/agent/clara-unified.ts:289—includeLeasing = caps.includes('LEASING') || !hasAnyWorkflowCapability. With LEASING incaps, the leasing workflow body loads alongside the turnover body. The invariant the code documents ("never grants both MAINTENANCE and LEASING",:275) says nothing about LEASING + TURNOVER, so this co-load is unguarded. The leasing body supplies the "you're not a resident" framing.- The specific "linked to your tour inquiry" wording comes from
prospectContext(conversation-manager.ts:2608), set because the PM's phone also matches a prospect-with-tour on the test property. That coincidence supplies the wording; the leasing body loads regardless of it.
2. senderRole is never threaded, so the PM persona overlay never fires.
agents/clara/lib/messaging/adapters/twilio-sms.ts:174hardcodessenderRole: 'tenant'on every inbound SMS envelope.process-envelope.ts:194→routeInboundMessage→handleIncomingMessagenever extract or passsenderRole, so it defaults to'tenant'(conversation-manager.ts:2027).applyPmPersona(composedSystemPrompt, senderRole)(conversation-manager.ts:3117) is a no-op unlesssenderRole === 'pm'. The PM audience-switch overlay is dead on the SMS path.
Both are symptoms of the same architecture: capability and persona are re-derived, defensively, every message — there is no memory that this sender is a PM mid-turnover.
The deeper problem
The machinery is correct for anonymous traffic and miscalibrated for trusted staff. The cost asymmetry is the whole argument: for a trusted user a wrong guess is recoverable ("which unit?"), a demotion to the stranger persona is not. The fix is not another gate — it is to anchor on resolved identity and reason from accumulated context, the direction every external best-practice source converges on (Anthropic Building Effective Agents; OpenAI Practical Guide to Building Agents; Microsoft "authorization is an identity-enforcement problem, not a reasoning problem"; LangGraph thread-state + checkpointer; Google ADK user-auth + in-tool guardrails).
What's already built that proves the pattern
The in-house handyman capability already does the smart version and is the template to
generalize (verified chain):
resolveInboundVendorMembership → membership.isInHouse
(inbound-router.ts:338) → listHandymanAssignedJobs({vendorId, propertyIds})
(:344-352) → handymanAssignedWorkOrders threaded to handleIncomingMessage (:374) →
HANDYMAN capability (capabilities/index.ts:92) → renderAssignedJobsBlock injects the
handyman's REAL open jobs into the prompt (handyman.ts:40-62) so the tech never has to
name a WO number. That is exactly "load what's on the trusted user's plate and let the
model reason." It exists for handymen and was never applied to PMs/turnover.
Decision
Treat identity-resolved (and identity-resolvable) senders as operational participants and select capability + persona by reasoning over accumulated context, not defensive per-message keyword/leasing-default classification. Four parts, smallest-correct-slice first.
1. Operational identity suppresses the leasing default (the floor — ship first).
When an operational capability is active (TURNOVER today; HANDYMAN already; PM-maintenance
next) OR the sender resolves to a pm/maintenance-staff runtime tier, LEASING must NOT be
co-added and the leasing body must NOT load. A known operational user is structurally
incapable of receiving the "you're not a resident" persona. Concretely: gate the LEASING
push in composeCapabilities on the absence of an operational capability, and thread
senderRole end-to-end so applyPmPersona fires. This alone kills the incident.
2. Persist the working-object binding on the conversation. Add
Conversation.activeContext = { kind: 'turnover' | 'work_order' | …, id, boundAt }
(mirrors the existing Conversation.workOrderId precedent; the version field +
single-table DDB make this a no-migration add). Once a binding resolves (by continuity,
label, or inference), stamp it; subsequent inbounds short-circuit on the stamp before
re-deriving. This retires per-message re-derivation and is the stateful successor ADR-0042
flagged. Cleared on completion or explicit topic change; conflicting signals force
disambiguation, never silent overwrite.
3. Infer intent from "what's on their plate" + recency (generalize the handyman pattern). For staff senders, load their open work (turnovers on managed properties, assigned WOs) and bind to the single obvious candidate. Keyword signals (a unit number) become disambiguators, not entry gates. More than one candidate → load the staff persona and ask; never refuse, never misroute.
4. Tenant self-identification + in-thread request submission. An unknown sender who
claims residency enters a lightweight verification mini-flow (confirm name + unit against
the lease spine — not full auth) and, on a confident match, receives MAINTENANCE /
RESIDENT_SERVICES and can submit a maintenance request in-thread rather than only being
forwarded. A weak/failed match falls back to PM handoff (current behavior) — never a flat
refusal. (Note the existing risk in identify_caller's name+unit fallback — Trello
JeaEDSdZ: verification must require a phone match or PM confirmation, not bare name+unit,
or it becomes an impersonation vector.)
inbound message
│
▼
Conversation.activeContext set? ──yes──▶ resume bound capability (turnover/WO) ← part 2
│ no
▼
resolve identity (runtime trust tier from resolveIdentity — NOT stored PersonRole)
│
├── pm / maintenance-staff ─▶ load "plate": open turnovers / assigned WOs ← part 3
│ ├─ exactly 1 obvious candidate ─▶ bind + act (label = disambiguator)
│ ├─ >1 candidate ───────────────▶ staff persona, ASK which
│ └─ none ────────────────────────▶ staff-helpful prompt (NEVER leasing) ← part 1 (floor)
│
├── tenant (known phone) ───▶ MAINTENANCE / RESIDENT_SERVICES
│
├── claims residency (unknown phone) ─▶ verify name+unit vs lease spine ← part 4
│ ├─ match ─▶ MAINTENANCE, submit request in-thread
│ └─ no match ─▶ PM handoff
│
└── prospect / truly anonymous ─▶ LEASING (defensive default — unchanged)
Invariants — smart where cheap-to-undo, structured where it isn't
- Infer freely for reversible/gathering actions (attach photo, open draft, read a job).
- Identity/authorization stays deterministic — runtime trust tier from
resolveIdentity, neverPersonRolerows (those exist only for signed-in staff → gating on them denies every inbound caller; the load-bearing learning from ADR-0029's rescope). - Human-in-the-loop stays on irreversible/outward actions (vendor dispatch, spend, tenant outbound). Autonomy gates remain off by default; the guardrail lives on the tool, not the router.
- Money/legal classifications stay deterministic — the condition projection that drives deposit deductions is pinned (temp 0, versioned, ADR-0034). Smart routing must not fuzz the deduction call.
- Property scope stays hard — a multi-property PM must disambiguate; never silently bind a photo to the wrong property's turnover.
Entity classification
Per ADR-0027:
| Entity | Class | Naming | Spine trace / drift |
|---|---|---|---|
Conversation.activeContext (embedded field, not a new top-level entity) |
canonical | bare field on Conversation |
Owns the working-object binding for the thread. Spine trace: via the Conversation's own personId stamp (ADR-0032). Holds { kind, id, boundAt } only — IDs, never PII. Mirrors the existing Conversation.workOrderId binding. No reconciler; written on bind, cleared on completion/topic-change. |
No other new entities. activeContext is an additive field; DynamoDB is schemaless and the
existing version-checked putItemWithVersion write covers it (no migration).
Consequences
Easier
- A PM/handyman is never demoted to the stranger persona — the incident is structurally impossible after Part 1.
- Photo-only turnover replies "just work" — no unit number, no 24h-window dependency, once Part 2 persists the binding.
- The handyman one-off becomes a general principle; turnover and PM-maintenance reuse it.
- Tenants self-serve a maintenance request instead of being forwarded.
Harder
- A persisted binding can go stale → mitigate with TTL + explicit clear + disambiguate-on-conflict.
- Inference can mis-bind → mitigate with single-candidate-only auto-bind; otherwise ask.
- Tenant self-verification is a new identity surface → match the lease spine, require a phone match or PM confirmation (not bare name+unit), rate-limit attempts, never echo PII in the prompt.
- One more field on the hot
Conversationwrite path (negligible; covered by existing version check).
Doesn't move
- Identity resolution remains authoritative; this ADR changes how capability/persona are selected from it, not how identity is resolved.
- The capture→project→freeze spine (ADR-0034) is untouched — this routes inbounds into it.
- The ADR-0042
outbound-thread-logprimitive stays as the cold-start continuity signal;activeContextsupersedes it for warm threads (binding persists instead of being re-looked-up). clara-maintenance.ts/clara-leasing.ts/clara-pm.tsstay (ADR-0029 Phase-6 "delete" was explicitly retracted — they're live).
Alternatives considered
A. Just fix the LEASING co-load + thread senderRole (the two-line incident fix)
The minimal patch: gate the LEASING push on no-operational-capability, and thread senderRole.
Necessary but insufficient as the whole answer. It stops the demotion but leaves capability
re-derived every message (the photo-only case still leans on ADR-0042's 24h window). Ship it as
Part 1; it is the floor, not the ceiling.
B. Keep widening ADR-0042 continuity (more outbound-log writers, longer windows)
Record more outbound capabilities, shorten the gate's reliance. Rejected as the primary answer — still stateless and fragile (depends on Clara having texted first), and fixes neither the unconditional leasing co-load nor the tenant gap. The log stays as a cold-start signal; the durable fix is binding state on the conversation.
C. Drop personas; one mega-prompt that reasons everything
Rejected — token cost (the unified prompt is already ~18–22k tokens, Trello 1ySEvGCM) and
weaker guardrails on the money/scope invariants. The consensus (OpenAI/Microsoft) is
single-agent-first with deterministic identity + reasoned intent, not one undifferentiated prompt.
D. Full tenant auth before any action
Rejected for the request-submission path — correct for sensitive operations, far too heavy for "report a leak." Verification stays lightweight (phone/lease-spine match); sensitive actions stay separately gated.
Implementation phases
- Phase 1 — The floor. Gate LEASING in
composeCapabilitieson absence of an operational capability; threadsenderRoleend-to-end (twilio-sms→process-envelope→inbound-router→handleIncomingMessage) soapplyPmPersonafires. Regression test: PM SMS with photos on an open turnover → turnover persona, NOT leasing; assert the reply contains no "resident"/"tour inquiry" framing. Locked by this ADR. - Phase 2 — Persisted binding. Add
Conversation.activeContext; stamp on turnover/WO bind; read-first short-circuit. Supersedes the per-message re-derivation for warm threads. Locked. - Phase 3 — Plate-based inference. Generalize the handyman fetch to PMs (open turnovers on managed properties); single-candidate auto-bind, else ask. Unit label becomes a disambiguator. Locked in principle; design in follow-up.
- Phase 4 — Tenant self-ID + in-thread requests. Lightweight verification → MAINTENANCE /
RESIDENT_SERVICES; submit WO in-thread. Depends on hardening
identify_caller(TrelloJeaEDSdZ). Scoped, not yet locked.
Open questions
- Should an operational capability hard-suppress LEASING, or should LEASING still load when the same Person genuinely is also a prospect on a different property? (Lean: suppress when a turnover/WO binding is active on THIS thread; multi-role union is for genuinely concurrent roles, not a defensive default.)
activeContextTTL / clear semantics — explicit completion event vs idle expiry vs topic-change detection. (Lean: clear on terminal turnover/WO state + a 7-day idle expiry.)- Does Part 1 subsume the need for ADR-0042's photo-only continuity, or do both ship? (Lean:
both —
activeContextfor warm,outbound-thread-logfor cold-start before any binding exists.) - Tenant self-ID verification strength — phone-match required, or name+unit+one-knowledge-factor? (Lean: phone match OR PM confirmation; never bare name+unit.)
References
- Incident 2026-05-29 — PM turnover photos → leasing "not a resident" reply.
agents/clara/lib/messaging/process-envelope.ts:167(leasingContext always set for mapped property)agents/clara/lib/agent/capabilities/index.ts:72,83(LEASING + TURNOVER both pushed)agents/clara/lib/agent/clara-unified.ts:289(includeLeasing loads leasing body)agents/clara/lib/messaging/adapters/twilio-sms.ts:174(senderRole hardcoded 'tenant')agents/clara/lib/agent/conversation-manager.ts:3117(applyPmPersona no-op without senderRole)- Handyman precedent —
agents/clara/lib/agent/capabilities/handyman.ts:40-62,inbound-router.ts:338-374,list-handyman-assigned-jobs.ts Conversationinterface +workOrderIdbinding precedent —src/lib/data/types.ts- Trello:
OWTXOuTk(Multi-role Clara architecture),dMLsp0u3(Expand IdentityType: handyman=isInHouse vendor — describes this exact misroute class),JeaEDSdZ(tenant identity verification hardening) - External: Anthropic Building Effective Agents + Effective Context Engineering; OpenAI Practical Guide to Building Agents + guardrails/approvals; Microsoft "authorization is identity enforcement, not reasoning"; LangGraph persistence/checkpointer + human-in-the-loop; Google ADK sessions/state/memory + in-tool guardrails.