0042 — Inbound thread routing for capability continuity
- Status: Proposed
- Date: 2026-05-28
- Deciders: Fede
- Related: ADR-0029 (role/prompt/tool router), ADR-0031 (Agent Smith local-first WOs), ADR-0032 (spine-stamp construction invariant), ADR-0034 (turnover orchestration — owns
ConditionReport+ the capture→project→freeze spine), ADR-0038 (Meta-glasses MMS path → existing accrual pipeline). Incident: 2026-05-27 turnover demo (turnover_949affadaabd, voice callconv_voice_1e848229→ SMS reply → tenant-persona response).
Context
PropFlow's inbound router today is identity-anchored. An inbound SMS or voice call resolves the sender's wire identity (phone) to a Person, then routes by what roles that Person owns (tenant → leasing/maintenance Clara; prospect → leasing; pm + a unit label in the body → turnover-intake; VendorMembership.isInHouse → HANDYMAN; etc.). This works cleanly when one Person ↔ one role and the role tells you the capability. It breaks when the sender owns multiple roles, or when the inbound carries no body to disambiguate.
The incident that surfaced this
On 2026-05-27 a property manager finished a turnover voice call on Unit 304 (turnover_949affadaabd on the appfolio-45 test property). Clara texted the PM the captured-notes summary at 04:02:28 UTC. 19 minutes later the PM replied with a photo — no caption — to that same SMS thread.
What the PM saw back:
"Thanks for sending that over! It looks like a close-up of some scratches on what might be a stainless steel appliance or door panel. Can you tell me a bit more about what's going on — is this something you noticed when you moved in and want documented, or is there something specific you need help with?"
Clara answered as the tenant-facing persona ("when you moved in") to a PM who'd just dictated an inspection. The condition report stayed at photos: [], every line item kept evidencePhotoIds: [], and the structured projection never picked up the new evidence. The photo is sitting orphaned in the inbound conversation row; it never reached the open turnover.
Root cause — logical AND on signals where any-missing means fail
The persona-engagement gate for turnover, resolveTurnoverContext (src/lib/domain/turnover/resolve-turnover-context.ts:72), requires all three of:
- Sender phone → Person → active
pmrole in some org. - Message body contains a unit label (
204,apt 12B, etc.) extracted by regex. - There's an open Turnover (stage =
notice_received) for that unit in one of the PM's orgs.
When the PM texted a photo with no caption — or a caption without "304" — signal #2 returned [] → resolver returned undefined → composeTools built a tenant-flavored capability bundle → tenant-Clara persona answered as if the PM were a moving-in tenant.
The bug isn't unique to turnover. It surfaces wherever PropFlow's identity-routing model assumes a clean role-to-capability mapping. Same shape pops up in other surfaces today and tomorrow.
How the platform handles continuation per capability today
| Capability | Continuation signal | Photo-attach path | Status |
|---|---|---|---|
| Tenant maintenance | Phone → Tenant Person (identity-perfect; tenant phone is unambiguous) | Message.mediaS3Keys → add_note_to_work_order / create_work_order reads from latest message (handle-add-note-to-work-order.ts:310) |
✅ Works |
| Leasing prospect | Phone → Prospect Person; lead-aggregator emails carry leadInfo upstream |
Tour pipeline; photo support not required | ✅ Works |
| Vendor (external) | Phone → VendorCompany; bespoke handleVendorMessage lane |
Bespoke lane handles photos as completion proof | ✅ Works |
| In-house handyman | Phone → VendorMembership.isInHouse → inHouseHandyman: true flag → HANDYMAN capability (handyman.ts) |
add_note_to_work_order reads latest-message photos |
⚠️ Works only when the handyman volunteers the WO number in body. Slice-1.5 ("baked-in active-WO context") explicitly deferred in handyman handoff doc: "open the handyman conversation with their active WOs / 'Hey Jose, still on WO Yale-204?'" |
| Renewal | Tenant Person owns active renewal cycle; conversation-manager swaps in the renewal prompt | Photos not in scope | ✅ Works |
| PM turnover | Phone → PM role AND unit label in body. AND-gate fails on photo-only inbound | attachInboundPhotosToReport exists (_turnover-shared.ts:204) but only fires from inside the capture-tool handlers; never from the inbound path |
❌ The 2026-05-27 bug |
What's already built that we'll reuse
The PR-0 contract is already in the repo, with locked tests:
- Helper signatures:
lookupActiveThreadFromOutbound(from, to, lookbackHours, lookup)anddecideInboundCapability(inbound, activeThread, opts)insrc/lib/domain/turnover/inbound-thread-routing.ts. Pure functions, no I/O — caller supplies the lookup. - Regression test src/tests/turnover-thread-continuity.test.ts (T6 + T7) locks the shape PR-2 was supposed to wire.
What was missing to make those helpers useful: there was no feeder. The outbound send_summary SMS calls sendSms directly (handle-send-summary.ts:121) — it doesn't append a Message to any conversation, doesn't write to any log, doesn't tag a capability. So lookupActiveThreadFromOutbound had nothing to look up.
Decision
Adopt a single canonical primitive for inbound thread routing that all capabilities can hook into when identity-based routing is ambiguous or absent. Ship it scoped to turnover (the broken case) and design it so handymen can adopt it next without redesign.
The primitive — the outbound-thread-log partition
A per-recipient-phone log of capability-bearing outbounds. One DynamoDB row per outbound that needs continuation:
PK: OUTLOG_THREAD#<recipientPhone> (E.164 normalized)
SK: SENT#<sentAtIso>#<conversationId> (sortable, unique per outbound)
attributes:
entityType = 'OutboundThreadLog'
propertyId = <propertyId of the property that sent>
propertyPhone = <Twilio number the outbound came from>
recipientPhone = <recipient phone in E.164>
conversationId = <conversation the outbound belongs to>
turnoverId? = <entity hint, capability-specific> // future: workOrderId, renewalId, etc.
capability = 'turnover' | 'maintenance' | 'renewal' | ...
sentAt = <ISO timestamp>
ttl = <epoch seconds, 30d from sentAt>
The PK is the recipient phone, not the conversation. Two PMs sharing one phone don't share a thread log (different conversations get different rows). One PM working two properties gets one row per property (propertyPhone discriminates at lookup time).
The protocol
Outbound side. When a capability dispatches an outbound that expects a follow-up (a turnover summary, a renewal-letter MMS, a "still on WO-204?" handyman ping), it calls
recordTurnoverOutbound(turnover-specific today; rename torecordCapabilityOutboundwhen the second capability adopts it). Fail-soft — a missed log row degrades the next inbound's routing but never blocks the send. Lives insrc/lib/domain/turnover/outbound-thread-log.ts.Inbound side. Before
routeInboundMessageruns its identity-based pipeline (vendor → tour → scheduling → rating → Clara), it callslookupActiveTurnoverThread(propertyPhone, sender, lookbackHours=24). If a row exists in window:- Pass
confirmedTurnoverIdthrough tohandleIncomingMessage→resolveTurnoverContext. The unit-label gate is bypassed; the resolver hydratesTurnoverContextdirectly from the confirmed ID, subject to a fresh PM-org re-check (we never trust the log for authorization, only for routing). - If the inbound has media, fire
attachInboundPhotosToReport(reportId, conversation)(the existing helper) so the photo lands on the openConditionReport. - composeTools sees
turnoverContextset → engagesclara-turnoverpersona → Clara responds as the PM-facing scribe, not as tenant-Clara.
- Pass
Confidence + degradation. Window is 24h for the strong-evidence band (silent attach + ack). 24h–7d is a soft-revival band (ask before attaching). Beyond 7d, fall through to identity-based routing (today's behavior). The thresholds live in one constants block; per-property override is a future knob.
What the primitive is, and is not
It IS:
- A fast disambiguator read before identity resolution, scoped to capabilities that need follow-up. The router consults it; if no row, the router does what it does today.
- A per-capability hint carrier:
turnoverIdtoday,workOrderIdtomorrow when the handyman case adopts it. Schema-additive — new capabilities add their entity hint without breaking old readers. - Fail-soft on both sides — a missing log row falls back to identity routing (the prior behavior). A lookup blowup logs + returns empty (the router proceeds as if nothing was found, never 500s).
It is NOT:
- A conversation router. The conversationId on the row is a hint — the inbound webhook continues to find-or-create its own conversation through the existing pipeline. The log says "this PM is in a turnover with you," not "post to this exact thread."
- An authorization signal. The PM-org guard is re-checked when the resolver hydrates from
confirmedTurnoverId. A stale or malicious log row cannot grant access the caller doesn't already have. - A replacement for identity resolution. Identity resolution still runs (and is still authoritative for everything the log doesn't cover). The log is a routing override for confirmed continuations, not a parallel pipeline.
Cross-capability migration path
Turnover is the only broken case today, so ship the primitive scoped to it. Handymen can adopt the same primitive at low cost when Slice 1.5 lands:
| Capability | Migration to the primitive | Effort | Trigger |
|---|---|---|---|
| Turnover (today) | recordTurnoverOutbound on send_summary; lookupActiveTurnoverThread in router; confirmedTurnoverId bypass on resolveTurnoverContext |
Small — this ADR's scope | The 2026-05-27 bug |
| Handyman (next) | Add workOrderId to the log row; emit a row on the wo_handyman_dispatch_sms send ("you've been dispatched to WO Yale-204"); router consults the log when handyman has multiple open WOs and ambiguous inbound |
Small | Slice 1.5 — open the conversation with their active WOs |
| Renewal | Could record renewalSagaId on outreach SMSes so a tenant's reply lands on the right cycle even if their phone matches two leases. Not a known incident today. |
Small | If a multi-lease tenant ever surfaces (rare) |
| Tenant maintenance | Identity is unambiguous; no migration needed. | None | — |
| External vendors | Bespoke lane; primitive doesn't apply. | None | — |
The decision to scope V1 to turnover is deliberate — don't migrate working flows. The primitive's value is realized as each next-broken case adopts it.
Capability-prefix naming (not "turnover" branding)
Today's module is outbound-thread-log.ts with recordTurnoverOutbound and lookupActiveTurnoverThread — turnover-specific names because that's V1's only caller. When the handyman case adopts the primitive, the API should generalize to recordCapabilityOutbound(params) (capability + entity hint as fields) and lookupActiveThread(propertyPhone, recipientPhone, lookbackHours) (returns the row, the router branches on capability). The schema already supports this — no migration needed; only the wrapper function names change.
Entity classification
Per ADR-0027:
| Entity | Class | Naming | Spine trace / drift |
|---|---|---|---|
OutboundThreadLog (row in single-table DDB) |
derived | bare name; not exported as a TypeScript interface in V1 (single-purpose row written + read by one module pair) |
derived from: outbound Message writes — every recorded row corresponds to an SMS that was just dispatched. Rebuilt by: the dispatch helper itself; no async reconciler. Drift tolerance: zero by construction (write happens immediately post-sendSms); a missed write degrades routing for the next inbound but is recoverable on the next outbound. TTL: 30 days — rows beyond that are unused by the lookback window and DDB sweeps them. |
Consequences
Easier
- The 2026-05-27 bug is structurally impossible to repeat. A photo-only inbound from a PM within 24h of a turnover summary lands on the open
ConditionReport, engages PM-mode persona, re-projects with the new evidence. - The unit-label requirement on
resolveTurnoverContextbecomes a fallback, not a gate. Cold-start (no recent outbound, no thread log row) still requires the unit label in the body — that path is unchanged. The thread log is purely additive. - Handymen get a clear migration story. When Gera's Slice 1.5 ships, the same primitive carries the "you've been dispatched to WO Yale-204" continuation without rebuilding a parallel mechanism.
- PII boundary unchanged. The log row carries only IDs (propertyId, conversationId, turnoverId) + phone numbers (which already exist on the conversation rows). No tenant names, no message bodies, no photo bytes.
- Cross-org guard preserved. The PM-org check in
resolveTurnoverContextruns on the confirmed-turnover path too. A stale log row pointing at an out-of-scope turnover falls through.
Harder
- Two writes per outbound. Each turnover summary now does
sendSms+recordTurnoverOutbound. The second is best-effort, ~1 DDB write, no latency added to the SMS-send path. Trade-off accepted. - One new DDB partition shape to operate. TTL is automatic; reads are bounded to a single PK; no GSI needed. Operational cost is near zero.
- A confidence band between 24h and 7d we're deliberately leaving undefined for V1 — the design slot is reserved (see "Open questions" below). V1 ships 24h hard cutoff; soft-revival is a polish PR.
- The router gains an early branch. A new check before the existing pipeline. Logged and traced so the funnel shape stays readable; the branch is fail-soft so a primitive outage means routing degrades to today's behavior rather than 500-ing the webhook.
Doesn't move
- Identity resolution remains the source of truth for everything not covered by a log row.
- The capture→project→freeze spine (ADR-0034) is unchanged — this ADR routes inbounds into that spine; it doesn't touch the spine itself.
- The PM-mode persona (clara-turnover.ts) is unchanged structurally — we just stop missing engagement when we should have engaged.
- The handyman ownership guard (
assertWorkOrderActorAuthorized) is unchanged — when handymen adopt this primitive in Slice 1.5, the guard still runs.
Alternatives considered
A. Denormalize the capability + sentAt onto the Conversation row
Add Conversation.lastOutboundCapability, Conversation.lastOutboundAt. Query GSI1 by recipient phone → conversation metas → filter.
Rejected. Two problems. First, the conversation metadata becomes overloaded — every capability that writes outbounds has to maintain these fields, and a renewal outbound onto the same conversation would clobber a turnover field (or vice versa). Second, the GSI1-by-recipient-phone query returns conversations across capabilities, so the router would still need to inspect each conversation's most-recent assistant message to disambiguate — defeating the denorm's purpose.
A purpose-built per-capability log row is cheaper to write, cheaper to read, and doesn't couple the conversation schema to routing concerns.
B. Patch the existing Message.kind + add a Message.capability field; lookup queries Message rows directly
Use the existing outbound Message records as the log.
Rejected for V1. Three issues. First, the send_summary handler doesn't write a Message today at all (it calls sendSms directly), so we'd still have to add a write — at which point the cost of a purpose-built row equals the cost of a Message row. Second, Message rows live on the per-conversation partition (PK = CONV#<convId>), so a lookup by recipient phone has to first find the conversation (GSI1 query), then read messages on each candidate. Two queries vs. one. Third, the Message shape is heavily overloaded already — adding a routing-discriminator field that's only read by the inbound router muddles the data model.
The purpose-built OUTLOG_THREAD#<phone> partition is a single direct GetItem-equivalent read.
C. Re-derive continuation from the conversation tree on every inbound
Skip the log; on inbound, walk getConversationsByRecipientPhone(phone) → check each conversation's recent messages for capability tags → pick the most recent.
Rejected. Bounded-but-unbounded — the conversation graph for an active PM grows over time; a per-inbound full walk is O(N) in conversation count. Today's PMs have low counts, but designing for 10k+ units means designing for PMs with hundreds of conversations. Single direct read against a TTL'd log partition is O(1).
D. Punt on the disambiguation; have Clara ask "is this for the Unit 304 turnover?"
The handyman case lives here today: when ambiguous, Clara just asks. Apply the same pattern to PM turnover.
Rejected for the photo-only inbound case. A PM who's been dictating a walk for 10 minutes, hangs up, and texts a photo back does not expect to be asked "what's this for?" by the same assistant they just briefed. The expectation is continuity. The handyman case tolerates the question because the handyman's first inbound on a job is typically text-with-WO-number; the ambiguity case is the exception. The PM-photo case is the common case for the turnover walk.
The right design is: the primitive resolves continuation silently when strong evidence exists (24h post-turnover-summary), asks softly in the middle band (24h–7d), falls through cold beyond.
E. Treat the voice-call → SMS-reply as one conversation row
Force the inbound SMS to land on the voice conversation that just ended, rather than creating a new SMS conversation.
Rejected on schema grounds. A conversation's channel is one of sms | voice | telegram | email; cross-channel writes are not modeled. Conversations are per-channel by construction (the dispatcher's smsFrom / telegramChatId / etc. would have to switch on per-message channel). The thread-log approach lets each channel keep its own conversation while the routing decision sees them as a continuation.
Implementation phases
Mirrors the plan doc. This ADR locks Phases 1 + 2. Later phases are noted so reviewers can see the trajectory.
- Phase 1 — Routing primitive. New
outbound-thread-log.tsmodule (recorder + reader, fail-soft).send_summarycalls the recorder.resolveTurnoverContextaccepts aconfirmedTurnoverIdbypass that still re-checks the PM-org guard.routeInboundMessageconsults the log before identity-based routing. Locked by this ADR. - Phase 2 — Acknowledgment + re-projection. When the router lands a photo on a turnover thread, the response names the unit + what was attached. Server-side re-projection runs after
attachInboundPhotosToReportwrites ≥1 new photo, debounced 30s. SMS Clara's turnover persona gets a "photo-only inbound on an active thread" block. Locked by this ADR. - Phase 3 — Vision-aware routing for cold/ambiguous inbounds. When no thread-log row exists, a cheap vision classifier on the photo + a sharp two-option intent question disambiguates between open contexts. Locked in principle by this ADR; design+code in a follow-up.
- Phase 4 — Per-item evidence linking. The projection accepts new-photo descriptions and links each to the best-matching line item (or "Other" if no confident match). Conservative — false-positive linkage corrupts the deposit-dispute evidence chain. Out of scope for this ADR.
- Phase 5 — Burst grouping + stale-but-soft revival. Photos arriving within 90s coalesce into one ack. 24h–7d band asks before attaching. Out of scope.
Open questions
- 24h vs. 12h vs. 48h hard cutoff. 24h is the V1 value; it covers the "send it tomorrow morning" follow-up while staying short enough that an unrelated photo a week later doesn't auto-attach. Per-property override is a future knob — should we add it now? (Lean: no, ship one value, add the knob when a property's behavior demands it.)
turnoverIdon the lookup row is an ad-hoc field today. When the second capability adopts the primitive, do we rename toentityId+entityType, or keep capability-specific fields? (Lean: capability-specific. The reader knows the capability and can read the right field;entityTypediscrimination at read time is unnecessary indirection.)- Should the recorder run on
turnover_capture_ack(Clara's continuation reply) the same way it runs onturnover_summary? (Lean: yes, in Phase 2. A second back-and-forth resets the 24h window; without it, a stretched-out walk could drop out of the window mid-conversation.) - Cross-channel: voice call ends, no outbound SMS, PM texts in immediately afterward. Today's
send_summaryis the only signal we write. Shouldend_callalso record a row? (Lean: yes — the implicit "Clara just talked to you about turnover X" is an even stronger continuity signal than the SMS summary. Phase 2.) - Multi-handyman case for Slice 1.5. When a handyman has two open WOs and we dispatch them in sequence, we'd write two thread-log rows. The router picks the most recent. Is that the right tiebreak, or should the photo content's description match against open-WO descriptions? (Defer to when Slice 1.5 lands.)
References
- Plan doc —
docs/planning/turnover-mms-routing-plan.md(the longer-form analysis this ADR formalizes) - PR-0 test contract —
src/__tests__/turnover-thread-continuity.test.ts - Outbound thread log module —
src/lib/domain/turnover/outbound-thread-log.ts - Pure routing helpers —
src/lib/domain/turnover/inbound-thread-routing.ts - Existing PM-org gate —
src/lib/domain/turnover/resolve-turnover-context.ts - The photo-attach helper this ADR finally connects to inbound —
src/lib/tools/handlers/_turnover-shared.ts:204 - Handyman capability + the still-deferred Slice 1.5 —
agents/clara/lib/agent/capabilities/handyman.ts+docs/planning/handyman-conversational-handoff.md - ADR-0034 — turnover orchestration; this ADR is a routing add-on to that architecture, not a change to it.
- Incident — turnover
turnover_949affadaabdonappfolio-45, voice conversationconv_voice_1e848229-018e-4f60-916d-25c018b0547b, 2026-05-27 04:02 → 04:21 UTC.