0057 — PropFlow owns dispatch for EVERY work order (handyman + tenant), no silent skips

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:

  1. 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. NOT status (the sync clobbers it).
  2. 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) → onCreated never fired for it → it relied solely on the Temporal pipeline, which silently died. The handleNewWorkOrder safety 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.
  3. No cutover guardhandleNewWorkOrder paged every fresh mint, so onboarding an AppFolio property would retro-blast its backlog. → route both sends through the pure decideWorkOrderDispatch gate (send-once + a 7-day createdAt freshness 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 routepatchWorkOrder 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:

  1. 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-processor lambda) 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: no maintenance-<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.)

  2. 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)

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:

Harder / cost / risk:

Follow-up implied:

Alternatives considered

References