> ## Documentation Index
> Fetch the complete documentation index at: https://f4c7a9e2d8b1-docs.tenzo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Outreach Decisions

> How Tenzo decides when (and whether) to reach out to a candidate — channel, timing, retries, and what stops outreach

## Overview

Once a candidate is added to a job, Tenzo decides **when** and **how** to reach out based on a stack of gates: stage, consent, business hours, cooldown, retry budget, and channel-specific guards (phone line type, ATS sync state). Every outbound attempt has to pass every gate.

This page describes the rules. For the consent rules in isolation, see [Candidate Consent](/internal/candidate-consent). For the broader stage flow, see [Candidate Stage Lifecycle](/candidate-stage-lifecycle/overview).

## When Outreach Starts

A candidate becomes eligible for outreach when their **tenzo\_stage** transitions to `OUTREACH`. Two paths get them there:

1. **Resume passes** — `AWAITING_RESUME_REVIEW → OUTREACH` after the resume scorer accepts the candidate against the job's requirements.
2. **Recruiter override** — `RESUME_REJECTED → OUTREACH` when a recruiter manually clicks **Progress to Interview**. Any scheduled rejection email is cancelled at the same time.

On entry to `OUTREACH`, a `PENDING` call row is created (idempotently — if one already exists, nothing happens). The `scheduled_datetime` on that row is what the call dispatcher polls for.

<Note>
  A candidate added to a job who is already being contacted on **another** job at the same org never goes to `OUTREACH` directly — they go to `COOLING_DOWN` first. See [Cooldown](#cooldown) below.
</Note>

## Scheduling the First Attempt: Outreach Hours

The first `scheduled_datetime` is computed from the org's **Outreach Hours** settings and the candidate's timezone.

* **Timezone:** `candidate.timezone` on the candidate row. The scheduling code itself does no inference at outreach time — it reads the field as-is and falls back to `US/Eastern` if empty. The field is populated at **candidate intake** in this order: (1) explicit value from the ATS/import, (2) zip-code geocoding, (3) the location point (offline lat/lon lookup, e.g. a city/state centroid when there is no zip), (4) phone area-code lookup. If all four fail at intake, the candidate stays untimezoned and is treated as Eastern at outreach time. A timezone the candidate has confirmed directly (call, SMS, or action token) outranks all derived sources for **90 days**; after that the confirmation expires, so location evidence can update the value again and call/SMS flows will re-confirm the timezone with the candidate.
* **Weekday window:** Configurable per org (default `08:00–21:00` candidate-local).
* **Weekend window:** Configurable per org. `call_on_weekends` is on by default and weekend hours default to the same window as weekdays. If `call_on_weekends` is off, weekend slots roll forward to Monday.

If the current time falls inside the window, the call is scheduled for **now**. If not, it rolls forward to the next valid slot.

### Initial Outreach Method

Each job has an `initial_outreach_method` setting that biases the first contact:

| Setting                    | Behavior                                                                                                                                                                                                                                                                         |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OUTREACH_HOURS` (default) | **First outreach only** (`is_first_outreach`, gated by `sms_status == NONE`): inside outreach hours → call; outside → SMS + companion email. Lets late-evening adds get a same-day touch. Retries always wait for the next outreach window; SMS is never re-fired outside hours. |
| `CALL`                     | Always place a call (subject to consent and line-type rules).                                                                                                                                                                                                                    |
| `SMS`                      | Send an SMS as the first touch; a companion email is sent alongside.                                                                                                                                                                                                             |

Jobs can also set `override_outreach_hours_for_initial_outreach=True`, which fires the first contact immediately without rolling forward to business hours. This is used by SMS-first and Outreach-Hours flows so an evening sync still gets a same-day touch.

### Web-call jobs

For jobs whose `Campaign.call_types.applicant_call_type` (or `sourced_call_type`) is `CallType.WEB`, every `PENDING` Call row is created with `web_call=True`. The dispatcher short-circuits the normal phone path at the top of `make_initiate_call_request`: it calls `invite_to_web_call` instead, which creates a `WebCallLink` row and POSTs the internal `/send_web_invite_email` endpoint. The email template comes from the customer's Cosmos `script_info.webCallEmailBody` / `webCallEmailSubject` and includes a candidate-specific link to the scheduling/start page.

Reminder emails respect outreach hours and the configured `VideoInterviewReminderSettings` cadence. Link expiration is governed by `VideoInterviewLinkExpirationSettings` (default: no expiration). When the candidate clicks the link and schedules, the stage transitions to `AI_INTERVIEW_SCHEDULED`.

Web-call invite outreach does **not** use the phone voicemail retry budget or **Max Attempts** Tenzo stages. Those candidates stay in **Outreach** while invites, reminders, or a scheduled interview slot are in flight. Once the configured invite/reminder budget is exhausted with no response, they move to the **All Invites Sent** soft-wait stage: no further invites are sent, but the emailed link stays live, so the candidate can still schedule, complete, decline, or opt out. When outreach goes stale in either state, the inactivity disposition sweep moves them to **Expired** (not Max Attempts). Phone-style retry exhaustion (`MAX_ATTEMPTS_NO_INTERVIEW` / `MAX_ATTEMPTS_NOT_COMPLETED`) applies only to outbound phone attempts.

## Channel Selection at Send Time

When the dispatcher picks up a `PENDING` call, it re-evaluates channel right before sending:

```
PENDING call due
    │
    ├─ Stage blocks outreach? ────────────────────────── CANCEL (see "What Stops Outreach")
    ├─ Master AI-communications consent off? ─────────── try email-only or cancel
    ├─ Cooldown on another job? ──────────────────────── move to COOLING_DOWN
    │
    ├─ initial_outreach_method = SMS?
    │   └─ phone line is sms-blocked (landline/VoIP/etc.)?
    │       ├─ yes → call + email fallback
    │       └─ no  → send SMS (+ companion email)
    │
    ├─ initial_outreach_method = OUTREACH_HOURS?
    │   └─ inside business hours? ─── yes → call
    │       outside business hours? ── send SMS (+ email)  (line-type gate same as above)
    │
    └─ initial_outreach_method = CALL
        └─ place call (or fall back per consent — see Candidate Consent)
```

**Line-type gate.** SMS is blocked when the candidate's stored phone line type is `LANDLINE`, `PAGER`, `VOICEMAIL`, `PREMIUM`, `SHARED_COST`, or `FIXED_VOIP`. Non-fixed VoIP numbers (e.g. Google Voice) can receive SMS and are allowed, matching call behavior. Tenzo looks the number up against Twilio and caches the result for 30 days. If the lookup is missing or fails, the SMS is allowed through.

**Consent gate.** Before any channel is used, consent is checked. See [Candidate Consent](/internal/candidate-consent) for the full matrix, but the short version:

* `all_ai_communications_consent=False` blocks **calls** only. SMS is independent of this flag and gated solely by `sms_consent`. Email is **not** gated by this flag either — email proceeds based on `email_consent` alone.
* `sms_consent=False` blocks SMS; the system falls back to email when possible.
* `call_consent=False` blocks calls.
* `email_consent=False` blocks email (no further fallback).

**SMS opt-in confirmation (per-script override).** When a script has **Require Opt-In Confirmation** turned on AND the candidate's `sms_consent` is `False`, Tenzo replaces the candidate's first failed-call SMS with a configurable opt-in prompt (`optInApplicantSms` or `optInSourcedSms`) and transitions the SMS conversation to `AWAITING_OPT_IN`. An LLM classifies the reply as opt-in / opt-out / unclear:

* **Opt-in** → grants `sms_consent=True`, sends `postOptInSms`, advances to `INITIAL_OUTREACH`. Normal outreach resumes.
* **Opt-out** → routes through the same consent-revocation path as a literal `STOP` reply: revokes text + AI communications consent (keeps email) and moves `tenzo_stage` to `CONSENT_REVOKED`.
* **Unclear** (first reply) → sends a clarification SMS, advances to `AWAITING_OPT_IN_CLARIFY`. A second unclear reply defaults to opt-out.

The opt-in prompt, clarification, and `postOptInSms` are sent with `skip_consent_check=True`, which bypasses the `sms_consent` gate and disables the email fallback — the consent ask must arrive on the SMS channel it's asking about, not via email. Outside this narrow flow the gates above apply unchanged.

## Retries and Cooldowns

### Retry Budget

When a call ends without a conversation (voicemail, no-answer, busy), a new `PENDING` call is scheduled. The behavior comes from the job's **Call Retry Settings**:

| Setting                   | Default | Meaning                                                                                          |
| ------------------------- | ------- | ------------------------------------------------------------------------------------------------ |
| `reschedule_on_voicemail` | `True`  | Master switch for voicemail retries **and** consent-gated retries. Turning it off disables both. |
| `retry_count`             | `5`     | Maximum retry attempts after the first call.                                                     |
| `retry_hours`             | `26.0`  | Spacing between retries (hours).                                                                 |

Each rescheduled call is re-bounded to outreach hours. When `retry_count` is exhausted, the candidate's stage transitions out of `OUTREACH` and no further calls are scheduled.

### Consent-Gated Retry

If a call is cancelled at dispatch time because consent is missing (and the candidate can't be reached via email fallback either), Tenzo schedules a **consent-gated retry** at `retry_hours` later — up to `retry_count + 1` total attempts. This exists so that a consent flip during the retry window (e.g. the ATS sync adds opt-in, or the candidate texts back `START`) gets picked up.

### Failure-Reschedule

A separate, short rescheduler kicks in when the **call initiation itself** fails (Retell rejects the request, phone-provider error, etc.). The call is bumped 10 minutes forward and re-bounded to outreach hours.

### Cooldown Across Jobs

When a candidate is added to a job, Tenzo checks if they're already in motion on **another** job at the same org. If so, the new job's `CandidateCampaignInfo` enters `COOLING_DOWN` instead of `OUTREACH`. They auto-resume once the other outreach concludes, or after 14 days they expire to `COOLDOWN_EXPIRED` with no contact.

If outreach is manually paused on a candidate or because the whole job is paused, Tenzo moves that candidate to `OUTREACH_PAUSED` and parks pending outreach calls as `PAUSED`. On resume, Tenzo re-runs cooldown checks before returning to `OUTREACH` (or leaving the candidate in `COOLING_DOWN` if another job is still active).

Cooldown days are configurable per org by candidate source and call type:

| Source    | Channel | Default      |
| --------- | ------- | ------------ |
| Sourced   | Phone   | 1 day        |
| Sourced   | Web     | 1 day        |
| Applicant | Phone   | 1 day        |
| Applicant | Web     | 0 (disabled) |

The day count controls only the "called within window" trigger. Active outreach and queued attempts on another job always trigger cooldown, regardless of the day setting. See [Cooldown across jobs](/candidate-stage-lifecycle/outreach#cooldown-across-jobs) for the full stage description.

## What Stops Outreach

Outreach is halted — pending calls cancelled, no new ones created — when any of these become true for the candidate:

### Stage-based stops

If the candidate's stage moves into any of the following, all non-terminal calls are marked `NOT_INTERESTED` and no new outreach is scheduled:

* `NOT_INTERESTED` — candidate declined **this job**; can be re-engaged for other jobs.
* `CONSENT_REVOKED` — candidate revoked consent on the channel(s) they were reachable on (STOP / call / email / ATS sync), leaving no usable channel; blocks outreach on every job that relied on the revoked channel. Recoverable — re-consent, a recruiter override, or the candidate calling in and completing a graded interview re-engages outreach. See [Consent Revocation](#consent-revocation) below.
* `DO_NOT_CONTACT` — **legacy** stage, retained for history; gets **no new entrants**. Opt-outs now route to `CONSENT_REVOKED`.
* `FRAUD_REJECTED`
* `DISPOSITIONED_IN_ATS` (the ATS marked them rejected/withdrawn)
* `USER_CANCELED` (recruiter explicitly stopped outreach)
* `FAILED_KNOCKOUT`
* `HUMAN_REVIEW_REQUESTED`
* `AI_INTERVIEW_SCHEDULED` (the candidate has scheduled a web call — typically by following an SMS invite link; phone outreach pauses until the web call lands)
* `SKIP_OUTREACH` (outreach is blocked before contact, either pre-dispatch or at dispatch time — e.g. an ATS-eligibility soft-block or a job-level AI Resume Review Only policy)
* `COOLING_DOWN`

`INVALID_CONTACT` is functionally a stop too, but it doesn't sit in the blocking-stages list — it cancels only the call that surfaced the problem and is documented under [Invalid contact and other dispatch gates](#invalid-contact-and-other-dispatch-gates) below.

### Consent-based stops

* `all_ai_communications_consent=False` → no calls. SMS is unaffected (gated solely by `sms_consent`), and email is independent too — it still goes through if `email_consent=True` and an email integration is connected.
* `has_any_consent(candidate)` is the master outreach gate: True when any of `sms_consent`, `email_consent`, or `all_ai_communications_consent` is set. With all three false the candidate has no reachable channel and outreach is cancelled entirely. The stage depends on whether consent was ever present: the **first time an application is ingested** with no consent on file (it was never revoked) the candidate transitions to `NO_CONSENT`; a **subsequent re-check** of an existing application whose channel(s) are now gone (e.g. via `outreach_blocking_service`) transitions to `CONSENT_REVOKED`. (`call_consent` is intentionally excluded from this predicate — unused/unmapped today.)

### Retry exhaustion

When `retry_count` is reached without a successful **phone** conversation, the candidate exits `OUTREACH` into a max-attempts stage and no more outbound calls are scheduled. This does not apply to web-call invite jobs: those candidates stay in `OUTREACH` while invites and reminders are in flight, move to `ALL_INVITES_SENT` once the invite/reminder budget is exhausted with no response, and from either state are picked up by the inactivity timeout (`EXPIRED`) sweep — unless they first complete the interview or reach another terminal stage. The emailed link stays live throughout, so a candidate in `ALL_INVITES_SENT` can still complete, decline, or self-schedule.

### Invalid contact and other dispatch gates

Beyond stage and consent, the dispatcher applies several operational gates at the moment a `PENDING` call comes due. These don't move the candidate to a "stopped" stage in advance — they only fire when dispatch tries to send.

* **Paused job.** Every dispatcher query filters out calls whose campaign status is `PAUSED`. While a job is paused, no candidate on that job is contacted, regardless of their scheduled time. Candidates in active outreach are moved to `OUTREACH_PAUSED` while the job is paused; when resumed, they re-enter `OUTREACH` only if cooldown allows it.
* **Missing or invalid phone.** Phoneless candidates with email + email-consent get a single outreach email + a consent-gated retry, cancelled with `NO_PHONE_EMAIL_FALLBACK` (analytics-distinct from the AI-comms-opt-out branch's `NO_AI_COMMUNICATIONS_CONSENT`, but both share the same retry-budget filter). The shared body lives in `_try_email_only_outreach`; the two outer branches differ on their precondition and their failure-mode resolution. If email isn't viable for a phoneless candidate (no email, no consent, or `get_no_answer_email_task` no-op'd), the email-only branch is skipped/falls through — the downstream no-phone gate in `make_initiate_call_request` then transitions the candidate to `INVALID_CONTACT`. **Web-call jobs (`Call.web_call=True`) are excluded** from this branch — `invite_to_web_call` handles them with a `webCallEmail` invite and doesn't need a phone.
* **SMS invalid-number callback.** When an SMS provider (Twilio or Telnyx) returns an invalid-number error code on send, the SMS failure policy transitions the candidate to `INVALID_CONTACT`. This is a single-strike rule — one invalid-number response from the carrier halts SMS for that candidate.
* **SMS error threshold.** Independent of invalid-number errors, repeated SMS failures against the same candidate-job pair (currently 10) disable further SMS attempts on that pair. This is a per-pair circuit breaker for transient provider issues.
* **From-number resolution.** A candidate's call must be placed from a phone number registered to the org. If no usable from-number is available at dispatch time (no registered numbers, all numbers exhausted), the call fails and is rescheduled via the failure-reschedule path described above.
* **ATS org-policy eligibility.** For ATS-synced orgs, the dispatcher re-runs the ATS outreach eligibility check just before sending. If the ATS now classifies the candidate as ineligible, the call is cancelled and the candidate transitions directly into a blocking stage. The `OutreachBlockCategory.DO_NOT_CONTACT` category now means "no consent on any channel" (set when `not has_any_consent`) and maps to `NO_CONSENT` on first ingestion of the application or `CONSENT_REVOKED` on a later re-check (the dispatcher's just-before-send check is always a re-check, so it reaches `CONSENT_REVOKED`); the `ORG_OUTREACH_POLICY` category (org-level reject/withdraw status, custom blocklists) maps to `SKIP_OUTREACH`. This is how org policy at the ATS reaches the dispatcher.
* **Concurrency cap.** The dispatcher processes one or two outbound calls at a time per phone number — two by default, one during peak hours (5–10 AM Pacific). On orgs with a large backlog, a `PENDING` call that comes due during a busy minute may slip a few seconds past `scheduled_datetime`. The limit can be raised for a specific customer when needed, but there is no self-serve setting — it is adjusted by engineering, not in the product.
* **Stale-call cleanup.** Long-pending calls on ATS-synced candidates are periodically checked against the ATS for fresh status. If the candidate has since been rejected or withdrawn in the ATS, the pending call is cancelled and the candidate moves to `DISPOSITIONED_IN_ATS`.

## Other Outreach Touches

The rest of this page is about the *prospecting* outbound path (call/SMS/companion email at the top of the funnel). A handful of other automated touches share the same gates but have their own schedulers, and a reader asking "why didn't this email go out?" needs to know they exist:

* **Rejection email.** Scheduled 24 hours after the candidate enters `RESUME_REJECTED`, then rounded forward to the next outreach-hours slot in the candidate's timezone. `enable_rejection_emails` defaults to **off** at the org level — most orgs don't send them unless they've opted in. Subject to email consent; if the candidate transitions back to `OUTREACH` before the email fires, the scheduled send is cancelled. Sent by `process_scheduled_rejection_emails`.
* **Resume feedback email.** Parallel flow to the rejection email but enabled by default. Same consent gate, same scheduler pattern (`process_scheduled_resume_feedback_emails`).
* **Scheduling reminders.** Once a candidate has agreed to schedule a call but hasn't picked a time, the system re-pings them on a configurable cadence (`process_scheduling_reminders`). These pings honor outreach hours and per-channel consent the same way prospecting touches do.
* **Meeting reminders.** Pre-interview SMS reminders (24h and 1h ahead of a scheduled call), sent by `process_scheduled_meeting_reminders`. Consent-gated; not subject to outreach hours because the meeting time itself is what's being honored.
* **Video interview reminder emails.** Email reminders before a self-scheduled web call, governed by `VideoInterviewReminderSettings` (defaults: `reminder_email_count=7`, `reminder_email_hours=12.0`). Distinct from the SMS meeting reminders above — these fire for video interviews only and use the email channel.

The cadence settings for the prospecting path (retry count/hours, cooldown days, outreach hours, initial outreach method) are defined at the org level and can be **overridden per job**. Job-level settings, when present, replace the org default for that job only — there's no merge.

## Inbound Call Routing

Outbound is only half the picture: candidates can dial the phone numbers Tenzo uses to reach them. The Twilio `/inbound_call` webhook routes incoming calls in this order:

1. **Invalid/anonymous caller ID** → reject.
2. **Permanent org phone number** (DB-mapped to a campaign) → candidate-intake flow (collect name/email at the top of the call).
3. **Legacy hardcoded mapping** (Dollar Tree).
4. **Resumable incomplete call** matched by from/to pair → resume the existing session via the State Snapshot Pattern.
5. **Known caller with prior call** → duplicate the prior call, start a new interview.
6. **No prior-call phone match → verification flow.** Creates a `VerificationCall` row and routes to a verification agent. Stage 1 identifies the caller, then it confirms their full name; on both validations the inbound call is linked to the candidate row and transitions into the interview. The stage-1 method is chosen by `resolve_verification_method(org_id)`: when `org_has_email_integration(org_id)` is true the caller reads back an 8-character code (`VerificationMethod.CODE`); when it returns false (SMS-only orgs with no email integration) the caller is asked which number they applied with / were texted on, which is matched against the candidate on file (`VerificationMethod.PHONE`). The method is resolved from the dialed number's org at routing time — LiveKit carries it in the room metadata, Retell via the dev override / websocket resolution — because the candidate (and campaign) isn't known yet.

The verification code is computed from `candidate_campaign_info.candidate_campaign_id[:8].upper()` and surfaced to candidates via the auto-appended footer block in every outreach email rendered by `send_virtual_interview_email` (the `email.footer.verification` i18n string). It only applies to the `CODE` method: SMS-only orgs never send an email and have no code, so they use the `PHONE` method instead. Either way, candidates with no usable phone-on-file can still convert by dialing in and verifying.

<Note>
  On every successful verification, the verified caller's number is written back to the candidate row (best-effort, only when the field is empty) so subsequent dispatch attempts pick up the candidate as phone-eligible. This happens on the shared `handle_transition_confirmation` path, so it applies to both Retell and LiveKit flows.
</Note>

## Consent Revocation

When a candidate opts out, Tenzo revokes consent only on the channel(s) they were reachable on rather than flipping a single global flag. The candidate's outreach then stops because there is **no usable channel left**, not because a separate suppression flag is set.

State involved:

* **Per-channel consent columns** on the candidate row — `sms_consent`, `email_consent`, `all_ai_communications_consent`. These are the sole outreach-suppression mechanism: `has_any_consent(candidate)` is True if at least one is set. When all relevant channels are revoked, `has_any_consent` is False and no job can reach out. (Per-channel consent persistence is now unconditional — the old `PER_CHANNEL_CONSENT_DB_SYNC` feature flag is removed.)
* `tenzo_stage = CONSENT_REVOKED` on each affected `CandidateCampaignInfo` — the per-job reflection of the opt-out. **Active + recoverable**: re-consent on a channel, a recruiter override, **or the candidate dialing in and completing a graded interview** re-engages outreach (see [Inbound re-engagement](#inbound-re-engagement-from-an-opt-out-stage) below).
* **Legacy:** the `Candidate.do_not_contact` / `dnc_reason` columns and the `DO_NOT_CONTACT` stage are retained (dark, unread) for history but get **no new entrants**; the column drop is deferred to a follow-up. `do_not_contact` survives only as a derived API field. The one writer that still *clears* it is re-engagement: reviving an opted-out candidate sets `do_not_contact = False`. Consent itself is no longer re-granted by the re-engagement path — it is owned by the per-channel inbound hard rules (inbound SMS → `sms_consent`, inbound call → `all_ai_communications_consent`), which fire on the same inbound event that drives the revive. A `DO_NOT_CONTACT` revive is therefore never left half-applied: the DNC clear and the channel's consent grant both land on that inbound.

All opt-out entry points route through `revoke_consent_and_transition` (in `consent/consent_revocation_service.py`), which persists the revoked consent columns, writes the revocation back to the ATS (best-effort, per channel), and transitions the candidate-campaign to `CONSENT_REVOKED`. Two revocation channels:

* `RevocationChannel.TEXT_AND_AI` — revokes `sms_consent` + `all_ai_communications_consent` (keeps email). Any SMS opt-out uses this; the old "SMS-only, keep AI" path is collapsed.
* `RevocationChannel.EMAIL` — revokes `email_consent` only (keeps SMS + AI).

`NOT_INTERESTED` vs `CONSENT_REVOKED`:

|                  | `NOT_INTERESTED`                                                                                                     | `CONSENT_REVOKED`                                                                                                                      |
| ---------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Scope            | This job only                                                                                                        | Every job that relied on the revoked channel(s)                                                                                        |
| Source           | Candidate declined this role                                                                                         | Candidate opted out of a channel (STOP / call / email / ATS sync)                                                                      |
| Channels revoked | None                                                                                                                 | Only the channel(s) the candidate objected to                                                                                          |
| Re-engageable    | Yes — recruiter can override, another job can re-contact, or the candidate calls in and completes a graded interview | Yes — re-consent on a channel, a recruiter override, or the candidate calling in and completing a graded interview re-engages outreach |

### How candidates end up in CONSENT\_REVOKED

1. **Inbound SMS, candidate asks to be removed.** During an SMS conversation, the LLM classifies the candidate's intent. An emphatic, global opt-out ("don't ever text me again", "remove me from your system", or clear anger at being contacted) is classified as `REQUESTED_NO_CONTACT`. Rather than revoking straight away, `transition_sms_state_action` first sends a confirmation message asking whether they'd still like to hear about future opportunities, and moves the SMS conversation state to the intermediate `AWAITING_DNC_CONFIRMATION` state. The final state depends on their reply:

   * A "no" to future contact calls `execute_confirm_global_dnc` with `RevocationChannel.TEXT_AND_AI` (source `SMS_CLASSIFIED_OPT_OUT`), revoking text + AI consent and transitioning to `CONSENT_REVOKED`. It also moves the SMS conversation state to `DO_NOT_CONTACT` so the texter stops and a later inbound reaches the re-engagement handler.
   * A "yes" calls `execute_confirm_campaign_opt_out`, which resolves to `NOT_INTERESTED` (this job only, consent intact).
   * An ambiguous or off-topic reply defaults to the opt-out, erring on the side of respecting their privacy.

   If the confirmation message itself fails to send, the state falls back to `NOT_INTERESTED`, so the candidate is not left stranded waiting on a question they never received. A message that reads as a **this-job-only** decline is classified `NOT_INTERESTED` from the start and never enters this confirmation flow.

2. **SMS `STOP` keyword.** A keyword-level STOP from the carrier (Twilio's built-in unsubscribe handling, or the explicit STOP-detection path) revokes `TEXT_AND_AI` consent with source `SMS_STOP`. This is independent of LLM classification.

3. **Email reply.** An email opt-out revokes `RevocationChannel.EMAIL` only (source `EMAIL_REPLY`), leaving SMS + AI intact.

4. **Call-side emphatic opt-out.** When a candidate asks not to be contacted during an AI phone screen, Tenzo revokes `TEXT_AND_AI` consent with source `CALL_CLASSIFIED_OPT_OUT` and transitions to `CONSENT_REVOKED`. Unlike the SMS emphatic path, there is **no** `AWAITING_DNC_CONFIRMATION` round-trip — the call path revokes consent immediately with no confirmation step.

5. **ATS sync / excluded status.** Excluded-status candidates do **not** revoke consent — they park in `SKIP_OUTREACH` per-application. Recovery to `OUTREACH` requires the blocking condition to clear **and** a passing full org outreach eligibility re-check (owner, placement, consent, and other policy rules), not just the ATS status change:
   * **JobDiva** — candidates with status "Unavailable Indefinitely" transition their active applications to `SKIP_OUTREACH` nightly, and recover via the `SKIP_OUTREACH → OUTREACH` transition when they become available again.
   * **Bullhorn** — excluded-status candidates likewise park in `SKIP_OUTREACH` (generalized to any Bullhorn org). When the excluded status clears on sync, Tenzo re-runs `candidate_outreach_eligibility()` and only recovers applications that pass; org policy blocks (for example owner or placement rules) leave the application in `SKIP_OUTREACH`. The Bullhorn-sync reconcile hook separately moves active apps to `CONSENT_REVOKED` when consent is revoked upstream.
   * **Outreach eligibility mapping** — `OutreachBlockCategory.DO_NOT_CONTACT` now means "no consent on any channel" (set when `not has_any_consent`) and maps to `NO_CONSENT` on first ingestion of the application or `CONSENT_REVOKED` on a later re-check (the `is_first_ingestion` flag threaded from the enrollment/ingestion call sites selects which); `OutreachBlockCategory.ORG_OUTREACH_POLICY` maps to `SKIP_OUTREACH`.

### Inbound re-engagement from an opt-out stage

A candidate in a terminal opt-out stage (`NOT_INTERESTED`, `CONSENT_REVOKED`, or `DO_NOT_CONTACT`) who **calls in** and completes a graded interview is automatically re-engaged — they don't need a recruiter override. This mirrors the SMS opt-back-in flow:

* A call the candidate places themselves (an inbound call, including the inbound verification flow that rolls straight into an interview) is evaluated on its own merits rather than inheriting the candidate's existing opt-out. A candidate who calls in and **declines again** is kept opted out.
* When the inbound call produces a real interview outcome (a completed interview, or one that fails a required knockout question), the candidate is re-engaged: the do-not-contact flag is cleared and the per-job stage moves back to `OUTREACH`. Consent is re-granted per-channel by the inbound hard rules — an inbound call grants `all_ai_communications_consent` (via `maybe_grant_ai_consent_on_inbound_call`); it does **not** touch `sms_consent`, so the candidate stays text-suppressed until they text in. Eligibility uses the same stage-transition rule as SMS re-engagement, which already permits all three opt-out stages.
* Re-engagement happens before the interview's own stage move (to `CALL_COMPLETED` or `FAILED_KNOCKOUT`), so that move starts from the fresh `OUTREACH` stage. Any follow-up call that re-engaging would otherwise queue is cleaned up by that same stage move, so no stray redial is left behind. Candidates who never call in remain fully suppressed.

<Note>
  Revoking consent does not retroactively rewrite every existing `CandidateCampaignInfo` to `CONSENT_REVOKED`. Other jobs reflect the revoked channel when their next dispatch re-runs eligibility (or, for ATS-driven flips, when add-to-campaign next evaluates the candidate). Outreach is suppressed everywhere immediately because every send gate reads the per-channel consent columns directly.
</Note>

## Related

* [Candidate Consent](/internal/candidate-consent) — full consent matrix and channel fallback rules
* [Candidate Stage Lifecycle](/candidate-stage-lifecycle/overview) — stage transitions and what each stage means
* [Cooldown across jobs](/candidate-stage-lifecycle/outreach#cooldown-across-jobs) — cross-job cooldown behavior, expiration
* [SMS Registration](/settings/sms-registration) — A2P 10DLC and SMS compliance
