Skip to content

Epic: three-tier artifacts — standard → company → job, for résumé and cover letter #765

Description

@s-annam

Today a résumé and a cover letter are each a flat, standalone artifact. This epic replaces that with a three-tier hierarchy — standard → company → job — for both.

The framing (this is the load-bearing part)

The tempting reading is "three scopes, pick one." That reading is wrong and would produce three independent copies that drift.

The standard artifact is the SOURCE. A company artifact is a derivation of it; a job artifact is a derivation of that. The standard résumé is what you have; the standard cover letter holds the candidate-specific material that has no place on a résumé — a career-change narrative, a relocation or visa fact, a reason for a gap. The system then customizes downward.

That reframing matters because it decides the data model: base + delta, not three rows. Improving the base has to reach the derivations that have not overridden the thing you improved.

It also answers the obvious objection. A generic cover letter is weak in a bad market, and nothing here proposes submitting one. Standard is the base you tailor from, and the resolution chain is what makes tailoring cheap: write the "why this company" paragraph once for the four roles you are applying to there, and tailor only the "why this role" part per posting.

Letters and résumés are NOT symmetric — this is the sequencing

Cover letter Résumé
Artifact Prose (LetterRecord.body, one markdown string) Structured (CanonicalResume: sections, roles, bullets)
What a derivation IS A different body A field- and bullet-level delta
Merge semantics needed None Yes — and this is the hard part
Blocked on Nothing Persisted deltas + a base-drift rule

So letters can ship the full three tiers before résumés can. A letter needs only a scope key. A résumé needs a delta that survives its base changing underneath it.

What already exists (verified at HEAD, not assumed)

The résumé half is much closer than it looks:

  • src/lib/edit/apply-overrides.ts is already a base + delta engine. It takes a frozen base parse plus ordered override maps (BulletOverrides, DescriptionOverrides, ContactOverrides, AddedBullets, … — declared in src/hooks/useEditableParse.ts) and folds them into a fresh { parsed, rawText, sections } the scorer re-grades from. It is pure and total.
  • Bullet identity already shipped (Stable bullet identity through parse → edit → export #648). src/lib/score/bullet-id.ts gives every bullet a self-describing id (${occurrence}|${normalizeBulletText(text)}), and apply-overrides.ts keys overrides by it. The "bullet identity" line item on the [epic] Architecture: max-impact modularization + parsing-accuracy roadmap #646 architecture roadmap is done, not pending.
  • docs/canonical-resume-model.md documents the résumé model the deltas would apply to.
  • docs/cover-letter-contract.md is a public, versioned contract with a defined bump procedure (§8), so the letter-side change has a clear path.

Two gaps, both verified:

  • Nothing persists an override map. saveResumeToLibrary (src/lib/resume-library.ts:115) stores a SavedResumeSnapshot = { result, score, sourceKind, shapeVersion } — a baked, flattened parse. The override maps live only in useEditableParse session state. There is no stored base to derive from.
  • LetterRecord.jobId is required (src/lib/storage/types.ts:377, contract §1) and deleting a job cascades to its letters (src/lib/storage/letters.ts:102). A company-scoped or standard letter cannot exist today.

The hard problem: base drift

src/lib/score/bullet-id.ts:28 states it directly:

What an id deliberately is NOT: stable across an edit OF that bullet.

Ids are content-derived. An override is the instruction "replace A with B" and stays keyed by id("A") precisely so it keeps naming its target.

That is correct for one editing session. It breaks the moment a delta outlives its base. If a job variant carries "replace A with B" and the standard résumé later edits A to A′, the variant's key names a line that no longer exists — the override silently no-ops, or first-matches a different line that happens to normalise-equal. Nothing throws. The user sees a variant that quietly stopped applying an edit they made.

Solving that is a contract question about bulletId, not a UI question, which is why it gets its own issue rather than being folded into the variant UI.

Company identity: never inferred, always confirmed

There is no company entity. JobRecord.company is free text and may be empty (src/lib/storage/types.ts:198). So a company key must be derived by normalisation, and normalisation collides.

The rule this epic adopts is the one types.ts already applies to JobRecord.aliasUrls: a link between two things is "never inferred from the URLs themselves" — it takes a user clicking Merge, or a producer with more context.

Applied here: the normaliser only ever suggests ("You have a letter for Northwind — start from it?"), and the user picks. A false merge is then a suggestion declined, not a letter silently attached to the wrong employer.

Reuse note: src/lib/job-search/raw-postings.ts:32 has a private normalizeField (lowercase + whitespace collapse) used for cross-provider dedup. It is not exported, has no legal-suffix handling, and lives in job-search/ — storage/ importing job-search/ would invert the layering. The normaliser is new code in storage/, with a docblock pointing at the sibling and saying why they are not shared.

Known gap: sync fidelity for company scope

public.cover_letters in the Recruidea project (gmlrsbnkdvzqmyocgmfa) has id, candidate_id, saved_job_id, resume_id, local_id, local_job_id, body, label, producer, and timestamps — no company column.

Both saved_job_id and local_job_id are is_nullable = YES, so the table holds a jobless letter fine. The client does not.

letter-mapping.ts in s-annam/recruidea-extension hard-refuses a pulled row with no local_job_id:

Cover letter "${row.local_id}" names no job and would be unreachable.

— citing the very contract rule this epic changes. Meanwhile letterPayloadFor would happily push a standard letter with local_job_id: undefined. That asymmetry is a one-way trip: the letter lands remotely and can never be pulled back, so it silently fails to appear on the user's second device.

So this is a blocking dependency, not a fidelity nicety. The extension must accept jobless rows and carry a company key before or with the letter scope-keys issue, or multi-device users lose standard and company letters on sync. Tracked in the extension repo and linked from the scope-keys issue.

Local storage and the backup document carry both new fields fine; it is only the sync path that is short.

Breakdown

  1. Letter scope keys (contract v2) — jobId optional, add companyKey, per-scope cascade rules, the company normaliser, docs → v2. Store and contract only. Ships alone.
  2. Letter tiering UI — the job → company → standard resolution chain, the picker, customize-from semantics. Needs 1.
  3. Persist the résumé override map — turn the session-only delta into a stored artifact beside the base snapshot. Ships alone, and is useful alone: résumé edits would survive a reload for the first time.
  4. Résumé variants: base drift — what a variant's delta does when the base edits the bullet it names. Needs 3.
  5. Résumé variant UI + scope selection — which variant a job or company uses, create-from, diff. Needs 4.

3 is held apart from 4 deliberately: persisting the delta is independently shippable, and it is what makes base drift observable enough to debug.

Out of scope

  • Letter export as a file. Contract §0 puts letter PDF export out of scope: render-ats-pdf.ts is résumé-shaped (sections / roles / bullets) and a letter is a different document model. Copy-to-clipboard is the export today.
  • Generating any of these artifacts. This epic is about how they relate, not about who writes them. The on-device rewrite lane (src/lib/webllm/) is a separate consumer that benefits from the hierarchy without being changed by it.
  • A company entity. companyKey is a derived, advisory string. Nothing here builds a companies table or a company record.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

architectureSystem design / coupling / representation decisionsfeatureNew functionality

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions