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
Stage actions are wired through the stage orchestrator (
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 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 TenzoOUTREACH/OUTREACH_PAUSEDtransition), the handler reports applied even when the application is already outside the start stage, so the throwaway outreach ATS write stays skipped.
PENDING_ACCOMMODATION_REVIEWis a stage overlay, not a call-status hold. The call keeps its real terminalCallStatus; 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 theAI_INTERVIEW_PASSED/AI_INTERVIEW_FAILEDATS move. Only after that transition commits doestransition_work_accommodation_review_stage_if_neededoverlay 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 onPENDING_ACCOMMODATION_REVIEWis not held. Overlay gate (should_overlay_work_accommodation_review) requires all three:Resolution releases the stage only.
final_status == COMPLETED. AFAILED_KNOCKOUTcall 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.- Unresolved review items exist on the persisted grades (
grades_need_work_accommodation_review), with the job’s setting enabled.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”.release_work_accommodation_review_stagere-reads the authoritative Cosmos grades and picks the outcome viareview_release_outcome_stage:FAILED_KNOCKOUTwhen the persisted status is already knockout or a reviewer’s grade now tripsgrading_has_failed_knockout(a provisionally-full-credit required question scored0), otherwiseCALL_COMPLETED. It transitions under the constrainedStageActionProfile.WORK_ACCOMMODATION_REVIEW_RELEASE, which preserves stage history / CAS / activity and fires the matchingAI_INTERVIEW_PASSED/AI_INTERVIEW_FAILEDATS move but does not replay results posting, notifications, tasks, scheduling, or billing. A configured ATS integration that cannot be created raisesTerminalOutcomeEvaluationErrorand retains the hold for recovery rather than releasing the Tenzo stage without its ATS move. That ATS resolution lives inapplication_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 onPENDING_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, insidemove_interview_outcome_for_review_releaseand against the application it is about to move:review_release_stage_presencecompares 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 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 (
- the review parking targets (
pending_accommodation_review_stage/..._workflow_step_idplus any storedPENDING_ACCOMMODATION_REVIEWrouting rule) when the org configures them, otherwise- the interview pass targets (
ai_interview_pass_stageand its workflow step, plus any storedAI_INTERVIEW_PASSEDrouting rule).should_overlay_work_accommodation_reviewrequiresreview_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 forWORK_ACCOMMODATION_REVIEW_RELEASEunless the reviewed-outcome move returnedMOVED(_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 viaStageActionProfile.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 persistedCOMPLETEDalready 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 persistedFAILED_KNOCKOUTestablishes nothing —determine_final_statusreturns it before the outro check — so when a regrade clears the knockout, completeness is re-established viaresolve_graded_terminal_status. If it does not hold the call isINCOMPLETE, which has no application stage to move to, and the persisted verdict is left untouched. The profile fires the sameAI_INTERVIEW_PASSED/AI_INTERVIEW_FAILEDmove, 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, andresult_edited_since_ats_pushkeeps 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-requiredINFOGRADE_ONLYfailures 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_gradeis the single definition of “this zero records a real failure” —grading_has_failed_knockoutdelegates 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_CONTACTstill exists as an inert enum member so previously persistedstage_routing_rulesblobs 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 useCONSENT_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:
Synthesis result:
- 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.
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.