0059 — Operator-side comms: agentic direction (deferred); maestro near-term

Context

Today every flow that reaches a property manager is one-way notify → act in the dashboard: renewals (notifyPmPendingReview) email/text the PM that a review is pending; escalate_to_human emails the PM for life-safety. There is no handler anywhere that takes a PM's reply and acts on it — no two-way PM flow exists.

The claimed_tenant PM Review Queue (ADR-adjacent, shipped Phase 1 in #2438) notifies the PM that a caller claims a unit; the PM approves/denies in the /review dashboard. Jose wants the PM to be able to reply to the text ("yes" / "no" / 👍) and have it resolve — and to see the whole PM conversation in the review drawer. This is the first two-way PM flow, so it sets the template for the obvious next ones (PM replies to approve a renewal, etc.).

Two patterns were on the table: (a) a deterministic "maestro" reply handler like the vendor/handyman WO path (handleVendorMessage — Haiku classifies the reply, deterministic code acts); (b) route the PM's reply into Clara's full agent loop with per-domain approve/deny tools, so Clara converses naturally and calls the tool on a clear decision. Jose chose (b), explicitly accepting that operator comms become a first-class agentic surface and that future PM two-way flows follow the same pattern.

Decision

Near-term (SHIPPED, this PR): PM confirmation-review replies are handled by a deterministic "maestro" handler — the same pattern as vendor/handyman WO replies (handleVendorMessage), NOT Clara's agent loop. Concretely, for the claimed_tenant flow:

  1. Routing recognition (pure). The inbound router recognizes "this sender is the notified PM of a pending TenantConfirmation" via matchConfirmationForPmReply (pure, adversarially tested — returns none for any non-PM sender so a real tenant/vendor message is never intercepted). On a match it routes to the maestro handler (routedTo: 'confirmation_review'), before staff-tier resolution and the agent loop.
  2. Maestro handler (deterministic). handleConfirmationReviewReply Haiku-classifies the reply (approve | deny | question | unclear), answers questions with claim context, and fires the signal only on a clear decision. The classify is an LLM sub-call; the decision is deterministic code — Clara's full agent loop is NOT involved.
  3. The action. On approve/deny it calls signalTenantConfirmationApproved/Rejected (the existing tenantConfirmationReviewWorkflow — the same path the dashboard decision route uses). Guardrail: approve grants account access, so the signal fires ONLY on an explicit classified decision — never on a question/unclear (those clarify once, then fall through to Clara).
  4. The PM thread is captured on the claim. Each turn (notification → reply → ack) is appended to TenantConfirmation.pmThread (a claim-scoped review artifact, NOT a standalone Conversation); the /review drawer renders it with the shared MessageThread/MessageBubble.
  5. tenant_confirmation topic. Stamped on the originating conversation at claim capture and kept STICKY across the message-based re-derivation (a voice claim's conversation has no row messages); excluded from the classifier (never LLM-assigned).
  6. Outbound stays templated. The initial notification SMS remains the structured Phase-1 template.

A future reader can tell THIS (shipped) decision is violated if the PM reply path stops being a deterministic, classify-then-signal handler that fires the privileged approve/deny ONLY on an explicit classified decision — e.g. if a signal is fired on a question/unclear, or the never-intercept guard (matchConfirmationForPmReplynone for non-PM senders) is weakened.

Future direction (DEFERRED — Trello iA0g56gn)

The richer end-state: route the PM's reply into Clara's full agent loop with per-domain approve_tenant_confirmation/deny_tenant_confirmation tools (Clara converses, calls the tool on a clear decision), and make the PM thread a real spine-stamped Conversation (shows in the conversations list, classifies tenant_confirmation from real messages, renders via ConversationThread, claim points to it via a future TenantConfirmation.pmConversationId). Jose chose this direction in principle, then pulled back to "ship the maestro now, ticket the agentic version" — operator comms as a first-class agentic surface is a larger commitment (agent-loop eval load + an LLM driving privileged actions) best taken deliberately. This is NOT what this PR builds.

Entity classification

No new top-level entity. TenantConfirmation (canonical, existing) gains pmThread?: ConfirmationThreadEntry[] (the claim-scoped PM message thread — a review artifact, rendered in the drawer) and ConfirmationThreadEntry (a derived value type: { direction, content, at, kind, providerId? }, no human-identity fields → not spine-stamped). The deferred agentic path would instead add pmConversationId + a real Conversation — NOT in this PR.

Consequences

Alternatives considered