Hiring architecture

Candidates and applications, stages, grading, what fires when

How the module is built. For how it is used — jobs, boards, assessments, the talent pool — see Hiring.

Pieces#

ModelTableWhat it is
HiringRequirementhiring_requirementsA job. organization_id is the workspace id (it has never had a foreign key)
HiringCandidatehiring_candidatesA person, once per (workspace_id, email). The talent pool
HiringApplicationhiring_applicationsA person applying to one job (or a free-text role with no job)
HiringPipelineStagehiring_pipeline_stagesOne column of one job's board; requirement_id IS NULL is the workspace default
Assessment … SubmissionEvaluationassessments …The assessment platform (models/assessment.py)
Candidateassessment_candidatesA test-taker; hiring_candidate_id links them to the person
AssessmentInvitationassessment_invitationshiring_application_id is the application it was sent for

Until 0.49.0 hiring_candidates was both the person and one stage in one job (stage was a string from a hardcoded list; email unique per workspace), so a person could only ever apply once. migrate_2026_09_25b_hiring_applications.sql split it, copying the old table into hiring_candidates_pre_0_49 first.

Stages#

kind is active, hired or closed; every board has exactly one of the last two. key is a stable slug for automations and analytics; name is what people see. assessment_id + auto_send_assessment send an assessment on entry; advance_threshold / reject_threshold (percent) only ever produce a suggestion. A job's board is created from the workspace default the first time it is read (HiringPipelineService.get_board), so editing the default changes new jobs only.

Applications#

status is active, hired, rejected or withdrawn. Hired sits in the hired stage; rejected and withdrawn sit in the closed stage. rejection_reason is one of REJECTION_REASONS (a fixed list, so analytics can count it) and close_note carries the specifics. last_active_stage_id is where it was before it closed, for reopening. A partial unique index allows one open application per person per job; closed ones stay.

form_answers is a copy of the answers as label/value pairs, taken when the application came in through a form, so they survive the form being edited or deleted. last_assessment_score is denormalised from the newest graded attempt so boards and the talent pool can sort on it. suggestion is {action: advance|reject, score, threshold, assessment_id, stage_id, …}, cleared by any move or a dismiss.

One place that moves an application#

services/hiring_pipeline_service.py. Every change of stage or outcome goes through HiringPipelineService.move (and reject / withdraw / reopen, which call it), whoever asks — the API, a form intake, an automation step. It:

  1. checks the stage is on the application's own board, that closing as rejected has a reason, and that reopening does not create a second open application for the same job;
  2. sets status, closed_at / closed_by_id, clears the suggestion;
  3. logs a hiring_candidate entity activity (the profile's timeline);
  4. tells the application's owner (candidate_stage_changed, dropped when they are the actor);
  5. fires candidate.stage_changed, plus candidate.rejected or candidate.hired, with entity_id = the application;
  6. sends the new stage's assessment if it auto-sends.

Before this the general PATCH endpoint could change a candidate's stage and fire none of the stage events.

Assessments and the pipeline#

HiringPipelineService.send_assessment finds or creates the test-taker for the person (same workspace, same email), links them (hiring_candidate_id), and creates — or re-links — the one invitation that person has for that assessment, setting hiring_application_id. A draft assessment keeps the invitation pending (publishing sends it); an active one queues the email as the Temporal activity send_assessment_invitation with workflow id assessment-invitation-{invitation_id} and REJECT_DUPLICATE, so the same person is never mailed twice. If Temporal cannot be reached the email is sent inline.

Grading runs in the evaluate_assessment_attempt activity (services/assessment_grading.py), queued by the candidate's /complete — which used to grade in the request. grade_attempt evaluates each answer that has no evaluation yet (so a retry resumes), then finish_if_graded:

  • scores the attempt out of every question in the assessment (an unanswered one is zero);
  • if any answer is evaluated_by = "pending_review" (file uploads, audio), stops: the attempt stays completed, nothing is announced;
  • otherwise marks it evaluated with a conditional update, and only the call that made that change fires assessment.completed and the score events and tells the creator — a later correction rescores without announcing again — then calls HiringPipelineService.apply_assessment_result, which records the score on the application and sets its suggestion from the stage's thresholds.

A reviewer's mark (POST /assessments/{id}/submissions/{submission_id}/grade) calls finish_if_graded too, so the last manual mark completes the attempt the same way.

Question types#

TypeAnswer contentGrading
mcqselected_answer or selected_answersSingle: exact. allow_multiple: (right − wrong) / correct, floored at 0
rating_scale, situationalselected_answerThe chosen option's score over the best option's
subjective, pseudo_codetextLLM via the gateway (hiring.answer_evaluation), keyword fallback
codecode, languageTest cases plus LLM
file_uploadfile: {key, filename, size, type} from POST /take/{token}/answer-upload/{question_id}A reviewer
audio_*—A reviewer

Candidates receive options as {id, text} only.

services/assessment_public_link.py. assessments.public_link_settings is {enabled, after_register, requirement_id, stage_id}; an assessment without it falls back to schedule.is_public. The token is made the first time the link is switched on and survives switching off, publishing and cloning (a copy gets none); …/public-link/rotate replaces it.

The public page is /public/assessments/{token} (GET /assessments/public/{token}, POST …/resume, POST …/register, all unauthenticated and rate-limited per IP). register creates the test-taker and an invitation, upserts the talent-pool person with the fill-only rule, optionally opens an application on the chosen job (an existing open one is reused) and links it to the invitation, then:

after_registerInvitationEmailResponse
startpendingqueued{status: "start", invitation_token}
verifypendingqueued{status: "check_email"}
approveawaiting_approvalon approval{status: "pending_approval"}

An address already registered is re-sent its link (resend_invitation, at most every RESEND_COOLDOWN_SECONDS from when the link was issued or last emailed) and answered check_email exactly as a first registration by email is, so the reply does not say whether it had registered. The token is never returned for it. Under start, an address the workspace already knows (a test-taker or a talent-pool person) is also answered check_email and emailed, rather than started: otherwise whoever typed it would take a test filed on that person's record and application.

awaiting_approval is not in the statuses send_invitation emails, and send_assessment leaves it alone unless called with approves=True — only the board's Send assessment does — so a stage or automation that sends the assessment on its own cannot skip approval. send_invitation also emails a started invitation that was never emailed, which is where start registrants usually are by the time the worker runs. /take/{public_token}/start no longer creates anyone.

Generating questions#

services/assessment_question_generation.py. The wizard starts a job (POST /assessments/{id}/questions/generation-jobs {topic_ids?, mode}), which returns at once; the generate_assessment_questions activity runs it on the analysis queue, TOPIC_CONCURRENCY topics at a time. Each topic has an assessment_generation_topics row, and commits its questions in the same transaction as that row's progress, so a retried activity skips the topics already done. A topic's failure is written on its row, never raised: raising would retry the activity and regenerate the topics that worked. The wizard polls GET …/generation-jobs/latest while a job is queued or running.

modeGeneratesRemoves
fillWhat each type is short ofNothing
replaceWhat each type is short of once the replaceable ones gois_ai_generated and edited_at IS NULL
addThe topic's full countNothing

A run takes a fresh run_token on the job and claims each topic row with it (claim_token); a topic's final write is conditional on still holding the claim while the job is active, so when runs overlap — a Temporal retry of an attempt that timed out but is still running, or the inline fallback racing a start that reached Temporal — the superseded one saves nothing. Rows are touched after every model call, and staleness is measured from that. _remove re-checks edited_at at write time, so a question edited while its topic generates is kept. topic_ids: [] means no topics, not all of them.

edited_at is set by update_question and by PUT /questions/{id}. A removed question that has submissions is soft-deleted (deleted_at), and get_questions leaves those out. One queued or running job per assessment (uq_assessment_generation_job_active); one with no progress for STALE_AFTER_SECONDS is marked failed the next time it is read. file_upload and audio_* are never generated.

Forms#

The hiring_candidate form connection (services/form_connections.py) upserts the person by email — filling only fields that are empty, since a public form proves nothing about who typed the address; tags are added — keeps a mapped file field as resume_file if they have none, and calls create_application with the job, starting stage, owner and the copied answers. An applicant who already has an open application for that job gets the answers appended to it and a skipped result. See Forms architecture.

Endpoints#

# Boards
GET  /hiring/stages?workspace_id=&requirement_id=        (creates the board on first read)
PUT  /hiring/stages?workspace_id=&requirement_id=        replace; admin
GET  /hiring/rejection-reasons

# Talent pool
GET    /hiring/candidates?workspace_id=&search=&outcome=&tag=&source=&min_score=&max_score=&requirement_id=&inactive_days=
POST   /hiring/candidates?workspace_id=                   person, optionally with a first application; 409 {message, existing_id}
GET    /hiring/candidates/{id}                            with applications, assessments
PATCH  /hiring/candidates/{id}
DELETE /hiring/candidates/{id}                            admin; erases the person and their applications
GET    /hiring/candidates/{id}/resume                     presigned link
POST   /hiring/candidates/{id}/applications               re-engage

# Applications
GET    /hiring/applications?workspace_id=&requirement_id=&no_requirement=&status=&stage_id=&owner_id=&source=&search=
GET    /hiring/applications/metrics?workspace_id=&requirement_id=
GET    /hiring/applications/{id}
PATCH  /hiring/applications/{id}                          owner, score, notes, role — never the stage
POST   /hiring/applications/{id}/move                     {stage_id, rejection_reason?, note?}
POST   /hiring/applications/{id}/reject                   {reason, note?}
POST   /hiring/applications/{id}/withdraw                 {note?}
POST   /hiring/applications/{id}/reopen
POST   /hiring/applications/{id}/send-assessment          {assessment_id}
POST   /hiring/applications/{id}/dismiss-suggestion
DELETE /hiring/applications/{id}                          admin

# A job's application forms
GET  /hiring/requirements/{id}/application-forms
POST /hiring/requirements/{id}/application-forms          admin

# Assessments (additions)
GET  /assessments/templates?organization_id=
POST /assessments/templates/{key}/use?organization_id=
POST /assessments/{id}/save-as-template
POST /assessments/{id}/use-template
GET  /assessments/{id}/review-queue
POST /assessments/{id}/submissions/{submission_id}/grade  {marks, feedback?}

Every route checks workspace membership at the role the action needs, that the workspace has the hiring app switched on, and that the caller has access to it. The questions and question-bank routes do the same; before 0.49.0 they checked only that the caller was logged in.

Automations#

Hiring events carry application_id and candidate_id, and use the application as entity_id. The hiring actions (move_stage, reject_candidate, send_assessment, add_note, create_offer, schedule_interview) live in services/automation_module_actions.py, resolve the application from application_id (or a person's newest open one from a candidate id) and act through HiringPipelineService. They used to write into the test-taker's custom_fields and change nothing on the pipeline. create_offer and schedule_interview record the offer or interview on the application and move it forward to Offer / Interview — never backwards.

Common pitfalls#

  • Writing stage_id directly. Go through move; otherwise nothing is closed, notified or fired, and no assessment is sent.
  • Treating organization_id on assessments and jobs as an organisation. It is the workspace id, without a foreign key; always compare it to the workspace you checked access for.
  • Seeding a board without the savepoint. get_board creates a board on first read; the unique (board, key) indexes make a concurrent second seed fail, and get_board reads the winner. Anything else that creates stages must expect the same.
  • Reading application.stage after a move in the same session. The relationship keeps what was loaded before; look the stage up by stage_id.