0061 — Automated Prospect Outreach Cadence (5-Touch Email + SMS)


Context

Today's automated prospect follow-up is a single-touch mechanism: a DISABLED EventBridge Lambda (propflow-first-reply-followup-schedule, decommissioned 2026-04-30) that fires one nudge 48 hours after claraEngagedAt and then stamps firstReplyFollowUpSentAt and stops. There is no multi-touch cadence, no toured-not-applied automated track, no AI-personalized copy, no per-property timezone, and the send window (8 am–10 pm America/Denver hardcoded) violates the TCPA federal floor (9 pm). The Lambda has no autonomous-domain gate.

Research from the ConAm/Mia "Yale Station" client call (2026-06-17) and the apartment lead-reengagement literature (ADR-0060, pm-domain-knowledge/lead-reengagement-and-followup-cadence.md) converges on a 5-touch, ~2-week automated burst as the effective ceiling before diminishing returns. This ADR specifies the full automated layer. The human phone layer (stale-lead call list, cohort eligibility, suppression rules) is owned by ADR-0060; this ADR references but does not duplicate it.

Scheduling mechanism: Temporal is the right platform. The existing tourWorkflow (per-entity durable condition()-based timer loops, WorkflowIdConflictPolicy.USE_EXISTING idempotent start) and maintenanceCommsWorkflow (long-lived while(true) + sleep() + holdForQuietHours()) are the direct analogues. A new prospectOutreachWorkflow lives on a new propflow-leasing task queue registered in the existing Fargate worker (services/renewal-worker/index.ts buildRegistry(), one-line append). The DISABLED EventBridge Lambda and its single-touch domain logic are retired once the workflow is verified in production.

Legal posture (TCPA, grounded in research current to 2026-06):


Decision

D1 — Replace the single-touch Lambda with a Temporal prospectOutreachWorkflow (one per prospect)

The 5-touch cadence is implemented as a durable Temporal workflow, not a re-armed EventBridge rule and not a cron re-scanner. One workflow instance per prospect (workflow ID prospect-outreach-${prospectId}), started when claraEngagedAt is stamped. The workflow owns all touch timers as condition()-based sleep() waits (pattern: tourWorkflow / runReminderTimer). It exits cleanly after the final touch or on an inbound-reply signal — no continueAsNew is needed (5 touches over 2 weeks produces bounded workflow history, unlike the 45-day maintenanceCommsWorkflow).

The workflow lives on a new propflow-leasing task queue registered via one-line append to buildRegistry() in services/renewal-worker/index.ts. No new ECS service is required; if simultaneous multi-queue bursts cause memory pressure, tune WORKER_MAX_ACTIVITY_TASK_EXECUTIONS per the existing comment at lines 218–226 of that file (note: the comment's "grown to 6 workers" figure is stale — the actual count is 7; update that comment as part of this PR).

The DISABLED EventBridge rule (propflow-first-reply-followup-schedule) and its Lambda handler remain decommissioned and are formally retired after the workflow is verified in production.

New files:

Temporal determinism boundary: Workflow code cannot import from @/lib, process.env, or anything non-deterministic. The cadence schedule and all prospect/property data must be passed as workflow input (pattern: tourWorkflow timeouts parameter). Resolution from PropertyLeasingSettings happens in the trigger-site activity before signalWithStart.

Idempotent start: The trigger site uses signalWithStart with WorkflowIdConflictPolicy.USE_EXISTING. A double-fire (e.g., claraEngagedAt stamped on both an SMS and email channel) joins the running workflow rather than duplicating it.

Migration guard: Prospects with firstReplyFollowUpSentAt already set (received the old single nudge before the Lambda was disabled) are excluded from workflow start. Treat firstReplyFollowUpSentAt set as "cadence already started or completed" until a later ADR-0035 Step-5 migration strips the field.

Superseded 2026-07-09: this migration guard was removed when the first-reply-followup system was fully decommissioned. It had begun tombstoning legitimate new leads (a lead that got the old single nudge would never enroll in the 4-touch cadence — root cause of the Jose Couoh a1b95a06 / 0e8e7666 dropped-follow-up incident). outreachCadenceAnchorIso is now the sole enrollment-idempotency key; firstReplyFollowUpSentAt is a pure historical record and gates nothing.

D2 — 5-touch cadence schedule (two tracks)

Track A — Inquired, not toured (stage INQUIRY):

Touch Delay from claraEngagedAt Default channel Notes
1 +24 h Email First contact; email-first rule (D3)
2 +48 h SMS (if consented) else email Channel switch only if SMS-eligible (D3)
3 +72 h SMS (if consented) else email
4 +9 days SMS (if consented) else email Weekly re-engagement
5 +16 days SMS (if consented) else email Final touch; cadence terminates

Cadence is considered exhausted at touch 5 or on a prospect inbound reply (whichever comes first). Exhausted prospects pass to ADR-0060's human call list at day 3+ (see D7).

Track B — Toured, not applied (stage TOURED):

Touch Delay from tour-completed timestamp Default channel Notes
1 Immediate (within quiet-hours window) SMS (if consented) else email Warm while interest is highest
2 +24 h SMS + email application link Dual-channel; application link bundled
3 +7 days SMS (if consented) else email Weekly if no application

Track B requires its own eligibility function (separate from Track A's isCandidate()), its own set of outreachTouch stamps, and its own OutboundMessageKind values. No code for this track exists today; it is fully net-new. The clock anchor for Track B touch 1 is an open question (see Open Questions Q2).

Per-property overrides: Cadence timing is stored in PropertyLeasingSettings as:

outreachCadenceTouches?: {
  delayMs: number;
  channel?: 'sms' | 'email' | 'prefer_sms';
}[]

Defaults are the table above encoded as milliseconds. Overrides are resolved by the trigger-site activity (not inside the workflow, to respect the determinism boundary) and passed as ProspectOutreachWorkflowInput.

Cohort eligibility (which prospects enter Track A or B) is owned by ADR-0060. This ADR specifies only the touch schedule and dispatch mechanics.

D3 — Channel rule: email-first, then SMS only for consented prospects

Touch 1 is always email. Subsequent touches switch to SMS only when both conditions hold:

  1. The prospect has a primary, non-deprecated IdentityClaim of type 'phone' (resolved via resolvePersonContactsByIds; an empty string means no claim — stay on email).
  2. The prospect is SMS-eligible: either (a) getSmsConsentByPhone(phone) returns a record with revokedAt === null (explicit opt-in via /api/sms-consent), or (b) the prospect's primary phone claim has verifiedBy in ['sms_inbound', 'sms_verified'] and that claim was established via a conversation or inquiry scoped to the same property as the current cadence (same propertyId). Consent granted to Property A does not extend to Property B — a prospect who texted Property A and submitted a web form for Property B is not SMS-eligible for Property B's cadence. Neither condition is met → stay on email for that touch.

resolveOutreachChannel(personId, orgId, propertyId, touchIndex) accepts propertyId and enforces this property-scoping as a hard gate. The sms_inbound property-scope check is not belt-and-suspenders — it is a hard consent-boundary requirement.

This channel decision is a pure exported function — testable in isolation. It calls resolvePersonContactsByIds and getSmsConsentByPhone directly. It does not use Person.preferences.smsOptOut (explicitly documented as a display-only cache, not for gating, types.ts line 6467) and does not use inferChannel() (which reads the conversation's last-outbound channel — a continuity heuristic, not a consent decision).

Email-only prospects (no phone claim or no SMS eligibility) receive all five touches via email. Touch 3's email body may contain a link to an opt-in landing page where the prospect can provide phone consent with full PEWC disclosure. Do not embed a "reply START" or similar mechanism in the email — an email reply does not constitute a valid PEWC capture. The opt-in landing page must present: property name, automated text notice, specific message frequency disclosure, "Msg & data rates may apply," STOP instructions, "consent is not a condition of renting," and an explicit checkbox + submit action. IP, timestamp, and verbatim disclosure version are logged at capture time (see D4).

dispatch() still runs for SMS — its dispatchEnvelope consent gate is the downstream safety net. The resolveOutreachChannel pre-check is belt-and-suspenders that makes eligibility logic self-documenting and unit-testable, not a replacement for the existing gate. isReply must be false for all cadence sends (proactive, not a reply); setting it true would silently bypass (a) the TCPA consent check via loadOutboundConsent, (b) the global settings.smsEnabled kill-switch, and (c) the settings.emailsEnabled gate for the email channel. All three of these bypasses are intentional for genuine replies; all three must be off for proactive cadence sends.

smsFrom guard: dispatch() requires smsFrom (the property's Twilio number). Before any SMS touch, gate on getPropertyPhone(propertyId) returning a non-null value. If null, fall back to email for that touch and log a warning.

Email path: Touch 1 uses the Microsoft Graph path (createInboxClient(accessToken, provider).sendReply()), same as today's first-reply-followup.ts, for sender-continuity (sends from the PM's address, threads correctly). Properties without a connected Microsoft mailbox fall back to the SendGrid path (different sender address, breaks threading — acceptable for later touches; see Open Questions Q3). The Graph path does not check settings.emailsEnabled or settings.emailShadowMode; in sendOutreachTouchActivity, before calling client.sendReply(), call getSettings() from @/lib/data and check settings.emailsEnabled — if false, log and return a no-op stamp. Also check property.emailShadowMode (the per-property shadow flag used by tour activities at src/lib/temporal/activities/tour.ts lines 223–224) and no-op if set. The SendGrid path's sendEmail() already applies this gate; the Graph path does not and requires the explicit pre-call check.

Opt-in capture (PEWC for marketing; prior express consent for informational):

The cadence touches in D2 are informational (about the prospect's own inquiry). PEWC is not required for them. However, to be safe and future-proof (NAA counsel advises treating all prospect texts as needing PEWC), the opt-in UI collects full PEWC language regardless:

"By providing your phone number and checking this box, you agree to receive automated text messages from [Property Name] about your rental inquiry. Consent is not a condition of renting. Up to 5 messages per inquiry. Additional messages may be sent if you respond. Msg & data rates may apply. Reply STOP to opt out."

For Track B (toured-not-applied, 3 touches): substitute "Up to 3 messages after your tour." For properties using per-property outreachCadenceTouches overrides, the disclosure must be generated dynamically from the configured touch count, not hardcoded to "5."

This language is stored verbatim in SmsConsentRecord.consentText. The existing POST /api/sms-consent endpoint accepts it.

Consent type discrimination: Add smsConsentType?: 'informational' | 'marketing' to SmsConsentRecord (additive, optional). A record created from an sms_inbound verifiedBy claim is always smsConsentType: 'informational' and must never be used to authorize a marketing/promotional send regardless of field value — this is a hard code gate in the consent-check path, not a documentation note. The existing endpoint accepts and persists the field. Cadence touches check only for revokedAt === null; the type field gates future promotional/marketing sends separately.

Consent at prospect creation: When a prospect's primary phone claim has verifiedBy === 'sms_inbound' (prospect texted in), write a SmsConsentRecord at prospect-save time with consentText: 'SMS inbound — prospect initiated contact', smsConsentType: 'informational', and propertyId scoped to the property the SMS was received on. This closes the gap where getSmsConsentByPhone returns null for most inbound prospects. Web-form inquiries (WEBSITE/ZILLOW/APARTMENTS_COM sources) write a consent record only when the prospect explicitly checks the opt-in box on the inquiry form.

Info vs. marketing classification: Cadence touches 1–5 must not contain any pricing specials, concessions, or promotional offers. Adding "first month free" or any unit-specific inducement reclassifies the message as marketing and requires PEWC. The AI copy generator (D6) enforces this via prompt instruction and a post-generation dollar-sign check. Promotional messaging is routed to a separately-consented marketing campaign outside this cadence.

Opt-out honoring: The existing Twilio webhook handler (route.ts line 93) handles STOP/UNSUBSCRIBE/CANCEL/END/QUIT. Two FCC-mandated per-se synonyms are missing: add 'opt out' and 'revoke' to STOP_KEYWORDS before go-live. These additions are the required keyword floor, not the complete solution: layer a semantic intent-detection pass (a small classifier or an LLM-based "does this message express intent to stop receiving texts?" check) on top of the keyword list to catch paraphrased revocations ("take me off your list," "do not text me," etc.). An append-only keyword list will always be incomplete; the semantic layer is required.

Inbound STOP signals must be processed immediately as an out-of-band event — a Temporal signal to the running workflow — not deferred to the next scheduled activity execution. The confirmation reply ("You have been unsubscribed. No further texts will be sent.") is subject to quiet-hours rules and may be deferred to the next open window if received outside hours. No marketing content in the confirmation.

Opt-out revocations are honored within 10 calendar days (immediate for text-based STOP commands is the safer posture and reflects emerging FCC interpretation). The FCC cross-channel revocation rule is delayed to 2027-01-31 but adopted as best practice in both directions: (a) a STOP received via SMS also marks the person's email as unsubscribed in ConsentRecord.email.unsubscribed; (b) an email unsubscribe (CAN-SPAM unsubscribe link click) must set a suppression flag checked by resolveOutreachChannel() before any SMS cadence touch. This bidirectional cross-channel suppression is the correct posture independent of the rule delay.

Consent recordkeeping: SmsConsentRecord stores: consentedAt (ISO), ip, userAgent, consentText (verbatim opt-in language), consentPageUrl (exact URL of the opt-in page), consentMethod ('checkbox_submit' | 'sms_inbound' | 'api'), and disclosureVersion (a hash or version identifier of the disclosure template rendered at consent time, so the exact UI state can be reconstructed for litigation defense).

Consent record retention: SmsConsentRecords must be retained for a minimum of 4 years from the date of last contact with the prospect (not from consent capture), consistent with the TCPA limitations period. Prospects who never convert to tenants are subject to the same retention floor. The data retention policy must explicitly exclude SmsConsentRecords from any earlier-deletion prospect-cleanup sweep.

Consent gate fail posture: dispatchEnvelope's loadOutboundConsent currently fails open on DDB errors (hardcoded in dispatch-envelope.ts lines 125–129). There is no option flag to override this behavior. For the proactive cadence path, supply a custom consent loader via the existing options.loadConsent hook in dispatchEnvelope. The cadence send activity's injected loader rethrows on DDB errors instead of logging-and-continuing — making cadence sends fail-closed on transient DDB outages, blocking the touch rather than sending to a potentially-opted-out number. No new option field on DispatchEnvelopeOptions is needed; the options.loadConsent injector is the correct mechanism.

D5 — Quiet hours: recipient-local, strictest applicable rule

All cadence touches use isWithinQuietHours(property.timezone, now) from the shared module at src/lib/temporal/activities/quiet-hours.ts (QUIET_HOURS_START_HOUR = 8, QUIET_HOURS_END_HOUR = 21 — 8 am to 9 pm recipient-local) for the federal baseline. The module uses Intl.DateTimeFormat with IANA timezone strings and falls back to 'America/Chicago' on a bad or missing value.

The first-reply-followup Lambda's inline send window (8 am–10 pm America/Denver hardcoded) had two bugs: a 10 pm cutoff (violates the TCPA federal 9 pm floor) and a hardcoded Denver timezone (wrong for any non-Colorado property). The new workflow uses the shared module with property.timezone resolved inside resolveOutreachQuietHoldActivity (same pattern as maintenanceCommsWorkflow's holdForQuietHours()). Fixing the old Lambda's bugs is deferred to its formal retirement cleanup.

State-specific stricter floors:

The shared isWithinQuietHours module signature is (propertyTimezone: string | undefined, now?: Date) and accepts no hour-override parameters. To enforce per-state overrides, resolveOutreachQuietHoldActivity performs its own hour comparison using PropertyLeasingSettings.quietHoursStartHour and PropertyLeasingSettings.quietHoursEndHour override values directly, rather than delegating to the shared module for the hour-boundary check. The shared module call determines "are we within the federal window?" and the activity's inline check additionally enforces any stricter state window. This avoids modifying the shared module's signature and existing call sites.

The quietHoursStartHour and quietHoursEndHour fields in PropertyLeasingSettings (default: 8 and 21 respectively) are operational configuration fields set at property onboarding. Example state values: FL, OK, WA → quietHoursEndHour: 20; TX (SB140, effective Sep 2025) → quietHoursStartHour: 9, quietHoursEndHour: 21.

This is not an exhaustive list of states with stricter requirements. Before arming any property in a state not listed here, confirm with counsel whether state mini-TCPA restrictions impose a stricter quiet-hours window, per-day message cap, or DNC list obligation. The operational property-onboarding process must include a state-law review step. The MD 3-message/24-hour cap requires separate handling — see Open Questions Q4.

When the quiet-hours check blocks a touch, the workflow parks (same condition()-based sleep pattern as holdForQuietHours()) and retries at the next open window. It does not skip the touch.

Reuse: The shared module is used by renewal workflows (4 call sites in renewal.ts, 1 in renewal-auto-start.ts). The leasing cadence is a fifth consumer. The module itself is not modified; the activity wraps it with an additional state-specific hour check as described above.

D6 — AI-personalized message generation (replaces hard-coded templates)

The hard-coded buildFollowUpSms() and buildFollowUpEmail() in first-reply-followup.ts are retired. A new generateFollowUpMessage() function at src/lib/domain/leasing/generate-followup-message.ts generates cadence copy via a Claude API call.

Call pattern:

Prompt structure (goal + context + template):

GOAL: Write a warm, brief follow-up [SMS/email] from Clara on behalf of [propertyName].
The prospect has not responded since their initial inquiry. Goal: re-engage them and
invite them to schedule a tour. Do not use em-dashes (—). Do not fabricate any detail
not provided below. Do not include pricing, specials, or concessions of any kind.
Do not include a STOP/opt-out footer in the generated body — the caller appends it.

CONTEXT:
  prospect_first_name: {firstName}
  property_name: {propertyName}
  inquiry_date: {inquiryDate}
  lead_source: {source}
  touch_number: {touchIndex} of 5
  days_since_inquiry: {daysSinceClaraEngaged}
  preferred_beds: {desiredBedrooms | "not specified"}
  target_move_in: {desiredMoveIn | "not specified"}
  pet_friendly_asked: {petsMentioned | "no"}
  language: {resolvedLanguage}

TEMPLATE RULES:
  [SMS] Plain text only, no markdown, 2–3 sentences, max 320 characters before the
  opt-out footer. Do not add a sign-off or signature.
  [Email body] 3–5 sentences, no markdown, no sign-off — the template adds a branded
  signature automatically.
  Tone: warm, direct. No "Great question!" or "Absolutely!" filler.
  [If language === 'es'] Reply in Spanish using the formal "usted" form. Keep it
  natural, not textbook. Property name and unit numbers stay in English.

Any untrusted prospect-supplied text injected into the prompt (e.g., aiNotes from prior conversation) is wrapped with wrapUserMessage() from src/lib/platform/security/input-tags.ts. Structured fields from ProspectInquiry (beds, move-in, source) are safe to inline directly.

Anti-fabrication safeguard: After generation, if the output contains any $ amount or a specific unit number not present in the input context, log a warning and fall back to a minimal hard-coded template. This post-generation check is implemented inline in generateFollowUpMessage() — there is no shared runHallucinationGuard() utility in the codebase; only the event-name string constant 'hallucination_guard' exists in the Pipeline Lab decision catalog.

Em-dash removal: Prompt instruction ("Do not use em-dashes") is the primary guard; a post-processing strip (generatedText.replace(/—/g, ' -')) is the safety net.

STOP footer: The AI-generated SMS body is stored as-is. The caller appends '\n\nReply STOP to opt out.' unconditionally before dispatch — never left to the LLM (it may rephrase or omit it). The LLM is instructed not to include a stop-footer in the generated body.

Email template: Use emailCardLayout from agents/clara/lib/messaging/transports/stripe-base.ts for brand consistency. The current buildFollowUpEmail() uses a bespoke inline HTML blob that does not use the shared primitives; the new generator produces text and the caller renders it into emailCardLayout.

Language source: For an outbound-initiated touch (no new inbound message to detect from), read Person.preferences.languagePreference from the DDB Person row. Do not attempt fresh language detection from the prospect's last message.

Tracing: If the activity runs inside Temporal, use tracedClaudeCall() from src/lib/platform/observability/trace-llm.ts with { name: 'prospect-followup', propertyId } so Langfuse groups cadence calls correctly. Confirm LANGFUSE_SECRET_KEY is wired in the Fargate worker environment before enabling tracing.

10DLC prompt-change gate: Any change to the generateFollowUpMessage() prompt that materially alters output style, topics, or length must trigger a 10DLC sample review before deployment to a production property. This is an operational process requirement; see D10 for registration details.

D7 — Handoff to ADR-0060's human call list (no double-contact)

The automated cadence owns days 0–3 (touches 1–3, the daily burst). The human call list (ADR-0060) picks up at day 3+ (touches 4–5 are lighter automated follow-ups; the call list fires independently based on ADR-0060's eligibility criteria).

Suppression rule: A prospect is suppressed from the human call list if outreachCadenceCompletedAt is null (cadence still running) and outreachTouchIndex < 3 (still in the daily burst). Once the burst completes (touch 3 sent, or the prospect enters the weekly phase), they become eligible for the call list per ADR-0060. If a prospect replies at any point, signalProspectReplied() cancels the workflow, stamps outreachCadenceCompletedAt, and ADR-0060's eligibility logic excludes them (a prospect who replied is not stale).

Concrete suppression check: ADR-0060's eligibility query reads ProspectInquiry.outreachTouchIndex and outreachCadenceCompletedAt. A prospect with outreachTouchIndex < 3 and no outreachCadenceCompletedAt is excluded from the call list. This is a single field check — no additional join or workflow query needed.

No double-contact on the same day: The cadence workflow does not fire touches on the same calendar day as a logged outbound from the call list (verified via ProspectInquiry.lastContactedAt). If the call-list PM contacted the prospect today, the next cadence touch parks until the following day.

Track A touches 4 and 5 overlap with call list: The weekly touches (days 9 and 16) overlap temporally with when ADR-0060's human call list is active. Whether to pause these touches when the prospect is on the active call list for that week, or run both channels in parallel, is deferred to Open Questions Q6.

D8 — Data model additions

The following fields are added to ProspectInquiry in types.ts and registered in InquiryFieldPatch / INQUIRY_C_FIELDS in dynamo/inquiries.ts. All writes go through updateInquiryFields() per the ADR-0035 C-writers pattern. No fields are added to the Prospect god-object.

Multi-touch state:

Track B state (toured-not-applied):

OutboundMessageKind additions (new values added to the union at types.ts line 2879):

PropertyLeasingSettings additions (stored at DDB SK=LEASING_SETTINGS):

Property additions:

SmsConsentRecord additions:

D9 — Autonomous gate: ships DISABLED, fail-closed

SUPERSEDED by PR #4694 (2026-07-27). D9's gate no longer exists. Both layers below were deleted — Property.autonomousProspectOutreachEnabled and the prospect-outreach-gate.ts module — and PROSPECT_OUTREACH_AUTONOMOUS_SENDING is now read by nothing. The cadence ships ON for every property. The reason: 16 of 17 properties sat silently dark behind these flags, which is the failure CLAUDE.md hot-rule 13 ("no arms by default") exists to prevent, and a per-property arm plus a cadence editor is two levers for one behavior. Follow-up control now lives in ONE place — the cadence in Follow-up settings; emptying or shortening it is how you stop a follow-up. The send-time checks (consent, ADR-0092 suppression, the 12h cross-path cooldown, quiet hours, the at-most-once send claim) are UNCHANGED and are invariants, not arms: they are evaluated fresh per message, about this recipient, and never graduate. The dry-run index behavior described below therefore has no trigger left — the "freeze the index while disarmed" rule is moot, though the underlying guarantee (a SKIPPED touch does not advance outreachTouchIndex) is live and still holds for every remaining skip reason (no-consent, suppressed, cooldown, …). The rest of this section is retained as history.

The cadence ships behind a two-layer gate identical to the leasing-digest pattern (digest-gate.ts / isLeasingDigestAllowed()):

  1. Global env var: PROSPECT_OUTREACH_AUTONOMOUS_SENDING must equal the literal string 'armed' (case-sensitive). Default: absent — cadence is blocked.
  2. Per-property flag: Property.autonomousProspectOutreachEnabled === true. Default: false.

Both must be true for any touch to dispatch. The gate is implemented in src/lib/domain/leasing/prospect-outreach-gate.ts using createDomainGate({ envVar: 'PROSPECT_OUTREACH_AUTONOMOUS_SENDING', propertyFlag: 'autonomousProspectOutreachEnabled' }) from src/lib/temporal/autonomous-gate-core.ts.

Dry-run index behavior: When the gate is disarmed, sendOutreachTouchActivity() logs the touch as a no-op with full context for monitoring but does not advance outreachTouchIndex. Freezing the index means that when the gate is later armed, prospects receive the full cadence from touch 1. The alternative (advancing the index during dry-run) would cause the first real send cohort to receive fewer touches — an unacceptable outcome. This is the permanent behavior: dry-run does not consume touches.

The existing Settings.smsEnabled and Settings.emailsEnabled kill-switches remain independent guards. The cadence checks them explicitly before any send — including the Microsoft Graph email path (see D3 for the explicit pre-call getSettings() check required there).

10DLC A2P registration is a hard go-live prerequisite (D10) and must be complete before PROSPECT_OUTREACH_AUTONOMOUS_SENDING is set to 'armed' for any property on a local number. Properties on the Camellia toll-free number (TFV approved) are unaffected. Yale Station's local number is currently pending 10DLC (noted in phone-lookup.ts line 118); it must not be armed before registration completes.

D10 — 10DLC A2P registration as a go-live prerequisite

10DLC A2P registration with The Campaign Registry is mandatory at the carrier layer; 100% of unregistered traffic has been blocked since Feb 2025. This is carrier enforcement, independent of TCPA compliance. [HARD]

Two separate campaigns are required:

  1. Informational/transactional campaign — covers cadence touches 1–5 (prospect re-engagement about their own inquiry). Use mixed or low volume mixed depending on message volume. Sample messages submitted at registration must represent the range of AI-generated output (not just the current hard-coded template). Register 3–5 representative samples covering English and Spanish variants.
  2. Marketing campaign — required before any promotional text (specials, concessions) is sent. This ADR's cadence does not send promotional content; this campaign is registered now as a forward compatibility measure.

Sample message alignment: The 10DLC carrier AI compares live traffic against registered samples. AI-generated messages that diverge significantly from registered samples risk carrier flagging. Mitigations: (a) keep registered samples general ("Hi Maria, just following up on your inquiry about Camellia Apartments. Are you still looking for a 2-bedroom?"); (b) run a sample-generation sweep before registration to confirm the AI output range is covered; (c) any change to generateFollowUpMessage() that materially alters output style, topics, or length must trigger a 10DLC sample review before deployment to a production property — this is a required operational step, not optional.

Camellia toll-free number: TFV already approved. Not affected by 10DLC requirements. Track A and B cadence can be armed for Camellia once all other prerequisites are met.


Consequences

What becomes easier

What this costs

Definition of done


Open questions

  1. Dry-run index advancement — resolved in D9: index is frozen when gate is disarmed.

  2. Track B timing anchor: "Immediate" for toured-not-applied (touch 1 within quiet-hours window) — what field is the clock anchor? Prospect.appliedAt is not set for toured-not-applied. Is it the tour-completed signal from the tour workflow (tourWorkflow exit), a new touredAt stamp on ProspectInquiry, or the existing lastContactedAt? Needs a definition before Track B can be wired.

  3. Email path for non-Microsoft properties: Properties without a connected Microsoft mailbox silently skip email touches today (first-reply-followup.ts line 281–285). For the cadence, should they fall back to SendGrid (different sender, breaks threading), skip the email touch and log an alert, or block arming for that property until a mailbox is connected?

  4. MD 3-message/24-hour cap implementation: The state-override config approach does not address the per-day message-count cap. Should this be tracked as a rolling counter on ProspectInquiry (new field outreachMessagesLast24h), or is it out of scope for launch and addressed when a MD property is onboarded? MD properties must not be armed until this is resolved.

  5. Consent backfill for existing prospects: Prospects who inquired before the consent-at-creation write is deployed have no SmsConsentRecord. Should there be a one-time backfill that writes smsConsentType: 'informational' for prospects whose primary phone claim has verifiedBy === 'sms_inbound'? Or is the forward-only approach (only new inquiries get a consent record) acceptable given that those prospects would stay on email anyway?

  6. Track A touches 4 and 5 vs. ADR-0060 call list overlap: The weekly touches (days 9 and 16) overlap temporally with when ADR-0060's human call list is active. Should the workflow pause touches 4 and 5 when the prospect is on the active call list for that week, or do both channels run in parallel (PM calls + automated text on the same day)?

  7. California and other unreviewed states: The D5 example list (FL, OK, WA, TX) does not cover CA (Invasion of Privacy Act + CCPA), IN, LA, MS, MT, NV, TN, or other states with potential mini-TCPA obligations. Confirm with counsel which states require quiet-hours, per-day cap, or DNC obligations before onboarding any property in those states.


Alternatives considered

Re-arm the EventBridge Lambda with multi-touch logic: Replacing the cron scanner with an N-touch counter in the same hourly-scan pattern. Rejected: stateless batch scanners require reading all in-progress prospects on every run; dedup relies on polling DDB timestamps rather than durable timers; a Lambda crash between touches has no replay; scheduling precision is ±1 hour rather than millisecond. Temporal already runs in the stack; the tourWorkflow is a direct, proven analogue.

Five separate EventBridge rules, one per touch: Each rule fires at the appropriate delay and checks its own stamp. Rejected: five separate Lambda deployments for what is logically one state machine; no signal-driven cancellation on prospect reply; each rule has its own dedup gap; harder to configure per-property overrides.

Vercel Cron route (like the leasing-digest): A stateless batch cron at /api/cron/prospect-outreach that re-scans on each run. Rejected for the same reasons as EventBridge: polling model, no reply-driven cancellation, ±1-minute precision rather than durable timers. Appropriate for stateless digests (ADR-0060's call list); wrong for a per-prospect timed state machine.

Single PEWC gate for all cadence touches (treat all as marketing): NAA counsel's safe-harbor recommendation is to collect PEWC for all prospect texts. Adopted as the opt-in UI approach (the opt-in form collects PEWC-grade language) but not as the operational gating rule for informational touches — doing so would block all SMS cadence for prospects who inquired before the opt-in form was deployed, eliminating SMS reach at launch. The ADR treats PEWC language as the disclosure standard for the opt-in form and 'sms_inbound' claim (property-scoped) as sufficient for informational touches. This is documented as a deliberate legal posture, not an oversight.

A/B testing varied cadence schedules: Deferred per the standup decision (message volume at current property count is too low to reach statistical significance). The outreachCadenceTouches per-property config field supports future experiments without a schema change.

Separate Fargate worker for the leasing task queue: Avoids concurrency contention with the 7 existing queues. Rejected for now: adding one queue to an existing 7-queue worker is a one-line config change and the worker has headroom; a new ECS service adds operational complexity and cost. Revisit if per-worker memory pressure is observed after launch.