Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/phase-2-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ Startup invokes a small runner which delegates to `CandidateProfileBootstrapServ

Older version facts are never updated by the mapper. Only their active marker and update timestamp change when a higher version becomes active. Logs contain only the version and fact counts.

### Runtime candidate compatibility boundary

Candidate-specific analysis and document generation resolve the configured
`jobpilot.candidate-profile.candidate-key` to its persistent candidate row. Runtime resolution
does not create a candidate and does not fall back to whichever candidate happens to exist. Each
candidate may have its own active profile, so multiple active profiles may coexist in one database
and `profileVersion` is unique only within a candidate.

Analysis persists the selected `candidate_profile_id`. Document generation uses that exact profile
identity and verifies that its owning candidate matches the configured runtime candidate;
`profileVersion` remains response and audit metadata, not a globally unique identity. HTTP and
Telegram contracts remain unchanged. Authenticated per-request candidate selection is future work.

## Validation

Validation covers required text, bounded string and collection sizes, positive profile versions, reasonable education years, non-negative bounded commercial Java experience, stable-key syntax, typed categories and levels, duplicate stable keys, duplicate active facts, and duplicate normalized technology/keyword values.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,4 @@ public interface CandidateProfileRepository extends JpaRepository<CandidateProfi
Optional<CandidateProfile> findByCandidateIdAndActiveTrue(Long candidateId);

Optional<CandidateProfile> findByCandidateIdAndProfileVersion(Long candidateId, int profileVersion);

/**
* Legacy installation-wide lookups. The database permits version reuse and one active profile
* per candidate, so these methods are safe only while their callers operate with one configured
* candidate. {@code JobAnalysisService} and {@code ResumeGenerationService} are migrated to
* candidate-scoped access when candidate context reaches those workflows.
*/
Optional<CandidateProfile> findByActiveTrue();

Optional<CandidateProfile> findByProfileVersion(int profileVersion);

long countByActiveTrue();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.jobpilot.candidate.service;

import com.jobpilot.candidate.config.CandidateProfileProperties;
import com.jobpilot.candidate.repository.CandidateRepository;
import java.util.Optional;
import org.springframework.stereotype.Service;

/** Resolves the configured compatibility identity for candidate-specific runtime workflows. */
@Service
public class RuntimeCandidateContext {
private final CandidateProfileProperties properties;
private final CandidateRepository candidates;

public RuntimeCandidateContext(CandidateProfileProperties properties,
CandidateRepository candidates) {
this.properties = properties;
this.candidates = candidates;
}

public Optional<Long> candidateId() {
return candidates.findByStableKey(properties.candidateKey())
.map(candidate -> candidate.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.jobpilot.candidate.domain.CandidateProfile;
import com.jobpilot.candidate.repository.CandidateProfileRepository;
import com.jobpilot.candidate.service.RuntimeCandidateContext;
import com.jobpilot.config.JobPilotProperties;
import com.jobpilot.jobs.domain.ExtractedRequirements;
import com.jobpilot.jobs.domain.Job;
Expand Down Expand Up @@ -46,6 +47,7 @@ public class JobAnalysisService {
private final JobRepository jobs;
private final JobRequirementRepository requirements;
private final CandidateProfileRepository profiles;
private final RuntimeCandidateContext candidateContext;
private final JobAnalysisRepository analyses;
private final JobAnalysisJson analysisJson;
private final JobAnalysisPromptBuilder prompts;
Expand All @@ -61,7 +63,9 @@ public class JobAnalysisService {
private final TransactionTemplate transactions;

public JobAnalysisService(JobRepository jobs, JobRequirementRepository requirements,
CandidateProfileRepository profiles, JobAnalysisRepository analyses,
CandidateProfileRepository profiles,
RuntimeCandidateContext candidateContext,
JobAnalysisRepository analyses,
JobAnalysisJson analysisJson, JobAnalysisPromptBuilder prompts,
JobAnalysisCacheKey keys,
LlmStructuredResponseValidator validator,
Expand All @@ -73,6 +77,7 @@ public JobAnalysisService(JobRepository jobs, JobRequirementRepository requireme
this.jobs = jobs;
this.requirements = requirements;
this.profiles = profiles;
this.candidateContext = candidateContext;
this.analyses = analyses;
this.analysisJson = analysisJson;
this.prompts = prompts;
Expand Down Expand Up @@ -137,7 +142,9 @@ private Preparation prepare(long jobId, boolean candidateSpecific) {
CandidateProfile profile = null;
CandidateTruthSnapshot truth = null;
if (candidateSpecific) {
profile = profiles.findByActiveTrue().orElse(null);
profile = candidateContext.candidateId()
.flatMap(profiles::findByCandidateIdAndActiveTrue)
.orElse(null);
if (profile == null) return Preparation.immediate(result(
JobAnalysisResultStatus.PROFILE_NOT_FOUND, null, jobId,
null, null, null));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.jobpilot.candidate.repository.CandidateProjectBulletRepository;
import com.jobpilot.candidate.repository.CandidateProjectRepository;
import com.jobpilot.candidate.repository.CandidateSkillRepository;
import com.jobpilot.candidate.service.RuntimeCandidateContext;
import com.jobpilot.jobs.domain.Job;
import com.jobpilot.jobs.repository.JobRepository;
import com.jobpilot.llm.application.JobAnalysisService;
Expand Down Expand Up @@ -68,6 +69,7 @@
public class ResumeGenerationService {
private final JobRepository jobs;
private final CandidateProfileRepository profiles;
private final RuntimeCandidateContext candidateContext;
private final CandidateSkillRepository skills;
private final CandidateLanguageRepository languages;
private final CandidateProjectRepository projects;
Expand Down Expand Up @@ -98,6 +100,7 @@ public class ResumeGenerationService {

public ResumeGenerationService(
JobRepository jobs, CandidateProfileRepository profiles,
RuntimeCandidateContext candidateContext,
CandidateSkillRepository skills, CandidateLanguageRepository languages,
CandidateProjectRepository projects, CandidateProjectBulletRepository bullets,
JobAnalysisRepository analyses, JobAnalysisJson analysisJson,
Expand All @@ -114,6 +117,7 @@ public ResumeGenerationService(
Clock clock, PlatformTransactionManager transactionManager) {
this.jobs = jobs;
this.profiles = profiles;
this.candidateContext = candidateContext;
this.skills = skills;
this.languages = languages;
this.projects = projects;
Expand Down Expand Up @@ -384,13 +388,21 @@ public DocumentDownload downloadCoverNote(long id, DocumentFormat format) {

private Context context(JobAnalysisResult result) {
Job job = jobs.findById(result.jobId()).orElseThrow();
CandidateProfile profile = profiles.findByActiveTrue().orElse(null);
if (profile == null || result.candidateProfileVersion() == null
|| profile.getProfileVersion() != result.candidateProfileVersion()) return null;
Long candidateId = candidateContext.candidateId().orElse(null);
if (candidateId == null) return null;
JobAnalysis analysis = analyses.findById(result.analysisId()).orElseThrow();
CandidateProfile analysisProfile = analysis.getCandidateProfile();
if (analysisProfile == null) {
throw new IllegalStateException("Candidate-specific analysis has no candidate profile");
}
CandidateProfile profile = profiles.findById(analysisProfile.getId()).orElseThrow();
if (!analysis.getJob().getId().equals(job.getId())
|| !profile.getCandidate().getId().equals(candidateId)
|| !profile.getId().equals(analysisProfile.getId())
|| !java.util.Objects.equals(analysis.getCandidateProfileVersion(),
profile.getProfileVersion())) {
profile.getProfileVersion())
|| !java.util.Objects.equals(result.candidateProfileVersion(),
analysis.getCandidateProfileVersion())) {
throw new IllegalStateException("Analysis identity is incompatible with document facts");
}
return new Context(CandidateDocumentFacts.from(profile),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,25 @@ class CandidateProfileBootstrapIntegrationTest {

@Test
void validProfileIsBootstrappedAndRepeatedBootstrapIsIdempotent() {
CandidateProfile active = profiles.findByActiveTrue().orElseThrow();
CandidateProfile active = activeConfiguredProfile();
long before = profiles.count();

CandidateProfileBootstrapResult result = bootstrap.bootstrap(configuredProfile);

assertThat(result.created()).isFalse();
assertThat(result.profileId()).isEqualTo(active.getId());
assertThat(profiles.count()).isEqualTo(before);
assertThat(profiles.countByActiveTrue()).isOne();
assertThat(profiles.findByCandidateIdAndActiveTrue(configuredCandidate().getId()))
.contains(active);
assertThat(active.getSkills()).hasSize(65);
assertThat(active.getLanguages()).hasSize(4);
assertThat(active.getProjects()).hasSize(4);
}

@Test
void bootstrappedProfileIsOwnedByTheConfiguredCandidate() {
CandidateProfile active = profiles.findByActiveTrue().orElseThrow();
Candidate owner = candidates.findByStableKey(configuredProfile.candidateKey()).orElseThrow();
CandidateProfile active = activeConfiguredProfile();
Candidate owner = configuredCandidate();

assertThat(active.getCandidate().getId()).isEqualTo(owner.getId());
assertThat(owner.getStableKey()).isEqualTo("default");
Expand Down Expand Up @@ -93,12 +94,13 @@ void candidatesIndependentlyBootstrapTheSameVersionAndRemainActive() {
assertThat(profiles.findByCandidateIdAndProfileVersion(other.getId(), 1))
.contains(otherProfile);
assertThat(profiles.findByCandidateIdAndActiveTrue(owner.getId())).contains(configured);
assertThat(profiles.countByActiveTrue()).isEqualTo(2);
assertThat(profiles.findAll()).filteredOn(CandidateProfile::isActive).hasSize(2);
}

@Test
void higherVersionCreatesNewActiveVersionAndPreservesPreviousFacts() {
CandidateProfile previous = profiles.findByActiveTrue().orElseThrow();
Candidate owner = configuredCandidate();
CandidateProfile previous = activeConfiguredProfile();
String originalName = previous.getFullName();
String originalSourceHash = previous.getSourceHash();
String originalSkill = previous.getSkills().getFirst().getDisplayName();
Expand All @@ -108,9 +110,9 @@ void higherVersionCreatesNewActiveVersionAndPreservesPreviousFacts() {

assertThat(result.created()).isTrue();
assertThat(profiles.count()).isEqualTo(2);
assertThat(profiles.countByActiveTrue()).isOne();
CandidateProfile current = profiles.findByActiveTrue().orElseThrow();
CandidateProfile storedPrevious = profiles.findByProfileVersion(1).orElseThrow();
CandidateProfile current = profiles.findByCandidateIdAndActiveTrue(owner.getId()).orElseThrow();
CandidateProfile storedPrevious = profiles.findByCandidateIdAndProfileVersion(owner.getId(), 1)
.orElseThrow();
assertThat(current.getProfileVersion()).isEqualTo(2);
assertThat(storedPrevious.isActive()).isFalse();
assertThat(storedPrevious.getFullName()).isEqualTo(originalName);
Expand All @@ -119,4 +121,12 @@ void higherVersionCreatesNewActiveVersionAndPreservesPreviousFacts() {
assertThat(storedPrevious.getProjects().getFirst().getBullets().getFirst().getVerifiedText())
.isEqualTo(originalBullet);
}

private Candidate configuredCandidate() {
return candidates.findByStableKey(configuredProfile.candidateKey()).orElseThrow();
}

private CandidateProfile activeConfiguredProfile() {
return profiles.findByCandidateIdAndActiveTrue(configuredCandidate().getId()).orElseThrow();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.jobpilot.candidate.service;

import static com.jobpilot.candidate.CandidateProfileTestData.validProfile;
import static com.jobpilot.candidate.CandidateProfileTestData.withCandidateKey;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

import com.jobpilot.candidate.domain.Candidate;
import com.jobpilot.candidate.repository.CandidateRepository;
import java.util.Optional;
import org.junit.jupiter.api.Test;

class RuntimeCandidateContextTest {
private final CandidateRepository candidates = mock(CandidateRepository.class);

@Test
void resolvesOnlyTheConfiguredStableKeyToPersistentIdentity() {
Candidate candidate = mock(Candidate.class);
RuntimeCandidateContext context = new RuntimeCandidateContext(
withCandidateKey(validProfile(1), "configured-candidate"), candidates);
when(candidates.findByStableKey("configured-candidate")).thenReturn(Optional.of(candidate));
when(candidate.getId()).thenReturn(42L);

assertThat(context.candidateId()).contains(42L);

verify(candidates).findByStableKey("configured-candidate");
verifyNoMoreInteractions(candidates);
}

@Test
void missingConfiguredCandidateReturnsAbsentWithoutCreatingOrFallingBack() {
RuntimeCandidateContext context = new RuntimeCandidateContext(
withCandidateKey(validProfile(1), "missing-candidate"), candidates);
when(candidates.findByStableKey("missing-candidate")).thenReturn(Optional.empty());

assertThat(context.candidateId()).isEmpty();

verify(candidates).findByStableKey("missing-candidate");
verifyNoMoreInteractions(candidates);
}
}
24 changes: 17 additions & 7 deletions src/test/java/com/jobpilot/jobs/PostgresPersistenceIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,12 @@ void cascadesJobDeletionToRequirementsAndScores() {

@Test
void roundTripsCandidateProfileAndAllVerifiedFactTypes() {
CandidateProfile profile = candidateProfiles.findByActiveTrue().orElseThrow();
CandidateProfile profile = activeDefaultProfile();
entityManager.flush();
entityManager.clear();

CandidateProfile reloaded = candidateProfiles.findByProfileVersion(1).orElseThrow();
CandidateProfile reloaded = candidateProfiles.findByCandidateIdAndProfileVersion(
defaultCandidate().getId(), 1).orElseThrow();

assertThat(reloaded.getFullName()).isEqualTo("Pavlo Sushkov");
assertThat(reloaded.getCommercialJavaExperienceYears()).isEqualByComparingTo(BigDecimal.ZERO);
Expand Down Expand Up @@ -322,7 +323,7 @@ void differentCandidatesCanReuseAProfileVersionAndEachRemainActive() {
.contains(otherProfile);
assertThat(candidateProfiles.findByCandidateIdAndActiveTrue(other.getId()))
.contains(otherProfile);
assertThat(candidateProfiles.countByActiveTrue()).isEqualTo(2);
assertThat(candidateProfiles.findAll()).filteredOn(CandidateProfile::isActive).hasSize(2);
}

@Test
Expand Down Expand Up @@ -380,7 +381,7 @@ void tracksApplicationTransitionsAndImmutableHistoryWithOptimisticVersioning() {
void persistsResumeVersionFactReferencesAndCascadesResumeDeletion() {
Instant now = Instant.parse("2026-07-19T11:00:00Z");
Job job = jobs.saveAndFlush(job("resume", "https://example.com/jobs/resume", now));
CandidateProfile profile = candidateProfiles.findByActiveTrue().orElseThrow();
CandidateProfile profile = activeDefaultProfile();
var skill = candidateSkills.findByCandidateProfileIdOrderByDisplayOrder(profile.getId()).getFirst();
var language = candidateLanguages.findByCandidateProfileIdOrderByDisplayOrder(profile.getId())
.stream().filter(value -> value.isAllowedInCv()).findFirst().orElseThrow();
Expand Down Expand Up @@ -421,7 +422,7 @@ void persistsResumeVersionFactReferencesAndCascadesResumeDeletion() {
void persistsCoverNoteLinkedToVerifiedProfileAndResume() {
Instant now = Instant.parse("2026-07-19T12:00:00Z");
Job job = jobs.saveAndFlush(job("cover", "https://example.com/jobs/cover", now));
CandidateProfile profile = candidateProfiles.findByActiveTrue().orElseThrow();
CandidateProfile profile = activeDefaultProfile();
ResumeVersion resume = resumeVersions.saveAndFlush(new ResumeVersion(
job, profile, profile.getProfileVersion(), "JAVA DEVELOPER INTERN",
"Verified summary", "Preview", "Changes", "Claims", null, null,
Expand Down Expand Up @@ -470,7 +471,7 @@ void persistsLlmUsageAccountingWithoutRawPayloadsOrSecrets() {
void persistsStructuredAnalysisReservationAndDeterministicCacheIdentity() {
Instant now = Instant.parse("2026-07-19T13:30:00Z");
Job job = jobs.saveAndFlush(job("analysis", "https://example.com/jobs/analysis", now));
CandidateProfile profile = candidateProfiles.findByActiveTrue().orElseThrow();
CandidateProfile profile = activeDefaultProfile();
LlmBudgetReservation reservation = llmBudgetReservations.saveAndFlush(
new LlmBudgetReservation("1".repeat(64), job, LlmOperationType.JOB_ANALYSIS,
"synthetic-provider", "model-a", java.time.LocalDate.parse("2026-07-19"),
Expand Down Expand Up @@ -856,7 +857,16 @@ private Job job(String externalId, String url, Instant seenAt) {

private CandidateProfile profile(int version, boolean active) {
// Owned by the candidate V14 seeds; a profile cannot exist without an owner.
return profile(candidates.findByStableKey("default").orElseThrow(), version, active);
return profile(defaultCandidate(), version, active);
}

private Candidate defaultCandidate() {
return candidates.findByStableKey("default").orElseThrow();
}

private CandidateProfile activeDefaultProfile() {
return candidateProfiles.findByCandidateIdAndActiveTrue(defaultCandidate().getId())
.orElseThrow();
}

private CandidateProfile profile(Candidate candidate, int version, boolean active) {
Expand Down
Loading
Loading