0054 — The Inbound Dispatcher: one entry for every inbound package (maintenance-first)
- Status: Accepted (build scoped to maintenance intake only)
- Date: 2026-06-10
- Deciders: Fede (owner), + maestro-phase3c session
Relationship to ADR-0053. ADR-0053 owns the maintenance lifecycle (the
maintenanceCommsWorkflow"Maestro" state machine + Clara's reduced waiter contract). This ADR (0054) owns the entry layer — the single front door that classifies an inbound package and produces a list of routings, one of which is "signal the maintenance lifecycle." The dispatcher is the companion ADR-0053's Re-scope amendment §1 calls "the entry layer reached through, not Maestro itself." Maestro is NOT the entry layer; the Dispatcher is. Per-domain orchestrators (Maestro=maintenance; renewal/tour Temporals) stay separate and are reached through the Dispatcher.Builds on: ADR-0031 (local-first work orders — WO# minted synchronously, instant), ADR-0026 (Temporal PII boundary — bodies ride DDB scratch, never workflow history/signal envelopes), ADR-0040 (identity-anchored reasoning), ADR-0042 (inbound thread routing). Supersedes the eager run-all ladder in
routeInboundMessage(agents/clara/lib/messaging/inbound-router.ts) for the maintenance lane.
Context
Today every inbound text/email/voice-handoff funnels into one channel-agnostic entry, routeInboundMessage (agents/clara/lib/messaging/inbound-router.ts:103), called by ~6 transports (SQS consumer, envelope processor, email record processor, the inbound-processor Lambda, the simulate route, Pipeline Lab). That function is an eager run-all ladder: it probes vendor-membership, staff-tier, tour-confirm, scheduling-reply, the Maestro resident-answer interception, rating-reply, the tour pipeline (Haiku), and PM-commitment extraction — accumulating across handlers — and finally always falls through to Clara's handleIncomingMessage. It returns a single routedTo enum (one of seven: vendor | scheduling | tour | renewal | rating | ai | test-menu), ephemeral, never persisted as a decision record.
This shape has three structural problems:
- Cost is independent of ambiguity. A reply that arrives on a conversation already keyed to a known WO still re-runs the vendor walk, staff walk, and tour/scheduling probes. There is no hint short-circuit. The expensive work (LLM classify) runs regardless of whether the answer was already determined upstream.
- There is no durable forensic record of why we routed. When a duplicate WO is opened for the same issue, or a resident answer lands as an internal note instead of a relay (the exact ADR-0029 B1
forward_to_handyman-vs-add_notefailure), there is no queryable "this is the routing verdict we made, here's the candidate WO we considered, here's the confidence" row.agent_tracescaptures LLM reasoning but is one-row-per-loop, heavy (steps[]), and stores rawmessageBody— wrong granularity and wrong PII posture for a root-cause artifact. - Voice and text run different classification brains. Text triage lives in Clara's Sonnet agent loop reading the
clara-maintenance.tsprompt; voice maintenance classifies post-call viamaybeCreateWorkOrderFromTranscript(voice/call-ended/route.ts). Two brains drift (the extractor enum already carriespest_controlthe prompt doesn't). There is no single dispatcher both feed.
We need ONE Inbound Dispatcher: the single entry for every package, where cost is proportional to ambiguity, that emits a list of routings (so a multi-intent message fans out conservatively), writes a durable Dispatch Decision Record for forensics, and is fed identically by voice and text via a source hint. Built fully and domain-agnostic, but scoped NOW to maintenance intake only — lock maintenance for reliability/scalability before opening the funnel to renewals/tours/office.
Decision
1. ONE dispatcher = the single entry for every inbound package
We introduce dispatchInbound(pkg): Promise<DispatchResult> as the SOLE post-transport entry. Every transport caller that today calls routeInboundMessage re-points to dispatchInbound. The eager maintenance pre-empts (the Maestro resident-answer interception + the maintenance-specific probes) are deleted; the non-maintenance accumulate-ladder (vendor-membership, staff-tier, tour-confirm, scheduling-reply, rating-reply, the tour pipeline, PM-commitment) is carried verbatim into the dispatcher's fail-open default lane — relocated from routeInboundMessage, NOT deleted. (Those handlers live ONLY in routeInboundMessage and are NOT reachable via Clara's handleIncomingMessage; deleting them would silently drop leasing/scheduling/rating/tour routing — verified by the post-critique repo check.) Net result is still ONE entry and exactly ONE path through it (ONE SOURCE OF TRUTH — no dispatchOld/dispatchNew, no // legacy fallback); the non-maintenance ladder moves inside the dispatcher rather than vanishing. The dispatcher lives at agents/clara/lib/messaging/inbound-dispatcher.ts (same tree as the router it replaces — resolved via the @/lib dual-path alias; do NOT create a src/lib/messaging/ duplicate).
The inbound package (InboundPackage) is the normalized input every transport adapter builds:
InboundPackage = {
channel: 'sms' | 'email' | 'telegram' | 'voice',
source: 'twilio-sms' | 'email-ses' | 'voice-el' | 'simulate' | 'pipeline-lab' | ...,
propertyId: string | null, // resolved UPSTREAM by the channel (TFN/email domain/EL agent) — pass-through
personId: string | null, // resolved UPSTREAM by the channel adapter's resolvePerson — pass-through
senderPhoneOrEmail: string,
body: string,
images: InboundImageRef[],
conversationId: string | null,
hint?: DispatchHint, // the cost-saving short-circuit (below)
traceId: string, // the correlation key, == requestId
}
propertyId/personId are pass-through, never re-resolved in the dispatcher (re-centralizing identity would duplicate per-channel resolvePerson/property-routing and risk wrong-property attachment). Identity is a fact set at the edge (ADR-0040); the dispatcher consumes it.
2. Cost ∝ ambiguity — the decision ladder
The dispatcher runs a fixed ladder where each rung is cheaper-the-less-ambiguous, and a confident upstream signal short-circuits the expensive rungs. The only unconditional, always-first rung is the safety pre-empt.
Rung 0 — Safety pre-empt (ALWAYS first, never skipped, no hint can bypass). Before any domain classify, the dispatcher runs the safety check. For maintenance the relevant case is emergency triage (gas / flood / no-heat / electrical-sparking — the STOP rules already in clara-maintenance.ts). The existing test-menu intercept (handleTestMenu) and test-data guard (allowTestData) fold in here as pre-empt steps (they are pre-routing today at inbound-router.ts:125/:133; they must NOT be lost in the collapse). A safety hit emits its routing immediately and short-circuits the rest of the ladder.
Rung 1 — Identity. Pass-through personId/propertyId; the only identity walks the dispatcher itself performs are the vendor-membership walk (resolveInboundVendorMembership) and staff-tier walk (resolveInboundStaffMembership, fail-soft) — preserved from the router. These are facts that shape the lane (external vendor → vendor lane; in-house handyman → handyman lane), not inferences.
Rung 2 — Domain via hint-or-classify. If the package carries a domain hint (the EL specialist that handed off, a conversation already keyed to a known WO, the voice agent slug), the dispatcher trusts it and skips domain classification entirely. A reply on a conversation already keyed to a WO goes straight to the maintenance lane — no leasing/tour/vendor probes. Only when there is no hint does the dispatcher run the real-model domain classify. NO Haiku for any decision — the classifier is Sonnet 4.6 (the spec forbids Haiku; the eval's classify_maintenance_intent Sonnet provider becomes the parity reference, Haiku kept only as a cost-baseline column, never the decision model).
Rung 3 — Workflow resolution within the domain (state-first → real-model classify → ask). Once the lane is maintenance, the dispatcher resolves which workflow/transition this package drives, in this strict order:
- (a) State-first, deterministic, NO LLM. Read durable awaiting-state for this sender:
commsAwaitingResidentSinceviaresolveResidentAwaitingWO(src/lib/domain/maintenance/resolve-resident-awaiting-wo.ts),schedulingState == SCHEDULING_AWAITING_TENANTviahandleTenantSchedulingReply, recently-completed-unrated viafindRatingTargetWO(30-day window). Each is guarded so it only fires when genuinely pending — preserving the existing!schedulingResult.stateChanged && !tourResult.stateChangedprecedence (scheduling/tour win when truly pending; a naive "maintenance first" misroutes a tour reply from a tenant who also has an awaiting WO). When state matches, the package is a continue/reply — signal the existingmaintenanceCommsWorkflowand returncomposeDumbAck. These reads are already deterministic and no-LLM; the dispatcher does NOT re-implement them. - (b) Real-model classify (only when state resolves nothing). Sonnet decides new-WO-vs-existing-WO-vs-reply and, for new-WO, triage (category + priority + troubleshoot-vs-create + photo-gate). This is the ADR-0053 transition-0
intakeClassifyActivity+triageActivity— which do not exist yet: they are GREENFIELD and the single largest net-new build in this effort (not a relocation of existing activity code). The triage logic today lives as prose inclara-maintenance.ts; the dispatcher reifies it as a real-model (Sonnet) classifier. Multi-open-WO disambiguation lives HERE (the Phase-2 intake-classify), NOT in the state probe (resolveResidentAwaitingWOis Phase-1 single-WO only — returns the freshest bycommsAwaitingResidentSince). - (c) Ask (last resort). When the classifier's confidence is low and it genuinely cannot infer the target WO, emit a "which work order?" disambiguation prompt. Never the default.
3. The dispatcher returns a LIST of routings — conservative fan-out
dispatchInbound returns DispatchResult { routings: Routing[], traceId }. A single inbound CAN carry multiple intents (e.g. "the toilet's fixed but the AC is now leaking"); the dispatcher emits N routings, one per intent chunk. The bias is under-split (conservative fan-out): when in doubt, emit fewer routings — a missed split degrades to a single Clara turn (safe), an over-split risks double-acting (e.g. closing one WO while opening a phantom second). Each Routing is:
Routing = {
intentChunk: string, // structural/redacted summary, NOT raw body
domain: 'maintenance' | 'renewal' | 'tour' | 'office' | 'leasing', // maintenance is the only LIVE target now
target: 'new' | 'existing' | 'reply' | 'troubleshoot' | 'escalate' | 'ask',
workOrderId?: string, // the resolved candidate (for existing/reply)
confidence: number,
reason: DispatchReasonCode, // BOUNDED ENUM, PII-free (see §5)
}
The dispatcher then executes each routing through the existing per-domain client functions — it does NOT re-derive workflowIds or call the Temporal SDK directly. The "route to the right Temporal" reduces to a per-domain (workflowId-builder, join-or-start-primitive, task-queue) triple, all already baked into each client:
- maintenance pipeline:
maintenance-${conversationId}(exportedmaintenanceWorkflowId), puresignalWithStart(USE_EXISTING) — one call both starts-if-absent and joins-if-present. - maintenance-comms (Maestro):
maintenance-comms-wo-${workOrderId}(exportedmaintenanceCommsWorkflowId), puresignalWithStart(USE_EXISTING). The dispatcher signals via the domain'ssignalMaintenance*exports — never the SDK. - WO create:
handleCreateWorkOrderUNCHANGED (local-first mint, ADR-0031 — WO# instant). The dispatcher passesinput.category/input.priorityexactly as the handler expects (normalizeCategory/normalizePriority— unknown priority REFUSES), keepsis_new_issuesemantics for the dedup gate, and does NOT mint IDs / duplicate dedup / duplicate auto-assign. - renewal / tour (SEAMS ONLY now — code-commented, not wired): these use a SPLIT primitive (separate
start*vssignalWithOptionalStart), and renewal additionally has the ADR-0052 migrated-cohort ghost-workflow trap (a naivesignalWithStartat the barerenewalIdspawns an empty ghost alongside the-migratedexecution). The dispatcher MUST go through the exported signal bridges for those, never hand-roll. Because the join-or-start contract differs per domain (maintenance/comms unified vs renewal/tour split), the dispatcher branches per domain — there is no uniform primitive.
4. Fail-open to Clara is load-bearing correctness, not just resilience
Every maintenance interception today is failure-isolated: ADR-0053's resident-answer step is wrapped in try/catch and deliberately FALLS THROUGH to Clara on Temporal failure (the dumb ack "passing that along" would be a lie if the signal silently dropped). The dispatcher preserves this exactly: any maintenance-lane error degrades to the default lane (the relocated non-maintenance ladder → then Clara's handleIncomingMessage) — never a 500, never a lying ack. The non-maintenance domains (vendor-completion, leasing, scheduling, rating, renewal, tour) are NOT reasoned about by the dispatcher in this build; their existing accumulate-ladder is carried verbatim as the dispatcher's default lane (relocated from routeInboundMessage, where those handlers exclusively live — they are NOT reachable via Clara's loop) so they do not regress. The ALS-seeding the create path depends on (setInboundImagesForCurrentContext / setInboundInHouseHandymanForCurrentContext, seeded by handleIncomingMessage) must be replicated by the dispatcher when it calls the create path directly — this is net-new plumbing, not free reuse.
The two AsyncLocalStorage reads (getInboundImages :740, getInboundInHouseHandyman :536) must keep working. handleCreateWorkOrder reads both from ALS seeded by handleIncomingMessage. The dispatcher MUST invoke the create path inside the same inbound async context that seeded them (preferred — zero handler change), OR fail silently (photos orphaned, #41 handyman-create branch dead). This is the highest-risk re-plumbing point and is pinned by a harness leg (§Completion).
5. The Dispatch Decision Record (DDR) — durable, queryable, PII-safe
We add a NEW DDB partition DISPATCH_LOG#<YYYY-MM-DD>, modeled directly on the strongest provenance model in the codebase (TOOL_LOG#<date>, src/lib/data/dynamo/wo-tool-calls.ts). We do NOT overload agent_traces (one-row-per-LLM-run, heavy, stores raw messageBody) or TOOL_LOG# (per-tool-invocation, downstream of the routing verdict). The DDR is the routing verdict itself — upstream of the tool call.
Shape (one row per intent chunk; N rows per multi-intent inbound share one traceId):
- PK
DISPATCH_LOG#<YYYY-MM-DD>, SK<iso-ts>#<traceId>#<decisionIdx>(collision-free + co-sortable; mirrorssaveOperationalSignal'sSIGNAL#<ts>#<id>). - Access paths: GSI3
entityType-createdAt-indexwithentityType='DispatchDecision'for the cross-person feed; mirroragent_traces' GSI2conversationId-indexso a personId/conversationId join is O(matching), not a scan. Queryable by personId AND traceId. - Payload:
{ traceId, personId, propertyId, source/channel, inboundSummary (structural/redacted), decision: { intentChunk, domain, target, workOrderId, confidence, reason }, ts }. reasonis a BOUNDED ENUM, never free-form prose (new_issue,dup_within_window,different_unit,low_confidence_split,hint_short_circuit,state_first_resident_answer,state_first_scheduling,state_first_rating,safety_preempt,fail_open_clara, …). This is exactly the fieldTOOL_LOG#deliberately REDACTS because escalate/forward tools echo tenant content — a free-formreasonreintroduces the leak the audit log was designed to avoid.inboundSummaryis the redacted structural summary, NOT the raw body (raw body already lives inagent_traces.messageBody— do not duplicate PII into a second durable store, ADR-0026 minimization).- Write AFTER the dispatch resolves, not before — the DDR records the ACTUAL outcome (if it claims
target:'existing' workOrderId:Xbut create-new still fired, the record must reflect reality). Idempotent on the SK (#<decisionIdx>+ traceId) to survive SQS redelivery / signal replay; dedup-sentinel pattern fromsaveOperationalSignalif needed. - Retention: pick deliberately.
TOOL_LOG#uses 30d TTL but table TTL is not yet enabled; "why did we open a dup" can surface weeks later, so the DDR retention is set LONGER than 30d (confirm table TTL state at build time). - The DDR REPLACES the ephemeral
InboundRouteResult.routedToas the routing-decision record (ONE SOURCE OF TRUTH — don't parallel it).
Temporal correlation: stamp traceId as a Keyword search attribute on the dispatched workflow, alongside the existing propertyId/workOrderId/currentStage (maintenance-workflow.ts upsert), gated behind the same fail-safe *_SEARCH_ATTRS_ENABLED default-OFF pattern. traceId MUST be registered on the namespace FIRST (tcld namespace search-attributes add --name traceId --type Keyword on BOTH prod + staging) — upsertSearchAttributes THROWS on an unregistered attribute. This gives Cloud-UI "show me the workflow this decision opened" and the reverse. The SAME traceId is stamped on the agent_trace AND the DDR so decision↔reasoning↔workflow join for free.
6. Voice/text consistency — one dispatcher, a source hint
The EL voice path passes source:'voice-el' + hint:{domain:'maintenance'} into the SAME dispatchInbound. The dispatcher then SKIPS domain-classify (hint short-circuit) but keeps safety / identity / new-vs-existing / triage / trace identically to text. The post-call maybeCreateWorkOrderFromTranscript is subsumed into the one canonical maintenance triage decision — no second triage brain. (Cost note: that extractor runs on Haiku today; subsuming it under the no-Haiku-for-decisions rule upgrades every voice maintenance call's post-call classify to Sonnet — a deliberate per-call cost increase bought for correctness.) The hint must use the gated per-tool route (per-tool is gated ADR-0029; unified is not). Drift-guarded: voice-agents-context-wiring.drift.test.ts is extended to assert the EL maintenance prompt no longer carries classification rules (triage moved to the dispatcher).
7. Scope: maintenance intake ONLY now; domain-agnostic shell with explicit seams
The shell is domain-agnostic (safety pre-empt, hint short-circuit, state-first-then-classify skeleton, fail-open-to-Clara wrapper, test-menu/test-data pre-empts, identity/property pass-through, the routing-list + DDR). The only LIVE lane plugged into it is maintenance (resolveResidentAwaitingWO, the signalMaintenance* comms signalling + writeCommsScratch + composeDumbAck, handleCreateWorkOrder unchanged, the in-house-handyman tryDivertInHouseComms divert). Renewals / tours / office are code-commented seams ONLY — the domain enum carries them, the per-domain (workflowId-builder, primitive, queue) triple is documented in a comment block referencing the renewal/tour split-primitive + ghost-workflow trap, but no renewal/tour routing is wired or reachable. Lock maintenance for reliability/scalability first; opening a seam = a later, separate PR.
Multi-tenant: any per-property dispatcher config (e.g. the eventual office-stream threshold) lives in PROPERTY_CONFIG#<id> (DDB), never in source.
Entity classification (per ADR-0027)
| Entity | Class | Naming | Spine trace / derived-from |
|---|---|---|---|
DispatchDecision (the DDR row) |
derived | bare name (audit record) | from: the InboundPackage + the dispatcher's resolution at ts; one row per intent chunk · Rebuilt by: not rebuilt (append-only forensic log, like ToolCall) · Drift tolerance: n/a (immutable) · Spine trace: via personId (pass-through, not re-resolved) |
InboundPackage |
canonical (transient — not persisted; the normalized dispatcher input) | bare name | Spine trace: via personId set upstream by the channel adapter |
DispatchResult/Routing are transient return shapes, not persisted entities.
Consequences
Easier: one place to reason about "how did this inbound route"; cost drops on hinted/known-conversation replies (no re-probe); forensic root-cause on dup-WO and mis-relay becomes a queryable DDR lookup by personId/traceId; voice and text provably share one classifier; opening a new domain = wiring one lane into a proven shell + commenting out one seam.
Harder / commitments: the cutover PR must delete the eager maintenance pre-empts, relocate the non-maintenance accumulate-ladder verbatim into the dispatcher's default lane, and re-point all ~6 transport callers in one PR (the no-mock-theater bar applies — this is agent-loop-adjacent). The non-maintenance handlers live ONLY in routeInboundMessage (NOT reachable via Clara's loop), so "delete the whole ladder" is wrong — they must be carried into the default lane or leasing/scheduling/rating/tour regress. The two ALS reads are a silent-failure landmine that the harness must prove. traceId search-attribute registration is fail-CLOSED (throws if unregistered) — namespace registration is an ops prereq before flipping the gate. The DDR adds a second durable store whose PII posture must stay enum-only.
Follow-up implied: per-property quiet-hours config (ADR-0053 Phase 5), router-collapse + escalation lane (Phase 4), and the renewal/tour/office seams are all later PRs. PR #2143's comms.* ops-tool drift-guard failure must be fixed before the comms surface is "clean."
Alternatives considered
- Keep the eager run-all ladder, add a hint param. Rejected — cost stays ∝ handler-count not ambiguity, and it leaves two ways to route (the ladder + the hint), violating ONE SOURCE OF TRUTH.
- Make Maestro the entry layer (one god-orchestrator). Rejected — ADR-0053 §1 amendment: property/org and domain decisions are upstream of and structurally distinct from any within-domain "which WO" question; renewals/tours get sibling Maestros reached through the dispatcher, never folded into one.
- Overload
agent_tracesorTOOL_LOG#for the DDR. Rejected — wrong granularity (LLM-run vs decision; tool-invocation vs routing-verdict) and wrong PII posture (rawmessageBody). The DDR clonesTOOL_LOG#'s shape (day-bucketed, append-only, enum-redacted) into its own partition. - Single routing return (keep
routedToenum). Rejected — a multi-intent inbound can't be one enum; the list + conservative under-split is the conservative-fan-out the owner specified. - Haiku for the classifier (cost). Rejected by owner — real model (Sonnet 4.6) for any decision; Haiku kept only as a cost-baseline reference column in the eval.