How the module is built. For how it is used — jobs, boards, assessments, the talent pool — see Hiring.
Pieces#
| Model | Table | What it is |
|---|---|---|
HiringRequirement | hiring_requirements | A job. organization_id is the workspace id (it has never had a foreign key) |
HiringCandidate | hiring_candidates | A person, once per (workspace_id, email). The talent pool |
HiringApplication | hiring_applications | A person applying to one job (or a free-text role with no job) |
HiringPipelineStage | hiring_pipeline_stages | One column of one job's board; requirement_id IS NULL is the workspace default |
Assessment … SubmissionEvaluation | assessments … | The assessment platform (models/assessment.py) |
Candidate | assessment_candidates | A test-taker; hiring_candidate_id links them to the person |
AssessmentInvitation | assessment_invitations | hiring_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:
- 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;
- sets status,
closed_at/closed_by_id, clears the suggestion; - logs a
hiring_candidateentity activity (the profile's timeline); - tells the application's owner (
candidate_stage_changed, dropped when they are the actor); - fires
candidate.stage_changed, pluscandidate.rejectedorcandidate.hired, withentity_id= the application; - 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 stayscompleted, nothing is announced; - otherwise marks it
evaluatedwith a conditional update, and only the call that made that change firesassessment.completedand the score events and tells the creator — a later correction rescores without announcing again — then callsHiringPipelineService.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#
| Type | Answer content | Grading |
|---|---|---|
mcq | selected_answer or selected_answers | Single: exact. allow_multiple: (right − wrong) / correct, floored at 0 |
rating_scale, situational | selected_answer | The chosen option's score over the best option's |
subjective, pseudo_code | text | LLM via the gateway (hiring.answer_evaluation), keyword fallback |
code | code, language | Test cases plus LLM |
file_upload | file: {key, filename, size, type} from POST /take/{token}/answer-upload/{question_id} | A reviewer |
audio_* | — | A reviewer |
Candidates receive options as {id, text} only.
The shareable link#
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_register | Invitation | Response | |
|---|---|---|---|
start | pending | queued | {status: "start", invitation_token} |
verify | pending | queued | {status: "check_email"} |
approve | awaiting_approval | on 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.
mode | Generates | Removes |
|---|---|---|
fill | What each type is short of | Nothing |
replace | What each type is short of once the replaceable ones go | is_ai_generated and edited_at IS NULL |
add | The topic's full count | Nothing |
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_iddirectly. Go throughmove; otherwise nothing is closed, notified or fired, and no assessment is sent. - Treating
organization_idon 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_boardcreates a board on first read; the unique(board, key)indexes make a concurrent second seed fail, andget_boardreads the winner. Anything else that creates stages must expect the same. - Reading
application.stageafter a move in the same session. The relationship keeps what was loaded before; look the stage up bystage_id.