You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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
Letter scope keys (contract v2) — jobId optional, add companyKey, per-scope cascade rules, the company normaliser, docs → v2. Store and contract only. Ships alone.
Letter tiering UI — the job → company → standard resolution chain, the picker, customize-from semantics. Needs 1.
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.
Résumé variants: base drift — what a variant's delta does when the base edits the bullet it names. Needs 3.
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.
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
LetterRecord.body, one markdown string)CanonicalResume: sections, roles, bullets)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.tsis already a base + delta engine. It takes a frozen base parse plus ordered override maps (BulletOverrides,DescriptionOverrides,ContactOverrides,AddedBullets, … — declared insrc/hooks/useEditableParse.ts) and folds them into a fresh{ parsed, rawText, sections }the scorer re-grades from. It is pure and total.src/lib/score/bullet-id.tsgives every bullet a self-describing id (${occurrence}|${normalizeBulletText(text)}), andapply-overrides.tskeys 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.mddocuments the résumé model the deltas would apply to.docs/cover-letter-contract.mdis a public, versioned contract with a defined bump procedure (§8), so the letter-side change has a clear path.Two gaps, both verified:
saveResumeToLibrary(src/lib/resume-library.ts:115) stores aSavedResumeSnapshot={ result, score, sourceKind, shapeVersion }— a baked, flattened parse. The override maps live only inuseEditableParsesession state. There is no stored base to derive from.LetterRecord.jobIdis 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:28states it directly: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.companyis 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.tsalready applies toJobRecord.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:32has a privatenormalizeField(lowercase + whitespace collapse) used for cross-provider dedup. It is not exported, has no legal-suffix handling, and lives injob-search/—storage/importingjob-search/would invert the layering. The normaliser is new code instorage/, with a docblock pointing at the sibling and saying why they are not shared.Known gap: sync fidelity for company scope
public.cover_lettersin the Recruidea project (gmlrsbnkdvzqmyocgmfa) hasid,candidate_id,saved_job_id,resume_id,local_id,local_job_id,body,label,producer, and timestamps — no company column.Both
saved_job_idandlocal_job_idareis_nullable = YES, so the table holds a jobless letter fine. The client does not.letter-mapping.tsins-annam/recruidea-extensionhard-refuses a pulled row with nolocal_job_id:— citing the very contract rule this epic changes. Meanwhile
letterPayloadForwould happily push a standard letter withlocal_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
jobIdoptional, addcompanyKey, per-scope cascade rules, the company normaliser, docs → v2. Store and contract only. Ships alone.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
render-ats-pdf.tsis résumé-shaped (sections / roles / bullets) and a letter is a different document model. Copy-to-clipboard is the export today.src/lib/webllm/) is a separate consumer that benefits from the hierarchy without being changed by it.companyKeyis a derived, advisory string. Nothing here builds a companies table or a company record.