0041 — Cross-Channel Operator Elevation (and Multimodal Turnover Capture)

Context — the triggering dogfood session (2026-05-29)

A live walk of the turnover feature surfaced three failures that are one root cause wearing three hats:

  1. Voice — PM blocked from a turnover. Fede called the Camellia line ("do a turnover on unit 615"). Triage correctly transferred to the turnover_intake specialist, but pm_is_pm was false, so find_turnover returned nothing and Clara said "I'm not seeing any open turnovers under your account." Root cause: the inbound phone +1404… resolved to a Person (pers_498ba960, "Federico Chapa") with zero active roles; the pm role lived on a different "Federico Chapa" Person (pers_44f39fe6). Identity fragmentation. Fixed in-session by granting an active pm role (scope org_default) to the phone-owning Person — verified pm_is_pm=true, 21 open turnovers surfaced. (Pure data; no deploy.)
  2. Voice — no hang-up. After the walk completed, Clara never fired end_call and lingered. Separate prompt-adherence bug; tracked as a fast-follow, not in this ADR's scope (see "Related bug" below).
  3. Text — PM photo mis-routed. The PM texted a move-out photo; Clara replied "…are you sending this in connection with a maintenance issue in your unit, or something else?" — the tenant-maintenance triage — and the photo attached to nothing.

#1 and #3 are the same disease: PropFlow resolves operator authority differently per channel, and neither inbound channel reads the durable pm/admin role the way an operator would expect.

How authority resolves today — the two channels diverge

Voice Text (SMS/MMS)
Tool availability Static per-specialist EL agent binding allowedToolKeysForRoles(resolveEffectiveRoleTiers(identity.type, …))
Dispatch gate requiresVerifiedTenant + requiresPmCallContext (src/app/api/voice/tools/[tool]/route.ts) checkToolAuthorization (text dispatcher)
Operator identity source resolvePmCallContext(callerPhone) (src/lib/domain/turnover/pm-call-context.ts) — matches role==='pm' ONLY; ignores org_admin/platform_admin resolveEffectiveRoleTiersdeliberately not a PersonRole query (the "deny-everyone" guard); identity.type ∈ {tenant, prospect, unknown}
Durable-role exception none for operators exactly one — inHouseHandyman (a VendorMembership read populated at inbound-router.ts:373)
Where it fails an operator non-pm (or wrong-Person) caller → pm_is_pm=false → gate fails closed a PM is a non-tenant → unknown → tenant-maintenance/leasing triage

Inbound photos (MMS) are handled by the general inbound agent in whatever capability the identity resolved to — the "Thanks for the photo!" line is LLM-generated by the maintenance/tenant capability, not a fixed string. There is no inbound-MMS → ConditionReport path; turnover photos are workspace-UI/API only (POST /api/condition-reports/photos, ADR-0034 Slice 3 / PR #1446).

The single-org wrinkle (why "all properties" is nearly free today)

Everything in prod is org_default. An org-scoped pm/org_admin role therefore already spans every property — verified: granting Fede pm surfaced 21 turnovers across Camellia + Camellia Sandbox + Test Property (all assignedPmPersonId unset → org-wide fallback). So "all properties" falls out for free now. True cross-customer-org admin only bites at the second customer org and is the genuinely dangerous slice — deferred.

Decision

Introduce a single, channel-agnostic operator-elevation resolver that both inbound channels consume — modeled on the existing inHouseHandyman durable-signal exception (the only sanctioned non-identity PersonRole read).

resolveOperatorElevation(callerAddress) → {
  isPm: boolean,           // holds an active pm/org_admin role
  isAdmin: boolean,        // holds platform_admin
  orgScope: string[],      // org ids the operator may act within
  personId: string | null,
}

reading durable PersonRole (pm | org_admin | platform_admin) for the Person owning the inbound address (phone on voice; the resolveParticipant wire address on text, per ADR-0022).

One chokepoint = one place to later attach step-up auth (a PIN). Today elevation is caller-ID only (Fede, 2026-05-29) — accepted spoofing risk, see Consequences. Do not scatter elevation reads; the single chokepoint is the security property.

Voice changes

Text changes

Authority breadth

Multimodal turnover capture — the #3 fix, first consumer of elevation

Once a PM is recognized on text, add an inbound-MMS → ConditionReport photo path:

Architecture

flowchart TD
  V[Inbound voice
caller phone] --> R{resolveOperatorElevation
(single chokepoint)} T[Inbound text/MMS
wire address] --> R R -->|reads durable PersonRole
pm / org_admin / platform_admin| DDB[(PersonRole)] R --> VG[Voice gates:
requiresVerifiedTenant /
requiresPmCallContext bypass
+ resolvePmCallContext widened] R --> TG[Text tier:
resolveEffectiveRoleTiers
isOperator union] VG --> VW[Turnover voice walk
start_inspection → notes → summary] TG --> CAP[Operator capability
+ MMS photo → ConditionReport] VW --> CR[(ConditionReport)] CAP --> CR R -. future: PIN step-up .-> PIN[Slice D]

Consequences

Caller-ID-only — accepted risk (Fede, 2026-05-29)

Both channels key elevation on the inbound address; caller-ID and SMS sender are spoofable. An elevated operator can read any tenant's balance/lease and create/modify work orders + turnovers (admins: across all customers). Accepted because (a) the operator allowlist is tiny and internal, (b) demo/dogfood phase, (c) every operator tool call is audited (clara-voice/clara-text actor). Mitigation (spoken PIN on voice / one-time code on text) is pre-socketed at resolveOperatorElevation — flipping it on is a one-surface change (Slice D). Defense-in-depth retained: the turnover handlers' getPmOrgIdsForConversation cross-org guard (_turnover-shared.ts) still runs; elevation widens, never deletes it.

Reuse

Mirrors inHouseHandyman. Reuses TOOL_ROLE_MATRIXplatform_admin/org_admin already hold broad grants, so this ADR makes the tier resolve, it does not re-grant tools. Reuses POST /api/condition-reports/photos + the ADR-0034 S3/report linkage for capture.

Drift / tests

Phased delivery (vertical slices, each prod-verifiable on appfolio-45 + Camellia)

Slice What Verifies
A — Voice operator parity resolveOperatorElevation chokepoint; widen resolvePmCallContext role set; voice-gate bypass; personalization is_operator + Triage power-user routing A real PM/admin call walks a turnover (fixes #1 for org_admin/platform_admin, not just the hand-granted pm)
B — Text operator identity isOperator opt on resolveEffectiveRoleTiers + inbound-router population; operator inbound → operator capability An operator SMS is not treated as tenant-maintenance
C — MMS turnover photo capturedemo needs this operator inbound w/ media → attach to the open turnover's ConditionReport (reuse photos API); unit disambiguation Text a photo mid-walk → it appears on the turnover
D — Step-up auth (deferred) PIN at the chokepoint Flips the accepted spoofing risk to mitigated
E — Cross-org admin (deferred to 2nd customer org) platform_admin cross-org + per-cross-org-call audit + in-agent org disambiguation An admin acts across customer orgs safely

turnover_intake skips end_call after send_summary (no proactive hang-up). Tracked as a separate prompt fix. Deploy caveat: turnover_intake is excluded from SPECIALISTS_IN_UNIFIED_SYNC, so its prompt does not auto-sync on merge — it needs npx tsx scripts/sync-specialist-prompts.ts --slug turnover_intake --apply (manual or workflow_dispatch).