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
Part of #765. Prerequisite for the résumé half of that epic, and useful on its own: it is what would make a saved résumé's edits re-editable rather than baked. #769 (delta drift) and #770 (variant UI) both depend on this.
Problem
Saving a résumé to the library flattens the edits away.
The chain, verified at HEAD (post-#824, #576, #1022):
Saving is autosave, not a button. useAutosaveResume (src/hooks/useAutosaveResume.ts) writes on the first edit and debounces after, via performSave (:205), which calls librarySave({ id, bytesUnchanged, filename, bytes, sourceKind, result, score }) (:211).
src/App.tsx:163 wires it: resume.result is recovery.isLlmRecovered ? recovery.activeResult : savableResult.
savableResult (src/hooks/useAnalyzedResume.ts:318) is flattenEditedResult(base, editedCore), where base is the pristine parse (state.result in phase done) and editedCore is applyOverrides(editBaseFromResult(base, doneScoreBullets), snapshot).
So what lands in IndexedDB is one baked artifact. The override maps that produced it are gone. Reload a saved résumé and you get the edited text, with no record of which bullets were user-corrected, what they were corrected from, or which fields the parser got right on its own.
Nothing is lost for viewing. Everything is lost for deriving: a variant is a delta over a base, and a flattened save has neither — no base to derive from, no delta to compare against.
What already exists (do not rebuild it)
The delta format is already designed, already JSON-safe, and already migration-aware. EditSnapshot (src/hooks/useEditableParse.ts:235) holds every override map — contactOverrides, experienceOverrides, bulletOverrides, descriptionOverrides, removedBullets, educationOverrides, achievementOverrides, skillsOverride, summaryOverride, addedEntries, addedBullets, profileOverrides (and the other keys the interface lists) — with its own docblock rule:
Every override map must appear here. A silently-absent one is exactly how team (#425) and achievementType (#455) got dropped on restore.
It already round-trips in one place today:
Blank drafts — writeBlankDraft / readBlankDraft (src/hooks/useResumeAnalysis.ts:210 / :197) persist it to localStorage, with migrateBlankDraft (:158) upconverting older shapes, and useAnalyzedResume's resumeDraft replays it into the edit hook. (The / → /jd-fit handoff that used to be the second precedent was removed with the /jd-fit/ entry in Deprecate /jd-fit/ — migrate JD paste + JD-driven rewrite into the Find Jobs tab, then remove the second entry #576.)
useEditableParse already exposes snapshot (memo at :1790) and replay (:1825), and applyOverrides (src/lib/edit/apply-overrides.ts) is pure and total. src/lib/edit/edit-pipeline.ts already holds editBaseFromResult and flattenEditedResult.
So this issue does not invent a delta format, a replay engine, or a migration story. It stores an existing type in one more place.
Design
SavedResumeSnapshot gains the pristine result and the delta, keeping the edited result it already stores:
interfaceSavedResumeSnapshot{result: CascadeResult;// stays: the EDITED (flattened) result, so listing/scoring is unchangedscore: AnonymousAtsScore;sourceKind: SourceKind;shapeVersion?: string;// UNCHANGED — see "No shape-version bump"baseResult?: CascadeResult;// new: the PRISTINE parseedit?: EditSnapshot;// new: the delta that turns base into result}
Both new fields are optional, and that is what makes this backward-compatible without a migration pass: a record saved before this change simply has neither, and reads exactly as it does today. readSnapshot (:97) keeps a record whose snapshot is malformed in the list with hasCachedParse: false (listLibrary, :184), so the degrade path exists. readSnapshot passes the delta through only when both baseResult and edit are present; a lone one is ignored and the record loads flat.
result is kept rather than recomputed on load for two reasons: listLibrary reads snap.score.overall without replaying anything, and a stored edited result is the only thing that guarantees a reloaded résumé looks byte-identical to what the user saved even if applyOverrides later changes behaviour.
The invariant
The stored result is not literally applyOverrides(baseResult, edit); it is the flatten of that fold (#1022). The contract is: re-running the same fold useAnalyzedResume performs over baseResult + edit reproduces the stored result (deep-equal). That fold is flattenEditedResult(base, applyOverrides(editBaseFromResult(base, scoreBullets), edit)), where scoreBullets are the pristine parse's score.bullets ?? [].
To keep that from drifting between two copies, extract the fold into one exported pure helper in src/lib/edit/edit-pipeline.ts (next to flattenEditedResult) and have both useAnalyzedResume (for savableResult) and the invariant test call it. The hook may keep its separate editedCore memo for scoring, but the helper is the single definition of "the savable result of a base + delta".
Restore keying (decided)
adopt binds a record id to the parse it arrives with, and that parse is the next parseKey (parseKey = state.result in phase done). If the key does not match, the next edit mints a duplicate record. Since the reloaded state must be editable from the pristine parse, restore keys everything to the pristine parse:
When a loaded record carries baseResult + edit, hydrateFromLibrary (src/App.tsx:188) hydrates the done state with result: baseResult, a score computed as scoreParsedResume(baseResult) (the shared base-grade recipe loadResumeFromLibrary already uses on its re-parse path, so it matches what a fresh upload would carry — editBaseFromResult reads the done score's bullets), and calls autosave.adopt(baseResult, loaded.id).
A record without a delta keeps today's behaviour exactly: hydrate loaded.result, autosave.adopt(loaded.result, loaded.id).
Both restore callers — the Saved-resumes Load button (loadAndResume) and cold-mount useAutoRestoreResume — go through hydrateFromLibrary, so the branch lives there once.
Replay ordering (decided)
useAnalyzedResume has an effect that calls resetAll() whenever parseKey changes (:345–:347). A replay(edit) issued in the same event as loadSavedResume would be wiped by that effect after commit. So the delta travels with the hydrated state:
LoadedDoneState (src/hooks/useResumeAnalysis.ts:109) gains an optional restoredEdit?: EditSnapshot; loadSavedResume (:326) already spreads the whole object into the done state, so it rides along.
The reset effect becomes resetAll() followed, when the current done state carries restoredEdit, by edit.replay(restoredEdit), inside the same effect. There is no separate same-event replay for the reset to clobber.
replay is additive (it merges onto current state) and mints fresh ids for profile extras and added entries, so it is not idempotent by itself; it is safe here because the effect always resets first. That must hold under React StrictMode's double effect invocation (reset → replay → reset → replay ends in the same state) — require a test, do not assume it.
Hand-audit the effect's dependency array (exhaustive-deps is not enforced). It should stay keyed on parseKey; restoredEdit is set in the same setState as the result that defines parseKey, so it cannot be stale relative to it.
LLM-recovered saves (decided)
When recovery.isLlmRecovered is true, the stored result is recovery.activeResult, which is not base + edit. That save stores nobaseResult and noedit and degrades to today's flattened record. Only the un-recovered branch stores the delta.
No shape-version bump (decided)
Do not change CACHE_SHAPE_VERSION (src/lib/resume-library.ts:49, now ${ATS_SCORE_ALGO_VERSION}:${CANONICAL_SHAPE_VERSION}). Bumping it makes snap.shapeVersion !== CACHE_SHAPE_VERSION true for every stored record and forces a full re-parse from bytes on next load. The two new fields are optional and absence reads as today, so no bump is needed. New records are stamped with the unchanged current version.
Steps
src/lib/resume-library.ts — add baseResult? and edit? to SavedResumeSnapshot and to LoadedResume; extend readSnapshot to carry them only when both are present; leave CACHE_SHAPE_VERSION untouched. Document why result stays.
saveResumeToLibrary / SaveResumeToLibraryInput — accept baseResult? and edit?, both optional, and write them into the snapshot.
src/lib/edit/edit-pipeline.ts — add the exported pure fold helper (see The invariant) and switch useAnalyzedResume's savableResult to call it.
src/hooks/useAutosaveResume.ts — add optional baseResult? and edit? to AutosavableResume and pass them through performSave to librarySave. Hand-audit performSave's dependency array.
src/App.tsx (useAutosaveResume call, :163) — in the un-recovered branch pass the pristine state.result and edit.snapshot; in the recovery.isLlmRecovered branch pass neither.
loadResumeFromLibrary — return baseResult and edit when the snapshot carries both. The stale-shape / no-snapshot re-parse path is unchanged and returns neither.
hydrateFromLibrary (src/App.tsx:188) and useAnalyzedResume's reset effect — implement Restore keying and Replay ordering above (restoredEdit on LoadedDoneState, replay inside the reset effect).
No new module. No new delta type. No new persistence layer.
Acceptance criteria
A résumé saved with edits stores the pristine result, the edited result, and the EditSnapshot
Re-running the extracted fold helper over the stored baseResult + edit reproduces the stored result (deep-equal), asserted directly, with the test and useAnalyzedResume calling the same exported helper — this is the invariant that rots silently
A record saved before this change (no baseResult, no edit) still loads, lists, and scores identically — no migration required, asserted with a fixture record at the old shape
CACHE_SHAPE_VERSION is unchanged and an existing record at the current shape version does not re-parse on load (asserted: runCascade is not called for it)
Reloading a saved résumé with a delta lands in an editable state: the user can undo or change an edit they made in the earlier session, and this holds in the Saved-resumes Load path and the cold-mount auto-restore path
Restore-then-edit does not mint a duplicate record: after restoring a delta-carrying record (via both loadAndResume and useAutoRestoreResume) and making an edit, the library still holds one record for that résumé, keyed to the same id
A restored delta survives React StrictMode's double effect invocation: the replayed state equals a single replay, with no duplicated profile extras or added entries
A record without a delta restores exactly as today (loaded.result hydrated, adopted on loaded.result)
A save made while recovery.isLlmRecovered is true stores no baseResult and no edit
A stale-shape record that is re-parsed on load comes back without baseResult/edit (unchanged behaviour)
listLibrary still reads a score without replaying anything
A malformed snapshot still keeps the record listed, with hasCachedParse: false, rather than hiding it
The saved record stays JSON-safe: it must survive export → import → export unchanged (src/lib/storage/backup.ts)
Base drift (Résumé variants: what a delta does when the base edits the bullet it names #769). What happens when a delta's target text changes underneath it is its own issue. In particular, carrying a delta across a stale-shape re-parse is exactly that drift problem, so a re-parsed record drops baseResult/edit just as it drops edits today.
LLM-recovered résumés: no delta is stored for them (see above); making them re-editable is not part of this issue.
Changing applyOverrides, bulletId, or the override maps themselves.
Part of #765. Prerequisite for the résumé half of that epic, and useful on its own: it is what would make a saved résumé's edits re-editable rather than baked. #769 (delta drift) and #770 (variant UI) both depend on this.
Problem
Saving a résumé to the library flattens the edits away.
The chain, verified at HEAD (post-#824, #576, #1022):
useAutosaveResume(src/hooks/useAutosaveResume.ts) writes on the first edit and debounces after, viaperformSave(:205), which callslibrarySave({ id, bytesUnchanged, filename, bytes, sourceKind, result, score })(:211).src/App.tsx:163wires it:resume.resultisrecovery.isLlmRecovered ? recovery.activeResult : savableResult.savableResult(src/hooks/useAnalyzedResume.ts:318) isflattenEditedResult(base, editedCore), wherebaseis the pristine parse (state.resultin phasedone) andeditedCoreisapplyOverrides(editBaseFromResult(base, doneScoreBullets), snapshot).saveResumeToLibrary(src/lib/resume-library.ts:161) storesparse: { result, score, sourceKind, shapeVersion }— aSavedResumeSnapshot.So what lands in IndexedDB is one baked artifact. The override maps that produced it are gone. Reload a saved résumé and you get the edited text, with no record of which bullets were user-corrected, what they were corrected from, or which fields the parser got right on its own.
Nothing is lost for viewing. Everything is lost for deriving: a variant is a delta over a base, and a flattened save has neither — no base to derive from, no delta to compare against.
What already exists (do not rebuild it)
The delta format is already designed, already JSON-safe, and already migration-aware.
EditSnapshot(src/hooks/useEditableParse.ts:235) holds every override map —contactOverrides,experienceOverrides,bulletOverrides,descriptionOverrides,removedBullets,educationOverrides,achievementOverrides,skillsOverride,summaryOverride,addedEntries,addedBullets,profileOverrides(and the other keys the interface lists) — with its own docblock rule:It already round-trips in one place today:
writeBlankDraft/readBlankDraft(src/hooks/useResumeAnalysis.ts:210/:197) persist it tolocalStorage, withmigrateBlankDraft(:158) upconverting older shapes, anduseAnalyzedResume'sresumeDraftreplays it into the edit hook. (The/→/jd-fithandoff that used to be the second precedent was removed with the/jd-fit/entry in Deprecate /jd-fit/ — migrate JD paste + JD-driven rewrite into the Find Jobs tab, then remove the second entry #576.)useEditableParsealready exposessnapshot(memo at:1790) andreplay(:1825), andapplyOverrides(src/lib/edit/apply-overrides.ts) is pure and total.src/lib/edit/edit-pipeline.tsalready holdseditBaseFromResultandflattenEditedResult.So this issue does not invent a delta format, a replay engine, or a migration story. It stores an existing type in one more place.
Design
SavedResumeSnapshotgains the pristine result and the delta, keeping the edited result it already stores:Both new fields are optional, and that is what makes this backward-compatible without a migration pass: a record saved before this change simply has neither, and reads exactly as it does today.
readSnapshot(:97) keeps a record whose snapshot is malformed in the list withhasCachedParse: false(listLibrary,:184), so the degrade path exists.readSnapshotpasses the delta through only when bothbaseResultandeditare present; a lone one is ignored and the record loads flat.resultis kept rather than recomputed on load for two reasons:listLibraryreadssnap.score.overallwithout replaying anything, and a stored edited result is the only thing that guarantees a reloaded résumé looks byte-identical to what the user saved even ifapplyOverrideslater changes behaviour.The invariant
The stored
resultis not literallyapplyOverrides(baseResult, edit); it is the flatten of that fold (#1022). The contract is: re-running the same folduseAnalyzedResumeperforms overbaseResult+editreproduces the storedresult(deep-equal). That fold isflattenEditedResult(base, applyOverrides(editBaseFromResult(base, scoreBullets), edit)), wherescoreBulletsare the pristine parse'sscore.bullets ?? [].To keep that from drifting between two copies, extract the fold into one exported pure helper in
src/lib/edit/edit-pipeline.ts(next toflattenEditedResult) and have bothuseAnalyzedResume(forsavableResult) and the invariant test call it. The hook may keep its separateeditedCorememo for scoring, but the helper is the single definition of "the savable result of a base + delta".Restore keying (decided)
adoptbinds a record id to the parse it arrives with, and that parse is the nextparseKey(parseKey = state.resultin phasedone). If the key does not match, the next edit mints a duplicate record. Since the reloaded state must be editable from the pristine parse, restore keys everything to the pristine parse:baseResult+edit,hydrateFromLibrary(src/App.tsx:188) hydrates thedonestate withresult: baseResult, ascorecomputed asscoreParsedResume(baseResult)(the shared base-grade recipeloadResumeFromLibraryalready uses on its re-parse path, so it matches what a fresh upload would carry —editBaseFromResultreads the done score's bullets), and callsautosave.adopt(baseResult, loaded.id).loaded.result,autosave.adopt(loaded.result, loaded.id).loadAndResume) and cold-mountuseAutoRestoreResume— go throughhydrateFromLibrary, so the branch lives there once.Replay ordering (decided)
useAnalyzedResumehas an effect that callsresetAll()wheneverparseKeychanges (:345–:347). Areplay(edit)issued in the same event asloadSavedResumewould be wiped by that effect after commit. So the delta travels with the hydrated state:LoadedDoneState(src/hooks/useResumeAnalysis.ts:109) gains an optionalrestoredEdit?: EditSnapshot;loadSavedResume(:326) already spreads the whole object into thedonestate, so it rides along.resetAll()followed, when the currentdonestate carriesrestoredEdit, byedit.replay(restoredEdit), inside the same effect. There is no separate same-event replay for the reset to clobber.replayis additive (it merges onto current state) and mints fresh ids for profile extras and added entries, so it is not idempotent by itself; it is safe here because the effect always resets first. That must hold under React StrictMode's double effect invocation (reset → replay → reset → replay ends in the same state) — require a test, do not assume it.exhaustive-depsis not enforced). It should stay keyed onparseKey;restoredEditis set in the samesetStateas theresultthat definesparseKey, so it cannot be stale relative to it.LLM-recovered saves (decided)
When
recovery.isLlmRecoveredis true, the storedresultisrecovery.activeResult, which is not base + edit. That save stores nobaseResultand noeditand degrades to today's flattened record. Only the un-recovered branch stores the delta.No shape-version bump (decided)
Do not change
CACHE_SHAPE_VERSION(src/lib/resume-library.ts:49, now${ATS_SCORE_ALGO_VERSION}:${CANONICAL_SHAPE_VERSION}). Bumping it makessnap.shapeVersion !== CACHE_SHAPE_VERSIONtrue for every stored record and forces a full re-parse from bytes on next load. The two new fields are optional and absence reads as today, so no bump is needed. New records are stamped with the unchanged current version.Steps
src/lib/resume-library.ts— addbaseResult?andedit?toSavedResumeSnapshotand toLoadedResume; extendreadSnapshotto carry them only when both are present; leaveCACHE_SHAPE_VERSIONuntouched. Document whyresultstays.saveResumeToLibrary/SaveResumeToLibraryInput— acceptbaseResult?andedit?, both optional, and write them into the snapshot.src/lib/edit/edit-pipeline.ts— add the exported pure fold helper (see The invariant) and switchuseAnalyzedResume'ssavableResultto call it.src/hooks/useAutosaveResume.ts— add optionalbaseResult?andedit?toAutosavableResumeand pass them throughperformSavetolibrarySave. Hand-auditperformSave's dependency array.src/App.tsx(useAutosaveResumecall,:163) — in the un-recovered branch pass the pristinestate.resultandedit.snapshot; in therecovery.isLlmRecoveredbranch pass neither.loadResumeFromLibrary— returnbaseResultandeditwhen the snapshot carries both. The stale-shape / no-snapshot re-parse path is unchanged and returns neither.hydrateFromLibrary(src/App.tsx:188) anduseAnalyzedResume's reset effect — implement Restore keying and Replay ordering above (restoredEditonLoadedDoneState, replay inside the reset effect).No new module. No new delta type. No new persistence layer.
Acceptance criteria
EditSnapshotbaseResult+editreproduces the storedresult(deep-equal), asserted directly, with the test anduseAnalyzedResumecalling the same exported helper — this is the invariant that rots silentlybaseResult, noedit) still loads, lists, and scores identically — no migration required, asserted with a fixture record at the old shapeCACHE_SHAPE_VERSIONis unchanged and an existing record at the current shape version does not re-parse on load (asserted:runCascadeis not called for it)loadAndResumeanduseAutoRestoreResume) and making an edit, the library still holds one record for that résumé, keyed to the same idloaded.resulthydrated, adopted onloaded.result)recovery.isLlmRecoveredis true stores nobaseResultand noeditbaseResult/edit(unchanged behaviour)listLibrarystill reads a score without replaying anythinghasCachedParse: false, rather than hiding itsrc/lib/storage/backup.ts)EditSnapshot's "every override map must appear here" rule holds — a round-trip test covers all its keys, including the optional ones (descriptionOverrides,achievementOverrides,summaryOverride,profileOverrides), since absent-key regressions are how Download PDF formatting fidelity — right-align dates, strip URL scheme, unbold skills, restore headline, hyperlink links #425 and Structured add-achievement flow — type + description + year fields #455 were lost beforecorpus-roundtrip.test.tsandrender-roundtrip.repro.test.tsstill pass — export fidelity is unchanged by thisnpm run verifygreenOut of scope
baseResult/editjust as it drops edits today.applyOverrides,bulletId, or the override maps themselves.