Materials & Tailoring Audit
The materials pipeline turns canonical employer analysis into tailored, provenance-backed artifacts, then audits every displayed claim against the rendered text. The prompt/validation contract itself is documented in the Tailoring Contract.
Read this if you need to know how tailored artifacts are generated and how every displayed keyword, provenance, and truthfulness claim is audited.
The four Materials sub-steps run in order; each gate re-checks against the actual rendered text, and per-bullet provenance, coverage, and voice are served from canonical projection rows.
Canonical Employer Analysis (Materials sub-step)
The Materials context owns a persisted, inspectable "ideal candidate" analysis that is the source of truth for tailoring keyword selection. It is produced by a three-SDK agent ensemble — Claude Agent SDK + Codex SDK + Google Antigravity (Gemini) SDK — running in parallel and reconciled by a Claude synthesizer pass. The ensemble orchestrator is N-leg and partial-failure safe (asyncio.gather(return_exceptions=True)), so one SDK failure records a degraded per-leg failure instead of cancelling the healthy legs. Employer analysis runs through the analysis draft/synthesizer ports, not the generic LLM client.
- Domain model (
domain/materials/analysis.py):JobAnalysis/JobAnalysisDraft(Pydantic) carry role framing, inferred seniority, an ideal-candidate narrative, requirements classifiedmust_havevsnice_to_havewith a 0–1 priority weight, and reasoned keywords each tied to a quoted job-description evidence span and linked to the requirement they support (orphans allowed but flagged). TheEmployerAnalysisaggregate is generation-versioned (mirroringMaterialsSet) and retains the reconciled canonical record plus every per-model sub-analysis, the per-leg failures, and the cross-model agreement signal. - Grounding gate (
domain/materials/analysis_grounding.py): a deterministic normalized substring + snap-to-source validator (formatting-tolerant, content-exact). It locates each evidence span in the posting snapshot after folding formatting-insignificant variation (whitespace runs, Unicode hyphen/dash variants, smart quotes, case), then snaps the stored span to the JD's verbatim text at the match so persisted evidence is always copy-paste-findable. The match must align to whole-token boundaries (its outer edges border a non-alphanumeric or the string edge), so a short span cannot ground INSIDE a larger word (a fabricated"Go"against"goals"). A span whose WORDS are absent from the snapshot (a paraphrase, synonym, or hallucination) is still rejected — the cardinal correctness gate, run on every draft and on the synthesized canonical before persistence. JSON Schema cannot express this, so it is a separate hard check. - Ports + adapters (
domain/ports/materials.py,infrastructure/analysis/): the use case depends onAnalysisDraftPort/AnalysisSynthesizerPort, never on a concrete SDK. The ensemble runs the legs withasyncio.gather(..., return_exceptions=True)so one SDK failure never cancels the healthy legs; partial failures are persisted as degraded-ensemble audit data and the analysis records itslegs_succeeded / legs_attemptedcompleteness. A hard error surfaces only when all legs fail. There is no wall-clock timeout on the analysis path — the only stop is cooperative cancellation. - Reproducibility is a cache contract, not determinism: the analysis is keyed on
snapshot_hash + prompt-version + SDK-set-version. Re-tailoring the same posting reuses the cached canonical record instead of re-reasoning; an explicitforcerecompute supersedes (never destroys) the prior generation. - Lifecycle:
AnalyzeJobUseCaseruns as the_run_analyzefront-half sub-step of tailor (TailorResumeUseCaseconsumes the persisted analysis), and is also reachable standalone through theanalyze_jobJSON-RPC method. Persistence is canonical rows (job_employer_analysis+ per-model sub-analysis and failure child tables), nevermetadata_json. The use case publishesEmployerAnalyzed, which lands ajob_eventsrow so the projection rebuilds and the Server-Sent Events (SSE) invalidation router refreshes the job detail. - Read path: a single projection owner serves the analysis on the job-detail read model (
job_detail_projections.employer_analysis_json), built identically by the Python projection builder andapps/api/src/projections.tsand served byread-model.tsasJobDetail.employerAnalysis. A cross-runtime projection parity test covers the table on both runtimes. - Requirement fit read path: requirement-led scoring persists canonical
job_requirement_fit_reportsand orderedjob_requirement_fit_itemsrows. The Operations projection publishes the latest report tojob_detail_projections.requirement_fit_report_jsonand the API serves it asJobDetail.requirementFitReport. Compatibility score fields remain available for queue selectors and read models that need the compact score shape.
Per-Bullet Provenance + Granular Controls (Materials sub-step)
Building on the persisted employer analysis, every generated resume bullet (and the executive-profile / skills lines) carries a canonical provenance record so the user can trust each line — what real profile fact it derives from, which job requirement it serves, the transform that produced it, and the granular rule that governed it. Like the analysis, this is canonical rows, not metadata_json.
- Domain model (
domain/materials/provenance.py):BulletProvenanceis one record per rendered line —bullet_id(stable within job/generation/section/ index),section,source_id,evidence_ids(canonical profile evidence),requirement_ids(FK intoEmployerAnalysisrequirements),matched_keywords(verified against the generated text),transform_type,control, a humanrationale, andgenerated_text(the rendered line — the coverage anchor).BulletProvenanceSetis generation-versioned and bound to the artifact it explains; a forced/failed re-tailor writes a higher generation and never destroys the prior one.transform_typeandcontrolare closed enums invalue_objects.py:TransformType(verbatim / rephrase / reframe / synthesize_from_related / quantify_from_evidence) andControlRule(rephrase always allowed; invent only for closely-related experience; never fabricate metrics/titles/dates/employers). - Provenance builder (
domain/materials/provenance_builder.py): computes oneBulletProvenanceper bullet against the selected candidate's rendered text (using the sameresume_profilerendering helpers the assembler uses, so the text is identical), maps the existing change-type vocabulary to the closedTransformType, and bindsrequirement_idsas real foreign keys by matching the bullet against the analysis keywords. A fabricated evidence/requirement id is rejected before any row is built — provenance is FK bindings, not model-authored free text. - Deterministic never-fabricate detector (
domain/materials/fabrication_detector.py): a pure check that runs independently of the prompt. Every numeric, date, percentage, money, title, and company-suffixed employer token in a generated bullet must trace to recorded profile evidence; a token that does not is a fabrication and is hard-rejected at generation time. A metrics-hungry job paired with a numberless profile yields zero unsourced numerics in the output. - Deterministic prose skill/tool gate (same module, sibling of the numeric detector): the numeric/date/title/employer arms have no concept of a skill or tool, so a fabricated in-demand technology (Kubernetes, Terraform, Kafka, …) woven into an experience bullet or the executive summary would ship on the LLM judge alone.
scan_prose_skill_fabricationscloses that leak with an allowlist, not a denylist, scoped to invented named technologies so it never punishes concept keywords. It flags a job-TARGET keyword (from the persistedEmployerAnalysiskeywords) that is BOTH (1) a recognised named technology in the curatedKNOWN_TECHNOLOGY_LEXICON(languages/frameworks/cloud/ databases/tools) AND (2) grounds in NEITHER the candidate's profile-backed skill vocabulary (build_skill_vocabulary= skill-category items + evidence tools + evidence tags) NOR the evidence corpus. Grounding is word-form tolerant (scaled/scalable/scalabilitymutually ground), so a concept keyword the candidate demonstrated in a different word form is never a false positive; a pure concept/qualification keyword (scalability, observability, microservices) is never gated at all. A fabricatedKubernetesstill has no stem variant in a k8s-free profile, so it is still caught. Tools whose name is a homograph of a common word (HOMOGRAPH_TECHNOLOGY_TERMS= react, spark, rust, …) are the exception and require exact grounding, so a fabricated React cannot borrow the verbreacted. Matching is word-boundary anchored, so ordinary English words never false-fire; the skills SECTION is out of scope (it is governed by the skills-section allowlist). A hit is hard-rejected exactly like an invented metric (NEVER_FABRICATE_SKILLS). - Lifecycle: the gates (provenance FK bindings + never-fabricate numeric + prose skill/tool) run at two points. First, per candidate inside
TailorResumeUseCase._run_attempts: a candidate that clears the LLM judge but trips a gate is stampedfailed_fabrication_gate, dropped from selection, and its per-token findings are rendered asavoid_notesfed into the next attempt — the same retry channel the judge/adversarial rejections use — so the remaining retry budget is spent steering the generator off the exact fabricated token instead of failing the run outright. Second, after the selected candidate is voiced the same gate re-confirms against the rendered text (see the voice re-validation below). When the retry budget is exhausted with no clean candidate the tailor fails closed: validation is downgraded so the resume is not approved (the last accepted generation's artifact + provenance are preserved) and each rejected candidate's gate findings stay as repair-loop audit history. An accepted generation persists its rows throughBulletProvenanceRepositorytransactionally with the artifact and publishesBulletProvenanceRecorded(ajob_eventsrow → projection rebuild → SSE invalidation). Persistence is canonical rows (job_bullet_provenance), nevermetadata_json. - Read path: the single projection owner serves provenance on the artifact's tailoring explanation (
artifact_list_projections.bullet_provenance_json), built identically by the Python projection builder andapps/api/src/projections.tsand served byread-model.tsasArtifactTailoringExplanation.bulletProvenance(a PDF artifact resolves it from the sibling tailored-resume row). A cross-runtime projection parity test covers the table on both runtimes. - Cover-letter truthfulness gate (
scan_cover_letter, same module): the cover letter ships to the employer as a first-person claims document, so before it is acceptedGenerateCoverLetterUseCaseruns the SAME deterministic guards over the generated body — the never-fabricate detector (metrics/dates/titles/employers) plus the prose skill/tool gate (build_skill_vocabulary+ the persistedEmployerAnalysiskeywords as targets), so it inherits that gate's named-technology scope and word-form-tolerant grounding — a fabricatedKubernetesis rejected while JD concept keywords (scalability, reliability, observability) woven into a grounded letter are not. Two cover-letter allowances keep it precise, since a letter legitimately names the job it targets: the mandatoryDear …salutation is excluded from the scan (its addressee title is not a claim), the target role's title tokens ground the title arm, and an employer mention containing the target company is not a fabricated employer. The numeric/date arms stay strict (job-post text is never folded into the evidence corpus). A detected fabrication downgrades validation so the letter is REJECTED, never shipped as approved (the aggregate staysresume_approved, notcover_letter_ready), and the retry loop is told exactly what to drop. The generator runs at temperature 0.4 (lowered from 0.7). Every accepted or rejected letter carries a minimal truthfulness trail (fabrication_audit: the controls run, the target-keyword count, and each finding) on its artifactmetadata_json, so a rejected letter's failure survives as inspectable audit history.
Stored Interview Preparation
Interview prep is stored, pre-interview material for one job. It is generated by GenerateInterviewPrepUseCase under domain/interview/, but it deliberately imports the existing Materials gates rather than defining a parallel truthfulness pipeline.
- Inputs:
InterviewPrepWorkflowis user-triggered through thegenerate_interview_prepJSON-RPC method. The activity loads the selected job, the Candidate Profile snapshot, career evidence-map projections, requirement-fit rows, and latest accepted bullet provenance. It does not run as a discovery/tailoring side effect. - Truthfulness gates: prep candidates run through
scan_resume_bullets,scan_prose_skill_fabrications,ground_claim_mappings, and the sameADVERSARIAL_REVIEW_RESPONSE_SCHEMAjudge contract used by Materials. Unsupported metrics, invented tools, unknown evidence IDs, dishonest gap drills, ungrounded requirement claims, and judge blockers fail closed before an accepted generation can be saved. - Persistence: accepted and failed generations are canonical SQLite rows in
job_interview_prepandjob_interview_prep_items. A new accepted generation supersedes the prior accepted generation for that job; a failed generation remains audit history and never destroys the last accepted prep. Accepted and failed outcomes publishInterviewPrepGenerated/InterviewPrepFailedevents for projection invalidation. - Read model and UI: the Operations projection builders expose the latest accepted generation as
job_detail_projections.interview_prep_json, served by the TypeScript API asJobDetail.interviewPrep. The Jobs detail drawer renders the stored prep panel with themes, STAR drafts, honest gap drills, company notes, evidence-map links, requirement IDs, source snippets, gate/judge status, and accepted-residual warnings. Re-generation is an explicit button action; failed generations never hide the last accepted prep. - Post-interview reflections: the prep panel reuses the existing manual application outcome path. A reflection is an
application_outcomesrow withkind = 'interview'and nullableinterview_prep_generationset to the accepted or superseded prep generation it references. Reflection notes remain local outcome notes and are not copied intojob_events.payload_json. - Safety boundary: prep is not live interview assistance. There is no in-session, transcript, microphone, streaming, websocket, or real-time answer surface on the domain model, workflow input, or JSON-RPC contract.
Voice Pass + Final Audit Against Rendered Text (Materials sub-step)
An explicit voice pass runs after the selected candidate is chosen and before the final audit, so the audited text — provenance and keyword coverage — equals the rendered/PDF text the HTML renderer consumes. Grounding is the floor: the voice pass rewords, it never invents, and the deterministic gates are re-run against the voiced text.
- Voice transform (
VoicePort+infrastructure/materials/voice_adapter.py): an AI transform implemented via the Claude Agent SDK with native structured output. It de-buzzwords and varies bullet structure on the selected candidate's prose (executive profile + experience bullets only; skill term lists are left untouched). The SDK boundary is mocked in tests. - Deterministic voice proxies (
domain/materials/voice_metrics.py): the measurable gate for adopting voice edits. Buzzword density uses a focused lexicon built fromBANNED_WORDSplus the quality evaluator's stock-phrase markers; structural variety uses opening-token diversity and bullet-length variance. The voiced payload is adopted only when it measurably reduces buzzword density OR raises structural variety vs the input; a no-op or a regression keeps the pre-voice candidate. - Re-validation after voice:
TailorResumeUseCasere-runs the provenance builder + the deterministic never-fabricate detector against the voiced text. A voice edit that introduces an unsourced numeric/date/title/ employer is hard-rejected exactly like a generator fabrication: the voiced payload is discarded, the clean pre-voice candidate ships, and the failed voice stays as audit history. A reworded bullet is recorded withtransform_type = voice(the outermost transform) so the shipped wording is inspectable, not a hidden prompt tweak. - Final coverage audit (
domain/materials/coverage_audit.py): keyword coverage is computed at generation time against the actual rendered (voiced) bullet text — never inferred from the job description — and partitions the analysis keywords into three honestly-labeled buckets: covered (demonstrated), declared (rendered from the profile's canonical skills declaration but not demonstrated), and missing (rendered nowhere the employer reads). A keyword counts as covered only when it appears in a bullet backed by real profile evidence: the bullet carries a canonical evidence FK (evidence_ids), or the keyword itself traces (word-boundary) to the profile evidence corpus. A requirement FK does not credit coverage — the provenance builder binds a requirement whenever one of its keywords appears in the line, so counting that binding would let a keyword ground itself (circular), rewarding the stuffing the guard exists to catch. A keyword whose only home is a skills-section line that no evidence demonstrates is declared, notmissing— reporting a shipped skill as absent would be the same lying audit surface in the other direction. Its profile grounding is not "by construction" (the skills line renders the LLM's reorderedtailored_skill_items, not rawskill_categories): surfaced coverage is read only from the persistedcoverage_audit_json, which is recorded only for an approved resume, and approval requires validation whose "Fabricated skill" check rejects any skills item outside the profile's declared category (the prose fabrication gate likewise treats declared skills as backed).coverage_ratiostays covered/planned (demonstrated ratio) and is never inflated bydeclared. Substring false positives do not count. The read model carriescovered/declared/missingwithcovered_by/declared_byper-keyword bullet maps so all three are inspectable. - Final canonical text is the single voiced payload:
TailorResumeUseCaseassembles the plain-text resume from it, the provenance rows anchor to it, and the active resume PDF renderer consumesTailorOutcome.final_payload. The renderer isHtmlResumePdfAdapter(HTML/CSS + Playwright). Historicallatex_pdfrows are compatibility/migration metadata only. A round-trip fixture asserts the audited bullet text equals the rendered text. - Persistence + read path: the generation-time coverage and the voice-pass audit ride on the
BulletProvenanceSet(denormalised onto thejob_bullet_provenancerows ascoverage_json/voice_json). The single projection owner serves them on the artifact's tailoring explanation (artifact_list_projections.coverage_audit_json/voice_pass_json), built identically by the Python builder andapps/api/src/projections.tsand served byread-model.tsasArtifactTailoringExplanation.coverageAudit/.voicePass(a PDF resolves them from the sibling tailored-resume row). A cross-runtime parity test covers both.
Tailoring Explanation Read Model
The read model serves tailoring audit data from canonical projection rows.
- Artifact explanation shape. Each artifact's tailoring explanation is parsed from its own
metadata_jsonprojection column for non-coverage audit fields. Per-bullet provenance, coverage, and voice come from canonical projection columns. A PDF resolves those canonical audit fields from the sibling tailored-resume projection row because the PDF is a render of that same artifact. - Keyword summary. The
keywordssummary block (planned/covered/missing+ counts) is derived from the canonical coverage audit (coverage_audit_json, computed at generation time against the rendered voiced text). When a generation recorded no coverage the block is empty withcoverageRecorded: false; coverage is never inferred from the job description at read time. - Artifact comparison. Apply Review and Artifacts compose a Materials-owned comparison component over existing artifact detail reads. Its pure selector compares only stored
ArtifactTailoringExplanation.coverageAuditbuckets (covered,declared, andmissing), template metadata, validation/judge fields, and recorded resume-reviewriskLabelvalues. If either side has no coverage audit, the read model stayscoverage not recorded; it does not convert absent rows into zero coverage. Rendering a draft creates candidate artifacts for comparison, while the last accepted artifact remains visible until an approval replaces it. - Cross-runtime drift guard. The Python builder test (
workers/automation/tests/test_audit_projection_parity.py) and the TypeScript builder test (apps/api/test/audit-projection-parity.test.ts) both seed the canonical rows (scores, stage states, employer analysis, bullet provenance, materials artifacts) from the shared fixturepackages/domain-types/test/fixtures/audit_projection_parity.json. Each runtime runs its own projection builder and asserts, key-for-key, both the audit read shapes (employer analysis + provenance/coverage/voice) AND the full dual-written column set forjob_list_projections,job_detail_projections, anddashboard_projections(stage/score/compensation/apply/dashboard columns; the*_jsoncolumns are compared parsed, and the wall-clocklast_updated_at/generated_atcolumns are asserted present but excluded from value comparison). A column-set guard makes each runtime's emitted columns equal the fixture's expected keys plus those wall-clock columns, so a one-sided column addition in either builder fails against the shared fixture — the drift class that let the Python builder ship without the score criteria/trace/correction audit columns.