The vault was being written with `fs::write(tmp)` → `rename(tmp, path)`. The
rename is atomic, so readers never saw a half-written file — but atomicity is
not durability. Nothing fsynced the new bytes before the rename, so on ext4
(`data=ordered`, the default) a crash or hard power-off could commit the
rename while the data it published never landed. The result is precisely the
failure that stopped the container booting: `vault.json` present, zero bytes,
"EOF while parsing a value at line 1 column 0".
Two things made it worse. The write happened on *every* push tick, so a
ten-account vault was rewriting all of its secrets thousands of times a day,
and there was no backup to fall back on — a damaged file meant a crash loop
with the web UI locked behind the service that would not start.
Storage
* `store_io::write_atomic` fsyncs the new bytes, renames, then fsyncs the
directory. That ordering is the whole fix; the module docs explain why.
* Every vault write refreshes `vault.json.bak`, taken *after* the new file is
safely in place so the two always agree. Backing up first would leave the
backup a full change behind and quietly lose the most recent edit.
* `Vault::load` falls back to the backup when `vault.json` is unparseable,
moves the damaged file to `vault.json.corrupt-<ts>` rather than deleting
it, and writes the recovered accounts back so the next boot is ordinary.
With no usable backup it still refuses to start, as before.
Write volume
* Discord bookkeeping — message ids, last codes, last errors — moves to its
own `push-state.json`, batched and flushed at most every five seconds, with
a final flush on shutdown. Codes roll constantly; accounts change only when
the operator edits them, and keeping the two together was what put the
secrets in the path of every rotation.
* `push-state.json` is a cache by construction: losing or corrupting it costs
a reposted tile, never a secret, so it is never a reason to refuse to boot.
Tests: recovery from a zero-length vault, backup retention, write-back on
recovery, refusal when both files are bad, reload of tile ids, batching, and
that a rotation leaves `vault.json` byte-identical. 143 passing.