job-engine-spring
job-engine-spring is a local-only Model Context Protocol (MCP) server for storing normalized candidate profiles and jobs, producing explainable profile-to-job match reports, and generating resume documents. It is built with Java 25, Spring Boot, Spring AI, PostgreSQL, and Flyway.
The project is an MCP backend, not a hosted service, web UI, public REST API, or job-board scraper. Normal operation uses Streamable HTTP published only on host loopback. PostgreSQL is private to Docker Compose, and isolated STDIO exists only for CI, package verification, and diagnostics.
Quickstart: choose a path
Prerequisites
- Git
- Docker Engine or Docker Desktop with Docker Compose v2
- Python 3 for the bundled MCP smoke client
- Java 25 only when running Maven directly on the host
Get the deployment files
Both paths require this checkout for Compose, deployment scripts, the environment template, and the smoke client. The published-release path does not build source and does not require host Java or Maven.
git clone https://github.com/JonHHH09/job-engine-spring.git
cd job-engine-spring
The deployment scripts below create the git-ignored .env for you on first run
(./scripts/bootstrap-local-env.sh), with the local operator boundary enabled and a
freshly generated private bearer token. Run that script directly if you want the
environment file prepared before deploying, or copy .env.example to .env yourself
when you would rather keep the operator boundary off.
Run a published release (recommended for users)
Deploy a chosen published image with the guarded helper. Always resolve the current latest tag from GitHub Releases first — do not hardcode or assume a version number, since it changes with every release:
gh release list --limit 1
# or, without gh:
curl -fsSL https://api.github.com/repos/JonHHH09/job-engine-spring/releases/latest | grep '"tag_name"'
Then deploy that tag:
./scripts/run-release-mcp-http.sh ghcr.io/jonhhh09/job-engine-spring:v<semver> # e.g. v0.1.25
For an immutable deployment, pass the full published image digest instead of a tag. The helper accepts only ghcr.io/jonhhh09/job-engine-spring:v<semver> tags or a full digest, prepares .env, pulls the image, and recreates the persistent service without building. The helper deliberately rejects the mutable :latest tag even though stable releases publish it — always pass the explicit resolved version tag.
The deployment brings up both local surfaces: the MCP endpoint on /mcp and the privileged operator MVC boundary on /operator/ and /api/operator/v1/**. Both stay published on host loopback only, and the operator API still requires the bearer token from .env. Set JOB_ENGINE_SKIP_ENV_BOOTSTRAP=true to deploy without touching .env, or set JOB_ENGINE_OPERATOR_ENABLED in .env to any value other than false (for example disabled) to keep your own choice — the bootstrap never overwrites a value you set deliberately.
Build from source (for development)
Use this path when changing application code or testing local source changes:
./scripts/run-local-mcp-http.sh
That helper bootstraps .env the same way and then runs docker compose up -d --build --force-recreate --wait postgres mcp. Use the raw Compose command instead when you want full manual control of the environment file.
Verify and connect
After either deployment path, verify the persistent service:
python3 scripts/smoke-mcp-http.py
The smoke command initializes an MCP session, discovers the tool surface, calls health, and fails if the database is not ready. The supported endpoint is:
http://127.0.0.1:8080/mcp
The operator boundary is reachable in a browser at http://127.0.0.1:8080/operator/. Its JSON API requires the token stored in .env:
TOKEN="$(grep '^JOB_ENGINE_OPERATOR_BEARER_TOKEN=' .env | cut -d= -f2-)"
curl -fsS -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/operator/v1/health
Do not publish the MCP port on a non-loopback host address, and do not publish PostgreSQL. The values in .env.example are synthetic local-development defaults; keep the copied .env file private and replace its password before using the environment for anything beyond isolated local development.
Configure a Streamable HTTP MCP server named job-engine-spring with the URL above. For Hermes Agent:
mcp_servers:
job-engine-spring:
url: http://127.0.0.1:8080/mcp
enabled: true
timeout: 120
connect_timeout: 60
Reload the client's MCP connections, then call health. A successful response reports database readiness plus sanitized generated-resume cleanup queue counts, expired completed-row count, oldest-due age, and repeated-failure state without returning credentials, file paths, timestamps, or failure details. Cleanup health is degraded while any completed rows are older than the configured retention window.
Completed generated-resume cleanup audit rows are retained for 30 days and then deleted daily. Each run drains up to 10 batches of 1,000 rows by default, for a capacity of 10,000 rows per run, and stops early after a short or empty batch. Operators can tune the retention window, batch size, maximum batches per run, schedule, five-minute oldest-due health threshold, and three-attempt repeated-failure threshold with the JOB_ENGINE_CLEANUP_* environment variables documented in application.yaml. Increase batch size, maximum batches, or schedule frequency when completed-row throughput can exceed the configured per-run capacity. Cleanup warnings and errors use a dedicated stderr-only logger so STDIO stdout remains reserved for MCP JSON-RPC.
First useful workflow
Use synthetic text for the first job insertion; ordinary hostname job-board URLs are intentionally unsupported by direct link ingestion because URL fetching is restricted to connection-bound public IP-literal targets.
Call add_job_from_text with content similar to:
Senior Java Engineer at Example Corp in Berlin. Build local backend services
with Java 25, Spring Boot, PostgreSQL, Docker, and MCP. Five years of backend
experience required. Full-time.
Optionally provide structured fields such as title, company, location, skills, seniority, and employment type. Then call list_jobs or search_jobs to read the normalized result. Do not use real resumes, private applications, credentials, or production data while evaluating the project.
Persistence, source updates, and release upgrades
PostgreSQL data persists in the Compose-managed postgres-data volume.
Stop containers while preserving data:
docker compose down
Update a source-built checkout without deleting the database volume:
git pull --ff-only
./scripts/run-local-mcp-http.sh
python3 scripts/smoke-mcp-http.py
Before a significant source or release upgrade, create a transaction-consistent backup:
./scripts/postgres-backup.sh
To upgrade a published release safely, resolve the current latest tag (gh release list --limit 1, or immutable digest), then deploy, smoke-test, and reload the MCP client:
./scripts/postgres-backup.sh
./scripts/run-release-mcp-http.sh ghcr.io/jonhhh09/job-engine-spring:v<semver> # e.g. v0.1.25
python3 scripts/smoke-mcp-http.py
# Reload your MCP client connections (for Hermes Agent: /reload-mcp).
Pulling an image alone never changes a running container. Image rollback is not database rollback: never use an older image to reverse Flyway migrations. Use the explicit, disposable-target recovery procedure in PostgreSQL backup and recovery when database recovery is required.
Destructively uninstall the local containers and delete the PostgreSQL volume:
docker compose down -v
That final command permanently deletes local application data. See PostgreSQL backup and recovery before using it on data you need to keep.
Project status and non-goals
- The server is under active development; MCP schemas and persisted data contracts may evolve between pre-1.0 releases.
- Only the latest published release receives security fixes.
- The supported network model is local-only. Exposing MCP or PostgreSQL to another machine is unsupported.
- Provider-backed enrichment is optional. Deterministic storage, search, and matching remain independently usable.
- Direct hostname URL ingestion is deliberately unavailable; use
add_job_from_textfor normal job-board content.
See CONTRIBUTING.md for development and pull-request requirements, SUPPORT.md for issue routing, SECURITY.md for private vulnerability reports, and CODE_OF_CONDUCT.md for community expectations.
Persistent match analysis
The service persists immutable, explainable profile-to-job match reports in the match PostgreSQL schema. The deterministic deterministic-v1 baseline uses technical (40), experience/seniority (25), domain (15), delivery (10), and hard-requirement (10) components. Missing structured evidence is UNKNOWN, reduces confidence, and is never replaced with unstructured domain or delivery inference. Scores are normalized over known evidence, so known evidence can produce STRONG_MATCH while confidence remains lower. A job skill's required flag alone is not blocker provenance.
Advisory reviews are stored separately and never replace the baseline. Review summaries use only review_consistent, score_adjustment, outcome_adjustment, or evidence_defect_identified; free-form summaries are rejected. Advisory evidence must exactly reuse a fact from the deterministic baseline or use one of structured_evidence_missing, structured_evidence_incorrect, requirement_provenance_missing, or outcome_calibration_issue. Divergence policy divergence-v1 creates a deduplicated disagreement for an overall delta of at least 15, an outcome or blocker mismatch, a component delta of at least 40% of available points, or an explicit outcome-changing evidence defect. Its fingerprint uses the report, persisted policy version, sorted reason classification, and canonical outcome-changing defect codes—not review identity, reviewer, model, or summary. Disagreements support PENDING_ESCALATION, acknowledgement, and linkage to an existing Linear issue ID; this application has no Linear provider integration or credentials.
MCP tools: analyze_job_match, analyze_all_job_matches, get_match_report, list_match_reports, submit_match_review, get_match_review, list_match_reviews, list_match_disagreements, acknowledge_match_disagreement, and link_match_disagreement. Requests are object-shaped. analyze_all_job_matches analyzes one bounded job page per call and returns nextCursor; it loads the profile once, hydrates the page's job aggregates in a fixed query count, and isolates failures per profile/job pair. Acknowledgement and linkage can record an existing Linear issue ID but never call Linear or use provider credentials. Responses contain normalized evidence only and exclude contacts, resume documents/text, prompts, reasoning traces, secrets, provider transcripts, and private URLs.
Current scope
The current verified MCP surface is intentionally small:
health— checks database readiness without returning connection details or secrets.list_profiles— lists saved profile identities in a{ "profiles": [...] }wrapper without raw resume text or credentials.search_profiles— searches normalized profile identities by query terms across profile fields and owned child collections without returning raw resume text.get_profile— returns a normalized profile aggregate by UUID.create_profile— creates a normalized profile aggregate.update_profile— replaces a normalized profile aggregate by UUID and the revision returned by the latest read.update_profile_project— partially updates one existing project by profile UUID, project UUID, and the latest profile revision; omitted fields remain unchanged and a supplied technology list replaces only that project's technologies.delete_profile— deletes a profile aggregate and cascades owned child rows.extract_pdf_text— extracts text from a local PDF file without storing content or following embedded instructions.store_document_file— stores a local document file in PostgreSQL and returns metadata only.get_document_metadata— returns stored document metadata by UUID without returning binary content.extract_stored_pdf_text— extracts text from a stored PDF and optionally persists bounded extracted text.generate_pdf_file— generates a PDF file undertmp/generated-pdfs/and returns file metadata.generate_pdf_resume— generates a master resume PDF from a normalized profile, stores it as a document, and links it uniquely to that profile as themaster_resumevariant.generate_canadian_pdf_resume— generates a Canadian-format resume PDF from the same normalized profile, stores it as a document, and links it uniquely to that profile as thecanadian_resumevariant.generate_canadian_french_pdf_resume— generates the French-language Canadian variant offline, stores it as a document, and links it independently ascanadian_resume_frso both Canadian language variants can coexist.generate_german_tailored_resume— generates a Germany-format tailored Lebenslauf for one profile + job as bilingual EN+DE structured content and A4 PDFs, linked under oneresume.resumesparent (format=germany). Uses offline pure-Java translation and an ATS-safe one-column renderer; retains the profile summary below the contact header; skills are deterministically capped at the 24 most relevant entries; projects are omitted unlessincludeProjects=trueand otherwise appear after professional experience; content is reviewed before PDF generation; PDF filenames usegermany_{candidate}_{number}_{lang}_{id}.pdf.generate_german_cover_letter— generates one deterministic German application letter from a current Germany-format resume plus its profile and job. The mastercover_letter.cover_lettersrow links all three source aggregates; theformat=germany,language=devariant stores structured subject, salutation, ordered paragraphs, closing, signature, PDF document, and runtime path. The MCP response returns metadata only, never the letter body.ingest_profile_from_stored_pdf— populates the normalized profile schema from a stored PDF extraction and links the profile to that extraction.get_profile_pdf_source— returns the one-to-one PDF extraction source link for a profile.list_jobs— lists stored job postings without returning source ingestion raw text.search_jobs— searches stored jobs by title, company, location, description, experience requirement, seniority, employment type, and required skills.get_job— returns a stored job aggregate by UUID, including normalized skills and insertion provenance.update_job— partially updates a stored job using the revision returned by the latest read; omitted fields preserve current values, while a provided skills list replaces existing skills.delete_job— hard-deletes a stored job by UUID.add_job_from_text— inserts a job from pasted text plus optional structured fields; source text is hashed for idempotency and is not stored raw in the method-specific table.add_job_from_link— inserts a job from a public IP-literal HTTP(S) URL by fetching page content and combining extracted page text with optional structured fields. Hostname URLs are rejected until the HTTP client can bind validation to the address it actually connects to. If the page fetch is blocked, returns HTTP 4xx/5xx, or resolves to bot/security-check content, the tool rejects the insertion instead of creating a false job; useadd_job_from_textfor ordinary job-board URLs. The caller’s full retrieval URL is used only for the fetch itself and is never persisted or returned over MCP.scan_arbeitnow_jobs— read-only, bounded discovery over the public Arbeitnow job-board API. It never writes a job.import_arbeitnow_job— verifies and imports one short-lived signed candidate returned byscan_arbeitnow_jobs, without refetching the provider.
The application is MCP-first. Do not add REST controllers unless REST compatibility is explicitly required.
Architecture
The project follows a Spring Boot-first hexagonal layout:
domain— pure Java records/value objects with no Spring, MCP, JDBC, or persistence annotations.application— use cases, ports, transactions, and safe application errors.adapter/in/mcp— thin Spring AI MCP tool adapters.adapter/in/http/operator— a separately secured, local-only MVC operator boundary; it calls application use cases and never MCP adapters.adapter/out/postgres— PostgreSQL/JDBC adapters behind application ports.
Flyway owns schema creation and evolution under src/main/resources/db/migration. Treat applied V*__*.sql migrations as immutable; add a new versioned migration for schema changes.
Configuration
Configure the PostgreSQL connection through environment variables or safe local defaults:
Set JOB_ENGINE_CURSOR_HMAC_SECRET to at least 32 high-entropy characters when pagination cursors must remain valid across process restarts. When omitted, the server generates an ephemeral process-local key and existing cursors expire on restart.
spring:
main:
web-application-type: servlet
datasource:
url: ${JOB_ENGINE_POSTGRES_URL:jdbc:postgresql://localhost:5432/job_engine}
username: ${JOB_ENGINE_POSTGRES_USER:}
password: ${JOB_ENGINE_POSTGRES_PASSWORD:}
job-engine:
document:
import-root: ${JOB_ENGINE_DOCUMENT_IMPORT_ROOT:tmp/imports}
Never commit real credentials, private resume data, API keys, production connection details, or machine-local absolute paths. Configuration paths must not be hardcoded; use environment placeholders, documented safe relative defaults, or caller-supplied runtime settings.
MCP is local-only. Normal operation uses a persistent Streamable HTTP MCP container published only on the host loopback interface. McpLocalOnlyStartupGuard accepts loopback HTTP and the explicitly marked container runtime while rejecting unsafe transport/bind combinations. Tool schemas do not carry per-call access tokens; the security boundary is the loopback-only Docker publication plus the local OS boundary. STDIO remains available only through the stdio Spring profile for CI, release verification, and isolated engineering diagnosis. Local file imports for PDF extraction/storage are restricted to job-engine.document.import-root by default; generated resume PDFs bypass that import-root check because they are produced internally under tmp/generated-pdfs/. Job URL fetching is SSRF-hardened: redirects are not followed, local/private/metadata/userinfo targets are rejected before send, and only public IP-literal hosts are accepted so the validated address is the address used by the connection. Hostname allow-list configuration was intentionally removed because resolving a hostname before HttpClient connects does not prevent DNS-rebinding/TOCTOU attacks.
For STDIO MCP, keep banner/log output off stdout so JSON-RPC messages are not polluted.
Privileged operator HTTP boundary
/api/operator/v1/** and /operator/** serve the local operator UI/API. The application default is still off (job-engine.operator.enabled falls back to false, and every operator route answers 404 while it is false), so any deployment that does not supply the environment variables keeps the boundary closed. The supported local deployment turns it on: scripts/bootstrap-local-env.sh — invoked automatically by scripts/run-release-mcp-http.sh and scripts/run-local-mcp-http.sh — writes JOB_ENGINE_OPERATOR_ENABLED=true plus a freshly generated 64-character JOB_ENGINE_OPERATOR_BEARER_TOKEN into the git-ignored .env. The token is generated locally, never printed, and never committed.
Enabling changes only who may reach the routes, not where they are published. Requests to the API require that bearer token, an exact loopback Host, and, when supplied, an exact same-origin loopback Origin; the API sends Cache-Control: no-store and never enables CORS. Browser-facing /operator/** responses receive restrictive CSP and browser security headers. Startup still fails when the boundary is enabled with a token shorter than 32 characters. Do not expose this boundary through a proxy or non-loopback address.
The peer check is runtime-aware. On a host runtime only loopback peers are accepted. In the guarded container runtime (JOB_ENGINE_MCP_CONTAINERIZED=true) the port is still published on host loopback only, but Docker rewrites the source address of that published traffic to the container's default gateway, so that one gateway address is accepted as well. Sibling containers on the same bridge network keep their own addresses and are rejected, and the bearer token plus loopback Host/Origin checks apply on top. X-Forwarded-* headers are never trusted.
To opt out, either set JOB_ENGINE_SKIP_ENV_BOOTSTRAP=true before running a deployment script, or set JOB_ENGINE_OPERATOR_ENABLED in .env to an explicit non-default value such as disabled; the bootstrap rewrites the flag only when it is missing or literally false.
Local persistent MCP deployment
The normal local deployment is a persistent Spring Boot Streamable HTTP MCP container. Docker owns the application lifecycle and Hermes connects to http://127.0.0.1:8080/mcp, so reconnecting the client does not replace the application container.
Build and verify the local jar with:
./scripts/rebuild-local-mcp-jar.sh
The script runs ./mvnw test, packages target/job-engine-spring-0.0.1-SNAPSHOT.jar, and then runs hermes mcp test job-engine-spring when the Hermes CLI is available. To skip unit tests after they have already passed, run:
RUN_TESTS=false ./scripts/rebuild-local-mcp-jar.sh
Rebuilding a jar or pulling an image updates an artifact on disk only. Recreate the persistent service to deploy it, then run /reload-mcp in the active Hermes session. If tool names, argument schemas, prompts, or resources changed, start a fresh Hermes session with /reset after reloading so the tool schema in the agent context is current.
scripts/restart-local-mcp-server.sh is kept as a local maintenance helper because it stops stale matching local jar subprocesses, rebuilds the jar without recursively running the MCP smoke test, and optionally runs the server in foreground STDIO mode. It remains local-only and does not expose an HTTP daemon.
Local containerized MCP development details
compose.yaml starts private PostgreSQL plus the persistent MCP service. PostgreSQL has no host port. MCP publishes container port 8080 only as 127.0.0.1:${JOB_ENGINE_MCP_PORT:-8080} and uses restart: unless-stopped.
The source-development quickstart builds and starts this service. When iterating after it is already running, rebuild the local image with:
docker compose build mcp
Then recreate the persistent source-built service and rerun the shared smoke check:
docker compose up -d --wait postgres mcp
python3 scripts/smoke-mcp-http.py
After changing a deployed source image, reload the MCP client (for Hermes Agent: /reload-mcp). If tool names, argument schemas, prompts, or resources changed, start a fresh Hermes session with /reset after reloading so the tool schema in the agent context is current.
Isolated STDIO verification
The legacy scripts/run-local-mcp-container.sh launcher explicitly activates the stdio profile. It is retained for CI and release-package checks. Interactive diagnosis must use the unique-name launcher below so it cannot remove the persistent Compose mcp service.
For diagnosis or parallel smoke while Hermes is connected, use the safe diagnostic launcher (unique container name by default):
./scripts/run-mcp-stdio-diag.sh
MCP_CONTAINER_BUILD=never python3 scripts/smoke-mcp-stdio.py -- ./scripts/run-mcp-stdio-diag.sh
Recovery when Hermes reports MCP unreachable:
- Check
docker compose ps mcp postgresand the persistent service health. - Run
python3 scripts/smoke-mcp-http.pyto verify the transport independently. - Run
/reload-mcponce and call nativehealthbefore CRUD tools. - Use
run-mcp-stdio-diag.shonly for release/engineering diagnosis.
Host-visible local file imports are mounted from tmp/imports/ into the container as read-only files, and generated PDFs are mounted through tmp/generated-pdfs/. Paths outside that import root (for example Hermes document cache under ~/.hermes/cache/documents/) are not visible inside the MCP container; copy files into tmp/imports/ before store_document_file. Never change the MCP host bind from loopback or publish the database port without a separate authenticated-network architecture decision.
Smoke-test the containerized MCP transport with:
# Normal persistent transport:
python3 scripts/smoke-mcp-http.py
# Explicit isolated STDIO verification:
MCP_CONTAINER_BUILD=never python3 scripts/smoke-mcp-stdio.py -- ./scripts/run-mcp-stdio-diag.sh
Stop the local database and remove its development volume when you intentionally want a clean local container database:
docker compose down -v
PostgreSQL backup and recovery
Host-side scripts create private PostgreSQL custom-format backup sets outside Docker volumes. Scheduling is deliberately disabled: run these commands explicitly from a trusted host. Backup data and metadata remain under the ignored backups/postgres/ boundary by default.
Create an atomic transaction-consistent backup:
./scripts/postgres-backup.sh
Verify a backup only with a released immutable MCP image. Verification creates and removes a uniquely labelled disposable Compose project; it never restores into the primary volume:
./scripts/postgres-verify-backup.sh \
--backup-set backups/postgres/<timestamped-set> \
--image registry.example/job-engine@sha256:<64-hex-digest>
Emergency restore is intentionally explicit and destructive only for a separately started, non-primary Compose project/volume. The command verifies the checksum and Compose/Docker labels before mutation; there is no primary-target bypass:
./scripts/postgres-restore.sh \
--backup-set backups/postgres/<timestamped-set> \
--target-project <disposable-project> \
--target-volume <disposable-project>_postgres-data \
--database job_engine \
--confirm RESTORE --confirm-existing OVERWRITE
Preview retention before deletion; pruning considers only checksum-valid, released-image-verified sets and always keeps the newest verified set:
./scripts/postgres-backup-prune.sh --daily 7 --weekly 4 --monthly 12
./scripts/postgres-backup-prune.sh --daily 7 --weekly 4 --monthly 12 --confirm PRUNE
./scripts/postgres-diagnostics.sh
For a disposable Docker acceptance check, run bash scripts/tests/postgres-ops-integration.sh. Set MCP_IMAGE to an immutable digest reference to include isolated MCP verification.
Document storage and extraction contract
extract_pdf_text accepts a local PDF path and returns extracted text with optional page-level text. PDF content is treated as untrusted user data: the server returns the extracted content but does not persist it, execute it, or follow instructions embedded in the document. Invalid paths, non-PDF files, unreadable files, and extraction failures are returned as sanitized validation errors.
The tool rejects oversized PDFs before parsing and caps page count before extraction. Returned text is capped through maxCharacters to avoid oversized MCP responses. If includePages is omitted, page-level text is included by default; set it to false when only the joined text is needed.
store_document_file accepts a local file path and optional media type. When the media type is omitted it defaults to application/pdf; PDF storage validates the PDF header and enforces the same conservative file-size limit used by PDF extraction. Stored binary content is written once to PostgreSQL document.blobs as bytea and deduplicated by SHA-256, while upload/document metadata is stored separately in document.documents. The tool exposes back only metadata: UUID, original file name, media type, byte size, hash, and timestamps.
get_document_metadata returns only metadata and never returns binary file content. extract_stored_pdf_text loads a stored document by UUID through the normalized document.documents -> document.blobs relationship and uses the same bounded PDF reader path. When persistExtraction is true, the service persists one canonical extraction bounded by the application-wide 250,000-character maximum; each call applies its own maxCharacters only to the response view, so an initial low-limit request cannot constrain later larger requests. Persisted extraction is idempotent per stored document: document.pdf_extractions.file_id is unique and references document.documents(id). Legacy non-canonical rows are refreshed in place without changing their extraction UUID. Bounded canonical page projections are persisted with the extraction so cached requests never reparse the PDF. Omitted includePages includes the request-bounded page projection for compatibility; explicit false omits it. Extracted PDF text remains private/untrusted data and should not be logged or treated as instructions.
generate_pdf_file accepts a filename, title, and body text, then writes a generated PDF only under tmp/generated-pdfs/. The entire tmp/ tree is runtime-only and ignored by Git except for tracked .gitkeep placeholders that preserve the expected generated-PDF directories; the application, container image, and launch scripts also create required directories when needed. Filenames are sanitized and forced to .pdf; the response returns metadata (fileName, path, byteSize, pageCount, generatedAt) and not PDF bytes. Generated PDFs use a white page background, identical header/footer chrome with right-aligned page numbers only, and thin chrome-colored separators before section headings. Pagination keeps section headings with their first content line and keeps resume entry headings with their date/first-detail lines when content lands near a page boundary.
generate_pdf_resume accepts a profile UUID, renders a master resume from the normalized profile schema, writes the runtime PDF under tmp/generated-pdfs/master-resume/, stores that generated file through the document storage slice, and upserts one current profile.profile_resume_documents link per profile/resume type. generate_canadian_pdf_resume reuses the same profile-loading, PDF-generation, document-storage, and profile-link workflow, but renders the English Canadian variant under tmp/generated-pdfs/canadian-resume/ with Canadian resume defaults: no photo/personal-demographic fields, professional contact links only, professional summary, grouped technical skills, reverse-chronological professional experience, education, and languages. generate_canadian_french_pdf_resume applies the same ATS-safe Canadian structure in French under tmp/generated-pdfs/canadian-resume-fr/; headings, labels, date fallbacks, and supported narrative phrases are translated by a deterministic offline glossary. Span-aware preservation keeps email addresses and URLs unchanged inside translated narrative fields, while profile-supplied full names, company/institution/project names, and technology/skill values are also protected from glossary substitution. The current PDF font supports whitespace, printable ASCII (U+0020–U+007E), and printable Latin-1 (U+00A0–U+00FF); this French variant rejects unsupported title/body characters with a sanitized validation error instead of silently replacing protected text. It links as the independent canadian_resume_fr type, so regeneration replaces only the prior French Canadian artifact. Both Canadian variants intentionally omit projects so education remains visible and the resume stays concise. generate_german_tailored_resume requires profileId and jobId, builds structured Lebenslauf content tailored to the job (no North-American summary), translates EN→DE offline with an embedded glossary/phrase map, stores normalized sections/entries/bullets for both languages under one resume.resumes parent unique on (profile_id, job_id, format=germany), writes both PDFs under tmp/generated-pdfs/german-resume/, and replaces previous EN/DE variants with orphan document/file cleanup. Optional profile personal details live in profile.profile_personal_details (DOB, nationality, optional photo document). Resume PDFs use a modern ATS-safe single-column layout with compact margins, chrome header/footer bars with white right-aligned page numbers only, an inline contact/link header, larger name/title treatment, grouped skills, indented bullets, and thin chrome separators before section headings. Responses return profile/document/link/generated-file metadata only; they do not return PDF bytes or rendered resume body text.
generate_german_cover_letter requires profileId, jobId, and the exact resumeId returned by a current Germany-format tailored-resume generation. It rejects mismatched or stale source revisions before rendering and revalidates them inside the serialized persistence transaction. The A4 selectable-text PDF reuses the German résumé's restrained typography, colors, margins, and divider treatment and is written under JOB_ENGINE_GERMAN_COVER_LETTER_OUTPUT_DIR (default tmp/generated-pdfs/german-cover-letter/). Regeneration atomically replaces the German variant and durably queues old documents/files for cleanup. Regenerating the linked résumé first removes its now-stale cover letter under the same PostgreSQL advisory lock, preventing stale links and orphaned generated assets.
Profile PDF ingestion contract
ingest_profile_from_stored_pdf orchestrates stored-document extraction, conservative profile-field extraction, normalized profile persistence, and provenance linking. It always extracts stored PDFs with persistExtraction=true, then creates or updates a profile through ProfileService so profile validation/canonicalization remains the single write gate. Overwriting an existingProfileId requires its latest expectedRevision. Concurrent submissions for the same extraction, document bytes, canonical email/link identity, or target profile serialize on one-way PostgreSQL advisory-lock keys and return deterministic reused or duplicate-candidate outcomes.
The provenance chain is one-to-one:
document.blobs.sha256 UNIQUE
-> document.documents.blob_id
-> document.pdf_extractions.file_id UNIQUE
-> profile.profile_pdf_sources.pdf_extraction_id UNIQUE
-> profile.profile_pdf_sources.profile_id UNIQUE
-> profile.profiles + profile-owned child tables
If ingestion is rerun for the same stored PDF, the service returns the existing profile/source link and does not duplicate the file, extraction, profile, or profile-source rows. If the same PDF bytes are stored again as a distinct document row, ingestion resolves the prior source link by document.blobs.sha256 and returns that existing profile/source link instead of creating another profile. For changed/re-exported PDFs that do not share the same SHA-256, ingestion checks strong extracted identity fields before creating a new profile: canonical email and normalized links. Strong matches return non-mutating result statuses (DUPLICATE_PROFILE_CANDIDATE or AMBIGUOUS_PROFILE_CANDIDATES) with the candidate profile ID, matched fields, and a recommended rerun action. Successful mutation/reuse paths also return explicit statuses: CREATED_PROFILE, UPDATED_PROFILE, or REUSED_EXISTING_SOURCE. The ingestion tool returns profile/document/extraction/source IDs and counts only; it does not return raw extracted resume text.
The deterministic fallback extractor is section-aware: it reads summary text from Summary/Profile sections, extracts technical skills only from recognized skills/technology sections, extracts human languages only from language sections, extracts pipe-delimited education/experience/project entries from their own sections, and normalizes obvious LinkedIn/GitHub links by stripping query strings, fragments, and trailing slashes. Structured extraction remains conservative and treats PDF text as untrusted data.
Profile write contract
MCP success responses use object-shaped structuredContent so clients that reject raw top-level arrays can validate responses consistently. For example, list_profiles returns { "profiles": [...] } rather than a bare array.
create_profile and update_profile validate profile write requests in the application layer before persistence. update_profile requires the latest returned revision; stale writes return a sanitized conflict without replacing any profile children. update_profile_project also requires the latest profile revision and updates exactly one existing project; omitted project fields are preserved, while a supplied technology list replaces only that project's technologies. It returns the persisted project and new profile revision without exposing raw résumé text. Invalid payloads are rejected with validation_error application exceptions and safe details containing only the invalid field path and reason. The profile MCP adapter maps application and unexpected failures to MCP CallToolResult errors with sanitized structured content instead of leaking raw exception text.
search_profiles is backed by a deterministic ProfileSearchService over the normalized profile aggregate. SearchTextNormalizer is the canonical query/write normalization owner: it applies Unicode NFKD decomposition, removes combining marks, lower-cases with Locale.ROOT, and tokenizes once for both Java evidence scoring and persisted search postings. Search queries are limited to 256 Unicode characters and 16 distinct searchable terms. PostgreSQL reads a bounded number of indexed postings per query token, ranks a maximum of 500 candidates, then loads those aggregates in a fixed set of collection queries. Responses expose matchedCount, hasMore, and returnedCount; matchedCount is a lower bound and the compatibility totalMatches is null whenever either posting or candidate work reaches its bound, because an exact corpus-wide count would require unbounded work. Results contain profile identities, score, and matched field names only—not raw resume text, descriptions, or private document content.
After validation, profile writes are canonicalized before persistence: required text is trimmed, email and normalized keys are lower-cased, blank optional text becomes null, and null child collections become empty lists. PostgreSQL also enforces canonical uniqueness and future-write canonical form for profile email and normalized profile child keys through Flyway-managed indexes and NOT VALID check constraints. Persistence constraint failures are mapped to sanitized validation errors.
Currently validated examples include:
- non-blank
fullNameand email - basic email shape
- required contact, link, skill, language, and project technology fields
- non-negative display order values
- education/experience date ranges
- duplicate normalized skills, languages, links, contacts, education entries, experiences, projects, and project technologies within one request
Database constraints still protect persistence integrity, but expected request problems should fail before PostgreSQL constraint errors.
Job insertion contract
Job storage uses the Flyway-managed job_schema schema. Canonical job fields live in job_schema.jobs, required/normalized skills live in job_schema.job_skills, and method-specific provenance is separated by insertion path:
job_schema.job_text_ingestionsfor pasted-text insertion metadata, keyed by a SHA-256 hash of canonical source text.job_schema.job_link_ingestionsfor URL insertion metadata, keyed by normalized URL and storing fetch status/title provenance.
Every stored job has exactly one provenance source, and it must match job_schema.jobs.source_method: text-backed jobs require exactly one job_text_ingestions row and no job_link_ingestions row, while link-backed jobs require the inverse. The invariant is enforced in the domain aggregate, in the PostgreSQL repository write path, and by Flyway migration V14__enforce_job_source_provenance.sql, after the V13 safe URL identity migration.
add_job_from_text and add_job_from_link are idempotent, but link identity is intentionally split into three forms. The full retrieval URL is ephemeral and used only for the outbound fetch. Persisted/MCP-visible url is a redacted safe display URL with userinfo, query, and fragment removed. Persisted normalized_url is the canonical link identity used for dedupe; it retains only constrained posting identifiers for recognized ATS hosts (jk on Indeed and gh_jid on Greenhouse). Tracking values such as gh_src, referral/session values, unrecognized-host query parameters, and malformed or oversized identifiers are always dropped. Link submissions first check normalized_url, then the canonical job fingerprint, which now includes the canonical link identity for link-based jobs so distinct ATS posting IDs do not collapse. Text submissions remain keyed by input_text_hash and then the content fingerprint. The direct link path is deliberately restricted to public IP-literal URLs; use pasted text for normal hostname-based job boards. The application layer derives a conservative title, required skills, and experience requirement from the supplied/fetched text when explicit fields are absent, but callers can override the structured fields when better data is available. update_job requires the latest returned revision, preserves omitted fields, recalculates the canonical fingerprint when title/company/location/description change, replaces skills only when a skills list is provided, and preserves insertion provenance; stale writes return a sanitized conflict before child replacement. delete_job removes the canonical row and cascades owned skill/provenance rows through the Flyway-managed schema.
Job search uses the same canonical Unicode normalizer, 256-character/16-term query limits, and indexed weighted-posting design as profile search. It deterministically ranks title/company/location/description/experience/employment/seniority/skills, hydrates at most 500 candidates in a fixed set of queries, and returns matched field names with a score. Its count metadata follows the same matchedCount/hasMore contract and nullable compatibility totalMatches. The score is internal search metadata, not applicant-facing resume content.
Arbeitnow scan-to-import contract
scan_arbeitnow_jobs reads only the documented public Arbeitnow endpoint (https://www.arbeitnow.com/api/job-board-api), with attribution and a link back to the provider. Use of its data remains subject to Arbeitnow's applicable terms and the provider's availability/rate limits. The service never follows a provider-supplied pagination URL; it accepts only the exact documented HTTPS endpoint on www.arbeitnow.com or arbeitnow.com, then requests the fixed endpoint itself. Returned job URLs are canonical no-www provider URLs (https://arbeitnow.com/view/{slug}).
The object-shaped scan request accepts optional case-insensitive all-term query (maximum 256 characters and 16 terms), location (128 characters), remoteOnly, documented visaSponsorship, tags and jobTypes filters (up to 10 nonblank values of 128 characters), limit (default 25, maximum 100), maxPages (default 1, maximum 5), and an opaque cursor. Results include bounded normalized plain text, source/attribution, inspected/page counts, a continuation cursor when applicable, and a signed candidateToken per returned job. Scans are mutable provider reads, not a snapshot; there is no bulk-import or automation endpoint and no language classification.
All exposed provider text is normalized to bounded plain text: markup (including script/style), C0/C1 controls, tabs, newlines, and repeated whitespace are removed or collapsed. Structural slugs are validated rather than truncated. Each candidate token has a strict versioned Arbeitnow payload and an HMAC-SHA-256 signature; malformed, extra, missing, expired, cross-provider, or tampered values are rejected without parsing details being exposed. Tokens expire after at most 15 minutes, reject issue times more than 60 seconds in the future, and use a process-local signing key, so every server restart invalidates previously issued tokens. Clients must treat tokens and cursors as opaque.
import_arbeitnow_job accepts exactly one candidateToken. It verifies the token before any persistence and makes no Arbeitnow HTTP request. A valid import reuses the established job/link provenance and normalized-URL idempotency: it returns the existing job as reused_existing_job when present, otherwise created_job. Imported provenance uses sourceLabel=arbeitnow; tags seed job skills, the first provider job type seeds employment type, and the remote flag is bounded provenance metadata only. Existing job CRUD behavior is unchanged.
Bounded list pagination
list_jobs, list_profiles, analyze_all_job_matches, list_match_reports, list_match_reviews, and list_match_disagreements accept limit (default 20, maximum 100) plus an opaque versioned cursor. Match review and disagreement lists may be filtered by reportId; omitting it lists across reports. The cursor embeds the immutable created_at, id keyset, a first-page database-time watermark, and a SHA-256 fingerprint of the tool scope and filters; an HMAC-SHA-256 envelope authenticates the complete payload. Deleting the prior page's anchor or updating mutable disagreement state does not invalidate traversal; rows whose immutable created_at is later than the first-page watermark are intentionally excluded. This is bounded watermark traversal, not a cross-request PostgreSQL MVCC snapshot: a transaction that began before the first page and commits afterward can still contribute a row when its created_at is at or before the watermark. Oversized, malformed, tampered, cross-tool, and changed-filter cursors return a sanitized validation_error. Cursors are API tokens and clients must not parse or manufacture them. Legacy requests that omit the new paging fields retain the bounded default page, and null request objects remain valid for list_jobs and list_profiles.
Document tools remain point operations (store_document_file, get_document_metadata, and extraction/generation by UUID); there is no document-list snapshot API or residual unbounded document listing in this change. If document listing is added later, it must adopt the same opaque snapshot/keyset policy rather than exposing raw UUID anchors.
Verification
Run focused unit tests while working on profile or document behavior:
./mvnw -q -Dtest=ProfileWriteValidatorTests,ProfileWriteCanonicalizerTests,ProfileServiceTests,ProfileMcpAdapterTests test
./mvnw -q -Dtest=PdfTextExtractionServiceTests,DocumentStorageServiceTests,PdfGenerationServiceTests,GeneratePdfResumeServiceTests,OfflineFrenchResumeTranslatorTests,DocumentMcpAdapterTests,DocumentPdfGenerateResumeMcpTests,DocumentCaPdfGenerateResumeMcpTests,DocumentCanadianFrenchPdfGenerateResumeMcpTests test
./mvnw -q -Dtest=ProfilePdfIngestionServiceTests,ProfilePdfIngestionMcpAdapterTests test
./mvnw -q -Dtest=JobServiceTests,JobMcpAdapterTests,HttpJobLinkContentFetcherTests test
./mvnw -q -Dtest=PostgresProfileSearchIntegrationTests,PostgresJobSearchIntegrationTests,PostgresJobRepositoryIntegrationTests test
Run the unit test suite before claiming non-database completion:
./mvnw test
Run Docker-backed PostgreSQL integration tests and the JaCoCo coverage gate explicitly when Docker/Testcontainers is available:
./mvnw -Pintegration-tests verify
The GitHub Actions pipeline runs fast validation and unit tests on pull requests to development. Feature/fix pull requests targeting master additionally run the Docker-backed integration/coverage gate, containerized MCP STDIO smoke, and Qodana. A development → master promotion PR is a special case whose candidate tree must match origin/development exactly; the same promotion guard runs on direct pushes to master as defense in depth. Trusted pushes to development and manual CI runs also retain the heavy integration and container smoke gates. Successfully merging a same-repository development → master pull request automatically starts the release workflow; direct pushes, fork pull requests, other source branches, and closed-but-unmerged pull requests cannot publish. The workflow confirms the merge commit is still the current origin/master tip and tree-identical to the immutable head SHA recorded on the merged promotion pull request, so later legitimate changes on development do not invalidate the release. It then increments the patch component of the highest stable vMAJOR.MINOR.PATCH tag (ignoring prerelease tags). A rerun reuses a stable tag only when it already points to the exact verified source commit. Automatic promotions share a serialized concurrency group with cancel-in-progress: false; GitHub retains only one pending run per group, so rapid overlapping promotions may coalesce and only the newest pending promotion is attempted. Stale runs fail closed when their source is no longer the current master tip. Manual dry runs use a per-run group and cannot displace an automatic promotion. The workflow runs clean full verification (./mvnw clean -Drevision=<version> -Pintegration-tests verify), copies the verified Maven jar to a versioned release artifact, builds and smoke-tests linux/amd64 and linux/arm64 release images from that same verified jar, records and transfers each exact smoke-tested platform image, and generates CycloneDX and SPDX SBOMs for the jar and each image. Immediately before publication, it rechecks the verified source against current master, creates and pushes the annotated release tag idempotently, and publishes without relying on that bot-created tag push to trigger another workflow. The publishing job verifies each loaded image ID and the corresponding registry config digest before composing a GHCR multi-platform manifest list. Stable releases also publish :latest; the workflow verifies both required platforms, signs and attests the immutable manifest-list digest with keyless Cosign, then uploads the jar, checksum, per-platform SBOMs, image IDs/digests, and verification reports as release artifacts. Docker Desktop automatically selects the matching Linux architecture on macOS and Windows; the project does not publish darwin/* or Windows-container variants.
Container inputs are digest-pinned in Dockerfile, Dockerfile.release, and compose.yaml. .github/dependabot.yml tracks both GitHub Actions and Docker references weekly so pinned bases and pinned workflow actions can be refreshed intentionally instead of drifting silently.
Releases are promotion-driven; do not create or push release tags manually during the normal release path. Merge a reviewed development → master pull request after its required checks pass, and the successful merge event performs the next stable patch release automatically.
To exercise the release flow safely without publishing anything, run .github/workflows/release.yml manually from a branch or development. The workflow dispatch path performs clean Maven verification, verified-jar packaging, builds and smoke-tests both Linux release architectures, and generates jar plus per-platform image SBOMs. It also exercises keyless Cosign signing and SLSA provenance attestation against SHA256SUMS, then verifies both with the expected GitHub Actions OIDC workflow identity. Manual dispatch always remains a dry run: it never creates a tag or GitHub Release and never publishes a GHCR package. Download the dry-run artifacts and verify the JAR checksum locally with:
sha256sum -c SHA256SUMS
The Maven Wrapper now records distributionSha256Sum for the pinned Apache Maven 3.9.16 distribution. When updating .mvn/wrapper/maven-wrapper.properties, download the exact replacement archive from Apache, verify its upstream checksum/signature, compute its SHA-256 locally, and update both distributionUrl and distributionSha256Sum together.
License
job-engine-spring is licensed under the Apache License 2.0. Third-party dependencies remain subject to their respective licenses.