0054 — The Inbound Dispatcher: one entry for every inbound package (maintenance-first)

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:

  1. 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.
  2. 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_note failure), there is no queryable "this is the routing verdict we made, here's the candidate WO we considered, here's the confidence" row. agent_traces captures LLM reasoning but is one-row-per-loop, heavy (steps[]), and stores raw messageBody — wrong granularity and wrong PII posture for a root-cause artifact.
  3. Voice and text run different classification brains. Text triage lives in Clara's Sonnet agent loop reading the clara-maintenance.ts prompt; voice maintenance classifies post-call via maybeCreateWorkOrderFromTranscript (voice/call-ended/route.ts). Two brains drift (the extractor enum already carries pest_control the 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:

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:

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):

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