0057 — PropFlow owns dispatch for EVERY work order (handyman + tenant), no silent skips
- Status: Accepted (revised 2026-06-16 — see "Revision" + "Revision 2" below)
- Date: 2026-06-16
- Deciders: Gera (Jose), Claude
- Supersedes: nothing — the ADR-0031 "synced ⇒ no dispatch" pipeline clause stands (the Temporal pipeline does skip synced WOs); their dispatch runs separately via the sync
onCreatedhook. See Revision.
Revision (2026-06-16) — reconciled with code reality
The original Context below (kept as history) was drafted from a read that
concluded "AppFolio-synced WOs never dispatch from PropFlow (by design)."
That premise turned out to be stale: by the time this shipped,
handleNewWorkOrder (the sync's onCreated hook) already pages the handyman
AND acks the tenant (notifyTenantOfPmsCreation, kind wo_pms_confirmation_sms)
for every freshly-synced WO. The // no dispatch needed comment in
work-order.ts refers only to the Temporal work-order pipeline skipping
synced WOs — not to dispatch as a whole.
So the real, narrower gaps (and what was actually built) are:
- No durable "was the handyman told" truth was stamped anywhere — so there
was no send-once durability and, worse, no signal a health check could read.
→
WorkOrder.handymanNotifiedAt/tenantNotifiedAt, stamped at the single page choke point (sendHandymanPageCore) and the tenant-ack site (notifyTenantOfPmsCreation) on a successful send. NOTstatus(the sync clobbers it). - CAM-1006 was the Clara-path, not a synced skip. It was Clara-created, so
its AppFolio sync-back was a match-back (not a fresh mint) →
onCreatednever fired for it → it relied solely on the Temporal pipeline, which silently died. ThehandleNewWorkOrdersafety net only covers AppFolio-first WOs. → a dispatch-health check (scripts/dispatch-health-check.ts) that flags any open + assigned + never-paged WO across BOTH paths — the safety net that would have caught it. This is the highest-value piece. - No cutover guard —
handleNewWorkOrderpaged every fresh mint, so onboarding an AppFolio property would retro-blast its backlog. → route both sends through the puredecideWorkOrderDispatchgate (send-once + a 7-daycreatedAtfreshness window = "new vs backlog", shared with the health check).
Tenant copy decision (Gera, 2026-06-16): KEEP the existing behavior. The
synced-WO tenant ack deliberately does not promise "the tech will reach
out" — Clara owns the follow-up. So no new tenant-notice kind/primitive was
added (the draft maintenance_synced_wo_tenant_notice was dropped); the build
just stamps tenantNotifiedAt on the existing notifyTenantOfPmsCreation.
The Decision points below remain directionally correct (PropFlow owns notifying handyman + tenant for every WO, send-once via a durable field, no retro-blast, with health observability) — only the "delete the synced-skip" / "new tenant notice" framing was wrong and is superseded by this Revision.
Revision 2 (2026-06-16) — the enrich-path page was never firing
Revision 1 above asserted that handleNewWorkOrder (the sync's onCreated
hook) "already pages the handyman … for every freshly-synced WO." A live
prod bench test on appfolio-45 disproved that — the AppFolio-synced WO was
enriched and the tenant was acked, but the in-house handyman (Ghostbusters)
was never paged. Revision 1 was itself partially stale.
Root cause. dispatchNewWorkOrderToTech (wo-enrich-dispatch.ts) has three
arms after the Direct-L4 enrich attempt: success, retryable-failure (→ Queued
L4), and non-retryable-failure (→ immediate SMS). The two failure arms call
dispatchToMaintenanceTech directly. The success arm did not — it applied
the enrichment via the data-layer patchWorkOrder and returned, leaning on a
stale comment ("the PATCH endpoint's first-time-enrichment side effect fires the
tech SMS"). But the inline Direct-L4 path does not round-trip through the PATCH
route — patchWorkOrder is a direct DDB write — so the route's
isFirstEnrichmentLanding side effect (route.ts) only ever fires on the
Queued-L4 fallback arm, where the agent-runtime Lambda PATCHes back. The
success arm — the common case for healthy L4 — silently paged no one. This is a
regression from the Direct-L4 inline-enrich migration (PR2), and it is the same
class as CAM-1006: tenant acked, handyman never told.
Fix. The success arm now pages the handyman inline:
dispatchToMaintenanceTech(enrichedWo, { trigger: 'wo_enrichment_landed' }),
send-once-guarded on the durable handymanNotifiedAt field that
dispatchToMaintenanceTech stamps. The PATCH-route fallback arm is now also
gated on !workOrder.handymanNotifiedAt, so the inline path and the Queued-L4
path can never double-page the same WO (they are already mutually exclusive —
the fallback only runs on inline failure — but the durable guard makes
send-once provable rather than incidental). The bug was encoded as a passing
test (wo-enrich-dispatch.test.ts asserted dispatchToMaintenanceTech was NOT
called on the happy path); that assertion is flipped, and a send-once regression
case is added. Real-bench proof: an AppFolio WO on a clean unit mints → enriches
→ pages Ghostbusters (+ stamps handymanNotifiedAt).
Lesson: "verified the handyman path works" must mean a real end-to-end send on the bench, not a code read — the unit suite was green while prod paged no one.
Context
On 2026-06-15 a real Camellia tenant (Linnet Fauxton, unit 310) reported a drain/pipe
clog through Clara at 10:44am MT. A work order (CAM-1006) was created, assigned to
the in-house handyman, and synced to AppFolio — but the handyman was never
texted. It sat un-dispatched for ~8.5 hours until found by hand. Investigating it
surfaced a backlog of 11 open Camellia work orders never paged to the handyman,
some 7 weeks old (AC won't turn on, shower won't drain, door off the hinges) —
all confirmed still-open in AppFolio (Status=Assigned, no CompletedOn).
The investigation found two distinct root causes — neither acceptable:
Clara-created WOs can silently fail to dispatch (a bug). Dispatch runs in a per-conversation Temporal workflow that a handler signals fire-and-forget. When the WO-creating runtime (the
inbound-processorlambda) lost its Temporal env (~06-13 → 06-15), the signal silently no-op'd and the workflow never started — so a fully-formed WO (vendor assigned, AppFolio-synced) just sat. Temporal-confirmed: nomaintenance-<conversationId>workflow ever existed for CAM-1006. (PR #2381 restored the env; #2363 made the failing signal skip gracefully — but the architectural flaw remains: dispatch is a separable, silently-failable hop.)AppFolio-created WOs never dispatch from PropFlow (by design — the wrong design). When a PM creates a WO directly in AppFolio, the sync imports it, assigns the property's handyman to the row, and stamps
pmsSyncStatus:'synced', which makes the work-order pipeline skip it entirely (work-order.ts:494— "no dispatch needed", ADR-0031). The assumption was "AppFolio is the source of truth for these — AppFolio/the PM notifies the handyman." That assumption is wrong in practice: the 11 stuck WOs prove the handyman was never told by anyone PropFlow can see.
Both failures were invisible. Nobody knew until a human asked — there is no signal that fires when a WO has an assigned handyman who was never paged.
Product decision (Gera, 2026-06-16): "We own that. We should always send to the tenant and the handyman." PropFlow is the action layer; notifying the people who need to act on a work order is PropFlow's job for every WO, regardless of where the WO was born.
Data backing the decision (prod, read-only, 2026-06-16)
- Real (non-test) properties: 2 — Yale 25 (not AppFolio-connected, 0 WOs) and Camellia (af propertyId 7).
- Open AppFolio-synced WOs with an assigned handyman: 11, all Camellia.
- Of those, never paged: 11 at discovery → 0 after the manual catch-up.
- Cutover blast radius today: ~0 — the only AppFolio property's backlog is already cleared, so shipping the change (with a send-once guard) pages no history.
The scale is small and contained today — but the send-once + cutover guards are correctness-critical (the sync runs every few minutes; every future AppFolio property onboards with its own backlog).
Decision
1. PropFlow dispatches EVERY work order — to the handyman AND the tenant — the
moment it has dispatch-ready data, regardless of origin (Clara-created or
AppFolio-synced). Delete the pmsSyncStatus:'synced' ⇒ skip dispatch path.
2. Dispatch is no longer a silently-failable async hop ("never pend for dispatch").
A WO that has an assigned handyman is dispatch-ready; it must not sit in a
pending_dispatch limbo waiting on a separable signal that can fail open. Where the
signal genuinely can't be delivered, that is a loud failure (logged + surfaced by
the dispatch-health check below), never a silent skip.
3. Send-once, idempotent, durably guarded. A WO is dispatched to the handyman
exactly once. The guard is a dedicated durable field handymanNotifiedAt (set
when the page is actually sent) — NOT status, because the appfolio-sync re-writes
status from AppFolio's view every few minutes (observed: CAM-1006's dispatched
flip reverted to pending_dispatch 9 min later while the SMS + dispatchedAt + the
timeline event survived). Status is unreliable; handymanNotifiedAt is the single
source of truth for "was the handyman told."
4. Cutover boundary — no retroactive blast. Dispatch fires only for WOs first
seen by the dispatch path after this ships (handymanNotifiedAt == null AND first
observed post-cutover). The existing backlog is NOT retro-paged. (Camellia's is
already hand-cleared; any future property's pre-existing backlog is handled
deliberately, never as an accident of flipping a flag.)
5. Tenant notification on every WO. Clara-channel WOs already get Clara's flow,
and PM-created-in-AppFolio WOs already get the existing notifyTenantOfPmsCreation
ack (kind wo_pms_confirmation_sms). ⚠️ Superseded by the Revision (2026-06-16):
the original draft here added a new kind maintenance_synced_wo_tenant_notice with
copy "<handyman> will reach out to schedule" — that was dropped. The existing
ack deliberately does NOT promise the tech will reach out (Clara owns the follow-up),
and adding a second path would duplicate it. As shipped: no new kind; the build only
adds a tenantNotifiedAt send-once stamp on the existing notifyTenantOfPmsCreation.
6. Quiet hours unchanged. The per-vendor quiet-hours hold (and the tenant-message hold where applicable) still apply — dispatch respects the window.
7. Dispatch-health observability — the gap must never be invisible again. A daily
read-only check (scripts/dispatch-health-check.ts, mirroring spine-health-check)
flags any open WO with an assigned handyman and handymanNotifiedAt == null older
than a grace window, and posts a one-line verdict to the ops channel. Turns this
incident's hours of manual DB+AppFolio archaeology into an automated daily signal.
Entity classification (per ADR-0027)
No new entities. Two new optional fields on the existing WorkOrder
(canonical entity, spine-traced via personId):
| Field | Class | Notes |
|---|---|---|
WorkOrder.handymanNotifiedAt?: string |
canonical field on a canonical entity | ISO ts the handyman page actually sent; the send-once guard. Single-writer = the dispatch path. |
WorkOrder.tenantNotifiedAt?: string |
canonical field on a canonical entity | ISO ts the synced-WO tenant ack sent; send-once guard, stamped on the existing notifyTenantOfPmsCreation. |
⚠️ Superseded by the Revision: the draft added a new OutboundMessageKind
(maintenance_synced_wo_tenant_notice) lockstep across both type trees — that work was
dropped. The shipped tenant ack reuses the existing wo_pms_confirmation_sms kind;
no new kind was added.
Consequences
Easier / better:
- Every WO's handyman + tenant get told — no class of WO silently un-dispatched.
- The dispatch-health check makes un-dispatched WOs a visible daily signal, not a weeks-long blind spot.
handymanNotifiedAtgives a reliable "was the handyman told" signal that the status-clobber can't corrupt.
Harder / cost / risk:
- Outbound-comms blast radius — this sends real SMS to real handymen + tenants.
The send-once + cutover guards are load-bearing; a bug here spams customers. Must
ship behind the cutover boundary and be proven on the
appfolio-45bench first (no-mock-theater: a real dispatch on the test property, not a unit stub). - Touches the sync writer (
work-order.ts), the dispatch path, the WO type (both trees), a new message kind, and adds a cron — multi-file, agent-loop-adjacent → full plan discipline (the companion plan doc). - The per-WO tenant notice for AppFolio WOs is new tenant-facing comms — copy needs a human sign-off before arming.
Follow-up implied:
- A drift guard that
work-order.tsno longer skips dispatch onpmsSyncStatus:'synced'. - Decide whether
dispatch-health-checkalso alarms on thestatus-vs-handymanNotifiedAtdivergence (adispatchedstatus with nullhandymanNotifiedAt= the clobber).
Alternatives considered
- Keep ADR-0031 (AppFolio owns synced-WO notification); just fix the Temporal hop. Rejected by the data: the 11 stuck WOs prove AppFolio/the PM did NOT notify the handyman. The assumption is empirically false; PropFlow owning it is the decision.
- Use
statusas the send-once guard. Rejected — the appfolio-sync clobbersstatusevery few minutes (observed), so it can't gate "already paged." A dedicatedhandymanNotifiedAtis required. - Retro-dispatch the whole existing backlog on cutover. Rejected — at fleet scale that's an SMS flood to real handymen (the exact spam this incident was about). New WOs only; existing backlog handled deliberately.
- Dispatch synced WOs but skip the tenant notice. Rejected by the decision — "send to the tenant AND the handyman." (Copy still needs sign-off before arming.)
References
- ADR-0031 — local-first WOs; the superseded "no dispatch for synced" clause.
- ADR-0030 — PMS owns WO identifiers (unchanged; this is about notification, not identity).
docs/planning/maintenance-dispatch-ownership.md— the implementation plan.- Incident evidence: CAM-1006 (Temporal-confirmed no workflow), the 11 Camellia stragglers (AppFolio
Status=Assigned), the status-clobber (CAM-1006 revert at +9 min). src/lib/domain/pms/writers/work-order.ts:489-497(the skip),src/lib/domain/maintenance/tech-sms-dispatch.ts(sendHandymanPageCore),src/lib/maintenance/work-orders/dispatch.ts.