Skip to content

Persist the résumé override map: save a pristine base + EditSnapshot, not a flattened parse #768

Description

@s-annam

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).
  • saveResumeToLibrary (src/lib/resume-library.ts:161) stores parse: { result, score, sourceKind, shapeVersion } — a SavedResumeSnapshot.

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:

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:

interface SavedResumeSnapshot {
  result: CascadeResult;        // stays: the EDITED (flattened) result, so listing/scoring is unchanged
  score: AnonymousAtsScore;
  sourceKind: SourceKind;
  shapeVersion?: string;        // UNCHANGED — see "No shape-version bump"
  baseResult?: CascadeResult;   // new: the PRISTINE parse
  edit?: 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 no baseResult and no edit 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

  1. 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.
  2. saveResumeToLibrary / SaveResumeToLibraryInput — accept baseResult? and edit?, both optional, and write them into the snapshot.
  3. src/lib/edit/edit-pipeline.ts — add the exported pure fold helper (see The invariant) and switch useAnalyzedResume's savableResult to call it.
  4. src/hooks/useAutosaveResume.ts — add optional baseResult? and edit? to AutosavableResume and pass them through performSave to librarySave. Hand-audit performSave's dependency array.
  5. 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.
  6. loadResumeFromLibrary — return baseResult and edit when the snapshot carries both. The stale-shape / no-snapshot re-parse path is unchanged and returns neither.
  7. 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)
  • 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 before
  • corpus-roundtrip.test.ts and render-roundtrip.repro.test.ts still pass — export fidelity is unchanged by this
  • npm run verify green

Out of scope

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureSystem design / coupling / representation decisionsgaalHand this issue to Gaal, the repo's coding agentimprovementEnhancing existing functionality

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions