Skip to main content

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. For the broader stage flow, see Candidate Stage Lifecycle.

When Outreach Starts

A candidate becomes eligible for outreach when their tenzo_stage transitions to OUTREACH. Two paths get them there:
  1. Resume passesAWAITING_RESUME_REVIEW → OUTREACH after the resume scorer accepts the candidate against the job’s requirements.
  2. Recruiter overrideRESUME_REJECTED → OUTREACH when a recruiter manually clicks Progress to Interview. Any scheduled resume-feedback email (or rejection email, if one was scheduled) is cancelled at the same time.
On entry to OUTREACH, a PENDING call row is created idempotently. If a queued or in-flight call already exists (PENDING, INVITED, INITIATED, CALLING, CONNECTED, or AWAIT_PROCESSING), Tenzo does not create a second row. An existing INVITED web call is adopted by the transition: its schedule is refreshed when needed, and a concurrent terminal-stage reconciliation cancellation is repaired before OUTREACH commits. The scheduled_datetime on that row is what the call dispatcher polls for.
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 below.

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 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 candidate.timezone is empty at outreach time, the scheduling code falls back in this order: phone area-code lookup → the org’s default candidate timezone (if configured) → Org.timezone. Blank default_candidate_timezone therefore inherits the organization timezone rather than a hard-coded US Eastern default. UTC is used only when the org row cannot be loaded or Org.timezone is corrupt. 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: 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. Production invite sending runs through poll_web_call_invitesdispatch_claimed_call(..., WEB_INVITE_FIRST): the same outreach eligibility evaluator used for phone dispatch (including ATS org-policy when ats_candidate_id is set). ATS check failures defer with bounded retry rather than sending. After eligibility and a web-specific SMS-or-email consent gate, dispatch calls invite_to_web_call, 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. A legacy short-circuit at the top of make_initiate_call_request still routes accidental web_call=True rows on the phone path into invite_to_web_call; the dedicated web poller no longer relies on that path. Enrollment transient ATS parks: If first enrollment cannot create an ATS client, the candidate is fail-closed into SKIP_OUTREACH with no scheduled retry. Application-backed jobs recover when ATS application sync clears the transient park. Candidate-only / manual jobs without application sync recover only when add_to_campaign runs again while the transient history marker is still latest (tearsheet redelivery, UI re-add, etc.). 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:
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 for the full matrix, but the short version:
  • call_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 (candidate-level or Workday application questionnaire) blocks SMS only. The system falls through to call and email when those channels still have consent.
  • Calling consent is call_consent.
  • 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. Exact carrier opt-in keywords (START / UNSTOP) bypass the classifier in either opt-in state. After Tenzo’s fixed clarification prompt, exact YES / NO replies also bypass it; they are not deterministic against the initial configurable prompt, which may use inverted wording. Other replies are classified 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 + call 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.
Infrastructure or response-validation failures from the ordinary decision classifier do not count as unclear replies: Tenzo leaves the conversation state and consent unchanged so a model failure cannot advance the clarification flow or revoke consent. A scope-only classifier failure after a deterministic carrier opt-out still honors the opt-out and defaults its scope to SMS only. The opt-in prompt, clarification, and postOptInSms are sent with allow_pre_consent_send=True. The flag does not bypass the sms_consent gate: the sender still checks consent, allows the send only for the opt-in message types (PRE_CONSENT_MESSAGE_TYPES, anything else raises), and logs pre_consent_send_redundant when the candidate already had consent. It also 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, or declined), a new PENDING call is scheduled. The behavior comes from the job’s Call Retry Settings: 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. 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 (the call provider 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 — except candidates who already have a pending CANDIDATE_RESCHEDULED phone callback, who stay in OUTREACH so the booked screen can still dial. On resume, Tenzo first checks interview-result reuse (if enabled and a matching interview was completed while paused, the candidate exits directly to CALL_COMPLETED or FAILED_KNOCKOUT, and unresolved work-accommodation review items then overlay PENDING_ACCOMMODATION_REVIEW on top of CALL_COMPLETED), then 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: 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 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 below.
  • 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 closes the call that surfaced the problem (cancelled with an “Invalid contact information” or “No email address” reason when nothing was ever dialed, INVALID_CONTACT when a provider refused a dial we placed) and then cancels any other queued legs for the same candidate–job pair post-commit, leaving live legs alone. Details under Invalid contact and other dispatch gates below.
  • call_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 call_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.

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. Routine dispatcher queries filter out calls whose campaign status is PAUSED, so first-touch dials, voicemail retries, and other non-promised outreach do not fire. Exception: a candidate with a pending CANDIDATE_RESCHEDULED phone callback is left in OUTREACH (the call stays PENDING) — OUTREACH_PAUSED is in STAGES_BLOCKING_OUTBOUND_CALLS and would cancel the promised dial. pause_outreach_calls_action also leaves those rows PENDING (exclude_initiated_by) as defense in depth. They are claimed by get_next_rescheduled_phone_call_set_initiated, which omits the paused-job filter. Booked video interviews are a separate gate: allows_paused_video_interviews must be on or initiation is rejected. Other outreach candidates who have not booked a time move to OUTREACH_PAUSED while the job is paused; when resumed, interview-result reuse can complete the application immediately (CALL_COMPLETED / FAILED_KNOCKOUT, then overlaying PENDING_ACCOMMODATION_REVIEW if review items remain), otherwise 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 call-opt-out branch’s NO_CALL_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 failures that say something about the destination. Provider-side failures are excluded: a rate-limit or 5xx answer from Twilio or Telnyx, and a dropped connection or lost response on the Telnyx send, do not count toward the threshold, so a provider outage cannot disable SMS for a candidate whose number is fine. The WhatsApp template paths apply the same transient exemption, but still count an invalid-number result, since nothing else on those paths acts on one.
  • From-number resolution. First outreach interviews are created without a company caller ID. When the interview is due, a pre-dispatch assignment step picks a live org number (skipping a job-tied line that has since been released) and stamps it onto the row. Phone dialing and web invites both wait for that stamp. If no healthy number is available, the interview stays pending and unassigned and is retried on the next assignment cycle — it does not fail or consume a retry. Once a number is stamped, later dispatch still requires a usable org line; if that later check fails, the call follows the failure-reschedule path described above.
  • International calling restriction. Before PSTN/SIP dial, candidate outbound calls compare the calling codes of the selected from_phone_number and destination to_phone_number. Same calling code is allowed. US/Canada (+1) ↔ Mexico (+52) is also allowed. Other mismatches are blocked by default and the call is cancelled with CancellationReason.INTERNATIONAL_CALL_RESTRICTED without changing the candidate’s stage. Unparseable or invalid phone numbers fail closed (blocked), even when the support override is on. Support can enable international_calling_settings.allow_all_country_codes (org default or per-job override) via the Support Only settings surface. WhatsApp call invites are exempt (they are not PSTN dials). Email is unaffected — including first-touch email that may still send when a PSTN fallback dial is cancelled for country restriction.
  • Org international policy (YAML). Engineering can opt specific orgs into server/international_policy/org_international_policy.yaml. When present: (1) from-number selection prefers a pool number matching the candidate’s calling code, else a number in the org’s primary_country; (2) the dial gate additionally allows a dial when the selected from-number’s calling code matches that primary country (ORG_PRIMARY_COUNTRY); (3) when allow_international_sms is false, cross-country PSTN SMS is blocked before the provider call (blocked_by_international_sms_policy) and first-touch outreach falls through to call + email (same permanent-block path as the per-candidate SMS error limit). Orgs absent from the YAML keep prior behavior (no SMS country gate; dials still restricted by the default country policy 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. Innova Bullhorn: that same pre-call Candidate GET already requests configured consent fields; Tenzo reconciles them onto the local candidate (same change-detection rules as ATS sync) before dialing so an opt-out that landed in Bullhorn after the last sync still blocks contact.
  • 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 when a candidate is thumbs-downed or transitions to DISPOSITIONED_IN_ATS (not RESUME_REJECTED — that sends the resume feedback email), after a configurable delay (rejection_email_delay_hours, default 48h, range 0–336h), 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; the scheduled send is cancelled if the candidate transitions back to OUTREACH, and a send-time state guard skips it if the candidate is no longer thumbs-down / dispositioned when the delay elapses. Sent by process_scheduled_rejection_emails. A thumbs-down only schedules the email when Tenzo actually rejects the candidate in the ATS (linked ATS application + configured thumbs-down stage/disposition, or a successful ATS reject/stage-move). Returning to OUTREACH keeps the schedule if the candidate is still thumbs-down; undoing thumbs-down also cancels a pending send.
  • Resume feedback email. Parallel flow to the rejection email but enabled by default. Same consent gate, same scheduler pattern (process_scheduled_resume_feedback_emails). Gated by enable_resume_feedback_emails. Uses the same rejection_email_delay_hours delay. Send-time guard requires the candidate still be RESUME_REJECTED.
  • 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. Inbound calls arrive on LiveKit SIP trunks and are routed in this order (the legacy Twilio /inbound_call webhook now rejects any number not yet migrated to a trunk):
  1. Invalid/anonymous caller ID → reject.
  2. Permanent org phone number (DB-mapped to a campaign) → candidate-intake flow (collect name, and optionally 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. After both validations, an incomplete interview normally continues in the same room; an already-completed interview gets a farewell, while unavailable continuity gets a safe retry-later notice, and both terminal routes close the room without starting screening. 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 — 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.
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.
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, call_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 below).
  • Legacy: the DO_NOT_CONTACT stage is retained as a read-only member (no writers, no stage actions) only because live rows still hold it. Those rows are functionally closed (outbound blocked; exits to Outreach, Dispositioned and Wrong Number still allowed) and drain on their own; the member is dropped once the count reaches zero. The Candidate.do_not_contact / dnc_reason columns no longer exist: “no consent on any channel” is derived from the consent columns wherever it is needed. Re-engagement never touches consent: consent returns through the channel the candidate used (inbound SMS → sms_consent, inbound email → email_consent, inbound call → call_consent), and that grant is awaited on the same inbound event before the revive runs, so a revive is never left half-applied.
All opt-out entry points call ConsentService.apply (in consent/consent_service.py) with a ConsentTrigger; the trigger’s row in CONSENT_POLICY (consent/consent_policy.py) names the channels revoked and the ATS fields written back, and a closing row transitions the candidate-campaign to CONSENT_REVOKED. Two revocation channel groups, recorded in the stage-history reason as RevocationChannel:
  • RevocationChannel.TEXT_AND_CALL — revokes sms_consent + call_consent (keeps email). Every SMS- and call-arriving opt-out trigger uses this; the old “SMS-only, keep AI” path is collapsed.
  • RevocationChannel.EMAIL — revokes email_consent only (keeps SMS + call consent). Used by the EMAIL_REPLY trigger.
NOT_INTERESTED vs 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_revoke_channel_consent with ConsentTrigger.SMS_CLASSIFIED_OPT_OUT, which runs ConsentService.apply: revokes text + call consent and transitions to CONSENT_REVOKED. The stage transition’s post-commit action suspends the SMS conversation (SUSPENDED) so the texter stops and a later inbound reaches the re-engagement lane.
    • 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 (a carrier-level block included), nothing about the outcome changes: the application stays at NOT_INTERESTED, the failure is logged, and the conversation is suspended so the candidate is not left waiting on a question they never received. A delivery failure is never read as a consent revocation. 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_CALL 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 + call consent intact. A later inbound email from the same candidate re-grants email_consent (source inbound_email) before the reply is routed, and the reply is run through the re-engagement classifier: only a clear ask to be contacted again revives the application (see Inbound re-engagement below).
  4. Call-side emphatic opt-out. When a candidate asks not to be contacted during an AI phone screen, Tenzo revokes TEXT_AND_CALL consent and transitions to CONSENT_REVOKED. Two paths reach this, distinguished by the revocation source: the live agent recognizes the request mid-call, ends the call with a no-contact confirmation (scoped to calls and texts — email consent is untouched), and the post-call processor revokes with source CALL_IN_CALL_OPT_OUT; if the live agent missed it, the post-call transcript detector revokes with source CALL_CLASSIFIED_OPT_OUT. 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 — but only when the latest stage history shows the park came from jobdiva_unavailable (same history-trigger gate as Bullhorn excluded-status recovery).
    • 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 and whose latest stage history is a SKIP_OUTREACH park with trigger ats_excluded_status. Parks from other triggers (for example pre_call_validation when the application is not in a valid calling stage) stay in SKIP_OUTREACH so ATS sync does not undo them and create a recovery loop. Org policy blocks (for example owner or placement rules) also leave the application in SKIP_OUTREACH. The Bullhorn-sync reconcile hook separately moves apps to CONSENT_REVOKED when consent is revoked upstream — both active applications and applications parked in SKIP_OUTREACH from ats_excluded_status (so a later excluded-status recovery cannot un-park into OUTREACH past the consent revocation).
    • Outreach eligibility mappingOutreachBlockCategory.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 SMS routing

Every inbound text on a resolved application is routed by one pure decision (texter/inbound_routing_policy.py) over a snapshot of what Tenzo already knows. The rules run in order and the first match wins:
  1. A dev command (non-production CLEAR HISTORY).
  2. A live intake or interview call owns the SMS (answered before the application is resolved).
  3. The conversation is mid opt-in confirmation: the opt-in classifier owns the reply, a literal STOP included.
  4. A carrier STOP keyword: ConsentService.apply(SMS_STOP), no call required.
  5. No SMS consent: the message is dropped. This is the only rule that refuses to reply for lack of consent; the inbound consent grant is awaited before the decision, so it only fires when that grant did not apply.
  6. The candidate is dispositioned in the ATS, no other lane is mid-question with them, and the number reaches no other open application: the canned “contact the recruiting team” notice.
  7. The application is closed to the AI (NOT_INTERESTED, CONSENT_REVOKED, legacy DO_NOT_CONTACT, or HUMAN_REVIEW_REQUESTED) and no decline question is pending: the re-engagement classifier. A clear “I want back in” revives the application to OUTREACH; anything else is dropped, or on a human-owned thread left to the recruiter.
  8. The job is paused: the paused notice, or the follow-up / post-interview Q&A lanes for candidates who already have a booked interview or a graded one.
  9. A video-interview link is out: the web-call Q&A lane (finished web calls fall through to the post-call lanes).
  10. The not-interested flow asked a question (NOT_INTERESTED_AWAITING_REASON, AWAITING_DNC_CONFIRMATION): its handler reads the answer.
  11. The post-interview feedback survey is open: the feedback handler.
  12. Everything else is a normal dialog turn, routed pre/post-call on the durable conversation state.
Three facts, three owners: consent (sms_consent, written only by ConsentService) decides whether we may reply; the stage (tenzo_stage, written only by the stage orchestrator) decides what we say; the conversation state (sms_conversation_state, written only through the SMS rules table) picks the dialog lane a normal reply lands in. SUSPENDED is the single suspension marker and is a projection of the stage: every stage that closes the conversation to the AI writes it (whichever channel the opt-out arrived on, so a web-form or email opt-out suspends the text conversation the same as a STOP), OUTREACH and OUTREACH_PAUSED clear it, and nothing routes on it. Why a conversation is suspended is answered by the stage. Email replies apply the same consent-then-stage test before picking a lane, and share the stage half of it (stage_gate, in the same module) with the SMS router. Email awaits its own inbound email_consent grant first (skipped while the not-interested flow has a question pending, since that reply may be the “don’t contact me” answer), then checks its own channel consent. An application closed to the AI (NOT_INTERESTED, CONSENT_REVOKED, DO_NOT_CONTACT, or the HUMAN_REVIEW_REQUESTED hold) with no decline question pending goes to the same channel-neutral re-engagement classifier and handler as SMS rules 7 and 10; an open decline question goes to the not-interested flow. A SUSPENDED conversation row on an open stage is an invariant breach: the email is dropped and the row is left as-is (the SMS router repairs it; email does not).

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 a verified caller who is eligible to continue 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 per-job stage moves back to OUTREACH. Consent is re-granted per-channel by the inbound hard rules — an inbound call grants call_consent (via maybe_grant_call_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.
  • A candidate who resubmits the web interview form without the AI opt-out box is re-engaged the same way, regardless of which channel opted them out (web form, SMS, phone, ATS sync, or admin): call consent is re-granted where a channel opt-out (SMS, phone, ATS sync, admin) had removed it — HUMAN_REVIEW_REQUESTED itself never changes consent, so for a web-form opt-out the grant is a no-op — and the per-job stage moves back to OUTREACH from any candidate-closed stage (NOT_INTERESTED, CONSENT_REVOKED, DO_NOT_CONTACT, or the HUMAN_REVIEW_REQUESTED hold). Recruiter and system decisions (resume or fraud rejection, an ATS park, a cancelled application) are never overridden by a form submit. The one hold is an SMS opt-out: if the candidate’s SMS consent is revoked and this or any other application was closed by a consent revocation, the form does not revive it, because reviving would restart texting. An ATS do-not-contact that is reversed this way is re-applied by the next ATS sync if the ATS still says so.
  • A candidate who texts in from NOT_INTERESTED, CONSENT_REVOKED, DO_NOT_CONTACT or the HUMAN_REVIEW_REQUESTED hold is run through the re-engagement classifier (rule 7 above). A clear “I want back in” revives the application to OUTREACH (the inbound already granted sms_consent), which restarts the text conversation from INITIAL_OUTREACH; on a human-owned thread anything else is left to the recruiter.
  • A candidate who emails in from the same closed stages (NOT_INTERESTED, CONSENT_REVOKED, DO_NOT_CONTACT or the HUMAN_REVIEW_REQUESTED hold) with no decline question pending is run through the same channel-neutral re-engagement classifier and handler, judged on the latest email only rather than the whole thread. A clear “I want back in” revives the application to OUTREACH (the inbound already granted email_consent) and sends a confirmation email (“Welcome back! We’ll start reaching out about this job again.”, with no STOP clause); anything else is dropped, or on a human-owned thread left to the recruiter. Two consequences worth knowing: an opted-out candidate who emails “please stop” gets email_consent restored, but the classifier defaults to ignore, the stage stays closed, and every outbound email is gated by consent plus stage, so nothing is sent; and an email-driven revive restarts the SMS conversation projection at INITIAL_OUTREACH even while sms_consent is false — an accepted side effect, since SMS sends stay blocked by the consent gate.
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.