ADR-0053: Clara as dumb I/O; the "Maestro" owns maintenance-lifecycle orchestration + interpretation

Status: Accepted + Phase 1 IMPLEMENTED (grounded 2026-06-09)

πŸ“ Implementation status (grounded against rebased feat/forward-to-handyman, 2026-06-09)

The DESIGN below stands. The current-state facts in some sections are stale β€” this banner is authoritative where they conflict. The reuse-first, risk-ordered roadmap lives in docs/planning/maestro-rescope-plan.md, which supersedes the "Migration plan" section below (the original 4-phase sketch reorganized into 7 risk-ordered phases).

DONE + proven (Phases 0–1):

  • maintenanceCommsWorkflow (WO-keyed, propflow-maintenance-comms queue) + its dedicated worker.
  • 5 activities (NOT the compose*/classify* names sketched in "### New" β€” those are net-new for later phases): answerOrRelayActivity, relayToResidentActivity, forwardAnswerToTechActivity, escalateUnansweredActivity, resolveQuietHoldActivity.
  • 2 signals (NOT four): signalTechReplied + signalResidentReplied. signalTenantReported + signalRatingReplied are net-new for Phase 3.
  • comms-scratch (PII row), composeDumbAck (zero-LLM ack), holdForQuietHours (durable quiet-sleep), the 2 edge hooks (inbound-router step-2b resident-answer; handle-vendor-completion handleVendorTenantInquiry tech-question), the proof trio of tests.
  • forward_to_handyman Clara tool retired (decision is now transition 6a). relay_to_tenant is still live β€” it retires in Phase 2 when status/availability move to Maestro.

Stale facts corrected:

  • Quiet hours (Phase 5 β€” SHIPPED 2026-06-10, amended scope): the window source is per-HANDYMAN, not per-property β€” VendorMembership.quietHours (three-state: undefined = default 21:00–07:00 / a window = custom / 'none' = opt-out), resolved by resolveHandymanQuietWindow(membershipQuietHours) + the membership walk in handyman-quiet-window.ts. The gate is ON by default (wiring A). The MAINTENANCE_HANDYMAN_QUIET_HOURS env window-read is DELETED, and so is the fleet kill-switch MAINTENANCE_HANDYMAN_QUIET_HOURS_DISABLED (owner decision 2026-06-11, "no flags") β€” there is NO env input; deploys armed, unset memberships get the default hold immediately, the membership 'none' is the only opt-out. The per-property PROPERTY_CONFIG#<id>.maintenanceQuietHours idea below is superseded β€” owner decision 2026-06-10: quiet hours belong to the person being paged, and ADR-0033 puts person-scoped settings on the membership. Tenant-facing relays keep the default window in property-local time (unchanged this phase). Both quiet-hold surfaces (the ADR-0052 front edge AND resolveQuietHoldActivity, now direction-aware 'handyman' | 'tenant') read the same source. Self-adjust surfaces: the operator membership PATCH, the set_my_quiet_hours Clara tool, scripts/set-handyman-quiet-hours.ts.
  • Second quiet-hold surface: a standalone handymanNotificationHoldWorkflow (handyman-page-<woId>) shipped separately via ADR-0052 β€” handyman-quiet-hours-temporal-hold, started inside dispatchToMaintenanceTech. It was a SECOND, already-live hold mechanism. Phase 6 (SHIPPED 2026-06-11) consolidated it onto the comms machine β€” signalDispatchDue + holdForQuietHours('handyman') + sendHandymanPageActivity; the standalone workflow/client/activity are DELETED (PR-A #2229 additive β†’ PR-B caller-switch + retirement; prod drain runbook at docs/runbooks/handyman-hold-drain.md). See the decision #6 status note below.
  • intakeClassifyActivity (transition 0) is net-new (Phase 3+); Phase 1 uses the deterministic resolveResidentAwaitingWO prior.

Renumbered from 0050 β†’ 0053 (0050, then 0052, both collided with merged ADRs).

Supersedes the relay/forward-tool-on-Clara approach taken in ADR-0029 B1 (the forward_to_handyman / relay_to_tenant Clara tools). Builds on ADR-0031 (local-first work orders), ADR-0026 (Temporal PII boundary), and the maintenance-on-Temporal cutover (docs/maintenance-workflow.md).


πŸ”„ Re-scope amendment β€” 2026-06-10 (owner-confirmed; supersedes the noted lines below)

Grounded by an 8-agent study + adversarial critique (session b1457c1f). Where this amendment and an earlier line disagree, this wins. The earlier prose is kept for history, not authority.

Shipped status (corrects any "Phase 1 only" framing above): Phases 0, 1, 2a, 2b, 3a (rating), 3b (close), 3d (comms.* ops-tool surface) are MERGED. The remaining Phase-3 slice is 3c (triage/create) β€” in flight on feat/maestro-phase3c-triage. 3d is a real shipped slice, NOT "merged into broader Phase 3." 3a/3b shipped before 3c.

1 β€” Maestro is MAINTENANCE-ONLY; entry-routing is a separate upstream layer (re-scopes Β§1's "Master routing ladder β†’ MAESTRO (driver)" row). maintenanceCommsWorkflow is the maintenance-lifecycle engine, reached through the entry layer β€” it is not the entry layer. Two upstream decisions are NOT Maestro and never become Maestro: (a) property/org β€” pinned by the channel (Twilio TFN / EL agent / email domain), not an LLM; (b) domain (maintenance vs leasing vs renewal vs tour) β€” voice via triage.ts transfer_to_agent, SMS/text via conversation-manager first-message mode routing. Maestro's intakeClassifyActivity is a within-maintenance question ("which WO is this about"), structurally distinct from domain routing. Renewals/tours get sibling Maestros later (renewalCommsWorkflow / tourCommsWorkflow), reusing the same primitives β€” never one god-orchestrator. Code stays domain-named; "Maestro" never appears in a file/queue/signal name.

2 β€” Phase 3c reframed for LOCAL-FIRST (ADR-0031 supersedes ADR-0030 β€” the design must NOT re-propose the retired SQS-fallback/PMS-confirm pattern). Work-order create is ALREADY synchronous + local: mintLocalWorkOrderId β†’ PROP-XXXXX (displayId == id), the row saves immediately with pmsSyncStatus:'unsynced', and the canonical WO number is in Clara's reply instantly β€” no inline L4, no SQS-fallback, no PMS round-trip (verified handle-create-work-order.ts header + :645). So 3c moves ONLY the triage decision β€” category, priority, troubleshoot-vs-create, the ask-for-photo gate β€” out of Clara's prompt into a dedicated Maestro classifier. The create handler stays unchanged; the dumb-ack (or the inline classify result) carries the real WO number. There is no signal hop and no promise-only-ack redesign β€” that would solve a PMS-latency problem local-first already eliminated. Open design fork (owner's call): run the triage classifier inline at the inbound edge (Haiku <2s, keeps the synchronous WO-number confirmation, no re-plumb β€” RECOMMENDED) vs async behind signalTenantReported (adds a hop, loses the instant number, must re-plumb BOTH ALS reads: getInboundImages :740 + getInboundInHouseHandyman :536). #2140 added "auto-assign the in-house vendor at create" β€” that vendor pick is part of what Maestro's intake owns.

3 β€” Clara is a fuzzy messenger, not a tool-less mute (refines Β§4 "reduced contract"). The four-beat loop, all grounded in shipped primitives: (a) directive β†’ render β€” Maestro composes the words in a compose activity (composeDirectiveActivity({workOrderId, intent, scratchKey}), intent a bounded enum: ask_rating | propose_slots | ask_photo | relay_question | status_update; index-bearing intents stay template-locked), Clara delivers verbatim (precedents: sendRatingPrompt, relayMessageTo*, composeStatusRelayActivity); (b) Clara's ONE self-composed text is the zero-LLM composeDumbAck(role) β€” no intent claim, never branches on content; (c) capture β€” the edge writes the raw reply to scratch

4 β€” Phase 4 confirmed: collapse the run-all ladder; awaiting-states live on Maestro, not Clara. Today four mirrors of "awaiting" truth can drift (WorkOrder.schedulingState, the 30-day rating scan, commsAwaitingResidentSince, the workflow's own state). Phase 4 picks ONE authority β€” the maintenanceCommsWorkflow's durable state. A bare "5" in awaiting_rating = rating; the same "5" in awaiting_schedule_tenant = slot pick β€” state IS the classifier's context, so the ordering hack disappears. handleTenantSchedulingReply migrates onto the comms machine. Fail-open is load-bearing β€” a Temporal blip must degrade to Clara, never drop a resident answer behind a lying ack.

5 β€” Quiet hours are PER-HANDYMAN, on VendorMembership (supersedes decision #4's per-PROPERTY_CONFIG# for the handyman direction). Add optional quietHours?: QuietHoursWindow | 'none' to VendorMembership (alongside the existing per-membership notifyOnRoutineUpdates? / availability? / isInHouse precedents β€” VendorCompany has no human identity per ADR-0033, and a dispatcher vs a tech at one company can want different windows). Three-state: undefined β†’ default ~21:00–07:00 applied at READ time (no backfill); a window β†’ custom; 'none' β†’ opt-out ("text me whenever"). resolveHandymanQuietWindow(membership?) repoints both call sites (dispatchToMaintenanceTech, resolveQuietHoldActivity) in ONE PR; the env var becomes a fleet kill-switch, not the source. Tenant relays stay PER-PROPERTY (a tenant has no VendorMembership; tenant quiet hours are a TCPA-adjacent property-policy concern) β€” so the single holdForQuietHours() splits into handyman-direction (membership window) vs tenant-direction (property window); status relays keep their 2a bypass. Self-service: an operator edit on the membership PATCH route + an SMS command parsed at the vendor edge ("quiet 9pm-7am" / "quiet off"). Open sub-decision (owner): for the front dispatch edge, does unset default ON (held ~9pm–7am, aligning with the comms path) β€” recommended, but a real behavior change β€” or stay OFF-until-set?

6 — Phase 6 = MIGRATE the sync-import→handyman-notify onto Maestro, THEN retire the standalone hold. KEEP the catch. The catch stays: run-work-order-sync.ts onCreated → handleNewWorkOrder → dispatchToMaintenanceTech (an AppFolio WO appears → pull it in → page the handyman). Phase 6 adds a content-free signalNewWorkOrder + an awaiting_tech_dispatch transition (holdForQuietHours() with the per-membership window from #5, then a dispatchHandymanActivity that re-reads the WO at fire time so a cancelled-mid-hold WO skips); repoints the 6 dispatchToMaintenanceTech callers to signal; and retires handymanNotificationHoldWorkflow + its client/activity in the SAME PR (ONE SOURCE OF TRUTH). Phase 5 lands before Phase 6 (both surfaces must compute the same window). Risks to honor: preserve fail-open (down comms worker must still page), the emergency skip-the-hold bypass, double-page dedup (signalWithStart USE_EXISTING

Status (amended 2026-06-11): SHIPPED as two PRs, deploy-sequenced. PR-A (#2229, additive) landed signalDispatchDue + sendHandymanPageActivity + the dispatch-hold transition on the comms machine (named signalDispatchDue, not the draft signalNewWorkOrder; the page rides the existing holdForQuietHours('handyman') + transition arm rather than a separate awaiting_tech_dispatch phase) while the old hold stayed registered β€” deploying that worker image changed zero behavior. PR-B (this change) switches the single caller (dispatchToMaintenanceTech β€” its 6 upstream callers were already funneled through it, so no per-caller repoint was needed) and deletes handymanNotificationHoldWorkflow + its client + activity in the same PR (ONE SOURCE OF TRUTH). Fail-open, the emergency bypass, and the Temporal-unconfigured bail are preserved exactly; outside-window pages stay inline (never enter Temporal). One intentional behavior delta: a re-trigger AFTER the held page already fired now DEDUPS on the dispatch-page:<woId> eventId (no page), where the old handyman-page-<woId> REJECT_DUPLICATE reuse policy threw β†’ fail-open β†’ a second page. PR-B's worker image deploys ONLY after the prod census shows zero Running handyman-page-* executions β€” see docs/runbooks/handyman-hold-drain.md (drain script: scripts/temporal/drain-handyman-holds.ts).


Context β€” the waiter/cook split

Ground truth, verified against the tree on 2026-06-09

This ADR was written after grounding every load-bearing claim against the live code. The checkout state matters and corrects the design input:

Why forward_to_handyman-as-a-Clara-tool failed

ADR-0029 B1 added forward_to_handyman and relay_to_tenant as Clara tools (branch feat/forward-to-handyman). The observed behavior the product owner flagged: on the reverse leg β€” a tenant answers a tech's relayed question β€” Clara reliably chose add_note_to_work_order instead of forward_to_handyman. The tenant's answer landed as an internal note; the tech never saw it.

This is not a prompt-tuning bug; it is structural. Choosing "is this inbound a relay-to-tech vs. an internal note?" is a routing decision, and routing decisions are exactly what the dumb pass-through layer cannot reliably make from a soft prompt rule. The decision depends on durable cross-party state ("is a tech currently waiting on this resident?") that Clara does not hold β€” she sees only the current turn and a re-read of history. Every soft "when to X vs. Y" prompt bullet on Clara's tool layer is a latent version of this same failure.

The owner's intended split

The hard tension

Clara's SMS reply is synchronous (~15s Twilio budget). The maintenance Temporal workflow is asynchronous with a 5-minute idle debounce (it batches toward the AppFolio mirror). "The brain composes, Clara delivers" cannot mean "Clara blocks on the debounced workflow." This ADR resolves the two timescales.


Decision

1. Layer split

Clara keeps three things and nothing else: deterministic identity/photo I/O, read-only data lookups (invoked only when the Maestro asks), and a single identity-keyed, interpretation-free synchronous acknowledgement. Every transition decision and every cross-party composition moves to the Maestro.

Responsibility Today (main) Target layer Note
Resolve identity deterministically (pre-LLM) resolveIdentity / resolveInboundVendorMembership CLARA Identity is a fact, never an inference. Stays at the edge.
Photo upload at inbound boundary uploadInboundPhoto / getInboundImages CLARA Dumb I/O. Unchanged.
Synchronous ack inline in Clara's loop reply CLARA NEW reduced role: identity-keyed template, no LLM (Β§4).
Read-only lookups (get_unit_appliances, get_troubleshooting_steps, search_appliance_manual, get_available_vendors, propose_schedule, list_my_assigned_work_orders) tools.ts + handlers UNCHANGED INFRA Data tools. The decision to call them moves to the Maestro.
handleToolCall dispatch boundary tools/index.ts:120 UNCHANGED INFRA Dumb plumbing; identity-threading security preserved.
WO-target resolution (which WO is this inbound about?) handleVendorMessage scan (:1061-1069), findRatingTargetWO (:177) MAESTRO intake-classify (LLM) NEW: an LLM intake step at the entry point takes (last-few messages, identity, the counterparty's open WOs) β†’ (target workOrderId, intent) and routes the signal to the right WO-keyed workflow. See Β§2 / C-1. The Maestro then holds it as activeWorkOrderId. Clara's ack stays separate + zero-LLM.
Issue triage (category + priority) LLM in create_work_order MAESTRO triageActivity.
WO create decision (troubleshoot-vs-create) capabilities/maintenance.ts prose MAESTRO Soft rule β†’ deterministic edge.
Inline handyman auto-dispatch handle-create-work-order.ts:900-901 MAESTRO (decide) / INFRA (dispatchToMaintenanceTech, send) [dispatch] bracket; the durable quiet-hours gate (already in tech-sms-dispatch.ts via the F2 quiet-hours module) moves inside the workflow as an await sleep.
Tech reply classification classifyVendorMessage (:414) MAESTRO Function body reused as activity body; durable state around it is net-new.
Active-WO selection handleVendorMessage scan (:1061-1069), findRatingTargetWO (:177) MAESTRO intake-classify (resolve) + MAESTRO (hold) See Β§2 / C-1: the LLM intake-classify step resolves the right WO-keyed workflow to signal; the Maestro holds it as activeWorkOrderId.
Answer-or-relay (tech question) split today: answerVendorQuestion (sync, :846) vs relayInquiryToTenant (async, :699) MAESTRO (classify β†’ answer XOR relay) / INFRA (send) NEW unified classify+answer-or-relay layer: try to answer from WO context first; relay to the resident only what it can't. See Β§3 transitions 4a / 4a'.
Funnel-to-resident composition relayInquiryToTenant (:699) MAESTRO (compose) / INFRA (send) composeResidentRelayActivity + relayMessageToTenant.
Funnel-to-tech composition (reverse leg) not durably wired on main; tool on F2 MAESTRO (compose) / INFRA (send) NEW durable transition; composeTechForwardActivity + relayMessageToHandyman (the F2 primitive).
Status / availability relay handleVendorStatusUpdate (:521), relayStatusUpdateToTenant (:749), relayAvailabilityToTenant (:980) MAESTRO (compose) / INFRA (send) Non-terminal edges.
Completion judgement (β‰₯5 words + valid photo, accumulate) handleVendorCompletion (:140) MAESTRO The accumulate-across-messages logic is what the Maestro's batch owns.
Close-reason classification + proof-photo gate LLM in close_work_order; closeWorkOrder (close-work-order.ts:118) MAESTRO (classify) / INFRA (execute) closeWorkOrder enforces proofPhotoUrl for completed.
Internal-note vs relay routing LLM in add_note_to_work_order MAESTRO "Is someone waiting?" is durable state, not a prompt guess. This is the forward_to_handyman-vs-add_note failure, fixed.
Escalation decision (out-of-scope / gas / wants-human) LLM in escalate_to_human MAESTRO Gas/life-safety = strongest determinism argument. Paging side effect = forward_to_property_manager (reused).
Photo-gating decision (ask-before-create) LLM in request_photo MAESTRO When-to-ask is interpretation; the upload stays Clara.
Rating prompt fire sendRatingPrompt (rating.ts:427) MAESTRO (decide) / INFRA (send) [kick off rating] bracket.
Rating parse + target select parseRatingReply (:115, pure regex), findRatingTargetWO (:177) MAESTRO Selection becomes explicit state, not a 30-day scan.
Scheduling ping-pong classifySchedulingIntent (classify-intent.ts:48), handleTenantSchedulingReply MAESTRO Implicit machine across two handlers β†’ named states.
Master routing ladder routeInboundMessage (inbound-router.ts:99) MAESTRO (driver) Eager run-all β†’ "intake-classify once, drive ONE transition."
AppFolio mirror (gather→judge→dispatch→persist) maintenanceWorkflow (:189) UNCHANGED INFRA Stays as ONE transition (PMS mirror); judge stays the AppFolio-package reviewer, NOT overloaded for comms.
PII scratch boundary scratch.ts UNCHANGED INFRA (constraint) Reused for in-flight-turn bodies only (Β§4 / m-4).
5-min idle debounce maintenanceWorkflow:182-290 UNCHANGED INFRA (mirror only) Comms transitions bypass it (Β§3).
Carrier send / channel routing dispatch (dispatcher.ts:92), append-to-conversation UNCHANGED INFRA Transport routing β‰  lifecycle routing.
Test-menu intercept handleTestMenu (test-menu.ts:83) UNCHANGED INFRA Pre-Maestro intercept; does not move.

2. The Maestro's comms state machine β€” keyed on the WORK ORDER (resolves C-1)

The machine is keyed on the work order, NOT the conversation. This is the central correction over the design input. A maintenance lifecycle inherently spans two conversations β€” the tenant thread (wo.conversationId) and the separate vendor thread (ensureVendorConversation, tech-sms-dispatch.ts:334). A conversation-keyed workflow would split awaiting_resident_answer across two workflow instances that cannot see each other's state. The single entity both legs share is the work order.

WO-target resolution is a MAESTRO LLM intake-classify step, NOT a deterministic edge scan (resolved by owner 2026-06-09; see Resolved decisions #3). To address the right maintenance-comms-wo-<id> workflow, the entry point must resolve which WO an inbound is about. The owner's decision is that this is a brain step, not a dumb edge scan:

The machine is a long-lived loop (resolves C-3). It does not return after one transition. It loops on await condition(() => commsSignalPending || terminalReached), processes exactly one transition, re-arms, and only returns on a terminal state (closed after rating recorded/expired, or escalated +timeout, or a MAX_LIFETIME ceiling β€” 45 days, resolved by owner; see Resolved decisions #5). It uses Continue-As-New to bound history growth across a multi-hour/multi-day lifecycle β€” modeled on renewalWorkflow's long-lived shape. This is a different authoring shape than the one-shot maintenanceWorkflow, not a field addition.

States: idle β†’ triaging β†’ awaiting_tech_dispatch β†’ awaiting_tech_response β†’ pendingTechRelay β†’ awaiting_resident_answer β†’ (loop back to awaiting_tech_response) β†’ awaiting_completion β†’ closing β†’ awaiting_rating β†’ closed, plus escalated and awaiting_schedule_tenant.

# From Trigger Action To SCRIPTED / LLM Interpretation layer
0 (entry) any inbound Intake-classify: (last-few msgs, identity, counterparty's open WOs) β†’ (target workOrderId, intent); route signal to that WO's workflow; low-confidence β†’ "which WO?" prompt (signals target workflow) LLM intakeClassifyActivity (resolves C-1)
1 idle signalTenantReported Triage: category + priority + troubleshoot-vs-create triaging LLM triageActivity
2 triaging needs-WO [create WO] row write, set activeWorkOrderId awaiting_tech_dispatch SCRIPTED β€”
2b triaging self-serve / safety send troubleshooting OR escalate (gas β†’ page PM) idle / escalated SCRIPTED edge, LLM verdict gas branch deterministic once classified
3 awaiting_tech_dispatch entered [dispatch] dispatchToMaintenanceTech; quiet-hours β†’ await sleep until window end (per-property window, BOTH-direction policy β€” Β§"Quiet hours") awaiting_tech_response SCRIPTED durable quiet-hours timer (replaces the F2 inline defer)
4 awaiting_tech_response signalTechReplied Classify: question / completion / availability / status_update branch ↓ LLM classifyTechReplyActivity (body = classifyVendorMessage)
4a β†’ question, answer-or-relay β†’ cannot answer from WO context compose resident-facing question; enqueue (see M-3) pendingTechRelay LLM (answer-or-relay β†’ relay) answerOrRelayActivity β†’ composeResidentRelayActivity
4a' β†’ question, answer-or-relay β†’ answerable from WO context compose answer from WO context, send to tech async awaiting_tech_response LLM (answer-or-relay β†’ answer) answerOrRelayActivity (body = answerVendorQuestion)
4b β†’ status_update / availability compose status relay to resident (non-terminal) awaiting_tech_response / awaiting_schedule_tenant LLM compose composeResidentRelayActivity
4c β†’ completion accumulate text+photo; gate (β‰₯5 words + valid photo via vision) awaiting_completion LLM gate judgeCompletionActivity
5 pendingTechRelay composed send resident msg via relayMessageToTenant; quiet-hours β†’ await sleep (tenant direction) awaiting_resident_answer SCRIPTED durable quiet-hours timer (tenant)
6 awaiting_resident_answer signalResidentReplied Classify: answer / new-issue / unrelated branch ↓ LLM classifyResidentReplyActivity
6a β†’ answer compose tech-facing forward; send via relayMessageToHandyman; quiet-hours β†’ await sleep (handyman direction) awaiting_tech_response LLM compose + SCRIPTED send composeTechForwardActivity + the F2 primitive β€” this is the forward_to_handyman failure, now a durable transition
6b β†’ new-issue re-enter triage for a 2nd WO triaging LLM triageActivity
6t awaiting_resident_answer timeout (no reply) re-prompt once; 2nd timeout β†’ tell tech "couldn't reach resident, proceed/contact office"; clear slot awaiting_tech_response SCRIPTED await condition(timeout)
7 awaiting_completion gate satisfied [close WO] closeWorkOrder({reason:'completed', proofPhotoUrl}) closing SCRIPTED edge, LLM-gated proof-photo gate deterministic (close-work-order.ts:72)
7b awaiting_* tenant "nvm fixed itself" [close WO] reason=auto_resolved_by_tenant; sendTechStandDownSms closed SCRIPTED edge, LLM verdict classifyTenantReplyActivity
8 closing WO closed [kick off rating] sendRatingPrompt (separate outbound) awaiting_rating SCRIPTED β€” (idempotency guard stays in primitive)
9 awaiting_rating signalRatingReplied ("5") parse 1-5 + patchWorkOrder(rating) closed SCRIPTED parseRatingReply (pure regex); target is activeWorkOrderId, no 30-day scan
S1 awaiting_* tech availability compose numbered slot list to resident awaiting_schedule_tenant LLM compose composeResidentRelayActivity
S2 awaiting_schedule_tenant signalResidentReplied classify pick/reschedule/cancel (regex→Haiku) branch LLM-fallback classifySchedulingIntent (reused)
E1 any escalation verdict page PM via forward_to_property_manager escalated SCRIPTED edge, LLM verdict gas/flood deterministic once classified

Ambiguity arbitration is now explicit. Today a bare "5" is resolved by router ordering (rating runs last, gated on !scheduling && !tour, inbound-router.ts:218). In the Maestro the current commsState disambiguates: "5" in awaiting_schedule_tenant = slot pick; "5" in awaiting_rating = rating. The state is the classifier's context. Cross-WO ambiguity (a counterparty with multiple open WOs) is resolved one level up, by the intake-classify step (transition 0), before the signal even reaches a WO workflow.

pendingTechRelay is the load-bearing new state β€” the durable representation of "we have a tech question and are awaiting the resident's answer." Today this exists only in the LLM's reading of history (and the reverse leg isn't durably wired on main). As a workflow state it gains durable retry, a timeout (transition 6t), and an unambiguous "this resident reply answers that tech question" binding.

Multi-question / double-answer races (resolves M-3)

pendingTechRelay is not a single pointer + single state β€” it is a list of outstanding questions keyed by questionId (the question's scratch id), held in workflow state. Concretely:

3. The sync/async reconciliation + answer-or-relay (resolves C-2)

Two delivery paths off the comms workflow. The realization: the safe ack and the composed cross-party message were always different outbounds β€” main already splits them (the sync ack goes to the sender; the relay is a separate fire-and-forget SMS). We keep that split, move the composition onto the workflow, and bypass the debounce for comms.

(a) Clara's immediate synchronous ack β€” identity-keyed, ZERO LLM. Composed at the inbound edge from (resolved identity, coarse channel) alone, with no branch on message content:

It makes no intent claim and never asserts a state that isn't yet true. Dropping the input design's "or at most a 1-shot Haiku" escape hatch is deliberate (resolves m-1): a Haiku call is interpretation by degree, and it is the seam through which the brain leaks back onto the dumb layer. The ack is a pure template selection on identity.

(b) Answer-or-relay — the Maestro decides; not a flat sync→async regression (resolved by owner 2026-06-09; see Resolved decisions #2). The owner's decision corrects the original draft's "accepted regression" framing. Rather than demoting every system-answerable tech question to async, the Maestro gains an LLM "answer-or-relay" layer:

(c) Composed messages are async separate outbounds:

Composed message Fired on entering Delivery primitive (reused)
Handyman dispatch ([dispatch]) awaiting_tech_response dispatchToMaintenanceTech
System answer back to tech (answer-or-relay β†’ answer) awaiting_tech_response (4a') answerVendorQuestion's send half
Resident relay of tech question (answer-or-relay β†’ relay) awaiting_resident_answer relayMessageToTenant
Status / availability relay to resident status edge relayMessageToTenant
Tech forward of resident answer ([funnel to tech]) awaiting_tech_response (6a) relayMessageToHandyman (F2 primitive)
Tech stand-down closed (auto-resolved/canceled) sendTechStandDownSms
Rating prompt ([kick off rating]) awaiting_rating sendRatingPrompt

(d) Comms signals bypass the 5-min debounce. The debounce stays for the AppFolio mirror only (no human waits on the PMS sync; batching avoids partial/duplicate creates). The comms workflow is a separate workflow (§2) with no debounce — it wakes on the signal and runs classify→compose→send immediately (sub-second to the carrier). A relay 5 minutes late is a broken experience; this is why the comms classifiers must never be bolted onto judgeActivity (which is gated on the debounced mirror flush and scoped to the AppFolio package). The ONLY intentional delay on a comms send is the durable quiet-hours await sleep (§"Quiet hours").

Quiet hours β€” durable per-property window, BOTH directions (resolves Resolved decisions #4)

The owner's decision: NEVER message tenants OR handymen during a per-property quiet window. Concretely:

4. Clara's reduced contract

Clara is the waiter. She receives, acknowledges safely, delivers what the kitchen plates.

Clara MAY: receive raw inbound (text + photos); resolve identity deterministically (pre-LLM); give a promise-only, identity-keyed acknowledgement; deliver Maestro-composed outbounds verbatim; run read-only lookups only when the Maestro asks.

Clara MUST NEVER: assert a completed/status fact the Maestro hasn't made true (βœ— "your work order is ready", βœ— "the tech is on the way", βœ— "this is fixed", βœ— "the tech will be there at 3pm"); decide a transition (create vs troubleshoot, question vs done, note vs relay, which-WO); compose a cross-party message; re-verify a verified identity or fabricate "couldn't find your account."

The boundary is enforced by Clara's reduced prompt: it grants only ack-composition, lists the forbidden completion/status claims explicitly, and removes the transition tools from her bundle (create_work_order, add_note_to_work_order, close_work_order, escalate_to_human, forward_to_handyman, relay_to_tenant, request_photo leave Clara's maintenance capability β€” i.e. removed from agents/clara/lib/agent/tools.ts + the agents/clara/lib/agent/tools/index.ts dispatch). The forbid-false-completion line is the single most important rule.

Hand-off (raw in β†’ composed out):

  1. Raw in: the edge resolves identity, uploads photos, writes the raw body to scratch keyed (conversationId, turnId), and fires the content-free comms signal carrying {eventId, occurredAt, scratchKey}. The Maestro's intake-classify activity (transition 0) reads scratch + recent history to resolve the target workOrderId and route the signal. Clara returns the sync ack immediately, without waiting on intake-classify.
  2. Brain: the comms workflow wakes on the signal, reads the body off scratch in a classify activity, runs the transition, composes in a compose activity (body written to the durable WO/conversation timeline, not scratch β€” see m-4 below), checks the quiet-hours window (await sleep if needed), and calls the delivery primitive.
  3. Composed out: the primitive sends via dispatch then appends to the right thread. From the recipient's view, Clara delivered it.

Durable-content rule (resolves m-4). The scratch row is 1-hour TTL (scratch.ts:44), but the comms machine can sit in awaiting_resident_answer or awaiting_rating for hours-to-days (RATING_REPLY_WINDOW_DAYS = 30). So:

5. The name

Code stays domain-named regardless of the product name. The brand name is a concept for docs/ADRs/product only.

Pick: Maestro β€” locked by the owner. An orchestra maestro cues each section at the right moment without playing a note β€” exactly the role: it drives transitions and cues the primitives but never sends directly. It captures both halves the owner wants β€” a deterministic score (the lifecycle flowchart) and cueing each player at the right time (firing each composed outbound as its own async step) β€” and contrasts cleanly with Clara-the-waiter (front of house) vs Maestro-the-orchestrator (the pit). The agent's alternative considered was Conductor (same orchestra metaphor, reads more generic). Other alternatives weighed and rejected: Cortex (brain-region metaphor reads as generic-AI, muddies the dumb/smart split since Clara is also "AI"); Axon (carries a signal, undersells the deciding).

Code artifacts (all domain-named, all pass the drift guard): workflow maintenanceCommsWorkflow; queue propflow-maintenance-comms; signals signalTenantReported, signalTechReplied, signalResidentReplied, signalRatingReplied; activities intakeClassifyActivity, classifyTechReplyActivity, answerOrRelayActivity, composeResidentRelayActivity, composeTechForwardActivity, etc. No file, queue, signal, or activity is named "maestro", "conductor", "clara", "cortex", or "axon".


Consequences

Reused (no change β€” already the right shape)

Retired (each in the same PR as its replacement β€” ONE SOURCE OF TRUTH)

New

PR #2106 disposition

PR #2106 / branch feat/forward-to-handyman (the relayMessageToHandyman primitive + maintenance_handyman_relay message kind + the handleForwardToHandyman / handleRelayToTenant tools + the forward_to_handyman / relay_to_tenant dispatch cases + the quiet-hours module, F2 commit 53f041dfb). Disposition by component (resolved by owner 2026-06-09; see Resolved decisions #6 β€” accepted):

Compliance

Risks & how the critique's items are handled

Critique item Resolution in this ADR
C-1 state machine spans two conversations Β§2: keyed on workOrderId (maintenance-comms-wo-<id>), not conversationId. WO resolved at the entry by an LLM intake-classify step (transition 0). Mirror stays conversation-keyed; they are two sibling workflows.
C-2 sync ack smuggles the brain back §3: ack is identity-keyed, zero-LLM. Tech questions go through answerOrRelayActivity (answer-from-context-first, relay only the rest) — not a flat sync→async regression (Resolved decisions #2).
C-3 workflow exits after one flush Β§2: comms workflow is a long-lived loop with Continue-As-New, terminal only on closed/escalated/45-day ceiling (Resolved decisions #5).
M-1 big-bang, violates ONE SOURCE OF TRUTH Migration plan: Phase 1 is the purely additive reverse leg (nothing retired). Later phases migrate one routedTo at a time, each retiring its inline handler in the same PR.
M-2 retiring inline relays regresses status/availability latency Β§3(d): comms workflow has no debounce + intake-classify resolves the WO before signaling, so the signal carries workOrderId and the workflow wakes instantly with the right context.
M-3 multi-question / double-answer races Β§2: outstanding questions are a list keyed by questionId; resident-answer idempotent on (workOrderId, questionId); two-strike timeout with explicit tech-facing failure message.
m-1 1-shot Haiku ack leaks the brain Β§3(a): the Haiku escape hatch is dropped; ack is pure identity template.
m-2 judge overload risk Β§"New": comms activities live in activities/maintenance-comms.ts + a drift test forbids importing the mirror's judge/dispatch; separate queue.
m-3 signal-name drift-guard Β§"Compliance": explicit allowlist note + drift assertion for the actor-role signal names.
m-4 scratch TTL < lifecycle Β§4: scratch holds only the in-flight turn; durable cross-turn content rides the WO/conversation timeline.

Migration plan (phased, ONE SOURCE OF TRUTH per phase)

Each phase ships independently, retires the path it replaces in the same PR, and is verified on the real appfolio-45 bench (the disarmed/dry-run send-gate keeps L4 inert; the human-facing relay SMS is the actual signal under test). Build on the feat/forward-to-handyman branch (Resolved decisions #6) β€” it already carries the primitive, the message kind, and the quiet-hours module.

Phase 0 β€” land #2106's primitive + quiet-hours module (prerequisite, no new behavior)

Ships: the relayMessageToHandyman primitive + maintenance_handyman_relay message kind + the quiet-hours.ts module from feat/forward-to-handyman. Retires: the forward_to_handyman/relay_to_tenant tools from Clara's bundle

Phase 1 β€” the reverse-leg round-trip as a Maestro transition (the proof slice)

The first concrete end-to-end Maestro slice. Purely additive — nothing to retire, because on main the resident-answer→tech round-trip relies on Clara re-reading history (degraded but present), and on the F2 branch it relies on a tool Clara won't reliably pick.

Ships:

Replaces: the tenant_inquiry branch's inline relay (handle-vendor-completion.ts:673 + the reverse-leg tool). The forward_to_handyman/relay_to_tenant tools are removed from Clara's bundle in this PR (their decision is now transitions 4a/6a). All other routedTo destinations stay exactly where they are.

Verify on the real funnel:

  1. npx tsx scripts/pipeline-lab.ts run --to +18442853526 --property appfolio-45 --target prod --i-know-this-hits-prod --keep-conversation --body "<tenant reports leak>" β†’ seed a WO + dispatch to the bench handyman.
  2. Bench handyman texts a question Clara can't answer from context ("is someone home?") β†’ assert: tech gets the identity-keyed ack; resident gets the composed relay on the tenant thread (maintenance_tenant_relay); maintenanceCommsWorkflow is in awaiting_resident_answer (Temporal Cloud UI / getMaintenanceSnapshot-style query). 2b. Bench handyman texts a context-answerable question ("what unit?") β†’ assert: answerOrRelayActivity answers from WO context and the tech gets the answer as a second outbound (no resident relay).
  3. Resident answers ("yes, until 3") β†’ assert: the handyman receives the composed forward on the handyman thread (maintenance_handyman_relay) β€” the exact leg forward_to_handyman-as-a-tool failed to deliver.
  4. Resident never answers β†’ assert the two-strike timeout fires the tech-facing "couldn't reach the resident" message.
  5. Quiet-hours bench check: with the property's maintenanceQuietHours set to a 1-minute test window (mirroring the F2 22:00-22:01 test window), assert a relay/forward composed inside the window await sleeps and lands at the window edge.
  6. bash evals/run-all.sh maintenance-judge stays green (mirror untouched); add a maintenance-eval scenario for the reverse-leg round-trip.

This slice proves the WO-keyed partition, the LLM intake-classify, the durable pendingTechRelay state, the long-lived loop, the now-wired reverse primitive, the answer-or-relay split, the quiet-hours await sleep, and the dumb-ack/async-relay split β€” end to end on the real bench, with zero retirement risk.

Phase 2 β€” dispatch + status/availability relay + per-property quiet-hours config

Quiet-hours scope note (2026-06-10): the window-source piece of this phase shipped via the Phase 5 amendment as the per-HANDYMAN VendorMembership.quietHours (NOT a PROPERTY_CONFIG# field β€” superseded; see the amendment header). The env read is already retired.

Ships: awaiting_tech_dispatch (with durable quiet-hours await sleep) + the status/availability compose edges + the PROPERTY_CONFIG# quiet-hours field and its single-read resolver. Replaces: inline dispatchToMaintenanceTech from create_work_order (:900-901), the F2 inline quiet-hours defer (tech-sms-dispatch.ts:384), the MAINTENANCE_HANDYMAN_QUIET_HOURS env read, and the inline relayStatusUpdateToTenant/relayAvailabilityToTenant. Verify: bench WO create β†’ confirm dispatch fires from the workflow (not inline); a status text ("otw 15 min") relays to the resident within seconds; quiet-hours dispatch sleeps to the handyman's membership window.

Phase 3 β€” triage + create + close + rating (the full flowchart)

Ships: triageActivity (absorbs create_work_order triage + request_photo gating), judgeCompletionActivity, the closing/awaiting_rating states, classifyTenantReplyActivity (auto-resolved). Replaces: the triage/close/note LLM logic in create_work_order/close_work_order/add_note_to_work_order, and tryHandleTenantRatingReply in the router. Clara's maintenance bundle shrinks to the read-only lookups + the ack. Verify: full demo-scenario round-trips on the bench (report β†’ create β†’ dispatch β†’ completion-with-photo β†’ close β†’ rating "5"); proof-photo gate refusal without a photo; rating binds to activeWorkOrderId (no 30-day scan).

Phase 4 β€” escalation + scheduling ping-pong + router collapse

Ships: escalated (gas/life-safety deterministic edge), the awaiting_schedule_* sub-states, and the final collapse of routeInboundMessage to "resolve identity + intake-classify + scratch + signal." Replaces: the remaining inline escalate_to_human, handleTenantSchedulingReply, and the eager run-all ladder. Verify: gas-leak inbound pages the PM deterministically; a slot-pick "5" vs a rating "5" disambiguate by state; the router no longer runs every handler inline.

After Phase 4, Clara's maintenance capability is read-only lookups + one ack composer; every [bracket] is a Maestro transition; the AppFolio mirror is one of them.


Resolved decisions (owner, 2026-06-09)

All six prior open questions are now decided. They are folded into the body above; this section is the canonical record of the owner's calls.

  1. Product name = Maestro (locked). Code stays domain-named (maintenanceCommsWorkflow, propflow-maintenance-comms, the actor-role signals) regardless. Conductor is noted as the agent's alternative (same orchestra metaphor); Cortex / Axon rejected. "Maestro" never appears in a file, queue, signal, or activity name. (Folded into Β§5.)

  2. answerVendorQuestion is NOT a flat sync→async regression — Maestro gets an LLM "answer-or-relay" layer. For a tech question, the Maestro FIRST tries to answer from WO context; only what it genuinely cannot answer gets relayed to the resident. The Maestro makes that call (answerOrRelayActivity, transitions 4a / 4a'). (Folded into §1, §3(b), and the state table.)

  3. WO disambiguation is a MAESTRO LLM intake-classify step, not a dumb edge scan. The inbound intake passes the LAST FEW messages (not just the latest), the identity, AND ALL the counterparty's open WOs to an intake-classify LLM activity that INFERS the target WO from content (a toilet question + a toilet WO + a door WO β†’ the toilet WO) and routes the signal to the right WO-keyed workflow. An explicit "which work order?" prompt is ONLY a last resort when it truly cannot infer (low confidence). Clara's instant ack stays separate + zero-LLM and never waits on intake-classify. (Folded into Β§1, Β§2 transition 0, Β§4.)

  4. Quiet hours: NEVER message tenants OR handymen during a per-property quiet window, BOTH directions, durable Temporal timer. Every Maestro-composed human-targeted outbound (dispatch, handyman forward, resident relays) does await sleep to the window end when the send would land inside it. The F2 quiet-hours module is the reusable predicate foundation but currently gates the handyman direction only and reads a global env var β€” the owner's decision extends the policy to the tenant relays and (recommended + accepted) introduces a per-property PROPERTY_CONFIG#<propertyId>.maintenanceQuietHours field (the existing QuietHoursWindow shape) with a sane default of ~21:00–08:00 local when absent, property timezone from Property.timezone, emergency bypass preserved. (Folded into Β§"Quiet hours", Β§1, Β§2 transitions 3/5/6a, Β§"New", Β§"Retired".)

  5. Continue-As-New ceiling = 45 days (15 days past the 30-day rating window). The comms workflow force-terminates a stuck lifecycle at this MAX_LIFETIME. (Folded into Β§2.)

  6. #2106 ordering: KEEP the relayMessageToHandyman primitive + maintenance_handyman_relay kind (+ the quiet-hours module); RETIRE the forward_to_handyman / relay_to_tenant Clara tools (remove from Clara's tool bundle + dispatch). Build Maestro on the feat/forward-to-handyman branch. (Folded into Β§"PR #2106 disposition", Β§"Retired", and the migration plan's Phase 0.)