diff --git a/docs/architecture/career-profile-model.md b/docs/architecture/career-profile-model.md new file mode 100644 index 0000000..961faaa --- /dev/null +++ b/docs/architecture/career-profile-model.md @@ -0,0 +1,191 @@ +# Career Profile Data Model + +> Phase 3 foundation (2026-07-18). Defines the structured career-profile system: entities, +> relationships, ownership, source-of-truth, and snapshot rules. This is the model the CV Builder, +> tailored CVs, cover letters, portfolio, and interview prep will all consume in later phases. +> +> Companion to `docs/architecture/current.md` §4a (profile/career separation) and +> `docs/decisions/ADR-002-job-application-model.md`. Verified against code on 2026-07-18. + +--- + +## 1. Where we start (F1, on `main`) + +The Career Workspace foundation (`992f89e`) stores the whole profile as **one JSON blob**: + +- `ApplicationUser.ProfileCvStructureJson` — the **authoritative** column every existing read path + uses (the `/career` UI, CV rendering, tailoring, match-score, cover-letter drafts). +- `CareerProfile.ProfileJson` — a **mirror** of the same `StructuredCvProfile` shape, written by + `CareerProfileService.SaveVersionAsync` (dual-write). One row per user. +- `CareerProfileVersion` — append-only history: one row per save, with a `Source` discriminator. + +The blob shape (`Models/StructuredCvProfile.cs`) already has structured items for **Jobs, Education, +Certifications, Projects, Languages**, plus `Skills`/`Summary`/`Interests` (string lists) and +`OtherSections` (title + items). `CareerProfileService` already assigns **stable item IDs** to +Jobs/Education/Certifications/Projects and normalizes free-text dates to `YYYY-MM`. + +**The stable IDs are the seam.** They were added in F1 precisely so a relational model (and later CV +variants) can reference "this job" by identity instead of array position. + +--- + +## 2. Target model (Phase 3) + +Promote the well-structured, queryable career items to **relational child tables** of +`CareerProfile`. Keep the loosely-structured long tail as JSON on `CareerProfile`. + +### Decision: which entities are relational vs JSON + +Per the settled product decision (2026-07-17: "relational for Experience/Education/Skills/Projects; +JSON for the long tail") **and** the Phase 3 brief's "keep flexible JSON/custom sections": + +**Relational child tables** (clear structured shape, queried/sorted/edited item-by-item): + +| Entity | From blob | Key fields | +|---|---|---| +| `CareerExperience` | `Jobs` | Title, Company, Location, Start/End (+ normalized), IsCurrent, Bullets, Skills | +| `CareerEducation` | `Education` | Qualification, Level, Institution, Location, Start/End, Details | +| `CareerSkill` | `Skills` | Name, Category, Proficiency | +| `CareerProject` | `Projects` | Name, Role, Location, Start/End, Bullets, Skills, Links | +| `CareerCertification` | `Certifications` | Name, Issuer, Date (+ normalized), Details | +| `CareerLanguage` | `Languages` | Name, Level, Notes | + +**JSON on `CareerProfile`** (the long tail — flexible, low-query-value, or not yet shape-stable): + +- `Contact` (name, headline, email, phone, location, website, linkedin) — a single value object. +- `Summary` (string list), `Interests` (string list). +- `Achievements`, `Organisations`, `Publications`, `Courses` — kept in a `LongTailJson` blob for + now. The Phase 3 brief lists these as relational-*recommended*; they have **no structured shape + in the current model** and low query value, so they start as JSON and can be **promoted to + relational later** without a source-of-truth change (they already live under `CareerProfile`). +- `CustomSections` / `OtherSections` — arbitrary title + items, JSON by nature. +- `Metadata` (per-field confidence/provenance from AI extraction) — JSON. + +> This split is a decision, not a guess. If the user wants Achievements/Organisations/Publications/ +> Courses relational now, that is an additive change (new child tables under the same +> `CareerProfile`) — flagged here rather than silently chosen. + +### Entity shape (child tables) + +Every child table carries: + +- `Id` (int, PK, autoincrement). +- `CareerProfileId` (FK → `CareerProfile`, cascade delete). +- `OwnerUserId` (denormalized for the tenant query filter — same pattern as every other owned entity). +- `ItemKey` (string) — the **stable item ID** carried over from the blob, so a row keeps its identity + across imports/edits and future CV variants can reference it. +- `SortOrder` (int) — explicit ordering (the blob used array position; relational needs it explicit). +- Its domain fields. + +Free-text date fields keep the existing pattern: the original string (`Start`, `End`) **and** a +best-effort `YYYY-MM` normalization (`StartDate`, `EndDate`), never one replacing the other. + +--- + +## 3. Relationships + +``` +ApplicationUser (1) ──owns──> (1) CareerProfile ──> (many) CareerProfileVersion [append-only history] + │ + ├──> (many) CareerExperience + ├──> (many) CareerEducation + ├──> (many) CareerSkill + ├──> (many) CareerProject + ├──> (many) CareerCertification + └──> (many) CareerLanguage + +CareerProfile also holds: Contact, Summary, Interests, Achievements, Organisations, +Publications, Courses, CustomSections, Metadata (all JSON columns) +``` + +- One `CareerProfile` per user (unique index on `OwnerUserId` — already enforced in F1). +- Child rows cascade-delete with the profile. +- Deleting a `CareerProfile` never touches `JobApplication`/`TailoredApplication` — those *reference* + career outputs, they don't own them (same rule as ADR-002). + +--- + +## 4. Ownership & source of truth + +**`CareerProfile` (its relational children + JSON long tail) is the ONLY editable career source.** +Everything downstream is derived and must never be hand-edited as if it were the source: + +``` +CareerProfile (editable — the master profile) + ↓ derive +CV Variant (a lens: selections + overrides referencing CareerProfile item keys) [Phase 3/4] + ↓ derive +Generated CV (rendered from a variant + theme) [Phase 4] + ↓ snapshot +Application Snapshot (frozen copy attached to a job application) [Phase 3/4] + ↓ export +PDF / DOCX +``` + +### The transition rule (source-of-truth timing) + +Existing read paths (CV rendering, tailoring, match-score, cover letters) read +`ApplicationUser.ProfileCvStructureJson`. We do **not** rewrite all of them in Phase 3. Instead: + +1. **The relational model becomes the editable source of truth.** The `/career` structured editor + reads and writes the relational tables. +2. **On every save, the relational model is serialized back into the `StructuredCvProfile` blob** + (`ApplicationUser.ProfileCvStructureJson` + `CareerProfile.ProfileJson`). The blob becomes a + **derived read-model** — a projection kept for the legacy read paths — not an independently + editable source. +3. This keeps "do not duplicate career information" honest: there is exactly **one editable copy** + (relational); the blob is a generated projection, like a CV is. +4. The eventual removal of the blob (once every reader is migrated to read relational) is a later + phase and out of scope here. + +This mirrors the additive, non-destructive philosophy of Phase 0/ADR-002: introduce the new model, +keep the old surface working via a derived projection, flip readers later. + +--- + +## 5. Snapshot rules + +- **`CareerProfileVersion`** (exists) — an append-only **history** of the whole profile, one row per + save, tagged with `Source` (`manual` | `import` | `ai` | `rebuild` | …). It stores the serialized + `StructuredCvProfile` (a snapshot blob) — correct: a version is an immutable point-in-time record, + not something queried field-by-field. Used for restore (Phase 5) and to make AI/import changes + reversible. +- **Application snapshots** (later phase) — when a tailored CV is attached to a job application, it + is a **frozen snapshot** of the derived output, independent of later profile edits. The master + profile changing must never retroactively alter a submitted application. +- **Rule:** history snapshots and application snapshots are always *copies*, never live references to + the editable profile. Only the `CareerProfile` relational model is live-editable. + +--- + +## 6. Migration (blob → relational) + +**Non-destructive, additive.** The relational tables are empty today (F1 tables have 0 rows); +`ProfileCvStructureJson` holds the real data. + +- On first access of a user's structured profile after Phase 3 ships, if the relational tables are + empty for that user, **backfill them from the blob** (parse `StructuredCvProfile` → child rows, + carrying the stable item IDs into `ItemKey`). +- The blob is **retained** as the derived projection (see §4), so nothing that reads it breaks. +- No column is dropped, no data is overwritten. The backfill is idempotent (keyed on `ItemKey`). + +> This is a real data migration against the user's actual profile data. It is additive and +> reversible (the blob remains authoritative for readers until each is flipped), but the backfill +> step is the point to confirm before running against production — see the Phase 3 execution notes. + +--- + +## 7. What this explicitly is NOT (Phase 3 boundaries) + +Not built here (they belong to Phase 4): CV themes, PDF/DOCX generation, the CV Builder UI, AI +rewriting, CV variants. Phase 3 delivers only the **editable master profile** — the foundation those +consume. + +--- + +## 8. Open decisions (surface before/at implementation) + +1. **Long-tail scope.** Achievements/Organisations/Publications/Courses start as JSON (§2). Promote + to relational now, or defer? Additive either way. +2. **Backfill timing** (§6) — run the blob→relational backfill lazily on first access (recommended, + zero-downtime) vs a one-shot migration. Confirm before running against production data.