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

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 to move) and does not go through _handle_stage_movement_for_target. It is not currently routed via routing rules. Exception: if a prior resume-pass write already landed (retry after a failed Tenzo OUTREACH / OUTREACH_PAUSED transition), the handler reports applied even when the application is already outside the start stage, so the throwaway outreach ATS write stays skipped.
PENDING_ACCOMMODATION_REVIEW is a stage overlay, not a call-status hold. The call keeps its real terminal CallStatus; nothing is parked and nothing is deferred. The normal terminal transition (CALL_COMPLETED / FAILED_KNOCKOUT) runs first and owns all ordinary post-call effects — results posting, notifications, tasks, scheduling, billing, and the AI_INTERVIEW_PASSED / AI_INTERVIEW_FAILED ATS move. Only after that transition commits does transition_work_accommodation_review_stage_if_needed overlay the review stage on top, gated by the ownership CAS (expected_latest_history_call_id) so an older call cannot put a newer call’s outcome into review. The overlay stage is the only record that review is pending: there is no parallel durable marker, so an application not on PENDING_ACCOMMODATION_REVIEW is not held. Overlay gate (should_overlay_work_accommodation_review) requires all three:
  1. final_status == COMPLETED. A FAILED_KNOCKOUT call is never overlaid, and a non-terminal call (INCOMPLETE / RESCHEDULED) stays on its normal retry path — detection re-fires on the next completed attempt, so an unresolved flag is never lost.
  2. Unresolved review items exist on the persisted grades (grades_need_work_accommodation_review), with the job’s setting enabled.
  3. InterviewPassEvaluation.review_could_change_outcome — i.e. passed is not False. Flagged questions already carry provisional full credit (apply_provisional_full_credit), so a definite fail under that ceiling cannot be reversed by review and takes the ordinary below-threshold path instead (INFO questions are flagged for review and dismissal only without provisional score modifications). Unknown (None — no configured ATS client, or a transient lookup failure) is treated conservatively as “could still change”.
Resolution releases the stage only. release_work_accommodation_review_stage re-reads the authoritative Cosmos grades and picks the outcome via review_release_outcome_stage: FAILED_KNOCKOUT when the persisted status is already knockout or a reviewer’s grade now trips grading_has_failed_knockout (a provisionally-full-credit required question scored 0), otherwise CALL_COMPLETED. It transitions under the constrained StageActionProfile.WORK_ACCOMMODATION_REVIEW_RELEASE, which preserves stage history / CAS / activity and fires the matching AI_INTERVIEW_PASSED / AI_INTERVIEW_FAILED ATS move but does not replay results posting, notifications, tasks, scheduling, or billing. A configured ATS integration that cannot be created raises TerminalOutcomeEvaluationError and retains the hold for recovery rather than releasing the Tenzo stage without its ATS move. That ATS resolution lives in application_stage/terminal_outcome_ats_resolution.py, shared with the regrade outcome reconcile below so both restricted terminal moves resolve the client and the pass/fail verdict identically. The release never moves an application that already left review. Resolution is a no-op unless the CCI is still on PENDING_ACCOMMODATION_REVIEW: an overlay that never committed leaves the application on its ordinary terminal stage, which is a finished outcome rather than a state to repair. The ATS side is checked the same way, inside move_interview_outcome_for_review_release and against the application it is about to move: review_release_stage_presence compares that application’s current stage to the stage Tenzo itself last left it on. That is exactly ONE of two target sets — never their union — both read from org config independently of the routing feature flag and of the call’s completion rate:
  • the review parking targets (pending_accommodation_review_stage / ..._workflow_step_id plus any stored PENDING_ACCOMMODATION_REVIEW routing rule) when the org configures them, otherwise
  • the interview pass targets (ai_interview_pass_stage and its workflow step, plus any stored AI_INTERVIEW_PASSED routing rule).
The second set covers the common configuration: most orgs map no parking stage at all, so the overlay’s ATS move is a no-op and the application is still sitting on the pass stage the completing transition pushed. Without it, every release replayed that outcome move hours later and dragged applicants back out of stages recruiters had moved them to (TEN-809). The fail targets are deliberately in neither set. A recruiter can move someone to the fail stage on purpose, so accepting it as “where Tenzo left them” would let a reviewed pass undo that rejection; and a held application was never pushed there, because the overlay only forms over a completion whose pass evaluation was not a definite fail (should_overlay_work_accommodation_review requires review_could_change_outcome). Unioning the two sets is likewise wrong for a parked org: it would accept an outcome stage a recruiter chose. Targets are split into stages and workflow steps (StageTargetSet). A Workday outcome configured only by workflow-step reference can never compare equal to a fetched application’s stage, because SOAP moves by step even when Kombo exposes no stage that maps to it, so when nothing comparable is configured the check stands down and the move proceeds rather than reporting a recruiter move that did not happen. If the application has left the expected stage the move is skipped without failing — a recruiter’s stage choice wins, and the Tenzo overlay still resolves. If the current stage cannot be read (UNKNOWN — for example Innova’s live VRA read failing) the move fails closed and the hold is retained for a retry, since an unreadable stage cannot distinguish an application Tenzo placed from one already moved. The release comments only when it changed the ATS. The orchestrator’s stage comment is suppressed for WORK_ACCOMMODATION_REVIEW_RELEASE unless the reviewed-outcome move returned MOVED (_should_post_stage_comment / context.reviewed_outcome_ats_move_applied). The terminal stage the release returns to was already entered — and already commented on — before the overlay, so a release that confirms that outcome would write a duplicate note. A release that unparks an application, or that applies a verdict the reviewer flipped, does change what the org sees and keeps its comment.
Regrade outcome reconcile. Changing a knockout question and regrading re-derives the call’s terminal verdict, and the application stage follows it via StageActionProfile.REGRADE_OUTCOME_RECONCILE. It moves only an application still on the graded terminal stage (CALL_COMPLETED / FAILED_KNOCKOUT) that this call itself produced, so a bulk regrade cannot resurrect dispositioned or opted-out candidates. Applications under work-accommodation review are left to the release path above. Completeness is handled asymmetrically, because the two persisted statuses carry different evidence about it. A persisted COMPLETED already established that the candidate reached the scripted outro, so only the knockout dimension is re-decided; re-running completeness against the new question set would let a script edit retroactively demote a finished interview. A persisted FAILED_KNOCKOUT establishes nothing — determine_final_status returns it before the outro check — so when a regrade clears the knockout, completeness is re-established via resolve_graded_terminal_status. If it does not hold the call is INCOMPLETE, which has no application stage to move to, and the persisted verdict is left untouched. The profile fires the same AI_INTERVIEW_PASSED / AI_INTERVIEW_FAILED move, but as a post-commit action rather than a critical one. Critical actions run before the stage CAS, and this reconcile is driven once from an HTTP request with nothing to retry it, so a pre-CAS move that then lost the CAS would leave the ATS permanently ahead of Tenzo. Post-commit means the ATS moves only if the stage it reflects was actually written; a failing move leaves the stage correction standing but is reported as a failed reconcile rather than a clean move, so a Tenzo stage that is ahead of the ATS is visible, and result_edited_since_ats_push keeps the manual re-push available. A question the candidate was never asked — the row a regrade back-fills when it adds a question — can never fail a knockout or move the pass/fail score. The marker is structural (GradeResult.never_asked / CosmosRequirementGrade.never_asked), written only by the back-fill, because the same placeholder shape is also used when grading cannot resolve a requirement. Outcome-affecting unresolved placeholders abort post-call and regrade processing instead of being persisted as candidate outcomes; non-required INFO GRADE_ONLY failures may remain explicitly marked placeholders. The reviewer grade-edit endpoint accepts free-form answer text, so a text match would let a typed “Not asked” clear a genuine knockout. Rows persisted before the marker existed are recognised by the full shape the back-fill stamped (sentinel answer, no-evidence transcript index, sentinel rationale), which the edit endpoint cannot reproduce. grading_utils.is_failed_knockout_grade is the single definition of “this zero records a real failure” — grading_has_failed_knockout delegates to it — and the same exclusion reaches the weighted average, both all-binary lifts, the bucket rescale and the completion rate, so the knockout verdict and the persisted score cannot disagree.

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, OUTREACH_INELIGIBLE, CALL_LIMIT_NO_INTERVIEW, CALL_LIMIT_EXHAUSTED, CALL_LIMIT_REACHED, INVALID_CONTACT, WRONG_NUMBER, NO_CONSENT, CONSENT_REVOKED.
RoutingEvent.DO_NOT_CONTACT still exists as an inert enum member so previously persisted stage_routing_rules blobs continue to parse. Nothing fires it, no handler or flat config field backs it, and it is not offered in the admin routing UI. Opt-outs use CONSENT_REVOKED.
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.
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

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)


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):
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: Synthesis result:
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:
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.

5. Empty Kombo current_stage Is Unknown, Not Outreach-Ineligible

Kombo often returns current_stage: null for a newly created Crelate application even though Crelate already assigned New Candidates on AddedToJob. The client used to synthesize ApplicationStage(id='', remote_id='', name=''). That object is truthy, so Crelate’s remote-id start-stage check failed and pre-call eligibility cancelled into Outreach Ineligible (SKIP_OUTREACH). Calling eligibility now classifies stage evidence as RESOLVED | UNKNOWN | UNAVAILABLE | NOT_APPLICABLE. An empty normalized stage is unknown for Kombo (Crelate then reads GET /jobs/{job}/contacts/history). Unknown/unavailable defers (ATS_CHECK_FAILED); only a resolved stage that fails org config cancels. Empty stages are not written to the CCI application-stage cache. JobDiva, eRecruit, and TempWorks keep empty-stage as not applicable (stage gate does not apply). Paycor reads the stage natively through Kombo passthrough instead: a stage found is resolved, a failed native fetch is unknown (defers), and a candidate absent from the native list is not applicable — see Paycor ATS.

Code Locations


Tests