Skip to main content

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 the ATS_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

EventTriggerHandler (BaseAtsClient)Flat config fields
RESUME_REVIEW_PASSEDResume screening passhandle_resume_stage_movement(meets_requirements=True)resume_review_pass_stage, resume_review_pass_workflow_step_id
RESUME_REVIEW_FAILEDResume screening failhandle_resume_stage_movement(meets_requirements=False)resume_review_fail_stage, resume_review_fail_workflow_step_id
AI_INTERVIEW_PASSEDPost-call passhandle_ai_interview_pass_stage_movement(completion_rate=...)ai_interview_pass_stage, ai_interview_pass_workflow_step_id
AI_INTERVIEW_FAILEDPost-call fail / knockouthandle_ai_interview_fail_stage_movement(completion_rate=...)ai_interview_fail_stage, ai_interview_fail_workflow_step_id
HUMAN_REVIEWCandidate opts out of AIhandle_human_review_stage_movementhuman_review_stage, human_review_workflow_step_id
ACCOMMODATION_REQUESTCandidate requests accommodationhandle_accommodation_stage_movementaccommodation_request_stage, accommodation_request_workflow_step_id
PENDING_ACCOMMODATION_REVIEWInterview held for work-accommodation reviewhandle_pending_accommodation_review_stage_movementpending_accommodation_review_stage, pending_accommodation_review_workflow_step_id
COOLING_DOWNCandidate is in cooling-down statehandle_cooling_down_stage_movementcooling_down_stage, cooling_down_workflow_step_id
OUTREACHTenzo begins outreach to candidatehandle_outreach_stage_movementoutreach_stage, outreach_workflow_step_id
OUTREACH_PAUSEDTenzo pauses outreachhandle_outreach_paused_stage_movementoutreach_paused_stage, outreach_paused_workflow_step_id
CALL_LIMIT_NO_INTERVIEWMax attempts exhausted, no interview startedhandle_call_limit_exhausted_no_interview_stage_movementcall_limit_no_interview_stage, call_limit_no_interview_workflow_step_id
CALL_LIMIT_EXHAUSTEDMax attempts exhausted, interview was started but incompletehandle_call_limit_exhausted_not_completed_stage_movementcall_limit_not_completed_stage, call_limit_not_completed_workflow_step_id
CALL_LIMIT_REACHEDLegacy single call-limit event (deprecated)handle_call_limit_reached_stage_movementcall_limit_reached_stage, call_limit_reached_workflow_step_id
INVALID_CONTACTContact info invalid or missinghandle_invalid_contact_stage_movementinvalid_contact_stage, invalid_contact_workflow_step_id
WRONG_NUMBERPerson at the phone number says it’s the wrong numberhandle_wrong_number_stage_movementwrong_number_stage, wrong_number_workflow_step_id
DO_NOT_CONTACTCandidate is marked do-not-contacthandle_do_not_contact_stage_movementdo_not_contact_stage, do_not_contact_workflow_step_id
NO_CONSENTNo consent on filehandle_no_consent_stage_movementno_consent_stage, no_consent_workflow_step_id
CONSENT_REVOKEDCandidate revoked consent on the channel(s) they were reachable onhandle_consent_revoked_stage_movementconsent_revoked_stage, consent_revoked_workflow_step_id
NOT_INTERESTEDCandidate declineshandle_not_interested_applicationnot_interested_stage, not_interested_disposition_reason_id
INACTIVITY_TIMEOUTEXPIRED / inactivity sweephandle_inactivity_timeout_applicationauto_reject_timeout_stage, disposition_reason_id
THUMBS_DOWNRecruiter thumbs-downhandle_thumbs_down_applicationthumbs_down_stage, thumbs_down_disposition_reason_id
Stage actions are wired through the stage orchestrator (application_stage/actions/ats_stage_actions.py) → ats_stage_handlerBaseAtsClient 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.
_handle_stage_movement_for_target(ats_application_id, flat_stage, flat_wf_step, label,
                                   routing_event=..., completion_rate=...)

    ├─ if routing_event is not None:
    │     routed = await _move_application_for_routing_match(...)
    │     if routed: return   ← flat-field path is suppressed

    └─ flat-field path: load app/job, move to flat_stage / flat_wf_step
Routing is always attempted when 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.
handle_*_application(application_id, completion_rate=...)

    ├─ if await _apply_disposition_routing_outcome(...):
    │     return   ← legacy flat-field path skipped

    └─ legacy disposition or stage move from flat config
When routing is enabled but no rule set exists for the event, _apply_disposition_routing_outcome returns False so the legacy path still runs (same as stage moves).

_move_application_for_routing_match Decision Tree

routing_enabled = await _is_ats_stage_routing_rules_enabled()
match = resolve_routing_match(org_config, event, completion_rate, routing_enabled)

if not routing_enabled or match is None:
    return False   ← caller falls through to flat-field path

# routing_enabled=True and match is resolved
target_stage = match.action.stage
target_step  = flat_workflow_step (if non-empty) else match.action.workflow_step_id

if not has_configured_transition_target(target_stage, target_step):
    log WARNING "No ATS routing target configured"
    return True   ← flat-field path is SUPPRESSED (candidate NOT moved)

app = await get_application_by_application_id(ats_application_id)
if not app:
    log ERROR
    return True   ← flat-field path suppressed

job = await get_job_by_job_id(app.job_id)
if not job:
    log ERROR
    return True   ← flat-field path suppressed

await move_application_for_org_transition_config(app, job, target_stage, target_step)
return True       ← flat-field path suppressed
Key invariant: once 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)

resolve_routing_match(accessor, event, completion_rate, routing_rules_enabled)

    ├─ routing_rules_enabled=False → return None

    ├─ get_effective_stage_routing_rules(accessor)
    │     ├─ synthesize defaults from legacy flat fields (stage + disposition)
    │     ├─ accessor.stage_routing_rules is set and non-empty → merge persisted rule sets
    │     │     (persisted events win; synthesized fills gaps for other events)
    │     └─ otherwise → use synthesized rules only

    ├─ rules_value.get_rule_set(event)  → None if event has no rule set
    │     └─ None → return None (caller: _move_application_for_routing_match returns False)

    └─ evaluate_rule_set(rule_set, context)
          ├─ sort rules by priority (ascending)
          ├─ for each rule: evaluate ALL conditions (AND logic)
          │     condition: metric=completion_percentage, op in {gte,gt,lte,lt,eq}, value
          │     if completion_percentage is None → condition NEVER matches

          ├─ first matching rule → RoutingMatchResult(used_default=False, ...)
          └─ no rule matched → default_rule → RoutingMatchResult(used_default=True, ...)

Default Rule Synthesis from Flat Config

When no stage_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):
outreach_stage = "Outreach In Progress"
→ EventRoutingRuleSet(
      event=OUTREACH,
      default_rule=RoutingDefaultRule(action=RoutingAction(stage="Outreach In Progress")),
      rules=[]
  )
Workflow-step fields are excluded from synthesis — they remain flat-config-only and are applied at the _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 fieldRole
call_limit_no_interview_stageDefault rule target; also used when completion=0
call_limit_not_completed_stageConditional rule target (completion > 0%)
Synthesis result:
CALL_LIMIT_EXHAUSTED:
  default_rule.action.stage = call_limit_no_interview_stage  (or not_completed if former is None)
  rules = [
    RoutingRule(conditions=[completion_percentage > 0], action.stage = call_limit_not_completed_stage)
  ]
This means:
  • completion_rate=None → no conditional fires → default rule → no_interview_stage
  • completion_rate=0.0 → percentage=0 → condition > 0 is false → default → no_interview_stage
  • completion_rate=0.5 → percentage=50 → condition > 0 is true → not_completed_stage

Completion Rate Flow for Call-Limit Events

handle_call_limit_exhausted_no_interview_stage_movement normalises None0.0:
completion_rate=completion_rate if completion_rate is not None else 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:
  1. OUTREACH: Stage set when Tenzo begins contact. If unconfigured, no movement.
  2. OUTREACH_PAUSED: Set when outreach is temporarily paused.
  3. CALL_LIMIT_NO_INTERVIEW / CALL_LIMIT_EXHAUSTED: Terminal states after max attempts.
  4. CALL_LIMIT_REACHED (legacy): Used by orgs that haven’t migrated to the split events.
The legacy call_limit_reached_* fields do not serve as a fallback for the new split events. An org migrating to split call-limit stages must configure call_limit_no_interview_* and/or call_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

ConcernFile
Routing event enum and rule modelsserver/ats/routing/models.py
Rule evaluator (pure functions)server/ats/routing/rule_evaluator.py
Legacy synthesisserver/ats/routing/legacy_synthesis.py
Resolver (entry point)server/ats/routing/resolver.py
Client movement methodsserver/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 flagATS_STAGE_ROUTING_RULES in server/feature_flags/feature_flag_enum.py

Tests

Test fileWhat it covers
tests/unit/ats/routing/test_rule_evaluator.pyCondition evaluation, operator logic, completion_rate conversion
tests/unit/ats/routing/test_legacy_synthesis.pySynthesis from flat config, CALL_LIMIT_EXHAUSTED conditional rule
tests/unit/ats/routing/test_resolver.pyEnd-to-end resolver, persisted vs synthesised rules, all routing events
tests/unit/ats/base_ats/test_base_ats_stage_movement.pyClient-level routing gate, misconfigured target, missing app/job, workflow-step priority, completion_rate handling
tests/unit/application_stage/test_stage_actions_ats.pyAction-layer wrappers (call-limit split, outreach, cooling down, etc.)