0062 — Prospect Outreach Cadence: Production Activation & End-to-End Test (Willows)
- Status: Proposed
- Date: 2026-06-18
- Deciders: Fede
- Follows: ADR-0061 (cadence design — workflow, activities, gate, UI; shipped DISABLED)
Context
ADR-0061 shipped the prospect outreach cadence disabled behind a two-factor fail-closed gate: the prospectOutreachWorkflow, its activities, the propflow-leasing(+-eval) queues, the autonomous gate, and the prospect-page Re-engagement card (live, renders for any inquiry that has cadence state). Phase-1 review (PR #2470) surfaced three gaps that block an actual run:
- No production trigger — nothing in prod calls
startProspectOutreachWorkflow; the cadence can't begin on its own. - No inbound-reply bridge — nothing calls
signalProspectReplied, so a prospect's reply wouldn't pause the cadence. - The leasing queues' polling status is unverified. Push-to-main auto-deploys the worker to prod (see current state), so merging ADR-0061 (#2470) should have rolled
propflow-leasing(+-eval)onto the prod worker — but that the workers are actually polling those queues in Temporal Cloud has not been confirmed.
This ADR specifies the architecture to activate the cadence in production and to run one controlled end-to-end test at The Willows (appfolio-45, the bench property; inbox propflowaicalendar@outlook.com).
Temporal — current state (as of 2026-06-18)
Platform: Temporal Cloud. Auth via
TEMPORAL_API_KEY(+TEMPORAL_ADDRESS,TEMPORAL_NAMESPACE); mTLS-cert path is the fallback. Local dev defaults tolocalhost:7233. Config resolved insrc/lib/temporal/connection.ts.Worker process: ONE Fargate service (
services/renewal-worker/index.ts) hosts multiple Worker Entities — one per task queue — in a single Node process, sharing ONE webpack workflow bundle built fromsrc/lib/temporal/workflows/index.ts.Task queues registered (
buildRegistry()):propflow-renewal,propflow-tour,propflow-turnover,propflow-maintenance,propflow-maintenance-comms,propflow-conversation-topics,propflow-tenant-confirmation-review,propflow-leasing,propflow-leasing-eval(the last two added by ADR-0061; the eval queue bypasses quiet-hours holds for compressed demo runs). Per-worker concurrency default 5/5 → ~45 process-wide activity slots.Existing workflows (analogues):
renewalWorkflow,tourWorkflow,turnoverWorkflow,maintenanceCommsWorkflow, etc. — all per-entity,condition()-timer driven, started from app code (e.g.applyTourIntent→startTourWorkflow). The Vercel app has Temporal Cloud creds and already starts workflows (tour/renewal); a leasing trigger from the app is the same pattern.Arming: the two-factor gate (
autonomous-gate-core.ts) reads the global arm from a synchronous in-memory cache — either the durableCONFIG/RENEWAL_ARMSDDB row (for domains with anarmField) orprocess.env[ENVVAR] === 'armed'. The leasing gate is env-only (noarmField), so it readsPROSPECT_OUTREACH_AUTONOMOUS_SENDINGfrom the worker's environment + the per-propertyProperty.autonomousProspectOutreachEnabledflag.Two full environments — stage and prod — with separate everything:
Stage Prod Temporal Cloud namespace propflow-renewal-stage.hmqmapropflow-renewal-prod.hmqmaWorker (ECS Fargate cluster) propflow-renewal-worker-stagepropflow-renewal-worker-prodDynamoDB table propflow-stagepropflow-prodWebhook base (Twilio status) stage.propflowai.copropflowai.coTemporal API key per-env secret per-env secret Twilio / MS Graph shared stage+prod shared (same provider accounts) Deploy pipeline (
deploy-renewal-worker.yml): push-to-main auto-deploys the worker to PROD (changed 2026-05-21 after the PR #1248 incident — the prior "stage-auto/prod-manual" split shipped workflow code to Vercel while the prod worker lagged, so signals arrived without handlers). Stage is the MANUAL one (workflow_dispatch env=stage). ⇒ The prod worker should already pollpropflow-leasing(+-eval)from the #2470 merge (verify in Temporal Cloud); the stage worker is stale until someone manually deploys it.
Decision (proposed high-level architecture)
D1 — Production trigger: event-driven at claraEngagedAt
When Clara stamps claraEngagedAt on a prospect's first reply (the existing leasing reply path — conversation-manager / tools-leasing), fire startProspectOutreachWorkflow via signalWithStart (idempotent, USE_EXISTING), fire-and-forget. Gated by isProspectOutreachAllowed(propertyId). (A firstReplyFollowUpSentAt migration guard originally also skipped previously-nudged leads; it was removed 2026-07-09 with the first-reply-followup decommission — see ADR-0061 — because it tombstoned legitimate new leads. outreachCadenceAnchorIso presence is now the sole start-idempotency guard.) Pass anchorIso = claraEngagedAt so workflow timers and the UI's "next send" agree. One shared trigger helper called from each engage site (email/voice/sms), not duplicated per path.
D2 — Inbound-reply bridge → signalProspectReplied
When an inbound message from a prospect with a running cadence is processed, call signalProspectReplied(prospectId, { repliedAt }); the workflow short-circuits and stamps outreachCadenceCompletedAt. STOP / opt-out routes through the same signal plus consent suppression. Dedup against the existing leasing reply handling (Open Q2).
D3 — Simulated inbound lead (the test)
Inject a Zillow-formatted inbound email to propflowaicalendar@outlook.com for appfolio-45 via the existing email-stress SQS transport (propflow-inbound.fifo). The existing leasing flow creates the prospect, Clara replies by email, and stamps claraEngagedAt → D1 fires the cadence. No new ingestion code.
D4 — Worker readiness (verify, don't redeploy)
Push-to-main already auto-deployed the worker to prod when #2470 merged, so the prod Fargate worker should poll propflow-leasing(+-eval). Verify the queues show active pollers in Temporal Cloud (propflow-renewal-prod.hmqma) before arming — don't assume. If testing on stage (D8), manually deploy the stage worker first (workflow_dispatch env=stage), since stage is not auto-deployed.
D8 — Test environment: stage-first if data permits, else prod-with-fake-lead
A stage stack exists (separate namespace propflow-renewal-stage.hmqma, propflow-stage table, stage.propflowai.co; Twilio/Graph shared with prod). Preference order:
- Stage — safest (no prod-data/send risk), but requires: (a) a manual stage worker deploy, and (b) provisioning
appfolio-45+ a connected test inbox + a consent row in thepropflow-stagetable, which today likely only exist inpropflow-prod. If that seed is cheap, stage is the right place to run the end-to-end. - Prod with a fresh fake lead (D6) — if stage seeding is prohibitive. Arm
appfolio-45only, send to a controlled sink, disarm in afinally. Higher blast radius (real prod table + real provider sends), so it stays a deliberate, approved, one-shot run.
Decision deferred to the seed-cost check; both paths use the same gate, same activities, same UI card.
D5 — Gating & rollout
Two-factor, fail-closed. Production arming = set PROSPECT_OUTREACH_AUTONOMOUS_SENDING=armed on the worker env + Property.autonomousProspectOutreachEnabled=true for appfolio-45 only. Disarm = unset env or flip the flag → instant halt. Arming an autonomous-send flow in prod is a high-risk change requiring explicit per-property approval (CLAUDE.md). The compressed propflow-leasing-eval queue runs the demo in minutes. Follow-up for the D1/D2 code PR: add PROSPECT_OUTREACH_AUTONOMOUS_SENDING to CLAUDE.md's Temporal-worker safety env-var table (same format as RENEWAL_AUTONOMOUS_SENDING) so on-call has one authoritative place to look.
D6 — Test recipient strategy (no identity-persona risk)
Use a fresh fake lead (fake phone + fake email) so nothing attaches to a real Person/persona (the team member's real email and phone are separate identity records — combining them on one synthetic lead corrupts neither this way). For SMS, use a number the dispatcher's existing isTestSender synthetic-drop recognizes (+1555… / +100000…): the dispatch path logs the outbound and the cadence advances (so the UI card updates and is the verification source of truth) while the message is dropped before the real Twilio hop — no carrier delivery, no real device touched. (Twilio's magic test number +15005550006 is the alternative if we want a real provider round-trip without delivery.) Real personal-phone delivery is deferred (needs an identity merge or a per-test claim — Open Q3).
D7 — Verification & observability
- UI: the Re-engagement card (
queryProspectOutreachWorkflowsnapshot + loggedinquiry_cadence_touch_*messages) on the prospect page. - Temporal Cloud UI: workflow history / timers / signals.
- Sentry sweep over the run window; Langfuse once AI copy (Phase 2) is in.
Consequences
- New code is small + gated: a trigger call at the engage site + a reply-bridge call in the inbound handler. Both no-op unless armed.
- Prod arming is deliberate and reversible: unset env / flip flag halts immediately (fail-closed).
- No worker redeploy needed for prod — the queues shipped with #2470 (verify pollers); a stage test does need a one-off manual stage worker deploy.
- Stage is the lower-risk test bed but costs a
propflow-stageseed of the bench property + inbox + consent; prod-with-fake-lead trades that setup for real blast radius. - The eval queue enables a minutes-long, watchable end-to-end demo without a 2-week wait.
Open questions
- Trigger call site — exact shared insertion point for
claraEngagedAtacross email/voice/sms engage paths. - Reply-bridge dedup — coexistence with existing leasing inbound handling so a reply isn't double-counted.
- Real personal-phone delivery — requires merging the tester's email+phone identities or a one-off claim; deferred to keep personas clean.
- 10DLC/TFV —
appfolio-45uses the test TFN (TFV approved); real-volume SMS at a customer property needs the registration from ADR-0061 D10.
Rollback
Unset PROSPECT_OUTREACH_AUTONOMOUS_SENDING (or flip the property flag) → all sends stop immediately; in-flight workflows dry-run no-op. No data migration to undo.