Skip to main content

Candidate Management

A candidate represents a talent linked to a specific job posting. Candidates are tracked through a status pipeline from application to final decision, with support for notes, scoring, and bulk operations.

How a Talent Becomes a Candidate

There are three entry paths:

PathMutationWho
Self-applicationapplyAsCandidateThe user applies directly to a job
Recruiter additionaddTalentAsCandidateA recruiter attaches an existing talent to a job
Internal creationcreateCandidateAdmin/system creates the link programmatically

Auto-provisioning on self-application: If the user has no talent pool membership, applyAsCandidate automatically creates a default talent pool entry and talent record. It also generates a default motivation text from the talent and job context when none is supplied.

CV auto-selection: The system selects a CV in this priority order: provided cvId --> talent's activeCvId --> most recent CV.

warning

Duplicate applications are prevented. A user cannot apply to the same job twice -- the system checks for existing candidate records per user/job pair.

Application CV Snapshot

When a candidate is linked to a job, the CV is frozen at that moment by cloning it into an immutable snapshot. The candidate row points at the snapshot; the talent's activeCvId continues to point at the live CV. This means:

  • The employer's view of an application reflects the CV exactly as it was when the candidate was added — future edits by the job seeker do not bleed across.
  • Talent-pool browsing still shows the latest CV, because Talent.activeCvId is independent of Candidate.cvId.
  • The snapshot is immutable for everyone: neither the original CV owner nor any employer can mutate it. The mutation guards in cv-structured-mutation.service.ts (verifyCvOwnership, verifyRecordOwnership) and the auth check TalentAuthorizationService.hasWriteAccessToCV() all reject any CV with isApplicationSnapshot = true. See Structured CV Tables — Application Snapshots for the field-level mechanics.

The snapshot clone and the candidate write share a single transaction, so a partial failure cannot leave an orphan snapshot row behind.

When the snapshot is taken

PathMutationSnapshot?
Self-applicationapplyAsCandidateAlways
Recruiter additionaddTalentAsCandidateYes, unless the talent is editable on the company side
Internal creationcreateCandidateCaller's responsibility

The "editable" exception in addTalentAsCandidate mirrors the frontend canEditCandidate rule: a recruiter is still allowed to manage a CV when the talent was a MANUAL_ENTRY placeholder whose owner account is still inactive — i.e. the person has not yet claimed the profile (source === 'MANUAL_ENTRY' && isActive !== true). For every other combination — real applied job seekers, manual entries whose owner account is now active, etc. — the CV is snapshotted.

Frontend rule

The canEditCandidate(source, isActive) helper lives at lib/utils/candidates/canEditCandidate.ts — it returns source === 'MANUAL_ENTRY' && isActive !== true — and is consumed by:

  • CandidateDetails.tsx — hides the "Edit Profile" button
  • app/company/resume/candidates/[candidateId]/edit/page.tsx — route guard that redirects non-editable candidates back to the read-only detail view (frozen snapshots would be rejected server-side anyway, but the redirect avoids landing on a form that always fails to save)
Candidate gate ≠ Talent gate

The candidate gate is still partly source-based (MANUAL_ENTRY + inactive owner). The talent gate is different: it is owner-active-state only — an inactive owner's talent CV is directly editable regardless of source, and an active owner's is edited through a suggest-changes flow. See Editing a Talent.

Frontend read path

The backend snapshot is only half the fix — the UI layer must also read from the snapshot, not from the live CV. The rule for the candidate detail page is: all CV and preference data is sourced from Candidate.cv (the snapshot), never from Talent.activeCV or User.* live fields.

Three concrete implications, enforced across both app/company/resume/candidates/_components/CandidateDetails.tsx and the shared-link mirror at app/company/resume/sharelist/details/[id]/candidate/[candidateId]/_components/CandidateDetails.tsx:

  1. CV id resolutioncvId = candidate?.cv?.id ?? candidate?.talent?.activeCV?.id. Snapshot first; the ?? activeCV tail is a legacy fallback for pre-backfill rows only.

  2. Structured extractiongetStructuredFromCandidate(candidate) in dataTransformers.ts returns extractStructuredFromCv(candidate.cv) ?? extractStructuredFromCv(candidate.talent?.activeCV). The sibling getStructuredFromTalent correctly stays on activeCV — the talent pool view is intentionally live.

  3. Query shapeGET_CANDIDATE_DETAILED in graphql/operations/candidate/queries.ts deliberately does not fetch:

    • the talent.activeCV block — nothing on the candidate page should render live CV data
    • on user { … }: expectedSalary, salaryCurrency, salaryFrequency, preferredJobTypes, preferredWorkSite, preferredLocation, desiredJobTitles, willingToRelocate, availableIn — these would otherwise leak through fallback chains like (jobPref.expectedSalary ?? user?.expectedSalary) in transformCandidateDataForResume

    Identity fields (name, email, phone, avatar, linkedin/portfolio URLs, isActive, isOnboarding, jobMatchScores) and the cv { ...CvStructuredFields } block are the sanctioned selection. Any new field added to user in this query should be audited: if it describes a preference (freeze) don't fetch it; if it's identity (live is fine) it can stay.

The jobseeker's own resume edit flow and the talent-pool view (TalentDetails.tsx + GET_TALENT) are explicitly out of scope — those must stay live. Only the candidate-facing query is trimmed.

Why this matters — SUP-00049 regression (April 2026)

The original April 7 fix moved the freeze to the backend (isApplicationSnapshot). The dev database showed snapshots being written correctly, with up to three diverging values per applicant (snapshot, live CV, live User), but testers still saw live salary changes for ten days because the frontend was reading talent.activeCV and user.expectedSalary via the fallback chain — the snapshot was just never being read. The read-path rule above is the permanent guardrail against that class of regression; the backend guard alone is not enough.

List-view salary alignment (SUP-00048, 2026-04-24)

The candidate list now reads salary through the exact same resolver the detail view uses — they can no longer drift.

Two fixes shipped under this ticket number:

  1. 2026-04-21 — the original candidateRowBuilder.ts preferred the denormalised Candidate.expectedSalary over the CV snapshot's CvJobPreference.expectedSalary, so a candidate card showed 40 000 while the detail page showed 50 000. The row builder was flipped to read snapshot first, with Candidate.expectedSalary as a fallback.

  2. 2026-04-24 (the follow-up that actually closed the ticket) — the card was still diverging from detail. The row builder had three near-duplicate builder functions, all copy-pasted, and the live/snapshot priority was inverted in the one the list actually calls. Worse, Apollo Client normalises User by id, so GET_COMPANY_TALENTS (which legitimately fetches user.expectedSalary for the talents page) hydrates that field into the normalised cache — and the candidates list was reading it back through candidate.user.expectedSalary even though its own query GET_COMPANY_CANDIDATES_ONLY never selects it. That is exactly the leak SUP-00049 warned about, surfacing through a different path.

The permanent fix is a shared resolver, lib/utils/candidates/candidateViewResolver.ts, that every employer-facing candidate read delegates to:

HelperPriorityUsed by
resolveCandidateStructured(candidate, liveTalentCv?)candidate.cv (snapshot) → talent.activeCV (live, legacy fallback only)all candidate views
resolveCandidateIdentity(structured, user)structured CV profile → user.* identity fieldsall candidate views
resolveCandidateSalary(structured, candidate)snapshot jobPreferenceCandidate.expectedSalarynull. No user.* fallback.candidate card + detail
resolveTalentSalary(structured, user)live activeCV.jobPreferenceuser.*talents page only

Callers:

  • buildCandidateRowsWithTalents (candidate card) and CandidateDetails.tsx (candidate detail) both use resolveCandidateSalary.
  • buildRows (talents page) uses resolveTalentSalary — the live view legitimately needs the user.* fallback.
  • The unused legacy buildCandidateRows function was deleted; it carried the same wrong-priority bug and would have been a landmine for any future caller.
Apollo normalisation leak — applies beyond salary

Apollo Client merges all selections for a given __typename + id into one normalised entity. When two queries touch the same User and only one of them selects a sensitive field, readers of the other query can still see that field through the cache. For employer-facing candidate views the rule is: never fall back to user.* for preference-style data (salary, availability, preferred-work-site, …) — even if the field isn't in the component's query. If the data needs to come from the live user, read it from a talent/jobseeker view; otherwise it must come from the snapshot or the denormalised candidate columns only.

Contact button email fallback (SUP-00053, 2026-04-21)

The candidate-detail header's Contact button resolves the target email through a fallback chain so manual-entry talents (who have no linked User record) still get an active button:

  1. candidate.user?.email — primary
  2. structured?.profile?.email — CV profile email (kept frozen by the snapshot contract above)
  3. candidate.talent?.user?.email — the talent's linked user, if the candidate was created from a talent pool
  4. candidate.additionalEmails[] — any employer-added contact email, supports both string entries and { email } object entries

When every candidate resolves to an empty string (or nothing is a valid .+@.+\..+ match), the button stays rendered but disabled with a tooltip "No email on this candidate's profile or CV" instead of silently greying out.

Backfilling existing data

For environments deployed before this change shipped, existing candidates still point at live CVs and need to be migrated. See CV Snapshot Backfill for the operational runbook.

Candidate Status Flow

StatusDescription
NEWApplication received, not yet reviewed
IN_REVIEWRecruiter is evaluating the candidate
INTERVIEWCandidate is in the interview stage
APPROVEDCandidate is hired or accepted
REJECTEDCandidate was rejected (with optional reason)
WITHDRAWNCandidate withdrew their application

Status transitions emit candidate.status.changed events, which trigger notifications to the recruiter and — if the candidate source is INVITED — to the candidate as well.

Sources: DIRECT_APPLICATION, TALENT_POOL_MATCH, TALENT_POOL, MANUAL_ENTRY, INVITED

Active vs Inactive Candidates

The platform repeatedly needs to answer "is this candidate still actively engaged with this application?" — when scheduling a new interview, when listing the candidate's interviews/meetings in the UI, when handing out a Jitsi join link. The single source of truth lives in src/talent-management/candidate/candidate-status.constants.ts:

GroupStatuses
ACTIVE_CANDIDATE_STATUSESNEW, IN_REVIEW, INTERVIEW, APPROVED
INACTIVE_CANDIDATE_STATUSESWITHDRAWN, REJECTED

The module also exports isInactiveCandidateStatus(status) / isActiveCandidateStatus(status) predicates and two reusable Prisma where fragments (activeCandidateStatusFilter, meetingExcludingInactiveCandidatesFilter). Every domain check that needs the active/inactive distinction imports from this module — never inline string comparisons.

tip

Adding a new "inactive" status (e.g., BLOCKED, EXPIRED) means updating INACTIVE_CANDIDATE_STATUSES once. Every downstream filter, guard, and cascade picks it up automatically.

Withdrawal Lifecycle

Application withdrawal is handled exclusively by the dedicated withdrawCandidate mutation. The generic updateCandidate mutation rejects any attempt to set status to WITHDRAWN and points the caller back to withdrawCandidate. This invariant is enforced in CandidateService.update() and exists so the cleanup cascade can never be silently bypassed.

What the cascade does:

Side effectMechanism
Interview.statusCANCELLEDcancelBookingFromSystemEvent
InterviewBooking.statusCANCELLED (cancelledAt, cancelReason populated)cancelBookingFromSystemEvent
Meeting.statusCANCELLED (cancelledAt, cancelReason populated)cancelBookingFromSystemEvent
interview.cancelled event fires for each cancelled bookingExisting InterviewNotificationListener emails the recruiter and other attendees
Withdrawn candidate loses Jitsi join accessRuntime gate in MeetingService.getAttendeeLink (see Security & Authentication)
info

withdrawCandidate is idempotent. A second call on an already-withdrawn candidate is a no-op: it returns the existing record without re-emitting candidate.withdrawn and without re-running the cascade. The listener's pre-filter further skips bookings that have already reached a terminal state (CANCELLED / COMPLETED / NO_SHOW).

Rejection asymmetry

The cascade fires only on candidate.withdrawn, not on rejection. Rejection is recruiter-initiated and the recruiter may legitimately want to keep the room available for a wrap-up conversation. Rejected candidates are still blocked from joining the Jitsi room by the runtime gate at getAttendeeLink, but their existing bookings are not proactively cancelled.

Notification Rules

Automatic email and in-app notifications to candidates are restricted based on the candidate's source:

SourceAuto NotificationsRationale
INVITEDYes (email + in-app)Candidate was explicitly invited via the job seeker invitation flow
All othersNoPipeline owner decides when to contact non-invited candidates

This applies to:

  • Pipeline status changes (status changed, hired, rejected)
  • Interview notifications (scheduled, rescheduled, cancelled, no-show)
  • Interview reminders (24h, 6h, 1h before)
  • Job closure notifications to candidates

Internal team notifications (to job owner, recruiters, hiring contacts) are always sent regardless of candidate source.

Manual Notifications

Pipeline owners can manually send notifications to any candidate using the notifyCandidate mutation:

mutation {
notifyCandidate(input: {
candidateId: "candidate-uuid"
subject: "Update on your application"
message: "We'd like to invite you for an interview..."
})
}

Authorization: Only the job owner, responsible user, hiring company contact, or users with Company Admin/Recruiter roles can send manual notifications. The mutation sends both an in-app notification and an email.

Candidate Notes

Recruiters can attach notes to candidates for internal tracking. Notes are author-owned -- only the creator can edit or delete a note.

mutation {
createCandidateNote(createCandidateNoteInput: {
candidateId: "candidate-uuid"
content: "Strong technical skills, schedule second round"
}) { id content createdAt }
}

Notes are ordered by createdAt DESC by default.

Key Queries

# Current user's own applications
query {
myApplications {
id status
job { title company { name } }
createdAt
}
}

# Detailed candidate with all relations
query {
candidateDetailed(id: "candidate-uuid") {
id status matchScore
talent { user { firstName lastName } activeCv { url } }
job { title }
interviews { id status scheduledAt }
notes { id content author { firstName } }
}
}

Key Mutations

# Recruiter adds a talent as candidate
mutation {
addTalentAsCandidate(addTalentAsCandidateInput: {
talentId: "talent-uuid"
jobId: "job-uuid"
}) { id status }
}

# User self-applies
mutation {
applyAsCandidate(applyAsCandidateInput: {
jobId: "job-uuid"
motivation: "I have 5 years of experience..."
}) { id status }
}

# Withdraw application
mutation {
withdrawCandidate(withdrawCandidateInput: {
id: "candidate-uuid"
withdrawalReason: "Accepted another offer"
}) { id status }
}

# Manually notify a candidate (pipeline owner only)
mutation {
notifyCandidate(input: {
candidateId: "candidate-uuid"
subject: "Interview invitation"
message: "We'd like to schedule an interview..."
})
}

Match Insights (Employer View)

When a candidate has a match score computed by the AI matching engine, the employer can view a Match Insights tab on the candidate detail page. This surfaces the full breakdown stored in MatchScore.details — data that was previously computed but not exposed in the UI.

There are two distinct score surfaces, and they read from different places:

SurfaceSourceLive or frozen
List/board score chip (Match 82%)UserJobMatchScore keyed by userId — the same table the job seeker's own myMatchScore readsLive
Match Insights tab breakdownCandidate.matchReport (the frozen MatchScore snapshot)Frozen at add-time
List/board chip reads a single live source

useJobCandidates now reads the score chip from one source — UserJobMatchScore by userId — instead of the older Candidate.matchScore / MatchScore snapshot. Because this is the same table the job seeker's myMatchScore reads, the employer and job-seeker match views no longer diverge.

The chip's score type is number | null. A null renders a dashed "—" placeholder (Match —) while the score is still being computed — it is never shown as 0%. (0% is a real computed score; means "pending / not yet computed".)

Match Insights detail is a snapshot, not a live value

Candidate.matchReport is keyed to the Candidate's (usually frozen) CV snapshot, so the breakdown an employer sees on the Match Insights tab is the one computed when the candidate was added — it does not move if the talent later updates their live CV. This mirrors how Candidate.cvId itself is a snapshot. The report is populated asynchronously by the candidate.created event chain; see Matching Engine — Candidate Match Snapshots. The Match Insights modal that pops up right after adding a candidate polls matchReport every 4 s for up to 60 s while the bg-tasks endpoint computes the score.

What the employer sees

SectionData SourceDescription
Score OverviewMatchScore.score + details.detailedReport.fit_assessmentOverall percentage, hiring recommendation badge, basic score chips (skills, semantic, education)
Scoring Breakdowndetails.detailedReport.scoring_breakdown6 progress bars: technical skills, experience, education, location, salary, cultural fit
Strengthsdetails.detailedReport.strengths[]Green-accented list of candidate strengths
Gapsdetails.detailedReport.gaps[]Amber-accented list of identified gaps with impact assessment
Recommendationsdetails.detailedReport.recommendations[]Blue-accented actionable suggestions for improving the match
Skill Analysisdetails.detailedReport.skill_analysisChips grouped by: matched (green), missing critical (red), nice-to-have (amber), transferable (blue)
Experience Analysisdetails.detailedReport.experience_analysisAdvantages and gaps lists
Fit Assessmentdetails.detailedReport.fit_assessmentCards for technical readiness, growth potential, hiring recommendation, onboarding complexity, time to productivity

Score scale

The matching engine stores scores on a mixed scale:

  • MatchScore.score and detailedReport.overall_match_score are 0–100 (from the unified scorer)
  • detailedReport.scoring_breakdown.* values are 0–1 floats (from the LLM)
  • details.basicScores.* values are 0–1 floats

The frontend normalizes all values to 0–1 before display using a normalize() helper that detects the scale automatically.

GraphQL field

The matchReport field on Candidate returns the full MatchScore object (including the details JSON):

query CandidateDetailed($id: ID!) {
candidateDetailed(id: $id) {
matchScore # Float (0-100, for quick display)
matchReport { # Full MatchScore object
id
score
details # JSON with basicScores + detailedReport
createdAt
}
}
}

Visibility rules

  • The Match Insights nav item only appears in the sidebar when matchReport.details is non-null
  • The match score chip in the sidebar is clickable when insights are available — clicking navigates to the tab
  • In "resume mode" (viewing a talent outside of a job context), the tab is hidden

Pipeline Positioning

Each candidate has an order field used to position them within pipeline stages. This enables drag-and-drop reordering in the frontend's Kanban view. Candidates also link to SlotOffers for interview scheduling.

Job-detail board data sources

The job-detail candidates Kanban resolves each card's display fields from the structured CV profile first, falling back to the User record only when the CV profile is missing the value:

  • Name, title, avatar, salary — read from the structured CV profile first, then User.* as a fallback. GET_JOB_BY_ID selects CvStructuredListFields alongside talent.source so the card has the profile data to prefer.
  • Applied date — comes from Candidate.createdAt (falling back to job.createdAt). This keeps recruiter-added candidates from being back-dated to the job's creation date; the date reflects when the candidate was actually attached to the job.

Candidate, CandidateNote, Interview, MatchScore, Talent, CV, Job, TalentPool