An install from v2026.3.12 ships `.env` with `LLM_MODEL` set, because that
release's template wrote it. The upgrade runs config migration 12 -> 13, which
clears that dead var -- and the user-state verifier reported the change as the
upgrade modifying the user's own state, failing the leg.
The verifier exists to catch an upgrade taking state away; a var the CURRENT
tree retires is not that. The retired set is parsed out of
`hermes_cli/config_migrations.py` (the `for dead in (...): save_env_value(dead,
"")` shape) rather than restated here, so it cannot drift from the tree, and an
unreadable source retires nothing -- every .env change stays fatal.
Tolerance is deliberately narrow: only keys the tree retires, only when the
upgrade EMPTIED them, and only when no key was added or removed. Clearing a
live key, or deleting a retired one outright, still fails.
state.db was already handled that way; cron/executions.db was not, so the cron
ticker writing rows mid-window would have failed the leg as a byte modification of
user state. Any *.db is now row-judged: byte churn is tolerated, and a table that
LOSES rows still fails (_rows_shrank compares the counted tables, so genuine loss
is caught, as the new test pins against cron_jobs in cron/executions.db).
tests/scripts/test_verify_user_state.py: 23/23.
The last red leg failed with "2 deleted": cron/executions.db-shm and
cron/executions.db-wal -- SQLite's own sidecars, which exist only while a
connection is open. A snapshot taken with one live records them, they disappear
when it closes, and the report read that as the upgrade deleting user state.
-wal/-shm/-journal are tolerated deletions now and reported as such, while the
databases themselves stay judged (state.db by row counts), pinned by a test that
tolerates the sidecars vanishing AND still fails when executions.db goes.
tests/scripts/test_verify_user_state.py: 22/22.
desktop->desktop reported "0 deleted, 1 modified" with MODIFIED .env and NO
`variables ...` line: the per-key digests deliberately ignore comments, blank lines
and ordering, so a rewrite in exactly those parts is invisible to them. The snapshot
now records the file's line/comment/blank counts and its key order, and the report
prints that comparison when no key differs -- the next failure names the shape that
changed instead of leaving a bare "1 modified". Counts and names only, never
content, pinned by a new case (tests/scripts/test_verify_user_state.py 21/21).
cron/ticker_heartbeat and cron/ticker_last_success came back as fatal
modifications: the ticker writes them on its own schedule, inside the verified
window or not. On a cold home they land as ordinary additions (tolerated); once
the ticker exists, the same file moves -- and the harness's own background process
failed the user's upgrade outward. Both names are now tolerated under any cron/
directory, while the user's own cron definitions (cron/jobs.json) still fail if
they move, which the new test pins.
An app-driven upgrade rewrote $HERMES_HOME/.env to a different sha256 at an
IDENTICAL byte count, so the leg reported "0 deleted, 1 modified" and two hashes
of a secrets file with no lead. The snapshot now records per-key VALUE digests
for .env-shaped files and the report names the variables added/removed/changed --
names and equality only, never content, which is also asserted by the new test.
The verifier's judged walk pruned only plugins/**, so a bundled skill under
profiles/<name>/skills/** was judged while the identical tree at the home root
was advisory. Every leg that owns a second profile therefore failed
USER-STATE PRESERVATION on the update's own skills sync (21 modified entries,
all "advisory modified (skills sync)" in the same report).
_walk_root now carries the per-ENTRY classification instead of the per-root
flag, consulting _is_bundled_skill at both roots -- the profiles branch of that
predicate was unreachable before. skills/.archive/** stays judged at both
roots, since the curator's archive holds restorable user skills.
The install/update legs asserted plenty about the code -- the checkout
landed, the version bumped, the desktop artifact exists -- and nothing
about the user's own state. An upgrade that ate auth.json or truncated
state.db would have passed every leg.
Adds a read-only, stdlib-only verifier (snapshot/verify) plus the hooks
that drive it around the real upgrade, on the POSIX and Windows drivers.
The state it defends is produced through the ordinary CLI
(hermes chat -q / auth add / profile create), never seeded by the
harness, and each action asserts it actually landed so a leg cannot
'pass' while testing nothing.
Judged: config.yaml, .env, auth.json, state.db, gateway_state.json and
the user's trees. state.db is compared by row counts, not bytes -- a
live SQLite file moves for benign reasons. The bundled skills/ tree is
recorded but never judged (the product re-syncs it), and plugins/** is
left to verify-plugin-preservation.py.