ADR 0061 — The migration Down path is unsupported in production¶
- Status: accepted
- Date: 2026-08-07
Context¶
Every goose migration under internal/store/sqlite/migrations/ and internal/store/sqlite/migrations_history/ carries a syntactically valid -- +goose Down block, because goose requires one. An audit of all of them found that most Down blocks are not actually reversible: they drop the table or column the matching Up added, which destroys whatever data that table or column held in the meantime.
For some tables that is a cheap re-fetch — values_cache and master_values are CCU-side caches; dropping them just forces a re-read on next boot. For others it is not reversible by any means available to the daemon:
users/tokens(migration 017):password_hashandtoken_hashare bcrypt hashes with no plaintext stored anywhere. There is nothing to reconstruct them from.alarm_journal/alarm_incidents(migration 027): the journal is documented as append-only, deletable only through the privileged retention path in normal operation; the incident ledger carries safety-critical counters (silenced flag, retrigger cycles, acoustic seconds) with no other source.alarm_codes.hash(migration 028): argon2id PIN hashes, same irreversibility as the bcrypt case above.matter_node_identities(migration 006): the NOC, the operational private key, and the Identity Protection Key. The only way back is re-commissioning every fabric.config_sections(migration 002 foraudit_log; migration 020 for theschema_versioncolumn): a Down→Up round trip on migration 020 does not merely drop a column — every survivingconfig_sectionsrow comes back atschema_version = 0, andConfigSectionStore.WipeOutdatedSectionsdeletes every row whose version does not match the current one on the next boot. For an installation with no rows older than migration 020, that is the entire saved configuration.alarm_zones.slug(migration 034): the slug is a frozen, external-identifier-safe name baked into Home Assistant entity ids and MQTT topics. The Up migration derives it once from the zone name at creation time specifically so a later rename does not change it. Down drops it; the next Up re-derives it from whatever the zone is named then, which is a different value for any zone renamed in between — orphaning every entity of that zone in a consumer's registry, the exact failure the freeze exists to prevent.
None of this was ever exposed to an operator: there is no make target, no hmcli subcommand, and no documented procedure that runs goose down. Makefile:47 installs the goose binary only as a development dependency for authoring and testing new migrations. The risk was real in principle — the Down blocks exist and goose down/goose down-to work exactly as goose intends — but latent in practice.
Decision¶
The migration Down path is deliberately unsupported for production and operator use. It exists only so goose migrations stay well-formed and so a contributor can exercise Up→Down→Up locally while developing or testing a new migration.
Concretely:
- Every Down block that drops a table or column carries a short, factual comment directly above
-- +goose Downnaming what is destroyed and, where relevant, why it cannot be recovered. This is enforced byTestMigrationDownDropsHaveLossNotesintests/contract/. - No tooling is added to make
goose downeasier to reach — no Make target, nohmclisubcommand, no CI job that exercises it against a real database. The existinggooseCLI dependency (installed bymake setup) remains the only way to invoke it, andCONTRIBUTING.mddocuments that this is by design, not an oversight. - Restoring a daemon to an earlier schema version is a backup-and-restore operation (file-level SQLite backup, or the daemon's own backup/restore surface where one exists), never a
goose downinvocation against a live database. config_sectionsmigration 020 is deliberately left as-is rather than patched to dodge the Down→Up config wipe: theschema_version = 0default it writes is exactly what makesWipeOutdatedSectionsdo its real job on a genuine upgrade from a pre-versioning database, discarding rows whose serialisation format cannot be trusted. There is no value that reads as "known-stale" for a real upgrade and "known-current" for a Down→Up round trip at the same time; changing the default to dodge the round-trip case would silently defeat the wipe on the upgrade case it exists for.
Consequences¶
- A contributor developing a migration still gets a real Up→Down→Up cycle to test against — the Down blocks work, they are just understood to be a development tool, not an operator rollback mechanism.
- An operator who runs
goose downoutside of documented guidance does so against an explicit warning in the migration file itself, not silently. - Future migrations that drop a table or column must add the same kind of note or
TestMigrationDownDropsHaveLossNotesfails the build — the rule is mechanically enforced going forward, not just documented for the audited set. - Genuinely reversible Down blocks (renames, additive-only rollbacks — see
migrations/031_alarm_zones_rename.sql) are unaffected; the guard only fires onDROP TABLE/DROP COLUMN.