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

# Paycor ATS

> Feature coverage and implementation details for the Paycor integration

## Implementation Overview

**Implementation Type:** Kombo-based Integration (open beta connector)
**Note Format:** Plain Text
**Status:** Read-only

Paycor is integrated through Kombo's unified API, the same way as most of our other ATS
integrations (Bullhorn, Jobvite, Greenhouse, Workday, etc.). Kombo's Paycor connector is
currently in **open beta**, and Paycor's own underlying ATS API is too thin to trust for
write-back — so, unlike other Kombo-backed integrations, **this integration is strictly
read-only**.

Tenzo syncs jobs, candidates, applications and (natively, via passthrough) application stages
out of Paycor and runs the normal AI screening/interview flow, but never writes anything back
into Paycor.

## Configuration

| Property            | Value         |
| ------------------- | ------------- |
| Provider Enum       | `paycor`      |
| Note Format         | `PLAIN`       |
| Disposition Reasons | Not supported |

## Legal entity fan-out

Kombo returns **one ATS entity per Paycor legal entity** sharing an ATS account. The fan-out is
encoded in the remote id:

```
jobs:        <legalEntityId>:<atsAccountId>:<atsJobId>
candidates:  <legalEntityId>:<atsAccountId>:<atsJobId>:<atsCandidateId>
```

Because our sync dedups on Kombo's `id`, which differs per legal entity, one real job can become
several `campaigns` rows and one real person several `candidates` rows — each of which can be
called and texted independently.

**This is permanent, not a bug awaiting an upstream fix.** Paycor's API is legal-entity-scoped
(`/v1/legalEntities/{id}/ats/{account}/...`), so Kombo needs the prefix to route `getCandidate` /
`createCandidate`. Our own `server/ats/paycor/paycor_ats_client.py` does exactly the same.

### How we collapse the fan-out

`PaycorAtsClient` pins **one legal entity per `atsAccountId:atsJobId`** and drops every other
leg at the client boundary, so a real Paycor job produces exactly one campaign and its
candidates are ingested once.

The pin needs no new storage. `Campaign.ats_job_remote_id` already holds the full
`legalEntityId:atsAccountId:atsJobId`, so on first use of a sync the client derives the map
`{atsAccountId:atsJobId -> legalEntityId}` from the org's existing campaigns and extends it in
memory as new jobs stream past. Because the map is re-derived from campaigns every sync, a job
that has a campaign is pinned to the same leg every time — that is what makes the collapse
idempotent.

A job that does **not** have a campaign yet has nothing to re-derive from. Its leg is decided
by whichever copy the job stream yields first, and that becomes the campaign, after which the
pin is stable. Candidates and applications for such a job fall back to a separate
candidate-only map so their copies still collapse to one within a run; that fallback is
deliberately never written into the job pins, because the job and applicant streams can share
one client and a candidate-derived leg there could make the job stream drop the leg that
should have won.

The filter applies to the streaming sync paths only — jobs (`_stream_jobs`), candidates
(`_stream_candidates`) and, defensively, applications (`_stream_applications`). Records that
survive keep their untouched composite `remote_id`, so every native/passthrough read still has
the legal entity it needs to address Paycor. A `remote_id` that does not parse is kept and
warned about rather than dropped, and every skipped duplicate is logged with both legal entity
ids.

Fetch-by-id (`get_job_by_job_id`, `get_jobs_by_job_ids`) is deliberately **not** filtered.
Those methods answer "give me this exact job", and dozens of read paths — outreach
eligibility, notes context, SMS job messaging, the public application API — call them with a
campaign's own `ats_integration_job_id`. Substituting a sibling leg there would answer a
different question than the caller asked. The consequence is that manually syncing a job by id
on a non-pinned leg can still create a duplicate campaign; that is a deliberate single-job
action rather than the automatic fan-out this change targets.

Kombo also exposes a per-connection **"Legal entity IDs to sync"** setting — Alpine Intel's
connection is set to `190521,63178`. That is still worth curating at onboarding to cut wasted
API traffic, but it is no longer the only defense: an uncurated connection now dedups in our
code instead of fanning out. We never call Kombo's integration-settings API, so that setting
remains invisible to our code and can only be verified by observing a sync.

<Warning>
  **Limitation:** if the pinned legal entity is later removed from the Kombo connection, that
  job stops appearing even though other entities still expose it, because its campaign still
  names the dead entity. Recovery is clearing that campaign's `ats_job_remote_id` (or deleting
  the campaign); the next sync then adopts a surviving leg.
</Warning>

### Diagnosing a fan-out

Group an org's candidates by the legal-entity prefix. A healthy connection shows only the curated
entity ids:

```sql theme={null}
SELECT split_part(ats_candidate_remote_id, ':', 1) AS legal_entity_id, COUNT(*)
FROM candidates
WHERE org_id = '<org_id>'
  AND ats_candidate_remote_id IS NOT NULL
GROUP BY 1
ORDER BY 2 DESC;
```

### Cleaning up rows that already exist

Client-side pinning stops *new* duplicates; it cannot touch rows already written. Those are retired
by `server/one-time-jobs/cleanup_paycor_legal_entity_duplicates.py`:

```
python -m one-time-jobs.cleanup_paycor_legal_entity_duplicates \
    --integration <ats_integration_id> --retained-legal-entities 190521,63178
```

It is dry-run by default (`--apply` to write). A duplicate is deleted only when the sole rows
hanging off it are the ATS-derived projections the next sync regenerates on the survivor (resume,
experiences, attribute assignments, resume scores). Anything else — a call, a text, an
application, an interview, a placement, an intake link — counts as history: that row is reported
for hand-merging instead, as is every `campaigns` row. The probe is derived from the delete plan
itself, so no table can be deleted without first being checked.

Deleted rows are copied into `zz_paycor_retire_backup_*` tables first; **those snapshots are the
only undo.**

## Application stages

Kombo's unified API returns **no stage** for Paycor applications (`current_stage` is null), so
Tenzo reads the stage natively. Paycor's candidate records carry the hiring stage as a small
integer code (`stage`, alongside a `status` sub-code and `startingStage`/`startingStatus`), and
Paycor exposes no endpoint listing the stages.

`PaycorAtsClient` walks each job's native candidate list
(`/v1/legalEntities/{le}/ats/{account}/jobs/{job}/candidates`) through Kombo passthrough — one
page-walk per job per sync, shared by every application on that job — and attaches the stage to
each application before the start-stage gate, the cached `application_stage`, disposition
detection and outbound eligibility see it. Fetch-by-id paths carry the stage too. A failed fetch
leaves the stage empty (no gate) at sync time and reports the calling stage as *unknown* (outreach
defers) at eligibility time.

Stage identity is the code itself (`id`/`remote_id` = `"7"`); the display name comes from
`PAYCOR_STAGE_NAMES` in `server/ats/paycor/paycor_models.py`. Paycor does not publish the
code → name table, so until it is confirmed, stages render as `Stage 1`, `Stage 7`, etc. Observed
so far: codes 1, 2, 4, 5, 6, 7, 8 and account-specific 100/101; Kombo maps 6 → hired and 7 →
declined. Because **Start Stages** matches on the rendered name, filling in a name later means
re-selecting that stage in the org's settings.

The **Start Stages** dropdown offers the standard codes 1–8 plus any code already cached on the
org's synced candidates. Stage *moves* remain blocked — no stage-target field is configurable.

## What's supported

* Streaming/reading jobs, candidates, and applications via Kombo's unified API.
* Reading each application's hiring stage code from Paycor's native per-job candidate list through
  Kombo passthrough, and gating outreach on the configured **Start Stages**.
* Reading a candidate's primary resume as HTML text from Paycor's native candidate-detail
  endpoint through Kombo passthrough. Tenzo converts the HTML to plain text for resume review.
* Dynamically reading Paycor-specific job attributes through Kombo passthrough and exposing them
  as job custom fields in the Rules Engine. Current examples include the ATS account ID and title,
  activation date, openings, priority, target time to fill, and per-channel posting flags. New
  Paycor-native fields are discovered from the native job payload, while fields already returned
  by Kombo's normalized job model are not duplicated.

## What's explicitly blocked

Kombo's Paycor connector does not support notes, binary attachments, stage writes, or
disposition reasons at all. While Kombo does technically support creating
candidates/applications and moving stages for Paycor, Tenzo does not use any of that —
every write path is explicitly no-op'd rather than left to inherit the default
Kombo-backed behavior:

* Creating applications or candidates
* Moving an application to a stage
* Rejecting an application (single or bulk)
* Adding notes to an application or candidate
* Adding attachments to an application or candidate, or listing/downloading binary attachments
* Writing custom fields (not exposed by Kombo for Paycor)

These no-op rather than raise, so the call-processing pipeline is never interrupted by a
Paycor org's write path being invoked.
