ADR-0052 — Handyman quiet hours as a Temporal hold-and-release
Status: Accepted (2026-06-09) Supersedes: the v1 inline "drop-with-timeline-note" quiet-hours gate (PR #2077)
Amendment (2026-06-10 — ADR-0053 Phase 5, window source + default flip): the
MAINTENANCE_HANDYMAN_QUIET_HOURSenv window-read described below is retired. The window source is now the per-handymanVendorMembership.quietHours(three-state: unset = the default 21:00–07:00 window — the gate is ON by default — / a custom window /'none'= opt-out), resolved at the start edge viaresolveHandymanMembershipQuietHours+resolveHandymanQuietWindow. There is no surviving env at all: the fail-open fleet kill-switchMAINTENANCE_HANDYMAN_QUIET_HOURS_DISABLEDwas deleted pre-merge (owner decision 2026-06-11, "no flags"). Deploys armed — unset memberships get the default hold at dispatch immediately; the per-handyman membership'none'is the only opt-out. Everything else in this ADR — the hold-and-release shape, determinism split, fire-time re-read, fail-open, USE_EXISTING dedup — is unchanged.
Context
A new work order pages the property's maintenance handyman by SMS
(dispatchToMaintenanceTech, the single chokepoint for all handyman pages —
Clara-created WOs, AppFolio-sync-imported WOs, the PATCH enrichment landing).
Operators want a quiet-hours policy: don't page the handyman late at night
for a non-emergency; an emergency always pages.
The v1 implementation (ADR-free, PR #2077) gated inside
dispatchToMaintenanceTech and, during the window, dropped the page —
writing a passive timeline note and never paging. Its documented v1 limitation:
no auto-re-page when the window closes, because sync-imported WOs don't ride a
Temporal workflow and the page was sent inline from the poller Lambda.
That's the wrong shape. The right behavior is hold-and-release: a page
queued at 11pm should fire at 7am, not be lost. The user framed it precisely:
"instead of sending the message, we would just have it like waiting on a queue
that's paused" — i.e. a durable hold. Temporal's sleep(until window-close) is
the textbook primitive.
Decision
Move all non-emergency handyman pages onto a Temporal HOLD-AND-RELEASE workflow, uniformly across both WO origins, replacing the v1 inline drop.
- Single chokepoint, one structural change.
dispatchToMaintenanceTechis split into a front edge (decide immediate-vs-held) and a privatesendHandymanPageCore(the actual Twilio send). All 7 call sites keep callingdispatchToMaintenanceTechunchanged. The release activity calls the samesendHandymanPageCore— one send path, no parallel implementation (ONE SOURCE OF TRUTH). handymanNotificationHoldWorkflow(domain-named, no persona) on the existingpropflow-maintenancetask queue (so the held send rides the sameMAINTENANCE_AUTONOMOUS_SENDINGgate +MAINTENANCE_TASK_QUEUE_OVERRIDEeval-isolation asdispatchActivity— zero extra wiring). WorkflowIdhandyman-page-<woId>+USE_EXISTING→ one hold per WO, no double-page.- Deterministic split. The env read (
MAINTENANCE_HANDYMAN_QUIET_HOURS) and the property-timezone DDB read happen at the start edge (outside the workflow), which computesmsUntilQuietHoursEndand bakes it immutably into the START input. The workflow onlyawait sleep(input.sleepMs)— it never re-reads the clock or env (which would break replay determinism). The puremsUntilQuietHoursEndis DST-safe via a two-pass offset fixpoint. - Re-read at fire time. The release activity re-reads the WO (skips one cancelled/completed mid-hold) and re-resolves the tech phone at fire time — a multi-hour hold can outlast either.
- The handyman page is NOT send-gated. It's the maintenance notification,
distinct from the
dispatchActivityL4-write to AppFolio (which IS gated byMAINTENANCE_AUTONOMOUS_SENDING). The immediate-page path is ungated; the held path matches it. An early draft gated the held release onisMaintenanceSendAllowed, which silently DROPPED overnight non-emergency pages whenever the gate was disarmed (the prod default) — fixed. - One page per WO — workflowId
handyman-page-<woId>withUSE_EXISTING(running dup joins) ANDREJECT_DUPLICATEreuse (a completed hold's id can't spawn a fresh re-page; the reject throws → fail-open immediate page). - Emergency bypasses the hold (
ALWAYS_SEND_PRIORITIES = ['emergency']). isTemporalConfigured()guard. A runtime with no Temporal env (the connection would diallocalhost:7233and fail) skips the hold, logs LOUDLY, and pages immediately — never a silent bypass.- Fail-open. Any error computing the hold or starting the workflow pages immediately — a quiet-hours/Temporal hiccup must never drop a page.
- Ships OFF by default. Env unset →
resolveHandymanQuietWindow()returns null → immediate page, no workflow ever starts. Deploying changes nothing until an operator sets the window.
Consequences
- The
appfolio-synclambda becomes the first Temporal-starting lambda: it needsTEMPORAL_ADDRESS/TEMPORAL_NAMESPACE/TEMPORAL_API_KEY(API-key auth, namespacepropflow-renewal-prod.hmqma— the co-tenanted maintenance worker's namespace) and the client is lazy-imported insidedispatchToMaintenanceTechso the SDK stays out of cold-start (bundle size unchanged from baseline). Until the env is present, theisTemporalConfiguredguard degrades that runtime to immediate-page (loud). The Vercel handyman-page paths already have Temporal env.- Config source of truth (durability fix, 2026-06-09).
deploy.shresolves the Temporal env at deploy time, deliberately split by store:ADDRESS+NAMESPACE(non-secret, stable endpoints) from the/propflow/prodSSM tree viaresolve_ssm_param;API_KEYfrom the Secrets Manager secretpropflow-temporal-cloud-api-key-prodvia the newresolve_secrethelper — the same secret the renewal Fargate worker reads through ECSvalueFrom. All three arescript_owned(re-applied authoritatively on every deploy, so a rotated key self-corrects and a hand-pasted stale copy can't linger) AND in the create-path block (a from-scratch recreate is bootstrap-durable). This replaces the original "move it to SSM" follow-up: the key's canonical home is Secrets Manager, NOT SSM, so the whole renewal-prod namespace fleet has one rotation point. Lock-step of the two deploy.sh paths is pinned bysrc/__tests__/appfolio-sync-deploy-temporal-env.test.ts. The CI deploy user (fede-admin) already holdssecretsmanager:GetSecretValue+ssm:GetParameter— no IAM change. - Key rotation runbook. The Temporal Cloud API key is a JWT with a finite
exp(issued ~30-day or longer). When it nears expiry: mint a fresh key viatcld(additive — doesn't revoke the old one), then update the ONE secretpropflow-temporal-cloud-api-key-prod(aws secretsmanager put-secret-value), update the GitHub Actions secretTEMPORAL_API_KEY_PROD(nightly canary / harness), and force a new task on the renewal worker (aws ecs update-service --force-new-deployment) so it pulls the rotated value. For appfolio-sync, runlambda/appfolio-sync/deploy.sh --env=prod(or set the env var directly): a CI merge alone won't propagate the key, because the prod-promote job onlyupdate-function-codes and preserves the existing env — onlydeploy.sh'sbuild_merged_envre-resolvesTEMPORAL_API_KEYfrom Secrets Manager. TheisTemporalConfiguredguard makes a missed appfolio-sync step fail-open to immediate-page (loud), not a silent drop. Verify with a read-onlytemporal operator namespace describeusing the new key BEFORE cutting the live secret over. Incident 2026-06-09: the shared key was 4 days from expiry (would have dropped Temporal auth for the entire renewal/tour/turnover/maintenance fleet); rotated to a 365-day key, fleet reconnected clean.
- Config source of truth (durability fix, 2026-06-09).
- AppFolio-synced WOs ~never reach
'emergency'—mapAfPrioritymaps AppFolio "Urgent" AND "Emergency" both to PropFlow'high', never'emergency'. So AppFolio-initiated WOs always respect quiet hours; only a Clara-classified emergency bypasses the hold. Intentional — do NOT change the AppFolio mapping without a deliberate decision. - PII boundary (ADR-0026) preserved: workflow input is
woId/propertyId/sleepMsonly; phone + WO body resolved at activity time.
Alternatives considered
- DDB pending-queue + flush-on-poller-tick (stay on the Lambda). Rejected: reinvents a durable timer Temporal already provides, and the maintenance domain already runs on Temporal — a second timer mechanism is the wrong direction (mirrors the "no new cron/sweeper" rule in CLAUDE.md).
- Reroute all 7 call sites to start the workflow. Rejected: scatters the Temporal start across 3 files + 7 branches, risks a missed branch, violates ONE SOURCE OF TRUTH. Routing at the chokepoint collapses it to one change.
- A dedicated
propflow-handyman-notification-holdqueue. Rejected: independent scaling isn't needed and it would duplicate the gate + eval-isolation plumbing the sharedpropflow-maintenancequeue gives for free.