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-commsqueue) + 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+signalRatingRepliedare net-new for Phase 3.comms-scratch(PII row),composeDumbAck(zero-LLM ack),holdForQuietHours(durable quiet-sleep), the 2 edge hooks (inbound-routerstep-2b resident-answer;handle-vendor-completionhandleVendorTenantInquirytech-question), the proof trio of tests.forward_to_handymanClara tool retired (decision is now transition 6a).relay_to_tenantis 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 byresolveHandymanQuietWindow(membershipQuietHours)+ the membership walk inhandyman-quiet-window.ts. The gate is ON by default (wiring A). TheMAINTENANCE_HANDYMAN_QUIET_HOURSenv window-read is DELETED, and so is the fleet kill-switchMAINTENANCE_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-propertyPROPERTY_CONFIG#<id>.maintenanceQuietHoursidea 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 ANDresolveQuietHoldActivity, now direction-aware'handyman' | 'tenant') read the same source. Self-adjust surfaces: the operator membership PATCH, theset_my_quiet_hoursClara 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 insidedispatchToMaintenanceTech. 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 atdocs/runbooks/handyman-hold-drain.md). See the decision #6 status note below.intakeClassifyActivity(transition 0) is net-new (Phase 3+); Phase 1 uses the deterministicresolveResidentAwaitingWOprior.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_tenantClara 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
- fires a content-free signal (IDs only); (d) interpret β a classify activity re-reads
the body off scratch and decides (precedent:
answerOrRelayActivity). Fuzzy-capability / zero-execution is structural, not prompt discipline: every transition tool leaves Clara's bundle (a removed tool can't fire β a prompt rule is only a hope), and atools:[]capability carries apromptBlockvocabulary ("you can help file/cancel a WO, rent/lease questions, scheduling; you CANNOT delete a property / change rent / mark a job done β when asked, acknowledge warmly and hand off, never claim it's done"). That block shapes tone/coherence only; it is NEVER where a routing/execution decision lives. The single load-bearing rule: Clara must never assert a completed/status fact Maestro hasn't made true. Allowed seam: she MAY answer a pure status question by quoting Maestro's last published WO state (presentation = waiter work). The two-timescale invariant holds: the dumb ack is synchronous (~100ms), Maestro's composed message is a separate async outbound (~2β5s later) β Clara never blocks on Maestro's compose.
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
- eventId seen-set), and a pre-deploy DRAIN of in-flight
handyman-page-<woId>holds (worker-first deploy). Mirror-WO churn must be bench-verified β starting a comms workflow per sync-imported mirror WO compounds the open close/rating-vs-mirror race (rescope-plan Β§7); confirm it doesn't spawn thousands of workflows against the appfolio-45 recon mirrors before fleet.
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 (namedsignalDispatchDue, not the draftsignalNewWorkOrder; the page rides the existingholdForQuietHours('handyman')+ transition arm rather than a separateawaiting_tech_dispatchphase) 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 deleteshandymanNotificationHoldWorkflow+ 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 thedispatch-page:<woId>eventId (no page), where the oldhandyman-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 Runninghandyman-page-*executions β seedocs/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:
- The
mainbaseline vs. the F2 branch. The relay/forward work (handleRelayToTenant,handleForwardToHandyman,relayMessageToHandymanprimitives) exists on the unmergedfeat/forward-to-handymanbranch (tip825d4326, including F2 commit53f041dfb) β verified present athandle-vendor-completion.ts:793(relayMessageToHandyman),src/lib/tools/handlers/handle-forward-to-handyman.ts,src/lib/tools/handlers/handle-relay-to-tenant.ts, plus the dispatch cases atagents/clara/lib/agent/tools/index.ts:163(relay_to_tenant) /:170(forward_to_handyman) and Clara's tool bundle atagents/clara/lib/agent/tools.ts:289/:310. Onmainnone of those symbols resolve. The "relay/forward as scaffolding" framing in the design input is about that branch; onmainthe relay mechanism is the inline composer insidehandleVendorMessage, not a primitive + tool. - What
mainactually has for techβtenant relay:handleVendorMessage(src/lib/domain/vendors/handle-vendor-completion.ts:1046) βclassifyVendorMessage(:414, classesquestion | completion | availability | status_update, regex fast-paths + Haiku) β fire-and-forgetrelayInquiryToTenant(:699, called:673),relayStatusUpdateToTenant(:749, called:583),relayAvailabilityToTenant(:980, called:966). Each.catch()-swallows inline in the synchronous Vercel request. - The tech's question gets TWO different live treatments today:
- Resident-answerable question (
tenant_inquirybranch): relays to the tenant fire-and-forget and returns to the tech a generic async-promise ack β "Got it ${name}, asking the tenant now β I'll loop back as soon as they reply." (handle-vendor-completion.ts:692-696). Already dumb-ack + async-relay. - System-answerable question (
questionclass βanswerVendorQuestion,:846, called:1186andhandle-specialist-channel.ts:228): answered synchronously fromwoContext(unit #, access notes). This is a real synchronous, content-derived reply.
- Resident-answerable question (
- A handyman quiet-hours gate already exists on the F2 branch.
src/lib/domain/maintenance/quiet-hours.ts(shouldDeferHandymanSms,resolveHandymanQuietWindow,isWithinQuietHours,formatQuietWindow), wired intotech-sms-dispatch.ts:384-386so handyman dispatch is DEFERRED inside the window. It is property-timezone-aware (Property.timezone, IANA-safe viaIntl, Central fallback), wraps midnight, bypasses onemergencypriority, and is configured today by theMAINTENANCE_HANDYMAN_QUIET_HOURSenv var ("HH:MM-HH:MM", default real window21:00-07:00, dormant when unset). Its scope note explicitly says it gates the HANDYMAN SMS only β "the tenant acknowledgment is never gated." This is the foundation the owner's quiet-hours decision (#4) extends to BOTH directions and migrates from env-config to a per-property config row. - The maintenance workflow exits after one flush.
maintenanceWorkflow(src/lib/temporal/workflows/maintenance-workflow.ts:189) runs debounce β gather β judge β dispatch β persist βreturn outcome(:295-299). It is a one-shot, not a long-lived per-lifecycle actor.signalWithStart+WorkflowIdConflictPolicy.USE_EXISTING(maintenance-client.ts:120) joins a running run; once a run has completed, the next signal starts a fresh run with emptystate(:215). - The workflow is keyed on conversationId, and the two parties live on two
conversations.
workflowId = maintenance-${conversationId}(maintenance-client.ts:52). But the tech dispatch mints a separate vendor conversation viaensureVendorConversation(tech-sms-dispatch.ts:334), distinct fromwo.conversationId(:449). The tenant thread and the tech thread are two different conversation rows β two different workflow IDs under today's convention. - Other live facts used below: the router ladder
(
agents/clara/lib/messaging/inbound-router.ts:99, 7routedTodestinations, eager run-all);findRatingTargetWO30-day COMPLETED scan (rating.ts:177,RATING_REPLY_WINDOW_DAYS = 30:79);sendRatingPrompt(:427);closeWorkOrderreasonscompleted | auto_resolved_by_tenant | canceledwithproofPhotoUrlrequired forcompleted(close-work-order.ts:58-73); inlinedispatchToMaintenanceTechfromcreate_work_order(handle-create-work-order.ts:900-901); scratch row 1-hour TTL (scratch.ts:44, refreshed on each write:73); the persona drift guard banssmith/clara/jarvis/β¦on queue names +*_WORKFLOW_TYPEonly, not signal names (temporal-naming.drift.test.ts:41-55).
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
- Clara = the waiter (dumb, user-facing pass-through). She receives the raw inbound, gives a minimal safe acknowledgement, and delivers back whatever the brain composes. NO interpretation, NO routing, NO real composition. Her prompt permits only safe promises ("let me get that work order in for you" β fine, because it will happen) and forbids false completion/status claims ("the work order is ready", "the tech is on the way") unless the brain made them true.
- Maestro = the brain (orchestrator). It ingests Clara's raw hand-off,
runs LLM interpretation layers to classify it, drives a deterministic
flowchart of the maintenance lifecycle, and composes every meaningful
outbound. The lifecycle:
Everytenant reports β [create WO] β [dispatch: message handyman] handyman replies β (classify) β question? β [answer-or-relay] β (relay) funnel to resident (await answer) resident replies while awaiting β [funnel back to handyman] handyman replies β (classify) β done? β [close WO] β [kick off rating] tenant replies "5" β [record rating][bracket]is a Maestro transition.
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.
- New workflow:
maintenanceCommsWorkflow,workflowId = maintenance-comms-wo-<workOrderId>. Domain-named (Β§5). - Distinct from the existing AppFolio-mirror workflow. The mirror stays
maintenanceWorkflowkeyed on conversationId (correct β the PMS sync batches a conversation's dirty WOs). So the "two timers, one workflow" framing in the input is wrong: they are two differently-keyed workflows with different partition keys, lifecycles, and queues. The comms workflow and the mirror workflow are siblings, not the same instance.
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 inbound intake passes the last few messages (not just the latest)
PLUS the identity PLUS all of the counterparty's open WOs to a Maestro
intake-classify LLM activity, which infers the target WO from content
(e.g. a toilet question + an open toilet WO + an open door WO β the toilet WO)
and returns
(target workOrderId, intent). - The driver then signals the correct WO-keyed workflow.
- An explicit "which work order?" disambiguation prompt is a LAST RESORT β fired only when the intake-classify step's confidence is low (it genuinely cannot infer the target). It is not the default.
- This is the inverse of the original draft's framing ("deterministic edge
scan produces an ID, not PII, so it rides the signal envelope safely").
Because intake-classify is now an LLM step that reads message bodies, it runs
as an activity that reads the in-flight turn (and recent history) off scratch /
the conversation timeline β the signal envelope still carries only IDs
(
workOrderId,scratchKey,eventId), never bodies (PII boundary, Β§"Compliance"). - The
activeWOscan (handle-vendor-completion.ts:1061-1069) andfindRatingTargetWOremain as cheap deterministic priors the intake-classify activity can consult (single-open-WO is unambiguous; the LLM is only needed to disambiguate multiple open WOs), but the decision is the Maestro's, not the edge's. Clara's instant ack stays separate and zero-LLM β it never waits on intake-classify.
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:
- Two questions before an answer: each
signalTechRepliedclassified as a question appends a{questionId, ...}entry. SMS has no threading, so the pragmatic bind is "most-recent-unanswered question," but the list means Q1 isn't silently overwritten β both are tracked, both can time out independently. - Double / corrected answers: the resident-answer transition is idempotent on
(workOrderId, questionId), not just the transporteventId.eventIddedup catches Temporal redelivery; the(workOrderId, questionId)key catches a semantic second answer (a genuinely new inbound). A correction binds to the same open question and re-forwards; a stray answer with no open question is classifiedunrelatedand dropped with a log, not mis-bound. - Never-answered: transition 6t (
await condition(timeout)) re-prompts once, then tells the tech explicitly and clears the slot β no silently-stuck state.
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:
- tenant β "Got it β working on it. I'll text you back."
- tech β "Thanks, got it."
- resident β "Thanks, passing that along."
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:
- For a tech question, the Maestro first tries to ANSWER it from WO context
(unit #, access notes, schedule, the local WO record) in
answerOrRelayActivity. What it can answer, it answers and sends back to the tech as a separate async outbound (transition 4a',answerVendorQuestion's composition reused). - Only what it genuinely cannot answer from WO context gets relayed to the
resident (transition 4a β
pendingTechRelay). - The Maestro makes that call β it is a brain decision, not a fixed edge.
- The latency shape: the tech still gets the zero-LLM ack instantly, and the
answer arrives a moment later as its own outbound (the Maestro's compose β
send is sub-second to the carrier, the same latency the current inline relays
achieve). This is a small UX shift from
main's same-reply synchronous answer, but it is not the across-the-board regression the original draft accepted β context-answerable questions are still answered (not blindly relayed), they just arrive as a second message. Thetenant_inquiryrelay path already behaves this way today (generic async ack + async relay,handle-vendor-completion.ts:692-696) β no regression there.
(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:
- Both directions, durable timer. Every comms outbound that targets a human β
the handyman dispatch (transition 3), the handyman forward (6a), AND the
resident relays (5, 4b, S1) β checks the property-local quiet window before
sending. When the send would land inside the window, the workflow does
await sleep(<time until window end>)and sends when the window opens. This is the durable Temporal-native replacement for the F2 branch's inline fire-at-dispatch-time defer (which deferred only the handyman direction and only at dispatch). Inside the workflow, the timer survives worker restarts and replays β no cron, no sweeper. - Foundation already exists.
src/lib/domain/maintenance/quiet-hours.ts(F2 branch) providesisWithinQuietHours(now, window, timezone)(IANA-safe, midnight-wrapping),ALWAYS_SEND_PRIORITIES = ['emergency'](life-safety bypass), and the21:00-07:00default window β all reusable verbatim as the predicate the workflow'sawait sleepcomputes against. Its current scope note ("gates the HANDYMAN SMS only β the tenant acknowledgment is never gated") reflects the F2-branch behavior; this ADR extends the policy to the tenant relays too, per the owner. Note Clara's zero-LLM ack is never quiet-gated (it is an instant courtesy reply to the sender, not an outbound the Maestro composes); only Maestro-composed cross-party sends are. - Config source β recommend a
PROPERTY_CONFIG#field (resolved decisions #4). [SUPERSEDED 2026-06-10 β Phase 5 shipped the window source as the per-HANDYMANVendorMembership.quietHoursinstead; see the amendment note at the top of this ADR.] Today the window is read from the globalMAINTENANCE_HANDYMAN_QUIET_HOURSenv var (resolveHandymanQuietWindow), which is single-customer-shaped and violates the "no customer-specific workflow constants in source / per-property behavior belongs in DynamoDB" core principle once more than one property is live. The owner's recommendation (accepted): introduce a per-propertyPROPERTY_CONFIG#<propertyId>field βmaintenanceQuietHours: { startHour, startMin?, endHour, endMin? }(the existingQuietHoursWindowshape) β with a sane default of 21:00β08:00 local (9pmβ8am) when the row is absent. (The F2 module's hardcoded default is21:00-07:00; the owner's stated default is "e.g. 9pm-8am", so the recommended row-absent default is21:00β08:00; confirm the exact floor against TCPA/quiet-enjoyment policy at implementation time.) The property timezone continues to come fromProperty.timezone(Central fallback). Resolving the window becomes a single DDB read in the workflow's quiet-hours activity, replacing the env read. Theemergencybypass is preserved.
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):
- 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 targetworkOrderIdand route the signal. Clara returns the sync ack immediately, without waiting on intake-classify. - 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 sleepif needed), and calls the delivery primitive. - Composed out: the primitive sends via
dispatchthen 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:
- scratch holds only the in-flight turn body (this turn's message that a classify/compose/intake-classify activity reads now) β the existing, correct use.
- Anything the machine must reference across a later inbound (the relayed
question text, the open-question list, the rating prompt) is persisted to the
durable WorkOrder timeline / conversation row (where relays already append a
TimelineEventtoday,handle-vendor-completion.ts:717), not the 1h scratch. - The workflow holds IDs/keys, never bodies (PII boundary, Β§"Consequences").
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)
- Delivery primitives (note: the
relayMessage*primitive form lands with thefeat/forward-to-handymanmerge; onmainthe equivalents are the inline composers):relayMessageToTenant,relayMessageToHandyman(F2,handle-vendor-completion.ts:793),sendRatingPrompt(rating.ts:427),dispatchToMaintenanceTech(tech-sms-dispatch.ts:361),sendTechStandDownSms(:294),closeWorkOrder(close-work-order.ts:118),appendOutboundToConversation,dispatch(dispatcher.ts:92),forward_to_property_managerhandler (escalation send). - Quiet-hours module (
src/lib/domain/maintenance/quiet-hours.ts, F2 branch):isWithinQuietHours,QuietHoursWindow,ALWAYS_SEND_PRIORITIES,formatQuietWindowβ reused as the predicate the workflow'sawait sleepcomputes against; the env-config reader (resolveHandymanQuietWindow) is superseded by thePROPERTY_CONFIG#read. - Resolution sub-primitives:
ensureVendorConversation, theactiveWOscan,findRatingTargetWOβ now priors the intake-classify activity consults, not the decision. - Pure helpers liftable verbatim:
composeCapabilities/composeTools,parseRatingReply(regex),buildRatingPromptBody(template,rating.ts:82),classifySchedulingIntent(regexβHaiku). - Infra: the
scratchrow, the 5-min debounce (mirror only),gather/dispatch/persistactivities,judgeActivity(stays the AppFolio-package reviewer β NOT overloaded for comms), thehandleToolCalldispatcher, identity resolution.
Retired (each in the same PR as its replacement β ONE SOURCE OF TRUTH)
- The eager run-all router ladder (
routeInboundMessage:99) thins to "resolve identity + intake-classify (resolve WO) + write scratch + signal," oneroutedToat a time. - The inline composing relays β
relayInquiryToTenant(:699),relayStatusUpdateToTenant(:749),relayAvailabilityToTenant(:980): compose half β compose activities; send half βrelayMessageToTenant. Deleted once the Maestro composes. - The split sync/async tech-question handling β
answerVendorQuestion(sync,:846) and thetenant_inquiryrelay branch collapse into the singleanswerOrRelayActivity(try-answer-then-relay). The two halves are reused as the activity's answer/relay bodies; the inline split is deleted. - Clara's transition-deciding prompt bullets (
capabilities/maintenance.ts"when to create vs add_note vs forward vs close") β deterministic edges. - The
forward_to_handyman/relay_to_tenantClara tools (ADR-0029 B1) β superseded by Maestro transitions 6a / 4a. Removed from Clara's tool bundle (agents/clara/lib/agent/tools.ts) + the dispatch (tools/index.ts:163/170). See PR #2106 disposition. - Inline
dispatchToMaintenanceTechfromcreate_work_order(handle-create-work-order.ts:900-901) β theawaiting_tech_dispatchtransition (gains the durable quiet-hoursawait sleep); the function is reused. - The F2 inline quiet-hours defer at dispatch time (
tech-sms-dispatch.ts:384) β the workflow's durableawait sleep; the predicate module is reused, the defer-and-return call site is replaced. - The
MAINTENANCE_HANDYMAN_QUIET_HOURSenv config β the per-handymanVendorMembership.quietHoursfield (RETIRED 2026-06-10, Phase 5 β the single-customer env var is deleted; the fail-open kill-switchMAINTENANCE_HANDYMAN_QUIET_HOURS_DISABLEDwas ALSO deleted 2026-06-11, owner "no flags" β zero env input).
New
maintenanceCommsWorkflow(WO-keyed, long-lived loop, Continue-As-New) + queuepropflow-maintenance-comms+ the four content-free signals.- Comms activities:
intakeClassifyActivity,triageActivity,classifyTechReplyActivity,classifyResidentReplyActivity,judgeCompletionActivity,answerOrRelayActivity,composeResidentRelayActivity,composeTechForwardActivity,classifyTenantReplyActivity. In a separate moduleactivities/maintenance-comms.ts(NOTactivities/maintenance.ts) so no future PR can reusejudge/dispatchfor comms (resolves m-2). A drift test asserts comms activities never import the mirror'sjudge/dispatchproxies, and (given the short-LLM vs 9-min-L4 retry profiles) they run on the comms queue. - Durable comms timers: quiet-hours
await sleepon EVERY human-targeted comms outbound (both directions), resident-answer two-strike timeout (await condition(timeout)). - Per-property
PROPERTY_CONFIG#<propertyId>.maintenanceQuietHoursfield + its single-read resolver (replacesresolveHandymanQuietWindow's env read). - Clara's reduced, identity-keyed ack composer at the inbound edge.
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):
- KEEP
relayMessageToHandyman(and themaintenance_handyman_relaymessage kind) β it is exactly the forward-to-tech delivery primitive transition 6a needs (resolves handyman offassignedVendorCompanyIdβ VendorMembership phone β dispatch β append to the handyman thread, fail-loud onno_handyman/no_phone,handle-vendor-completion.ts:793-827). This is the valuable half of #2106 and is foundational. - KEEP the quiet-hours module β
src/lib/domain/maintenance/quiet-hours.tsis the predicate foundation the workflow's quiet-hoursawait sleepreuses. - RETIRE the
forward_to_handyman/relay_to_tenantClara tools β remove them from Clara's tool bundle (agents/clara/lib/agent/tools.ts) + the dispatch (agents/clara/lib/agent/tools/index.ts). Under the Maestro the decision to forward/relay is workflow transitions 6a / 4a, not a Clara tool β and the observed failure (Clara choseadd_note) is precisely why. The handler functions (handle-forward-to-handyman.ts,handle-relay-to-tenant.ts) stay on disk as the call shape but are invoked by the Maestro's compose-then-send transition, not Clara's loop. - Build Maestro on the F2 branch (not a fresh-off-
mainbranch) β the F2 branch already carries the primitive + message kind + quiet-hours module the Maestro reuses. - Net: #2106's primitive + message kind + quiet-hours module are kept; its Clara tool-bindings are superseded and removed in the same PR that wires the Maestro transition.
Compliance
- PII boundary (ADR-0026): preserved and extended. Signals carry
{eventId, occurredAt, scratchKey, workOrderId}β IDs only, never a body. The Maestro sees the tech's question / resident's answer / intake content only through a classify/compose/intake activity that reads scratch (in-flight turn) or the WO timeline (durable cross-turn), exactly asgather/judge/dispatchread scratch today. The intake-classify step is an activity for exactly this reason β it reads bodies, so it must not run in workflow code. Any new comms-activity input field carrying a string body is a PII leak β the same flag the CLAUDE.md PR-review enforces. - Signal-driven over polling: every transition is triggered by an inbound
signal; durable waits use
await condition(timeout)/await sleep(the quiet-hours window is anawait sleep, NOT a cron). No new cron/sweeper; the deleted v1 DDB-timer sweeper is not resurrected. - Domain-naming: "Maestro" is product-only. The persona drift guard
(
temporal-naming.drift.test.ts:41) bansclara/smith/jarvis/β¦on queue names +*_WORKFLOW_TYPE;maintenanceCommsWorkflow/propflow-maintenance-commspass. Signal names are not scanned by the guard, andtenant/tech/residentare actor-role words, not personas β but to pin this explicitly we add an allowlist note + amaintenance-on-temporal.drift.test.tsassertion that the four comms signals exist with the actor-role naming pattern (resolves m-3). - Multi-tenant by construction: the quiet-hours window moves from a global
env var to the per-handyman
VendorMembership.quietHoursrow (Phase 5, 2026-06-10) β aligning with the core principle ("per-person/per-property workflow toggles belong in DynamoDB, not source") and removing the single-customer-shapedMAINTENANCE_HANDYMAN_QUIET_HOURSenv. - Send-gate unchanged: the AppFolio L4 write still passes through
isMaintenanceSendAllowed(MAINTENANCE_AUTONOMOUS_SENDING=armedANDProperty.autonomousMaintenanceEnabled, fail-closed). The human-facing comms sends (relay/rating/dispatch) are a different delivery path (Twilio viadispatch) gated by the existing consent/registry/policy chain β not the L4 cutover gate. The two gates stay independent, as today.
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
- dispatch (or, if #2106 already merged them to
main, this is the cleanup PR). Replaces: nothing else β additive primitive + module. Verify:vendor-completion.test.tsprimitive block (real dispatch/append, only external edges mocked); a bench dispatch onappfolio-45confirming the primitive appends to the handyman thread; the quiet-hours predicate unit tests (isWithinQuietHoursmidnight-wrap + emergency-bypass).
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:
maintenanceCommsWorkflow(WO-keyed, long-lived loop, Continue-As-New) + thepropflow-maintenance-commsqueue + a worker registration.- Only these states wired:
awaiting_tech_response β pendingTechRelay β awaiting_resident_answer β (forward-to-tech) β awaiting_tech_response, plus the 6t timeout, plus the entry intake-classify (transition 0) scoped to resolving the WO for tech/resident inbounds. - Signals
signalTechReplied,signalResidentReplied; activitiesintakeClassifyActivity,classifyTechReplyActivity,answerOrRelayActivity,composeResidentRelayActivity,classifyResidentReplyActivity,composeTechForwardActivity. - The inbound edge: for a tech reply classified
question, and for a resident reply while a WO isawaiting_resident_answer, run intake-classify to resolve the WO, write scratch, fire the signal, return the identity-keyed ack. - Delivery:
relayMessageToTenant(relay leg, answer-or-relay β relay) +relayMessageToHandyman(reverse leg, Phase 0 primitive), each behind the quiet-hoursawait sleep.
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:
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.- 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);maintenanceCommsWorkflowis inawaiting_resident_answer(Temporal Cloud UI /getMaintenanceSnapshot-style query). 2b. Bench handyman texts a context-answerable question ("what unit?") β assert:answerOrRelayActivityanswers from WO context and the tech gets the answer as a second outbound (no resident relay). - Resident answers ("yes, until 3") β assert: the handyman receives the
composed forward on the handyman thread (
maintenance_handyman_relay) β the exact legforward_to_handyman-as-a-tool failed to deliver. - Resident never answers β assert the two-strike timeout fires the tech-facing "couldn't reach the resident" message.
- Quiet-hours bench check: with the property's
maintenanceQuietHoursset to a 1-minute test window (mirroring the F222:00-22:01test window), assert a relay/forward composed inside the windowawait sleeps and lands at the window edge. bash evals/run-all.sh maintenance-judgestays 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 aPROPERTY_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.
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.)answerVendorQuestionis 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.)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.)
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 sleepto 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-propertyPROPERTY_CONFIG#<propertyId>.maintenanceQuietHoursfield (the existingQuietHoursWindowshape) with a sane default of ~21:00β08:00 local when absent, property timezone fromProperty.timezone,emergencybypass preserved. (Folded into Β§"Quiet hours", Β§1, Β§2 transitions 3/5/6a, Β§"New", Β§"Retired".)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.)#2106 ordering: KEEP the
relayMessageToHandymanprimitive +maintenance_handyman_relaykind (+ the quiet-hours module); RETIRE theforward_to_handyman/relay_to_tenantClara tools (remove from Clara's tool bundle + dispatch). Build Maestro on thefeat/forward-to-handymanbranch. (Folded into Β§"PR #2106 disposition", Β§"Retired", and the migration plan's Phase 0.)