0040 — Identity-Anchored Reasoning Router for Operational Users

Context

PropFlow's inbound capability selection (composeCapabilitiesgetUnifiedSystemPrompt) 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.

2. senderRole is never threaded, so the PM persona overlay never fires.

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): resolveInboundVendorMembershipmembership.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

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

Harder

Doesn't move

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

Open questions

  1. 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.)
  2. activeContext TTL / clear semantics — explicit completion event vs idle expiry vs topic-change detection. (Lean: clear on terminal turnover/WO state + a 7-day idle expiry.)
  3. Does Part 1 subsume the need for ADR-0042's photo-only continuity, or do both ship? (Lean: both — activeContext for warm, outbound-thread-log for cold-start before any binding exists.)
  4. 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