0041 — Cross-Channel Operator Elevation (and Multimodal Turnover Capture)
- Status: Proposed
- Date: 2026-05-29
- Deciders: Fede (CTO) — scope (voice + text photos) and auth model (caller-ID only), both decided 2026-05-29; written architecture-first per the new/large-domain norm (ADR before code).
- Related: ADR-0029 (Role-Prompt-Tool Router — the RBAC chokepoints this extends), ADR-0034 (Turnover orchestration — the surface that exposed the gap; owns the ConditionReport + S3 photo upload this reuses), ADR-0020 (Person spine + 9-value
PersonRole), ADR-0028 (Tools catalog), ADR-0022 (Channel adapter /MessageEnvelope— the text-channel identity path this threads into).
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:
- Voice — PM blocked from a turnover. Fede called the Camellia line ("do a turnover on unit 615"). Triage correctly transferred to the
turnover_intakespecialist, butpm_is_pmwasfalse, sofind_turnoverreturned 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; thepmrole lived on a different "Federico Chapa" Person (pers_44f39fe6). Identity fragmentation. Fixed in-session by granting an activepmrole (scopeorg_default) to the phone-owning Person — verifiedpm_is_pm=true, 21 open turnovers surfaced. (Pure data; no deploy.) - Voice — no hang-up. After the walk completed, Clara never fired
end_calland lingered. Separate prompt-adherence bug; tracked as a fast-follow, not in this ADR's scope (see "Related bug" below). - 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 |
resolveEffectiveRoleTiers — deliberately 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
resolvePmCallContextwidens its role match from{pm}→{pm, org_admin, platform_admin}.requiresVerifiedTenant/requiresPmCallContextshort-circuit-allow for an elevated operator (still audited).- The personalization webhook emits
is_operator(+ property scope); Triage treats operators as power-users — honor explicit intent, ask "which property / which workflow?" when ambiguous, don't auto-bias to turnover.
Text changes
resolveEffectiveRoleTiersgains anisOperatoropt (identical shape toinHouseHandyman) that unionspm/org_admin(andplatform_admin) onto the tier.inbound-router.tspopulates it fromresolveOperatorElevation; an operator inbound routes to an operator capability, not tenant-maintenance.
Authority breadth
- PM (org-scoped): all properties in their org. Scope turnovers to
assignedPmPersonIdwhere set; org-wide fallback where unset (today: all unset). - Admin (
platform_admin): all tools, all properties, all orgs. Cross-customer-org reach is the high-blast-radius part — gate behind explicit in-agent property/org disambiguation + audit every cross-org operator tool call. Deferred until a second customer org exists (moot under single org).
Multimodal turnover capture — the #3 fix, first consumer of elevation
Once a PM is recognized on text, add an inbound-MMS → ConditionReport photo path:
- On an operator inbound carrying media, resolve the operator's open turnover set (the same
pm_open_turnoversthe voice path computes). Exactly one in-progress walk (or a named unit) → attach the photo to that turnover'sConditionReportvia the existingPOST /api/condition-reports/photoscontract (reuse the ADR-0034 S3 upload + report linkage — don't reinvent). - Disambiguation: >1 open turnover → Clara asks "which unit?", mirroring the voice whitelist.
- Cross-channel continuity: a voice walk creates the
ConditionReport(turnover.conditionReportIdFK, ADR-0034); a follow-up MMS attaches to that report, keyed(operator personId → open turnover → conditionReportId). - Out of V1 scope: structured projection from photos mid-call; that still runs server-side post-capture.
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_MATRIX — platform_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
- New drift guard:
resolveOperatorElevationis the only caller-address→operator-tier path (both channels consume it). - Voice eval extension (
evals/voice-simulation): operator turnover walk incl.end_callclose. - Multi-turn text eval: operator photo attaches to the right turnover; >1 open → disambiguates.
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 capture ← demo 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 |
Related bug — NOT in this ADR's scope
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).