0042 — Inbound thread routing for capability continuity

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:

  1. Sender phone → Person → active pm role in some org.
  2. Message body contains a unit label (204, apt 12B, etc.) extracted by regex.
  3. 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 undefinedcomposeTools 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.mediaS3Keysadd_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.isInHouseinHouseHandyman: 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:

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

  1. 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 to recordCapabilityOutbound when the second capability adopts it). Fail-soft — a missed log row degrades the next inbound's routing but never blocks the send. Lives in src/lib/domain/turnover/outbound-thread-log.ts.

  2. Inbound side. Before routeInboundMessage runs its identity-based pipeline (vendor → tour → scheduling → rating → Clara), it calls lookupActiveTurnoverThread(propertyPhone, sender, lookbackHours=24). If a row exists in window:

    • Pass confirmedTurnoverId through to handleIncomingMessageresolveTurnoverContext. The unit-label gate is bypassed; the resolver hydrates TurnoverContext directly 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 open ConditionReport.
    • composeTools sees turnoverContext set → engages clara-turnover persona → Clara responds as the PM-facing scribe, not as tenant-Clara.
  3. 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:

It is NOT:

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

Harder

Doesn't move

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.

Open questions

  1. 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.)
  2. turnoverId on the lookup row is an ad-hoc field today. When the second capability adopts the primitive, do we rename to entityId + entityType, or keep capability-specific fields? (Lean: capability-specific. The reader knows the capability and can read the right field; entityType discrimination at read time is unnecessary indirection.)
  3. Should the recorder run on turnover_capture_ack (Clara's continuation reply) the same way it runs on turnover_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.)
  4. Cross-channel: voice call ends, no outbound SMS, PM texts in immediately afterward. Today's send_summary is the only signal we write. Should end_call also 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.)
  5. 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