11 KiB
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) anddocs/decisions/ADR-002-job-application-model.md. Verified against code on 2026-07-18.
Implementation status (2026-07-18) — SHIPPED
The model below is implemented on
mainand verified against the live database:
- Entities (
Models/CareerEntities.cs):CareerExperience,CareerEducation,CareerSkill,CareerProject,CareerCertification,CareerLanguage— relational children ofCareerProfile; long tail inCareerProfile.LongTailJson. MigrationAddCareerProfileRelationalChildren(applied cleanly on the real dev DB and the running container).- Projection (
CareerProfileService+CareerProfileMapper): relational is the source of truth;SaveVersionAsyncsyncs children + long tail;LoadStructuredAsyncreads relationally with lazy backfill from the blob; theProfileCvStructureJsonblob is kept as the derived projection.- API (
CareerProfileController,/api/career/profile): GET/PUT profile, GET completeness, GET versions, POST version restore. Validation viaCareerProfileValidator(abuse limits).- Frontend (
/career): reads/writes the relational API, profile-completeness overview, version-history restore.- Tests: 23 career-focused backend tests (round-trip, item-key stability, replace-all, backfill, completeness, validation, versioning/restore) + frontend save/completeness tests.
Not yet done (later): promote the long tail to relational if needed; a full section-by-section UX redesign of the editor; the eventual removal of the derived blob once every reader flips.
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/careerUI, CV rendering, tailoring, match-score, cover-letter drafts).CareerProfile.ProfileJson— a mirror of the sameStructuredCvProfileshape, written byCareerProfileService.SaveVersionAsync(dual-write). One row per user.CareerProfileVersion— append-only history: one row per save, with aSourcediscriminator.
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 aLongTailJsonblob 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 underCareerProfile).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
CareerProfileper user (unique index onOwnerUserId— already enforced in F1). - Child rows cascade-delete with the profile.
- Deleting a
CareerProfilenever touchesJobApplication/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:
- The relational model becomes the editable source of truth. The
/careerstructured editor reads and writes the relational tables. - On every save, the relational model is serialized back into the
StructuredCvProfileblob (ApplicationUser.ProfileCvStructureJson+CareerProfile.ProfileJson). The blob becomes a derived read-model — a projection kept for the legacy read paths — not an independently editable source. - 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.
- 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 withSource(manual|import|ai|rebuild| …). It stores the serializedStructuredCvProfile(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
CareerProfilerelational 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 intoItemKey). - 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)
- Long-tail scope. Achievements/Organisations/Publications/Courses start as JSON (§2). Promote to relational now, or defer? Additive either way.
- 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.