Three layers of save protection, defeated by one missing try/catch

No. 03

A single corrupt value could destroy a save, and every safety net we had built failed at once — silently, and in a way that was invisible by reading the code.

This one is unflattering, so here it is in full.

A save could be destroyed by a single bad value. Not corrupted — destroyed, overwritten by a brand new empty shop. And the three independent safety systems built specifically to stop that all failed at the same moment, without printing a single warning.

What was supposed to happen

The save system has three layers, and on paper they cover each other.

1. The current save, written as you play. 2. A backup, kept one step behind, so a bad write has something to fall back to. 3. A quarantine, which takes anything unreadable and stores it verbatim instead of deleting it — on the theory that a save we cannot parse today might be recoverable tomorrow, and is certainly worth more than nothing.

On top of that sits a migration ladder: a chain of small upgrades that walks an old save forward, version by version, until it matches what the current build expects.

Before the ladder runs, a sanity check looks at the data. That check deliberately only validates fields that have existed in every version of the format. It has to. A check that demanded today's fields would reject the very old saves the ladder exists to carry forward — it would be a migration system that refuses to migrate anything.

What actually happened

That deliberate narrowness is the hole.

A field introduced in a later version could hold a corrupt value. The sanity check does not look at it, correctly, because it cannot. So the save passes the gate and enters the ladder. Then a migration step touches that field and throws.

And the throw happened inside the migration, which is to say inside the loader, which is to say it escaped the loader entirely. From there, everything downstream did the wrong thing for a defensible reason:

  • The quarantine never ran, because quarantine is for data that fails to parse. This data parsed fine. It failed later, somewhere the quarantine was never wired to hear about.
  • The backup was never tried, because nothing in the crash path knew a load had been attempted and failed. The exception went straight past it.
  • The boot code caught the error at the top and found itself holding no save. It could not distinguish "there is a damaged save here" from "this is a new player", and those two situations have opposite correct responses. It picked the wrong one: it started a fresh shop — and wrote it straight over the top of the save it had just failed to read.

Three layers, one uncaught exception, and the failure mode is the exact thing all three exist to prevent.

The comment that was wrong

The module's own documentation stated plainly that a save is never destroyed by this code.

That was not a lie anybody told. It was true for every failure the author had thought of, and false for the one they had not — which is the ordinary way a comment goes wrong. Documentation describes the cases you imagined. It cannot describe the case you missed, and it will keep confidently asserting the general claim long after the general claim stops holding.

We now treat a comment that promises a guarantee as a claim requiring a test, not as documentation.

The fix, and proving it

The fix is unglamorous: the migration ladder runs inside a boundary that treats any failure — not just a parse failure — as "this save is damaged", which routes it to the quarantine and then to the backup, and the boot path can now tell a damaged save from an absent one.

The part that matters is the order it was done in. The original destruction was reproduced first. A test was written that fed the loader the exact shape of bad data, and it was watched destroying the save — confirming the failure was real, that the test could see it, and that the test would have caught it. Only then was the fix applied, and the same test watched going green.

That order is not a formality. A test written after a fix, against code that already works, proves only that the code does what it currently does. It never demonstrates that it can detect the bug, and a test that cannot fail on the thing it is named after is decorative.

This was caught in development, before anybody outside the project had a save to lose. That is luck as much as process — but the process is what turned it from a mystery into a reproducible case in an afternoon.