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:
| Path | Mutation | Who |
|---|---|---|
| Self-application | applyAsCandidate | The user applies directly to a job |
| Recruiter addition | addTalentAsCandidate | A recruiter attaches an existing talent to a job |
| Internal creation | createCandidate | Admin/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.
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.activeCvIdis independent ofCandidate.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 checkTalentAuthorizationService.hasWriteAccessToCV()all reject any CV withisApplicationSnapshot = 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
| Path | Mutation | Snapshot? |
|---|---|---|
| Self-application | applyAsCandidate | Always |
| Recruiter addition | addTalentAsCandidate | Yes, unless the talent is editable on the company side |
| Internal creation | createCandidate | Caller'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 user has not yet claimed the profile (talent.source === 'MANUAL_ENTRY' && user.isOnboarding === false). For every other combination — real applied job seekers, manual entries whose user has onboarded, etc. — the CV is snapshotted.
Frontend rule
The same canEditCandidate(source, isOnboarding) helper lives at lib/utils/candidates/canEditCandidate.ts and is consumed by:
CandidateDetails.tsx— hides the "Edit Profile" buttonapp/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)
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:
-
CV id resolution —
cvId = candidate?.cv?.id ?? candidate?.talent?.activeCV?.id. Snapshot first; the?? activeCVtail is a legacy fallback for pre-backfill rows only. -
Structured extraction —
getStructuredFromCandidate(candidate)indataTransformers.tsreturnsextractStructuredFromCv(candidate.cv) ?? extractStructuredFromCv(candidate.talent?.activeCV). The siblinggetStructuredFromTalentcorrectly stays onactiveCV— the talent pool view is intentionally live. -
Query shape —
GET_CANDIDATE_DETAILEDingraphql/operations/candidate/queries.tsdeliberately does not fetch:- the
talent.activeCVblock — 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)intransformCandidateDataForResume
Identity fields (name, email, phone, avatar, linkedin/portfolio URLs,
isActive,isOnboarding,jobMatchScores) and thecv { ...CvStructuredFields }block are the sanctioned selection. Any new field added touserin 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
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.
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:
-
2026-04-21 — the original
candidateRowBuilder.tspreferred the denormalisedCandidate.expectedSalaryover the CV snapshot'sCvJobPreference.expectedSalary, so a candidate card showed40 000while the detail page showed50 000. The row builder was flipped to read snapshot first, withCandidate.expectedSalaryas a fallback. -
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
Userby id, soGET_COMPANY_TALENTS(which legitimately fetchesuser.expectedSalaryfor the talents page) hydrates that field into the normalised cache — and the candidates list was reading it back throughcandidate.user.expectedSalaryeven though its own queryGET_COMPANY_CANDIDATES_ONLYnever 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:
| Helper | Priority | Used by |
|---|---|---|
resolveCandidateStructured(candidate, liveTalentCv?) | candidate.cv (snapshot) → talent.activeCV (live, legacy fallback only) | all candidate views |
resolveCandidateIdentity(structured, user) | structured CV profile → user.* identity fields | all candidate views |
resolveCandidateSalary(structured, candidate) | snapshot jobPreference → Candidate.expectedSalary → null. No user.* fallback. | candidate card + detail |
resolveTalentSalary(structured, user) | live activeCV.jobPreference → user.* | talents page only |
Callers:
buildCandidateRowsWithTalents(candidate card) andCandidateDetails.tsx(candidate detail) both useresolveCandidateSalary.buildRows(talents page) usesresolveTalentSalary— the live view legitimately needs theuser.*fallback.- The unused legacy
buildCandidateRowsfunction was deleted; it carried the same wrong-priority bug and would have been a landmine for any future caller.
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:
candidate.user?.email— primarystructured?.profile?.email— CV profile email (kept frozen by the snapshot contract above)candidate.talent?.user?.email— the talent's linked user, if the candidate was created from a talent poolcandidate.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
| Status | Description |
|---|---|
NEW | Application received, not yet reviewed |
IN_REVIEW | Recruiter is evaluating the candidate |
INTERVIEW | Candidate is in the interview stage |
APPROVED | Candidate is hired or accepted |
REJECTED | Candidate was rejected (with optional reason) |
WITHDRAWN | Candidate 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:
| Group | Statuses |
|---|---|
ACTIVE_CANDIDATE_STATUSES | NEW, IN_REVIEW, INTERVIEW, APPROVED |
INACTIVE_CANDIDATE_STATUSES | WITHDRAWN, 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.
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 effect | Mechanism |
|---|---|
Interview.status → CANCELLED | cancelBookingFromSystemEvent |
InterviewBooking.status → CANCELLED (cancelledAt, cancelReason populated) | cancelBookingFromSystemEvent |
Meeting.status → CANCELLED (cancelledAt, cancelReason populated) | cancelBookingFromSystemEvent |
interview.cancelled event fires for each cancelled booking | Existing InterviewNotificationListener emails the recruiter and other attendees |
| Withdrawn candidate loses Jitsi join access | Runtime gate in MeetingService.getAttendeeLink (see Security & Authentication) |
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).
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:
| Source | Auto Notifications | Rationale |
|---|---|---|
INVITED | Yes (email + in-app) | Candidate was explicitly invited via the job seeker invitation flow |
| All others | No | Pipeline 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.
Candidate.matchReport is keyed to the Candidate's (usually frozen) CV snapshot, so the score an employer sees 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 score 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
| Section | Data Source | Description |
|---|---|---|
| Score Overview | MatchScore.score + details.detailedReport.fit_assessment | Overall percentage, hiring recommendation badge, basic score chips (skills, semantic, education) |
| Scoring Breakdown | details.detailedReport.scoring_breakdown | 6 progress bars: technical skills, experience, education, location, salary, cultural fit |
| Strengths | details.detailedReport.strengths[] | Green-accented list of candidate strengths |
| Gaps | details.detailedReport.gaps[] | Amber-accented list of identified gaps with impact assessment |
| Recommendations | details.detailedReport.recommendations[] | Blue-accented actionable suggestions for improving the match |
| Skill Analysis | details.detailedReport.skill_analysis | Chips grouped by: matched (green), missing critical (red), nice-to-have (amber), transferable (blue) |
| Experience Analysis | details.detailedReport.experience_analysis | Advantages and gaps lists |
| Fit Assessment | details.detailedReport.fit_assessment | Cards for technical readiness, growth potential, hiring recommendation, onboarding complexity, time to productivity |
Score scale
The matching engine stores scores on a mixed scale:
MatchScore.scoreanddetailedReport.overall_match_scoreare 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.detailsis 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.
Related Prisma Models
Candidate, CandidateNote, Interview, MatchScore, Talent, CV, Job, TalentPool