> ## Documentation Index
> Fetch the complete documentation index at: https://f4c7a9e2d8b1-docs.tenzo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# ATS Stage Routing — Internal Engineering Reference

> How conditional routing rules drive ATS stage movement for all Candidate Flow events.

# 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

| 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`                              |

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) 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 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%)        |

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 `None` → `0.0`:

```python theme={null}
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

| 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.)                                              |
