Applicant Tracking: Onsite Loops, Duplicate Candidates and Offer Approvals
MediumAI-assisted coding · AI-Assisted Feature Work
Applicant Tracking: Onsite Loops, Duplicate Candidates and Offer Approvals
You've joined the recruiting-tools team at a mid-size company. Their applicant tracking system (ATS) moves candidates through a hiring pipeline per job requisition, and it already works for the basics. Three tickets are queued: the onsite interview loop with its hiring decision (plus a metrics bug), merging candidates who applied twice, and a compliance-driven offer approval flow with headcount. Each Part builds on the previous one, and a Part 3 rule changes how Part 1 behaved, so keep your earlier tests running as you go.
Codebase
| File | What it does |
|---|---|
ats/api.py | run(operations) and the Dispatcher; the ROUTES table lists every operation and its argument types |
ats/service.py | AtsService: one method per operation; resolves candidate ids through the registry |
ats/pipeline.py | Pipeline: applications and the stage machine (can_move, move) |
ats/clock.py | StageClock: per-application stage spells and the time-in-stage report |
ats/scorecards.py | ScorecardBook: onsite scorecards and the loop decision (Part 1; stubbed) |
ats/offers.py | Offers and approval chains (Part 3; empty) |
ats/registry.py | CandidateRegistry and RequisitionBook |
ats/normalize.py | Canonical email and phone normalisation |
ats/models.py, ats/errors.py | Dataclasses, stage constants and every error code |
tests/ | Example tests (one fails today) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh ATS and returns one result per operation. Every operation is [name, at, *args]; at is an integer number of seconds and never decreases. A success returns the operation's value (below); a failure returns the string "error: <code>", e.g. "error: bad transition". Errors are returned, never raised, and a failed operation changes nothing.
A request that is not a list, has a non-string name, a non-integer at (true/false are not integers), too many arguments, or a missing or wrongly typed argument returns error: bad request; an unknown name returns error: unknown op. The ROUTES table in api.py is the source of truth for argument order and types; a trailing optional argument may be omitted or null.
Part 1 operations (errors are checked in the order listed)
| Operation | Arguments after at | Returns | Errors |
|---|---|---|---|
open_req | req, title | true | req exists |
apply | req, candidate, name, email, [phone] | "applied" | unknown req, invalid email, already applied |
move | candidate, req, to, [reason] | the new stage | unknown candidate, no application, bad transition, not cleared for offer |
application | candidate, req | [stage, reason] | unknown candidate, no application |
scorecard | candidate, req, interviewer, score | [late, count] | unknown candidate, no application, invalid interviewer, invalid score, not at onsite |
close_loop | candidate, req | [decision, average, counted] | unknown candidate, no application, not at onsite, already decided, need 3 scorecards |
scorecards | candidate, req | [decision, average, cards] | unknown candidate, no application |
stage_report | req | four [stage, spells, seconds] rows | unknown req |
seed | req, count, mod, screen_every | count | bad request, then any apply error |
seed_pairs | req, pairs, rng | pairs | bad request, then any error of the operations it performs |
applycreates the candidate on first sight (candidateis the id you choose) and storesname, the normalised email and, if usable, the normalised phone. Applying again with an existing candidate id to a different requisition adds an application and ignores the other arguments (they are still validated: an unusable email isinvalid email). Applying twice to the same requisition isalready applied. A phone that does not normalise to ten digits is simply not stored. Rules for "usable" are inats/normalize.py.- Stages are
applied -> screen -> onsite -> offer -> hired. Fromapplied,screen,onsiteorofferan application may also go torejectedorwithdrawn. Those three are final. Any othermove(skipping a stage, going backwards, leaving a final stage, or a made-up target name) isbad transition.reasonis stored only when moving torejectedorwithdrawn;applicationreturns it (nullotherwise). seedandseed_pairsare load-test fixtures;AtsService.seed/seed_pairsshow exactly what they do.seedappliescountgenerated candidatess0, s1, ...toreqatat, with emails that collide wheni % modis equal, and moves everyscreen_every-th one (i % screen_every == 0,0disables) toscreenatat + 60.seed_pairscreatespairscouplesp<i>/q<i>with the same address and drives each to a pseudo-random depth using the seedrng.
Part 1: The onsite loop and the hiring decision (TALENT-398, TALENT-412)
Goal: coordinators need interviewers to submit scorecards for a candidate's onsite loop, a hiring decision computed from them, and a gate so nobody reaches offer without a positive decision. Separately, recruiting ops report that time-in-stage numbers for rejected candidates keep growing.
A scorecard score is -2 (strong no), -1 (no), 0 (neutral), 1 (yes) or 2 (strong yes).
scorecardrecords one card per interviewer for an application;interviewermust contain a non-space character (invalid interviewer) andscoremust be one of the five values (invalid score). The application must have been inonsiteat some point, even if it has since moved on or been rejected (not at onsite). A second card from the same interviewer replaces the first. The result is[late, count]:countis the number of cards now stored for the application.close_loopdecides the loop. The application must be currently atonsite. Fewer than 3 stored cards isneed 3 scorecards(nothing is decided and the loop stays open). Otherwise, with *n* cards, the average is the exact fractionsum / nand the decision is"advance"if no card is-2and the average is at least 1; otherwise"decline".averageisstr(Fraction):"1","4/3","-1/2","0".countedis *n*. A loop can be closed only once.- The decision is a snapshot. A card submitted after
close_loop(including a resubmission replacing an earlier card) is stored and returned withlate = true, but never changes the decision or the average. Cards submitted before the close havelate = false. scorecardsreturns[decision, average, cards];decisionandaveragearenulluntil the loop is closed, andcardsis a list of[interviewer, score, late]in string order of interviewer name.movetoofferadditionally requires an"advance"decision for that application, elsenot cleared for offer(checked afterbad transition). Moves torejected/withdrawnnever need a decision, and a"decline"can never be reversed.- Time in stage. An application spends a *spell* in each active stage (
applied,screen,onsite,offer): from theatit entered the stage to theatit left it, where leaving includes going torejected,withdrawnorhired. Final stages have no spell.stage_reportreturns four[stage, spells, seconds]rows in pipeline order covering all applications of the requisition; a spell that is still open counts up to the report'sat.
Example 1
[["open_req", 0, "r1", "Backend Engineer"],
["apply", 10, "r1", "ana", "Ana Ruiz", "ana@example.com"],
["move", 100, "ana", "r1", "screen"],
["move", 200, "ana", "r1", "onsite"],
["scorecard", 300, "ana", "r1", "dee", 2],
["scorecard", 310, "ana", "r1", "eli", 1],
["scorecard", 320, "ana", "r1", "fay", 0],
["close_loop", 400, "ana", "r1"],
["move", 500, "ana", "r1", "offer"],
["stage_report", 700, "r1"]]returns (one result per operation)
[true, "applied", "screen", "onsite", [false, 1], [false, 2], [false, 3],
["advance", "1", 3], "offer",
[["applied", 1, 90], ["screen", 1, 100], ["onsite", 1, 300], ["offer", 1, 200]]]Example 2 (a veto, a late card and the gate; bo is already at onsite)
[["scorecard", 10, "bo", "r1", "dee", 2], ["scorecard", 11, "bo", "r1", "eli", 2], ["scorecard", 12, "bo", "r1", "fay", 2],
["scorecard", 13, "bo", "r1", "gus", -2], ["close_loop", 14, "bo", "r1"], ["scorecard", 15, "bo", "r1", "hal", 2],
["scorecards", 16, "bo", "r1"], ["move", 17, "bo", "r1", "offer"]]returns
[[false, 1], [false, 2], [false, 3], [false, 4], ["decline", "1", 4], [true, 5],
["decline", "1", [["dee", 2, false], ["eli", 2, false], ["fay", 2, false], ["gus", -2, false], ["hal", 2, true]]],
"error: not cleared for offer"]The average is 1, but the strong no vetoes it; hal's late card is kept (flagged) and changes nothing.
Part 2: Duplicate candidates and merging (TALENT-455)
Goal: candidates apply twice under different ids ("ana@example.com" and "Ana+jobs@Example.com"). Recruiters need to find those duplicates and merge them into one candidate without losing or double-counting anything.
New operations:
| Operation | Arguments after at | Returns | Errors |
|---|---|---|---|
duplicates | [limit] | [total, groups] | none |
merge | primary, duplicate | [primary_id, applications] | unknown candidate, same candidate |
merge_all | none | number of candidates folded away | none |
candidate | candidate | [id, name, emails, phones, aliases, applications] | unknown candidate |
- Two candidates are *duplicates* if they share a normalised email or a normalised phone (see
normalize.py: lower-case,+tagstripped from the local part; phone is the ten national digits). The relation is transitive: if A shares an email with B and B shares a phone with C, then A, B and C form one group. A candidate matches on every email and phone it has accumulated (see rule 6). duplicatesreturnstotal(number of groups) andgroups: each group is a list of candidate ids (two or more) ordered oldest-created first; groups are ordered by their oldest member;limitkeeps only the firstlimitgroups (totalstill counts all). Merged-away candidates never appear.mergefoldsduplicateintoprimary; errors areunknown candidate(primary checked first), thensame candidate(after resolving merged ids, see rule 6). Recruiters may merge any two candidates; they need not be flagged as duplicates. For each requisition:
- if only one of them has an application, it simply moves to the primary;
- if both do, keep one: the one in the more advanced stage, ranked
hired > offer > onsite > screen > applied > rejected > withdrawn. At equal rank keep the one with the earlierapplied_at; if that also ties, keep the primary's. The kept application keeps its own stage,reason,applied_at, creation order, scorecards (cards and decision) and stage history. Everything recorded for the other application, including its time-in-stage spells and scorecards, is discarded:stage_reportmust count only surviving applications.
mergereturns the primary's id andapplications:[req, stage]pairs for the merged candidate, sorted by requisition id.merge_allmerges every duplicate group (as of the moment it is called) into its oldest-created member, merging the others in creation order, and returns the number of candidates folded away.- After a merge the duplicate's id is an alias of the primary: every operation that takes a
candidateaccepts it and acts on the primary (includingapplywith a new requisition). Aliases chain (merge A into B, then B into C, and A resolves to C). The primary keeps its own name; it also gains the duplicate's emails and phones, so a later applicant sharing the old address is a duplicate of the primary. candidatereturns the canonicalid,name,emailsandphones(sorted lists of normalised values),aliases(ids merged into it, in merge order; a candidate's own aliases follow it) andapplications(as in rule 4).- Earlier behaviour is unchanged. Merges and
duplicatesmust stay fast on thousands of candidates and applications; amerge_allover a few thousand candidates is part of the hidden tests.
Example (after open_req r1 and r2)
[["apply", 10, "r1", "ana", "Ana Ruiz", "ana@example.com", "(555) 010-2000"],
["move", 20, "ana", "r1", "screen"],
["apply", 30, "r1", "ana2", "A. Ruiz", "Ana+jobs@Example.com", "1-555-010-2000"],
["apply", 40, "r2", "ana2", "A. Ruiz", "x@y.io"],
["duplicates", 50],
["merge", 60, "ana", "ana2"],
["application", 70, "ana2", "r2"],
["stage_report", 100, "r1"]]returns
["applied", "screen", "applied", "applied",
[1, [["ana", "ana2"]]],
["ana", [["r1", "screen"], ["r2", "applied"]]],
["applied", null],
[["applied", 1, 10], ["screen", 1, 80], ["onsite", 0, 0], ["offer", 0, 0]]]Part 3: Offers, approvals and headcount (TALENT-501)
Goal: Finance wants every offer approved by a chain of people that depends on the salary, wants any change to an offer to restart its approvals, and wants requisitions to close themselves when they are filled. Hiring now happens through the offer, not through a plain move.
| Operation | Arguments after at | Returns | Errors (in order) |
|---|---|---|---|
open_req | req, title, [headcount] | true | req exists, invalid headcount |
make_offer | candidate, req, salary | [chain, revision] | unknown candidate, no application, not at offer, offer exists, invalid salary |
approve | candidate, req, role | [status, approved] | unknown candidate, no application, no offer, not pending, out of turn |
edit_offer | candidate, req, salary | [chain, revision] | unknown candidate, no application, no offer, not editable, invalid salary |
get_offer | candidate, req | [salary, chain, approved, status, revision] | unknown candidate, no application, no offer |
accept_offer | candidate, req | ["hired", closed, auto_rejected] | unknown candidate, no application, no offer, not approved |
salaryis an integer> 0. The approval chain depends on the salary: below 100000["manager"]; 100000 up to (not including) 150000["manager", "director"]; 150000 and above["manager", "director", "vp"].make_offerneeds the application to be atofferand creates an offer with statuspending, no approvals andrevision1. An application has at most one offer.approvemust be given in chain order:rolemust be exactly the next role in the chain still missing (anything else, including an unknown role or a repeat, isout of turn). When the last role approves, the status becomesapproved;approvedlists the roles approved so far.edit_offer(allowed while the offer ispendingorapproved) replaces the salary, recomputes the chain from the new salary, discards every approval, sets the status back topendingand increasesrevisionby 1. This happens on every successful edit, even when the new salary equals the old one or stays in the same band. A failed edit changes nothing.accept_offerneeds anapprovedoffer. It moves the application tohired(offer statusaccepted).- Changed rule:
movetohiredno longer works; fromofferit returnsuse accept_offer(the check comes afterbad transitionand beforenot cleared for offer). Other moves are unchanged. - If an application leaves
offerby beingrejectedorwithdrawn, its offer becomesvoid: it can no longer be approved, edited or accepted (not pending/not editable/not approved). - Headcount.
open_reqtakes an optional integerheadcount(default 1,>= 1). Whenaccept_offermakes the number ofhiredapplications on the requisition reach its headcount, the requisition is closed: every other application on it that is stillapplied,screen,onsiteorofferis moved torejectedwith reason"req filled"at the sameat, in order of application creation, and their offers becomevoid.accept_offerreturnsclosed(whether this acceptance closed the requisition) andauto_rejected(the candidate ids rejected, in that order). A closed requisition stays closed;applyto it isreq closed, checked right afterunknown reqand beforeinvalid email. - Merging (Part 2) must carry offers too: the kept application keeps its offer exactly as it is, and the discarded application's offer disappears with it.
Example (continuing Example 1 of Part 1, where ana is at offer on r1 with headcount 1; bo applied to r1)
[["apply", 600, "r1", "bo", "Bo Chen", "bo@example.com"],
["make_offer", 710, "ana", "r1", 120000],
["approve", 720, "ana", "r1", "manager"],
["edit_offer", 730, "ana", "r1", 120000],
["approve", 740, "ana", "r1", "director"],
["approve", 750, "ana", "r1", "manager"],
["approve", 760, "ana", "r1", "director"],
["move", 770, "ana", "r1", "hired"],
["accept_offer", 800, "ana", "r1"],
["application", 810, "bo", "r1"]]returns
["applied", [["manager", "director"], 1], ["pending", ["manager"]], [["manager", "director"], 2],
"error: out of turn", ["pending", ["manager"]], ["approved", ["manager", "director"]],
"error: use accept_offer", ["hired", true, ["bo"]], ["rejected", "req filled"]]Notes
- Python 3 standard library only. Everything is deterministic.
- Hidden tests run every Part, so earlier behaviour must keep working after later Parts (except where a rule above says it changed).
Example 1
- Input:
- [["open_req",0,"r1","Backend Engineer"],["apply",10,"r1","ana","Ana Ruiz","ana@example.com"],["move",100,"ana","r1","screen"],["move",200,"ana","r1","onsite"],["scorecard",300,"ana","r1","dee",2],["scorecard",310,"ana","r1","eli",1],["scorecard",320,"ana","r1","fay",0],["close_loop",400,"ana","r1"],["move",500,"ana","r1","offer"]]
- Output:
- [true,"applied","screen","onsite",[false,1],[false,2],[false,3],["advance","1",3],"offer"]
- Explanation
- Scores 2, 1 and 0 average exactly 1 and contain no strong no, so the loop advances and the move to offer is allowed.
Example 2
- Input:
- [...ana (r1, screen), ana2 applies to r1 and r2 with the same phone..., ["duplicates",50], ["merge",60,"ana","ana2"]]
- Output:
- [...,[1,[["ana","ana2"]]],["ana",[["r1","screen"],["r2","applied"]]]]
- Explanation
- Ana's screen-stage application on r1 outranks ana2's applied one, so it is kept; ana2's r2 application moves to ana.
Example 3
- Input:
- [...ana at offer, bo applied, ["make_offer",710,"ana","r1",120000], approve manager, edit_offer 120000, approve director, approve manager, approve director, move hired, accept_offer]
- Output:
- [...,"error: out of turn",...,"error: use accept_offer",["hired",true,["bo"]]]
- Explanation
- The edit wiped the manager approval, so the director has to wait; hiring goes through accept_offer, which fills the single seat and rejects bo with reason 'req filled'.
Guidelines
- Python 3 standard library only
- Timestamps are integer seconds and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised, and a failed operation changes nothing
- Averages are exact fractions rendered with str(Fraction); no floating point in decisions
- Part 2 inputs contain thousands of candidates and applications, and merge_all must stay fast