# CV Builder (Career Workspace Builder) > Phase 4 (2026-07-18). The builder that turns the master career profile into themed, exportable, > optionally-public CVs. Companion to `docs/architecture/cv-theme-engine.md`, > `docs/architecture/career-profile-model.md`, and `MASTER_IMPLEMENTATION_GUIDE.md`. Verified against > code and a running container. ## Source-of-truth chain ``` CareerProfile (the only editable career source) ↓ read (never copied) CvVariant — a LENS: section order/visibility, per-item overrides, theme + builder settings ↓ resolve CvRenderModel — presentation projection (CvVariantResolver) ↓ render (one path, theme = data) HTML — ThemedCvRenderer ↓ export / share PDF | Public CV (/cv/{slug}) ``` The master profile is never written by the builder. A variant references career items by their stable `ItemKey`; overriding a bullet stores the override on the variant, leaving the profile untouched (`CvBuilderTests.Resolver_applies_item_override_bullets_without_touching_the_master`). ## Variant model `CvVariant` (`Models/CvVariant.cs`) + `CvVariantVersion` (autosave history). The whole lens lives in one `SettingsJson` blob (`CvVariantSettings`, `Models/CvVariantSettings.cs`) because it is edited and saved as a unit — never queried field-by-field. A variant stores: - `ThemeId` + overrides: accent, heading/body font, density, page size, photo/icons/page-numbers. - `Sections`: ordered list with per-section `Hidden` + optional renamed `Title`. - `Overrides`: keyed by `ItemKey` → `{ Hidden, Title, Subtitle, Bullets }` (per-item, job-specific). - `CustomSections`: variant-only sections not in the master profile. `PublicSlug` (unguessable, globally unique) backs the public URL. `IsPublic` is off by default. ## Rendering pipeline 1. `CvVariantResolver.Build(profile, settings, fallbackName, photoDataUrl)` → `CvRenderModel`. Applies order, visibility, item overrides and custom sections. Pure projection, owns nothing. 2. `ThemedCvRenderer.Render(model, theme, settings)` → HTML. One render path; the theme (data) drives layout/palette/typography/spacing. See `cv-theme-engine.md`. 3. `PlaywrightCvPdfExporter` (reused unchanged) turns the HTML into a PDF. ## API Authenticated (`/api/cv`, `CvVariantController`): `GET themes`; `GET/POST variants`; `GET/PUT/DELETE variants/{id}`; `POST variants/{id}/duplicate`; `PUT variants/{id}/public`; `GET variants/{id}/versions`, `POST …/versions/{v}/restore`; `GET variants/{id}/preview` and `POST preview` (live preview of unsaved settings); `POST variants/{id}/export-pdf`; `POST ai/assist`. Anonymous (`/api/public-cv/{slug}`, `PublicCvController`): serves a public variant read-only, `X-Robots-Tag: noindex`. Loads the owner's profile via `LoadStructuredForOwnerAsync` (bypasses the tenant filter — there is no current user). Unknown/private slug → 404 (`CvBuilderTests.Private_variant_is_not_served_publicly`). ## Builder workflow (frontend) `/career/builder` lists variants (`CvBuilderPage`); the editor (`CvBuilderEditor`) is three tabs — **Content**, **Customize**, **AI Tools** (plus **History**) — beside an always-on live preview that re-renders through `POST /api/cv/preview` on a 350 ms debounce. Edits autosave on an 800 ms debounce (`source: autosave`), appending a version each save. `/cv/:slug` (`PublicCvPage`) renders a public CV in a sandboxed iframe. ## AI `POST /api/cv/ai/assist` reuses the existing `ISummarizerService` provider abstraction. It returns a suggestion the user copies in themselves — it never mutates the profile or the variant. Every prompt carries "preserve every factual claim — never invent" (MASTER_IMPLEMENTATION_GUIDE AI philosophy). ## Extension points - **New theme**: append a `CvTheme` to the catalog (`cv-theme-engine.md`). No renderer change. - **New section kind**: add a builder in `CvVariantResolver` + a `Kind` branch in `ThemedCvRenderer`. - **DOCX export**: add an `IDocxExporter` consuming the same `CvRenderModel`; the model is format-neutral. - **Rich text / page numbers**: see `cv-theme-engine.md` "Deliberately not here (yet)". ## Known limitation Direct external loads of any deep link (including `/cv/{slug}`) currently bounce to the app root — the CSR lift-and-shift Next.js static-export setup only prerenders `/`, so a hard navigation to a deep path doesn't reach the react-router route (in-app client navigation works). This affects the whole SPA, not just public CVs, but public CVs are the first feature that depends on external direct links working. Fix path: serve `index.html` for all non-`/api` paths so react-router owns routing (nginx already has `try_files $uri /index.html`; the redirect is client-side in the Next shell). Tracked as a follow-up — see `docs/architecture/frontend.md` / the Next.js migration note.