Skip to content

fix(ui): seed Wan params fields in the v3->v4 persist migration (release-upgrade params wipe) - #9408

Open
lstein wants to merge 7 commits into
invoke-ai:mainfrom
lstein:fix/params-migration-wan-seeds
Open

fix(ui): seed Wan params fields in the v3->v4 persist migration (release-upgrade params wipe)#9408
lstein wants to merge 7 commits into
invoke-ai:mainfrom
lstein:fix/params-migration-wan-seeds

Conversation

@lstein

@lstein lstein commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Upgrading from any released v6.13.x build to current main silently wipes the user's entire persisted params slice (prompts, prompt history, model selection, dimensions, all generation settings) on first launch. This PR fixes it by seeding the five Wan component fields in the v3→v4 params persist migration.

Root cause

The Wan video PR (#9163) added five keys to zParamsStatewanTransformerLowNoise, wanComponentSource, wanVaeModel, wanT5EncoderModel, wanGuidanceScaleLowNoise — while the persisted schema was still at _version: 3, without a version bump or a migration seed. All five are .nullable() with no .default(), which zod 4 treats as required: a persisted blob missing the key fails parse().

migrate() ends with zParamsState.parse(state), and when that throws, unserialize (store.ts) catches it and silently replaces the slice with getInitialState().

Released v6.13.x builds write v3 blobs without these keys (no release contains the Wan merge), so a release → main upgrade hits: migrate runs → all seeds applied except wan → parse() throws on exactly the five wan paths → whole slice wiped, with only a log.warn. Verified with a field-accurate pre-Wan v3 blob: parse fails on precisely those five keys and nothing else.

Two effects masked this:

  • Dev machines wrote their v3 blobs after the Wan merge, so the keys are already present locally — dev upgrades don't reproduce it.
  • The migration test fixtures spread getInitialParamsState(), which carries the keys, so the suite couldn't catch it.

The fix

Seed the five keys conditionally (?? null) in the existing v3→v4 step. Conditional, because v3 blobs written by dev builds after the Wan merge can already hold real values (e.g. a selected Wan VAE) that must not be clobbered.

Tests:

  • a field-accurate released-build v3 fixture (wan keys deleted) that fails without the seed (mutation-checked) and asserts unrelated params survive;
  • a dev-build v3 fixture with real wan values asserting the conditional seeds don't overwrite them.

Related

  • The same class of gap exists for several older keys added mid-version without seeds (e.g. colorCompensation, fluxDype*, anima*, zImageSeedVariance* — affecting upgrades from pre-v6.13 releases), and the throw-and-wipe failure mode itself may be worth revisiting (repair-and-log, or a .default(null) convention for nullable slots). Kept out of scope here to keep this release-blocking fix minimal; can file a follow-up issue.
  • PR Feat: flux2 dev support #9234 (FLUX.2 [dev]) contains this same wan seeding as part of its persist-schema v5 bump (d3c7bde); the two dedupe on merge.

QA Instructions

  1. On a v6.13.x install, use the app (set a prompt/model) so a v3 params blob is persisted.
  2. Upgrade to main without this PR: params are reset on first launch.
  3. Upgrade to this branch instead: all params survive.

Or: pnpm vitest run src/features/controlLayers/store/paramsSlice.test.ts.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated
  • Documentation added / updated (n/a)

🤖 Generated with Claude Code

@github-actions github-actions Bot added the frontend PRs that change frontend files label Jul 31, 2026
@lstein lstein added the 6.14.0 label Jul 31, 2026
@lstein lstein moved this to 6.14.x Theme: USER EXPERIENCE in Invoke - Community Roadmap Jul 31, 2026
@Pfannkuchensack

Copy link
Copy Markdown
Collaborator

Findings

Medium: incomplete fix - upgrades from v6.12.0 and older still wipe the slice.
invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts:960-988

Evidence chain:

  1. git show v6.12.0:.../paramsSlice.ts ends its migration at state._version = 2, so v6.10.0 - v6.12.0 releases persist v2 blobs (v6.13.0 - v6.13.7 persist v3).
  2. Between v6.12.0 and origin/main, zParamsState gained 7 keys that are required (no .default(), no .optional(), no .catch()): animaQwen3EncoderModel, animaScheduler, animaVaeModel, qwenImageComponentSource, qwenImageQuantization, qwenImageShift, zImageShift (see invokeai/frontend/web/src/features/controlLayers/store/types.ts:818-944).
  3. The v2 -> v3 step seeds only qwenImageVaeModel and qwenImageQwenVLEncoderModel; the v3 -> v4 step seeds krea2/pid/wan. None of the 7 keys above is seeded anywhere.
  4. I built a field-accurate v6.12.0 blob (current initial state filtered to the v6.12.0 schema key set, _version: 2) and ran the real migrate(). It threw on exactly those 7 paths. Via invokeai/frontend/web/src/app/store/store.ts:186-192, that means the identical silent whole-slice wipe this PR exists to fix.

The PR body acknowledges this as out of scope, but the title/summary claim to fix the "release-upgrade params wipe", and the same one-line-per-field seed in the existing v2 -> v3 block would close it. Anyone upgrading from v6.12.0 or earlier still loses prompts, model selection, and dimensions.

To expose this issue, add a test that migrates a v2 blob built from an explicit v6.12.0-release key list (not getInitialParamsState()) and asserts positivePrompt survives.


Medium: the new regression tests do not lock in what their comments claim, and cannot catch the next occurrence.
invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts:214-244

The fixture is described as a "field-accurate released-build v3 blob", but it is {...getInitialParamsState()} with 5 keys deleted - so it still carries every other current-schema key (krea2*, ideogram4*, pid*, anima*), which a genuine v6.13.7 blob does not have. It is the exact anti-pattern the PR body identifies as having masked the bug, narrowed by 5 deletions. Proof it is inert against the general defect: the whole 31-test suite passes green on this branch while the v2 path (finding 1) is broken. Any future required-no-default key added without a seed reintroduces the wipe with no test failure.

To expose this issue, add a test that derives the fixture key set from an explicit per-release constant (or a checked-in JSON snapshot of a real v6.13.x blob) and, better, a schema-completeness test that asserts every top-level key of zParamsState is either defaulted/optional/catching or seeded by the migration chain for each supported starting _version.


Low: zParamsState.parse() failure remains fail-open-and-destroy; this PR patches one instance of it, not the class.
invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts:990 + invokeai/frontend/web/src/app/store/store.ts:186

A single missing nullable key discards the user's entire persisted params slice with only a log.warn. A per-key repair pass (fill missing keys from getInitialState() before/instead of throwing) or a .default(null) convention for nullable slots would make this class of defect non-destructive. Called out in the PR body as follow-up; flagged here because findings 1 and 2 are direct consequences of it still being live.

lstein and others added 3 commits August 2, 2026 13:45
The Wan video PR (invoke-ai#9163) added five keys to zParamsState
(wanTransformerLowNoise, wanComponentSource, wanVaeModel,
wanT5EncoderModel, wanGuidanceScaleLowNoise) while the persisted params
schema was still at _version 3, without a version bump or migration
seed. The keys are .nullable() with no .default(), which zod treats as
required, and migrate() ends with zParamsState.parse() whose failure
makes the store silently replace the slice with its initial state.

Released v6.13.x builds write v3 blobs without these keys, so any user
upgrading from a release to a build containing Wan loses their entire
params slice (prompts, prompt history, model selection, dimensions,
generation settings) on first launch. Dev machines don't reproduce it
because v3 blobs written after the Wan merge already carry the keys,
and the migration test fixtures spread getInitialParamsState() which
carries them too.

Seed the five keys conditionally (?? null) in the v3->v4 step so
released-build blobs migrate cleanly while dev-build blobs keep any
values they already hold. Add a field-accurate released-build v3
fixture that fails without the seed, plus a test that existing Wan
values survive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses review feedback on invoke-ai#9408.

Finding 1 (incomplete fix): the v3->v4 Wan seeds fixed upgrades from
v6.13.x, but releases v6.10.0 - v6.12.0 persist _version 2 blobs, and 15
keys added to zParamsState after v3 was cut are required (no .default(),
.optional() or .catch()) and seeded nowhere: fluxDype{Preset,Scale,
Exponent}, zImageShift, zImageSeedVariance{Enabled,Strength,
RandomizePercent}, anima{VaeModel,Qwen3EncoderModel,Scheduler},
klein{VaeModel,Qwen3EncoderModel} and qwenImage{ComponentSource,
Quantization,Shift}. Seed them conditionally in the v2->v3 step, so
released v2 blobs migrate cleanly and dev-build v2 blobs keep the values
they already hold. Verified by running the real migrate() over a blob
built from the v6.10.0 release key set: it threw on exactly those 15
paths before this change.

Finding 2 (tests can't catch the next occurrence): the fixtures spread
getInitialParamsState(), so they carry every current key and are inert
against the general defect. Replace them with the top-level zParamsState
key sets as actually shipped, read out of the release tags and checked
in, one per persisted version still in the wild (v6.10.0 for v2 and
v6.13.7 for v3 - each the narrowest key set among the releases writing
that version, so a subset of every real blob). Add a schema-completeness
test that runs the version steps over each release blob and asserts no
key of the current schema is left unhandled, naming the offending keys
and the step to fix. It fails on any future required-no-default key
added without a seed.

Finding 3 (fail-open-and-destroy): a single missing key made
zParamsState.parse() throw, and the caller in store.ts falls back to the
initial state, wiping prompts, model selection and dimensions with only
a log.warn. Add backfillMissingParamsKeys(): after the version steps,
fill any key that is absent and that the schema cannot fill itself, and
warn with the key names. Narrow by design - a key that is present but
invalid still throws, and anything with a .default()/.catch()/.optional()
is left to zod. So a forgotten seed now costs one field at its default
instead of the user's whole params slice. The completeness test above
deliberately bypasses the net so it still fails CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up from an adversarial review of the previous commit.

The v2 fixture was not the narrowest v2 release. The earlier survey
globbed tags as v6.1*, which silently excluded v6.7.0 - v6.9.0 — four
stable releases that also persist _version 2, with only 46 keys against
v6.10.0's 52. Six further keys are required-with-no-default and seeded
nowhere: fluxScheduler, zImageScheduler, colorCompensation,
zImageVaeModel, zImageQwen3EncoderModel and zImageQwen3SourceModel.
Verified by running the version steps over a v6.7.0-shaped blob: parse()
throws on exactly those six. Seed them in the v2 -> v3 step and replace
the fixture with the true narrowest set (v6.7.0, confirmed a strict
subset of v6.10.0/v6.11.x/v6.12.0). Add a v6.6.0 (_version 1) fixture
too; it is the v6.7.0 set minus positivePromptHistory, which the v1 step
already seeds.

Also close three edges the safety net did not cover:

- The v0 step dereferenced state.dimensions.rect unguarded, so a blob
  lacking dimensions threw a TypeError straight out of migrate() — the
  one remaining path that could still wipe the slice. Guard it and let
  the backfill repair dimensions instead.
- The v0 branch tested key presence (!('_version' in state)) while the
  backfill tests value (!== undefined). A blob with an explicit
  undefined _version matched no branch, reached the parse and took the
  slice down. Detect v0 by value so the two agree.
- Exclude _version from the backfill loop, so a future change cannot
  turn it into a version-detection bypass that stamps a blob current
  without running a single step.

Each fix is mutation-checked: reverting any one of them fails at least
one test, and the six seeds fail the schema-completeness test.

Not covered: v6.2.0a1 - v6.5.1 persist a blob with no _version at all
and predate the current dimensions shape, so a faithful fixture can't be
built by filtering getInitialParamsState(). Noted in the test file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein
lstein force-pushed the fix/params-migration-wan-seeds branch from 8d4247e to 4d0ccb0 Compare August 2, 2026 18:22
@lstein

lstein commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — all three findings confirmed and fixed, and finding 1 turned out to be wider than either of us had it. Rebased onto main (the conflict was an adjacency clash with the new ERNIE-Image migration test; both tests kept).

Finding 1 — incomplete fix, v2 blobs still wipe

Confirmed. Your v6.12.0 probe found 7 keys; the real number is 21, because v6.12.0 is the widest v2 blob, not the narrowest. Four stable releases persist _version: 2 with substantially fewer keys:

releases _version keys
v6.6.0 1 45
v6.7.0 – v6.9.0 2 46
v6.10.0 2 52
v6.11.0 – v6.12.0 2 60
v6.13.0 – v6.13.7 3 77–78

Running the version steps over a v6.7.0-shaped blob throws on 21 paths — your 7, plus fluxDype{Preset,Scale,Exponent}, klein{VaeModel,Qwen3EncoderModel}, zImageSeedVariance{Enabled,Strength,RandomizePercent} (missing from v6.10.0 and older), plus fluxScheduler, zImageScheduler, colorCompensation, zImageVaeModel, zImageQwen3EncoderModel, zImageQwen3SourceModel (missing from v6.7.0 – v6.9.0).

All 21 are now seeded in the _version === 2 step, conditionally (?? <initial>) so v2 blobs written by dev builds after each field landed keep the values they already hold.

v6.6.0 (_version: 1) needs nothing extra — its key set is the v6.7.0 set minus positivePromptHistory, which the v1 step already seeds.

Finding 2 — fixtures were inert

Agreed, and thanks for the precise diagnosis. Fixtures no longer derive from getInitialParamsState(). The test now carries the shipped zParamsState key sets, read out of the release tags with git show <tag>:.../types.ts and checked in — one per persisted version, each the narrowest among the releases writing that version, so it is a subset of every real blob of that version:

  • v1 → v6.6.0 (45 keys)
  • v2 → v6.7.0 (46 keys) — verified a strict subset of v6.10.0/v6.11.x/v6.12.0
  • v3 → v6.13.7 (77 keys) — v6.13.0 had one extra key, animaT5EncoderModel, since removed from the schema; unknown keys are stripped by the non-strict parse

Picking the narrowest is the whole point: my first pass at this used v6.10.0 as the v2 fixture and missed six keys precisely because it isn't the oldest v2 release.

On top of the per-release migrate tests, the schema-completeness test you asked for:

applyParamsVersionMigrations(blob);
const unseeded = backfillMissingParamsKeys(blob);
expect(unseeded).toEqual([]);

For each released blob version it asserts that no top-level key of the current schema is left unhandled — a key is acceptable only if the schema can fill it itself (.default()/.catch()/.optional()) or a version step seeds it. Any future required-no-default key added without a seed fails here, naming the keys and the step to fix:

Keys missing from a genuine v6.7.0 blob that neither carry a zod default nor get seeded by the
migration chain. Upgrading from v6.7.0 would throw in zParamsState.parse() and wipe the user's
whole params slice. Give each key a zod default, or seed it in the _version 2 migration step.

Finding 3 — fail-open-and-destroy

Fixed rather than deferred, scoped to this slice. After the version steps, backfillMissingParamsKeys() fills any top-level key that is undefined and that the schema cannot fill itself, and log.warns the names. Deliberately narrow:

  • only omissions — a key that is present but holds an invalid value still throws, so this repairs missing fields, not corruption;
  • only non-self-healing keys — anything with .default()/.catch()/.optional() is left to zod so the schema's default stays authoritative.

The obvious risk is that the net makes the completeness test vacuous, since it repairs exactly what that test looks for. So that test deliberately runs applyParamsVersionMigrations() directly and inspects what the net would have had to repair: the net protects users at runtime, the test still fails CI. That is why both functions are exported.

Three edges the net did not cover, also closed:

  • The v0 step dereferenced state.dimensions.rect unguarded, so a blob lacking dimensions threw a TypeError straight out of migrate() — the one remaining path that could still wipe the slice.
  • The v0 branch tested key presence (!('_version' in state)) while the backfill tests value. A blob with an explicit undefined _version matched no branch, reached the parse and took the slice down. v0 is now detected by value so the two agree.
  • _version is excluded from the backfill loop, so it can't become a version-detection bypass that stamps a blob current without running a step.

I did not change the generic rehydrate() in store.ts; per-key repair for every slice is a much broader behavioural change than this bugfix should carry. Happy to file that, plus the .default(null) convention for nullable slots, as a follow-up.

Not covered

v6.2.0a1 – v6.5.1 persist a blob with no _version at all and predate the current dimensions shape, so a faithful fixture can't be built by filtering getInitialParamsState() the way buildReleaseBlob does. Noted in the test file. The v0 path itself is now crash-safe (above), but it has no field-accurate coverage.

Verification

Every fix is mutation-checked — reverting any one of them fails at least one test, and dropping the 21 seeds fails the completeness test. eslint, prettier, tsc and dpdm (no cycles) are clean, and the full suite is green: 143 files / 1728 tests.

@Pfannkuchensack

Copy link
Copy Markdown
Collaborator

This fixes the reported problem, and the core of it is well-evidenced. I verified the migration
independently and it holds up:

Verified correct

  • The RELEASE_PARAMS_KEYS fixtures match the real releases exactly. I extracted the top-level
    zParamsState key sets from the tags and diffed them: v6.6.0 (45), v6.7.0 (46), v6.10.0 (52),
    v6.13.7 (77) - all four are an exact match, no drift in either direction.
  • The "narrowest blob per version" claims hold. v6.7.0 is a strict subset of v6.12.0, and v6.13.0
    differs from v6.13.7 only by the since-removed animaT5EncoderModel, exactly as documented.
    The version tiers (v1 = v6.6.0; v2 = v6.7.0-v6.12.0 at 46/52/60 keys; v3 = v6.13.x) reproduce
    from the tags.
  • The ?? guards on the Wan fields are correctly justified: wanTransformerLowNoise / wanVaeModel
    landed 2026-07-27 (eb9a951248), before the _version 3->4 bump on 2026-07-29 (1aeb05bbf0),
    so genuine dev-build v3 blobs carrying real Wan values do exist and must not be clobbered.
  • Conversely, the unconditional krea2/PiD assignments in the v3->v4 step are safe: krea2* landed in
    the same commit as the v4 bump and pid* one day after it, so no blob still at _version: 3 can
    carry either. No clobber is possible there.
  • Full suite passes locally: 44/44 in paramsSlice.test.ts (vitest 4.1.5, node 22).

Worth a follow-up PR

  1. The v4 tier is unguarded, and the safety net is currently the only thing holding it up.
    Because the PiD fields landed after the v4 bump, dev builds from the 07-29..07-30 window
    persist _version: 4 blobs that lack pidMode, pidDecoderModel, gemma2EncoderModel and
    pidSteps. For those blobs:

    • applyParamsVersionMigrations runs no step at all (_version === 4 matches no branch),
    • none of the four fields has a zod default, so zParamsState.parse() throws,
    • only backfillMissingParamsKeys prevents the whole-slice wipe - I confirmed it backfills
      exactly those four keys.

    So the net is load-bearing, not redundant - but it is untested at the one tier where it actually
    carries weight. it('seeds every key of the current schema in the version steps themselves')
    iterates v1/v2/v3 fixtures only, and there is no v4 fixture, so it cannot detect this. The
    structural consequence is that _version now sits at 4 and every future field added without a
    default lands in a tier with no migration step, where the "a forgotten seed still fails CI"
    guarantee does not apply.

    Suggested fix: add a v4 entry to RELEASE_PARAMS_KEYS using the key set as of the v4 bump commit
    and run the invariant against it too, or give the four PiD fields zod defaults.

  2. No v0 fixture in the invariant. The v0 path (v6.2.0a1-v6.5.1, no _version key) is the
    longest-lived and least-guarded branch, and it is excluded from the completeness test. It passes
    today - I built a faithful v6.5.1 fixture (44 keys, dimensions: {rect: {x,y,width,height}, aspectRatio}) and both applyParamsVersionMigrations + backfillMissingParamsKeys come back
    clean - but nothing fails CI if that breaks. The stated reason for omitting it (that a faithful
    fixture cannot be built by filtering getInitialParamsState()) only applies to dimensions,
    which a single explicit override handles.

  3. Two comments describe the presence check this PR replaced with a value check. The comment
    above the _version skip in backfillMissingParamsKeys says the version steps detect a v0 blob
    with !('_version' in state), "i.e. key presence" - but the step is now
    state._version === undefined, and the comment 30 lines below says so explicitly. The test
    rationale at it('never backfills _version', ...) repeats the stale claim.

    Related: since migrate()'s only production caller feeds it JSON.parse output (and params has
    no persistDenylist, so the merge is a no-op), a property can never hold an explicit
    undefined. Presence check and value check are therefore equivalent on all real input, and the
    test that constructs {_version: undefined} asserts a state that cannot occur. Harmless, but the
    comments should say what the code does.

  4. The dimensions guard is inert and its comment overclaims. state.dimensions && state.dimensions.rect prevents a TypeError, but the comment says this "lets
    backfillMissingParamsKeys() repair dimensions" - it cannot. The net only fills absent top-level
    keys; a present-but-incomplete dimensions object (and likewise dimensions: null) is skipped
    because !== undefined, and the parse still throws, so the user still loses the slice. The
    outcome is identical to before the guard, just via ZodError instead of TypeError.

    Low priority: I could not find a reachable trigger. Every v0 release from v6.0.0 to v6.5.1 wrote
    dimensions.rect; a truncated write fails at JSON.parse before migrate() is ever called; and
    the two schema-drift candidates are clean (zAspectRatioID is a strict superset of the old enum,
    and zParameterImageDimension is byte-identical to v6.0.0). That leaves hand-edited storage only.
    Worth fixing the comment either way, since it describes protection that is not there.

None of 2-4 blocks this PR. Item 1 is the one I would not leave open for long, since it only gets
easier to trip as more fields are added.

Addresses the four follow-ups from Pfannkuchensack's second review.

The v4 tier was unguarded: the PiD fields landed a day after the
_version 3 -> 4 bump, so dev builds from that window persist v4 blobs
without them, and a v4 blob matches no branch in the migration chain.
Give the four fields zod defaults, matching the ernieImage* precedent
set by the other two post-bump additions, and pin a RELEASE_PARAMS_KEYS
entry to the bump commit's key set so the invariant covers the tier no
version step can reach.

Add v0 fixtures. The claimed v0 range was wrong: it spans v6.0.0a1 -
v6.6.0rc2, and the oldest builds have no `dimensions` key at all, which
no step seeded — the invariant only held there because the safety net
caught it. Seed `dimensions` in the v0 step and cover both v0 shapes.

Widen the safety net from omissions to any key whose persisted value
fails its own field schema, so a `dimensions` the v0 guard left
incomplete, or a `model` whose base has since left zBaseModelType,
costs that one field instead of the whole slice. This makes true what
the guard's comment already claimed.

Fix the comments that still described the presence check this branch
replaced with a value check.

Also close three holes in the tests themselves, all found by mutating
the production code and watching nothing fail: the _version guard test
never reached the guard (the version steps normalise _version first),
the PiD test could not distinguish the new defaults from the safety net
backfilling the same values, and the fixtures carried initial values
throughout — including `model: null`, so nothing noticed a model being
silently cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lstein

lstein commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — all four are in this PR rather than a follow-up. Two of them turned out to be wider than reported, and item 2 in particular was hiding a real invariant violation.

1 — The v4 tier is unguarded

Confirmed, including the dating: the bump is 1aeb05bbf0 (2026-07-29) and the PiD fields land in 3f5588f21f (2026-07-30).

Fixed by giving the four PiD fields zod defaults rather than bumping to v5. That follows the precedent already set in this tier: ernieImageScheduler / ernieImageUsePromptEnhancer were also added after the bump and carry .default(), which is exactly why they are the two post-bump keys that don't break. A bump would also churn every user's _version to fix a window of dev builds.

I took the structural suggestion too: RELEASE_PARAMS_KEYS now has a '1aeb05bbf0' entry pinned to the bump commit's key set (97 keys), not to a tag. That is deliberately the durable form of the guarantee — the narrowest blob at the current version is by definition the one the bump commit wrote — so the invariant now covers the tier the version steps can never reach:

Keys missing from a blob written at 1aeb05bbf0, the commit that bumped _version to 4. A blob already
at the current version matches no branch in the migration chain, so no step can seed these — each
needs a zod default, or upgrading throws in zParamsState.parse() and wipes the user's whole params
slice.

2 — No v0 fixture in the invariant

Added, and the exercise justified itself: the v0 tier was actually violating the invariant, and the safety net was the only thing hiding it.

First, the range in my own comment was wrong. Reading _version and the key set out of all 66 v6.* tags rather than a subset:

builds _version keys
v6.0.0a1 – v6.0.0rc3 0 46, no dimensions
v6.0.0rc4 – v6.3.0rc2 0 47
v6.4.0 – v6.6.0rc2 0 44
v6.6.0, v6.7.0rc1 1 45
v6.7.0 – v6.13.0.rc1 2 46 … 73
v6.13.0 – v6.13.7 3 77–78

So v0 runs to v6.6.0rc2, not v6.5.1; v1 includes v6.7.0rc1; v2 runs through v6.13.0.rc1. (My v1/v2/v3/v4 fixtures are unaffected — v6.7.0 really is the intersection of all 21 v2 builds, and v6.13.7 of all 7 v3 builds.)

The consequence: dimensions does not exist in the oldest v0 blobs — it arrives in v6.0.0rc4 — and no step seeded it. The intersection across v0 builds is 43 keys, which no single build has. So applyParamsVersionMigrations on a genuine v6.0.0a1 blob leaves dimensions absent, zDimensionsState has no default, and only the repair pass keeps the slice alive. The v0 step now seeds it, and there are two v0 fixtures because one blob can't cover the tier:

  • v6.0.0a1 (46 keys) — the intersection once the three since-removed keys are filtered out. No dimensions; this is the one that fails if the seed is removed.
  • v6.5.1 (44 keys) — carries dimensions in the pre-flattening shape, the only way to exercise the v0 → v1 lift.

Second, and this applies to your fixture as much as mine: a v0 fixture asserting only that migrate() doesn't throw is close to inert now. I deleted the v0 → v1 width/height lift entirely and the whole suite stayed green, because the repair pass replaces the resulting invalid dimensions with the initial one — and the fixture's dimensions were the initial 512×512. The shared migrates a genuine %s blob test now seeds each fixture with a non-default 768×1024 in whatever shape that build persisted, and asserts it survives.

3 — Stale comments describing the presence check

Fixed both, and I folded your second point into the comment rather than dropping the value check:

// Value check rather than `!('_version' in state)`. The two are equivalent on real input, since
// the only production caller feeds this `JSON.parse` output, which cannot produce an explicit
// `undefined` — but a value check is what the rest of this file uses, and with a presence check a
// blob carrying `_version: undefined` would match no branch at all, reach the parse and take the
// whole slice down.

4 — The dimensions guard is inert

Right, and rather than fix the comment I made the comment true. The narrow "omissions only" scope was the actual defect — it left present-but-invalid as the last remaining whole-slice wipe, which is the same fail-open-and-destroy class as finding 3 from your first review.

backfillMissingParamsKeys is now repairParamsState, returning { backfilled, reset }. reset covers a key whose value doesn't satisfy that field's schema. The justification for widening: the alternative is not "the value is preserved", it's store.ts discarding every field. Both of your cases now behave as the comment claimed, and both are covered.

Agreed on reachability — I couldn't construct a non-hand-edited trigger for the dimensions case either. But widening it surfaced a reachable one elsewhere: zBaseModelType dropped the external-API bases (chatgpt-4o, imagen3, veo3, …) in v6.9.0rc1, so a genuine v6.5.1 – v6.8.1 blob can hold a model the current schema rejects. That used to be a whole-slice wipe; it now clears the model selection alone. Tested.

The granularity cost is now stated in the doc comment: it's one top-level key, so one malformed positivePromptHistory entry costs the whole history.

What the tests were not catching

Three of my own tests turned out to be vacuous under mutation, all fixed:

  • never repairs _version never reached the guard — the version steps normalise _version before the net sees it, so a blob with _version: undefined tells you nothing. It now calls the net directly with _version: 5 and asserts the parse still throws (correct for a downgrade). Removing the guard fails it; before, removing the guard failed nothing.
  • The PiD v4 test couldn't distinguish the new zod defaults from the net backfilling the same four values. It now parses directly after the version steps, bypassing the net.
  • The fixtures carried initial values throughout, including model: null — which is precisely why nothing noticed the model-clearing case above. They now carry a real model identifier and assert it survives.

Verification

Every fix is mutation-checked: reverting the PiD defaults fails the invariant test and the PiD test; removing the v0 dimensions seed fails the invariant test naming v6.0.0a1; removing the reset branch fails four tests; removing the _version guard fails one; deleting the v0 → v1 lift fails the v6.5.1 case.

One thing I did not change: the three PiD reducers gate on zParamsState.shape.X.safeParse(payload), so adding defaults means safeParse(undefined) now succeeds and pidStepsChanged(undefined) would set 4 instead of being a no-op. Not reachable — the payload types are number / {...} | null and the call sites are guarded — and animaLLLiteModel / animaLLLiteWeight already pair a .default() with that same gate on main, so this is the existing pattern rather than a new hazard. Happy to change the gate if you'd rather it not rely on that.

pnpm lint (prettier, eslint, knip, dpdm, tsc) clean; full suite 143 files / 1737 tests.

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

Labels

6.14.0 frontend PRs that change frontend files

Projects

Status: 6.14.x Theme: USER EXPERIENCE

Development

Successfully merging this pull request may close these issues.

2 participants