ATS Stage Routing
This document is an internal engineering reference. It explains every pipeline through which a candidate’s ATS application can be moved, how routing rules are evaluated, how legacy flat config fields interact with the routing system, and known failure modes.Overview
Every ATS stage movement is triggered by a RoutingEvent. Each event maps to one or more config fields (flat legacy fields) that name the target stage. When theATS_STAGE_ROUTING_RULES feature
flag is enabled, those fields can be supplemented — or replaced — by a rich per-event rule set
stored in stage_routing_rules.
All Routing Events and Their Pipelines
| Event | Trigger | Handler (BaseAtsClient) | Flat config fields |
|---|---|---|---|
RESUME_REVIEW_PASSED | Resume screening pass | handle_resume_stage_movement(meets_requirements=True) | resume_review_pass_stage, resume_review_pass_workflow_step_id |
RESUME_REVIEW_FAILED | Resume screening fail | handle_resume_stage_movement(meets_requirements=False) | resume_review_fail_stage, resume_review_fail_workflow_step_id |
AI_INTERVIEW_PASSED | Post-call pass | handle_ai_interview_pass_stage_movement(completion_rate=...) | ai_interview_pass_stage, ai_interview_pass_workflow_step_id |
AI_INTERVIEW_FAILED | Post-call fail / knockout | handle_ai_interview_fail_stage_movement(completion_rate=...) | ai_interview_fail_stage, ai_interview_fail_workflow_step_id |
HUMAN_REVIEW | Candidate opts out of AI | handle_human_review_stage_movement | human_review_stage, human_review_workflow_step_id |
ACCOMMODATION_REQUEST | Candidate requests accommodation | handle_accommodation_stage_movement | accommodation_request_stage, accommodation_request_workflow_step_id |
PENDING_ACCOMMODATION_REVIEW | Interview held for work-accommodation review | handle_pending_accommodation_review_stage_movement | pending_accommodation_review_stage, pending_accommodation_review_workflow_step_id |
COOLING_DOWN | Candidate is in cooling-down state | handle_cooling_down_stage_movement | cooling_down_stage, cooling_down_workflow_step_id |
OUTREACH | Tenzo begins outreach to candidate | handle_outreach_stage_movement | outreach_stage, outreach_workflow_step_id |
OUTREACH_PAUSED | Tenzo pauses outreach | handle_outreach_paused_stage_movement | outreach_paused_stage, outreach_paused_workflow_step_id |
CALL_LIMIT_NO_INTERVIEW | Max attempts exhausted, no interview started | handle_call_limit_exhausted_no_interview_stage_movement | call_limit_no_interview_stage, call_limit_no_interview_workflow_step_id |
CALL_LIMIT_EXHAUSTED | Max attempts exhausted, interview was started but incomplete | handle_call_limit_exhausted_not_completed_stage_movement | call_limit_not_completed_stage, call_limit_not_completed_workflow_step_id |
CALL_LIMIT_REACHED | Legacy single call-limit event (deprecated) | handle_call_limit_reached_stage_movement | call_limit_reached_stage, call_limit_reached_workflow_step_id |
INVALID_CONTACT | Contact info invalid or missing | handle_invalid_contact_stage_movement | invalid_contact_stage, invalid_contact_workflow_step_id |
WRONG_NUMBER | Person at the phone number says it’s the wrong number | handle_wrong_number_stage_movement | wrong_number_stage, wrong_number_workflow_step_id |
DO_NOT_CONTACT | Candidate is marked do-not-contact | handle_do_not_contact_stage_movement | do_not_contact_stage, do_not_contact_workflow_step_id |
NO_CONSENT | No consent on file | handle_no_consent_stage_movement | no_consent_stage, no_consent_workflow_step_id |
CONSENT_REVOKED | Candidate revoked consent on the channel(s) they were reachable on | handle_consent_revoked_stage_movement | consent_revoked_stage, consent_revoked_workflow_step_id |
NOT_INTERESTED | Candidate declines | handle_not_interested_application | not_interested_stage, not_interested_disposition_reason_id |
INACTIVITY_TIMEOUT | EXPIRED / inactivity sweep | handle_inactivity_timeout_application | auto_reject_timeout_stage, disposition_reason_id |
THUMBS_DOWN | Recruiter thumbs-down | handle_thumbs_down_application | thumbs_down_stage, thumbs_down_disposition_reason_id |
application_stage/actions/ats_stage_actions.py)
→ ats_stage_handler → BaseAtsClient handlers above.
Resume review is handled by a separate method (handle_resume_stage_movement) that has additional preconditions (application must be in start stage) and does not go through_handle_stage_movement_for_target. It is not currently routed via routing rules.
How Routing Is Evaluated
Stage-move path — _handle_stage_movement_for_target
Used by: AI_INTERVIEW_PASSED, AI_INTERVIEW_FAILED, HUMAN_REVIEW, ACCOMMODATION_REQUEST,
PENDING_ACCOMMODATION_REVIEW, COOLING_DOWN, OUTREACH, OUTREACH_PAUSED, CALL_LIMIT_NO_INTERVIEW,
CALL_LIMIT_EXHAUSTED, CALL_LIMIT_REACHED, INVALID_CONTACT, WRONG_NUMBER, DO_NOT_CONTACT,
NO_CONSENT, CONSENT_REVOKED.
routing_event is set, regardless of whether
completion_rate is None. For AI interview above/below threshold routing, completion_rate enables
completion-percentage conditional rules (e.g. split above-threshold targets by completion threshold).
Disposition path — _apply_disposition_routing_outcome
Used by: NOT_INTERESTED, INACTIVITY_TIMEOUT, THUMBS_DOWN.
_apply_disposition_routing_outcome
returns False so the legacy path still runs (same as stage moves).
_move_application_for_routing_match Decision Tree
routing_rules_enabled=True and a match exists, the method always
returns True, which suppresses the flat-field path even if the actual move didn’t happen (e.g.
misconfigured stage, missing app/job).
Routing Rule Resolution (resolve_routing_match)
Default Rule Synthesis from Flat Config
When nostage_routing_rules value is persisted, the system synthesises implicit default rules
from the flat config fields via synthesize_stage_routing_rules_from_legacy.
Each flat stage field becomes a single-node rule set (one default rule, no conditions):
_move_application_for_routing_match layer via the flat_workflow_step parameter.
Special Case: CALL_LIMIT_EXHAUSTED
The CALL_LIMIT_EXHAUSTED event is synthesised from two flat fields:
| Flat field | Role |
|---|---|
call_limit_no_interview_stage | Default rule target; also used when completion=0 |
call_limit_not_completed_stage | Conditional rule target (completion > 0%) |
- completion_rate=None → no conditional fires → default rule →
no_interview_stage - completion_rate=0.0 → percentage=0 → condition
> 0is false → default →no_interview_stage - completion_rate=0.5 → percentage=50 → condition
> 0is true →not_completed_stage
Completion Rate Flow for Call-Limit Events
handle_call_limit_exhausted_no_interview_stage_movement normalises None → 0.0:
handle_call_limit_exhausted_not_completed_stage_movement passes completion_rate as-is
(can be None). When None, no conditional routing rule fires (conditions require a number),
so the default rule is always used.
This normalisation also happens at the action layer in ats_stage_actions.py
(move_to_ats_call_limit_exhausted_no_interview_stage_action).
Terminal / Fallback Behaviour
Candidates in the outreach pipeline (OUTREACH, OUTREACH_PAUSED, CALL_LIMIT_*) should eventually arrive at a non-outreach ATS stage. The system provides these endpoints:- OUTREACH: Stage set when Tenzo begins contact. If unconfigured, no movement.
- OUTREACH_PAUSED: Set when outreach is temporarily paused.
- CALL_LIMIT_NO_INTERVIEW / CALL_LIMIT_EXHAUSTED: Terminal states after max attempts.
- CALL_LIMIT_REACHED (legacy): Used by orgs that haven’t migrated to the split events.
The legacycall_limit_reached_*fields do not serve as a fallback for the new split events. An org migrating to split call-limit stages must configurecall_limit_no_interview_*and/orcall_limit_not_completed_*explicitly.
Known Failure Modes and Risks
1. Misconfigured Routing Target → Candidate Stranded
If routing is enabled and a rule resolves to a stage name that doesn’t exist in the ATS job (or resolves to an empty string),has_configured_transition_target returns False. The code
returns True (handled) and logs a warning, but no movement occurs and no flat fallback
is attempted. This can leave candidates stuck at their current stage without any error surfaced
to the recruiter.
Mitigation: The admin routing UI validates rules on save. Monitor for the warning log:
"No ATS routing target configured for org".
2. No Rule Set for Event → Falls Through to Flat Field
When routing is enabled but the org has no rule set for the fired event (i.e.get_rule_set
returns None), resolve_routing_match returns None and
_move_application_for_routing_match returns False, causing the flat-field path to be used.
This is intentional for OUTREACH and similar events that weren’t historically configured, but
could be surprising if admins expect routing to apply to a newly enabled event.
3. Legacy CALL_LIMIT_REACHED vs Split Events
Orgs using the legacy call_limit_reached_* config receive RoutingEvent.CALL_LIMIT_REACHED.
Orgs using the split flow receive CALL_LIMIT_NO_INTERVIEW or CALL_LIMIT_EXHAUSTED. These are
distinct events with distinct rule sets — there is no implicit fallback from split → legacy.
4. Missing ATS Application or Job After Routing Resolves
If the ATS application or job cannot be fetched after a routing match resolves,_move_application_for_routing_match logs an error and returns True (handled), suppressing
the flat-field path. No movement occurs. This matches the flat-field path behaviour (which also
returns early on missing app/job) so the candidate is in the same state either way.
Code Locations
| Concern | File |
|---|---|
| Routing event enum and rule models | server/ats/routing/models.py |
| Rule evaluator (pure functions) | server/ats/routing/rule_evaluator.py |
| Legacy synthesis | server/ats/routing/legacy_synthesis.py |
| Resolver (entry point) | server/ats/routing/resolver.py |
| Client movement methods | server/ats/base_ats_client.py |
| Action wrappers (stage machine) | server/application_stage/actions/ats_stage_actions.py |
| ATS stage handler (adapter) | server/ats/ats_stage_handler.py |
| Feature flag | ATS_STAGE_ROUTING_RULES in server/feature_flags/feature_flag_enum.py |
Tests
| Test file | What it covers |
|---|---|
tests/unit/ats/routing/test_rule_evaluator.py | Condition evaluation, operator logic, completion_rate conversion |
tests/unit/ats/routing/test_legacy_synthesis.py | Synthesis from flat config, CALL_LIMIT_EXHAUSTED conditional rule |
tests/unit/ats/routing/test_resolver.py | End-to-end resolver, persisted vs synthesised rules, all routing events |
tests/unit/ats/base_ats/test_base_ats_stage_movement.py | Client-level routing gate, misconfigured target, missing app/job, workflow-step priority, completion_rate handling |
tests/unit/application_stage/test_stage_actions_ats.py | Action-layer wrappers (call-limit split, outreach, cooling down, etc.) |