Upgrade compatibility
This is the canonical supported-upgrade statement (issue #529). The upgrade test matrix (MIG-01 fixtures in backend/internal/schemafixture, DEPLOY-02’s automated harness) and the startup migration gate (database.RunMigrations) are all defined against this document; a change to the policy below is a change to those, and vice versa. This page publishes the decided policy; it does not re-derive it.
The supported range
In-place upgrade is supported from v0.6.0 and later.
v0.6.0is the floor. It is the last release before the hardening series began, and every release at or above it is covered by a committed schema fixture (see “How upgrades are tested” below).- Version-skipping within the range is supported.
v0.6.0 → currentdirectly must work, not onlyv0.6.0 → v0.6.1 → …. Self-hosted operators skip versions routinely. - Post-
1.0: any1.xupgrade from any earlier1.x, and from the final0.9.x. The floor moves only at a major version — which is itself a breaking change under MAINT-02: raising a supported-version minimum requires the major version and process that page describes. The upgrade floor is a covered surface of that policy. - Downgrade is unsupported. Rolling back means installing the previous version and restoring the pre-upgrade backup (see below).
Upgrading
docker compose pull
docker compose up -d
Database migrations run automatically on startup. Before applying any pending migration the server takes a verified SQLite snapshot into a pre-migration/ directory beside the database (MYCORRHIZAL_PRE_MIGRATION_BACKUP_DIR moves it) and refuses to migrate if it cannot write one (issue #530). That snapshot is the rollback point — downgrade is unsupported, so getting back to the previous version means installing it and restoring this snapshot. It covers the database only; photos and attachments are unchanged by migrations, so a full rollback still restores those from the routine three-piece backup (docs/deployment.md → Backups).
Upgrading is unattended; that is exactly why the supported range is bounded and why the refusal cases below fail loudly instead of best-effort.
The client side of an upgrade
Upgrading the server does not, by itself, require anyone to update their clients. Backward compatibility is the norm here: a server upgrade never breaks an existing client until the server explicitly declares a floor, and shipping a floor is a deliberate, documented, breaking event — not a side effect of a routine upgrade. The full statement, including the supported server×client combinations, is the client/server compatibility policy (ANDROID-01, issue #478); this section is the operator-facing summary:
- Web client. Nothing to do. Every user’s next page load fetches the current build through the service worker; the app handles asset-skew and a stale-
/health-contract window itself (prompting or force-reloading the tab as needed). See Service-worker updates for the mechanics and the/_recovery.htmlescape hatch. - Android app. A newer server keeps working with older app releases until the server declares a floor. If a future server release does raise
MIN_CLIENT_VERSIONabove the app someone is running, that app shows a blocking “Update required” screen at the next login and cannot proceed until updated — it does not fail mid-sync or corrupt anything. There is currently no floor declared (the supported combinations table in the policy has a single row covering serversv0.6.0and later: every released client works with every released server in that range), so today an upgrade needs no app update. Servers belowv0.6.0are outside that range in both directions — the app refuses them and the backend will not migrate them in place. - What to do when the release notes say otherwise. The only case an operator acts on is a server release whose notes declare a new client floor — a MAINT-02 breaking change that ships through the deprecation process, never silently. Then update the Android app before (or promptly after) the server; the app’s own “Update required” screen is the signal that matters. A newer app on an older server is a separate, non-breaking direction: the app hides or disables features the older server does not have (see the policy’s “newer client, older server” section).
Known defect — upgrading into v0.6.1–v0.6.8 with existing audit history
Affected: a direct upgrade from ≤ v0.6.0 to any of v0.6.1 – v0.6.8 on an instance that has ever written an audit event with a before_snapshot (every contact edit or delete writes one). Fixed in v0.6.9 — upgrading straight from v0.6.0 to v0.6.9 or later does not hit this.
Symptom. Migrations apply and commit, then the backend exits at startup and crash-loops, so every request returns 502 (nginx is up, the backend is not):
atrest: backfill audit_events.before_snapshot: constraint failed: audit_events is append-only: UPDATE is not allowed (1811)
Failed to backfill at-rest encryption
Cause. The at-rest-encryption backfill (issue #380) encrypts pre-existing plaintext rows with in-place UPDATEs. audit_events carries a BEFORE UPDATE trigger that makes it append-only, and the backfill did not drop that trigger around its own writes the way the other sanctioned audit-table writers (migration 000034, RecomputeAuditChain) do. schema_migrations is left at the new version and not dirty — this is a startup-job failure, not a migration-state refusal, so none of the four refusal states below describe it.
Recovery. migration-recovery.md → At-rest backfill vs. the audit-events trigger: roll back to the pre-migration snapshot and wait for v0.6.9, or — to stay on the upgraded release — drop the audit_events_no_update trigger for one successful boot and let startup recreate it.
What happens below the floor
A database whose schema predates v0.6.0 refuses to migrate. This is a deliberate, loud refusal — never a partial migration, never a crash, never a best-effort single hop:
- The server prints a message naming
v0.6.0as the required intermediate and exits (theFailed to initialize databasefatal log line). cmd/migrate upprints the same message and exits nonzero.- Nothing is written to the database; the version is untouched and the database is left clean.
The exact message:
database schema version 30 predates the supported upgrade floor (v0.6.0, migration 31).
In-place upgrade is supported only from v0.6.0 and later; this version refuses to migrate a pre-floor database.
Upgrade this instance to v0.6.0 first, then run this version again — see docs/upgrade-compatibility.md.
Two-step upgrade for a pre-floor instance
- Back up the database and the file directories (
docs/deployment.md→ Backups — the three-piece backup: database,PROFILE_PHOTO_DIR,ATTACHMENTS_DIR). - Deploy the
v0.6.0release (imageghcr.io/<org>/mycorrhizal-crm:0.6.0— the published tag drops the leadingv— or equivalent). Its startup migrations move the database to thev0.6.0schema (000031). - Verify it boots and serves.
- Deploy the current release. It now sees a database at or above the floor and continues normally.
This is a documented two-step, not a supported single hop: only v0.6.0 (and later) is guaranteed to read the pre-floor schema and preserve its data.
The one-time v0.2.0-alpha-candidate bridge
There is exactly one known sub-floor installation: the maintainer’s own, deployed at v0.2.0-alpha-candidate (2026-08-04). It gets a one-time, documented bridge, not a standing support promise:
- Back up everything (database,
PROFILE_PHOTO_DIR,ATTACHMENTS_DIR; seedocs/deployment.md). - Copy the database and run the bridge against the copy first:
MYCORRHIZAL_ALLOW_SUB_FLOOR_MIGRATION=1 make migrate-up(or set the same env var when booting the current server against the copy). This env var is the only code-level way a pre-floor database can be migrated in one binary; it is deliberate, logged, and only for this bridge. A second exception would be a policy change, not a config option. - Verify the copy (boot it, log in, search, export; check the audit trail).
- If the copy is clean, run the same step against the real database.
The bridge exists because the two-step path above depends on producing the v0.6.0 binary’s exact startup migration run against a real database; if that cannot be reproduced, the env var is the fallback. It is exercised in CI by database.TestFullChainMigrationPreservesRealData (which asserts the refusal, then runs the full chain through the override and asserts the seed data survived).
Refusal states
Four startup states are enforced, all fail-closed: the server refuses to start, logs at error level, and names the condition and its recovery. A message that names the condition but not the remedy sends the operator to the source; these all state both. In no case does any configuration setting turn a refusal into a warning — the one exception is the documented one-time v0.2.0-alpha-candidate bridge above, which is a policy exception for a single known sub-floor deployment, not a bypass knob. The step-by-step recovery for each state — including exactly which pre-upgrade backup file to restore and how long it must be kept — is docs/operations/migration-recovery.md.
The first three are the migration-state gates (MIG-04, issue #439). The fourth is the mandatory pre-migration backup (issue #530): a snapshot the server must be able to write before it will migrate, because downgrade is unsupported and that snapshot is the only rollback point.
| State | Behavior | Operator action |
|---|---|---|
Sub-floor schema (below 000031) | Refuse, print the two-step message above, exit | Two-step through v0.6.0, or the documented bridge — see the below-the-floor section |
| Dirty schema | Refuse (ErrDirtyMigration): a migration started and did not finish, so the schema state is unknown | Restore the pre-migration backup and start again — see the dirty-schema section. Only after verifying the schema actually matches the named version, make migrate-force (prompted, operator-only) — never automatic |
| Schema ahead of the binary | Refuse (ErrSchemaAheadOfBinary): the database knows migrations this binary does not, meaning a rollback is in progress | Deploy a binary that knows the newer migration, or restore the backup taken before the newer release ran — see the ahead-of-the-binary section |
| Pre-migration backup target unwritable | Refuse (ErrPreMigrationBackupFailed): pending migrations exist but the mandatory snapshot could not be written; the database is untouched | Make the backup directory writable, or set MYCORRHIZAL_PRE_MIGRATION_BACKUP_DIR to a writable path, then start again — see The pre-migration backup |
Dirty schema — interrupted migration
The schema_migrations.dirty flag is set when a migration starts and does not finish: the process was killed, the container was OOM-killed, the host lost power, or the SQL itself failed partway. The flag exists to say “the schema is in an unknown, partially-applied state; a human must look at it.”
Previously the server force-cleared the flag at every boot and migrated on top of the torn schema — presenting a half-applied database as healthy (issue #546). It does not do that anymore. On a dirty database the server refuses to start with a message naming the dirty version, what it means, and the recovery:
database is in a dirty migration state at version 43: a migration started and did not finish, so the schema may be only partially applied and does not match any known version. Refusing to start (fail-closed). Restore the pre-migration backup and start again — see docs/deployment.md (Backups → Restore). If you have verified the schema actually matches version 43, the operator-only escape hatch is `make migrate-force` (or `go run cmd/migrate force`), which prompts for explicit confirmation; it is never applied on the startup path.
Primary recovery: restore the pre-migration backup. The schema state is unknown by definition; the backup is the only state you can trust. Restore per docs/deployment.md → Restore, then start again — the restored snapshot is clean and migrations run normally.
Operator-only escape hatch: make migrate-force. Each individual migration runs inside one transaction (SQLite DDL is transactional), so an interrupted migration’s DDL rolls back cleanly rather than landing torn — the schema is either fully the previous version or fully the interrupted version, never somewhere between. The recovery is therefore: verify which of those two the schema actually matches, then clear the dirty flag at that version and let the pending migrations run. migrate-force does exactly this for the current dirty version, and it prompts (Type 'yes' to continue:) before doing anything — it cannot be triggered non-interactively by accident, and it is the only path that can clear the flag. The startup path has no equivalent.
Schema ahead of the binary — bad rollback in progress
A database whose applied version is higher than this binary’s newest migration means the database was migrated by a newer release and this binary has been rolled back. Downgrade is unsupported (see “The supported range”), so starting anyway would have the binary misread columns it does not know about — the same silent-corruption class as the dirty-force bug above. The server refuses to start with ErrSchemaAheadOfBinary, naming both versions and the recovery:
database schema version 45 is ahead of this binary (latest known migration 44): the database was migrated by a newer release and this binary has been rolled back. Downgrade is unsupported, so refusing to start. Deploy a binary that knows migration 45 (or newer) and start again, or restore the backup taken before the newer release ran — see docs/deployment.md (Backups → Restore).
How each refusal is surfaced
Each refusal is its own typed error (ErrSubFloorMigration, ErrDirtyMigration, ErrSchemaAheadOfBinary, and ErrPreMigrationBackupFailed for the pre-migration backup gate) so health/readiness and the diagnostics run can report which state an install is in rather than a generic boot failure, and so the migrate CLI prints the same message. The server start path logs it via logger.Fatal (Failed to initialize database); nothing is written to the database by any refusal.
How upgrades are tested
- Schema fixtures (MIG-01, issue #436): one committed schema-only dump per release at or above the floor lives in
backend/database/testdata/schemas/(see that directory’s README). Each is generated from the embedded migration chain (frozen and append-only) and populated at test time from the canonical TEST-02 manifest. - Chain upgrades (MIG-02, issue #437):
internal/schemafixture’s upgrade tests run every adjacent hop (v0.6.0 → v0.6.1 → … → current) and the longest supported skip (v0.6.0 → current) against real migrated fixture databases, asserting row counts and search consistency survive. The migration-tests CI workflow matrixes one job per supported release (v0.6.0 → current,v0.6.1 → current, …) — the legs are derived fromschemafixture.SupportedReleases(cmd/releaselist), not hand-listed, so a release added byrelease.ymlis covered without a workflow edit. Each migrates its fixture throughdatabase.InitDB(the path the server boots through) and asserts the final version and row counts, plus a down-direction job that round-trips every migration up → down → up against a populated fixture and gates on every migration shipping its.down.sql. A new release without a fixture fails CI (the completeness test plus the docker-publish gate). - Full-stack upgrades (DEPLOY-02, issue #451):
internal/schemafixture’sdeploy02_test.goupgrades a real three-piece install — the database file beside realPROFILE_PHOTO_DIR/ATTACHMENTS_DIRdirectories, the shape a Docker volume actually holds — IN PLACE throughdatabase.InitDBfor every supported release (the v0.6.0 case is the longest skip), and validates the whole install rather than the database alone: row counts and the MIG-03 semantic content survive, every live attachment/photo row still resolves to a real file after the in-place migrate, and the mandatory pre-migration backup (#530) is confirmed to have actually been written during the upgrade and to be a valid, restorable snapshot at the PRE-upgrade schema. The migrated instance is then driven through the real HTTP stack (routes.RegisterRoutes, exactly as the server wires it up): logging in with the PRE-EXISTING account (the bcrypt hash must still validate after the upgrade), an FTS search for a pre-upgrade contact, reading and editing a contact, and exporting. Sub-floor and dirty databases are refused on this same full-install path (issues #529/#439/#546). - Large datasets (issue #495): the same chain-upgrade path is also tested against databases populated at 134x the canonical manifest (2,010 contacts, pathological records included) — every supported release migrates to the current schema with row counts and integrity intact, and the measured resource requirements (duration / peak memory / peak disk per path) are recorded in
docs/development/scale-testing.md. Disk exhaustion during a large migration is asserted to fail closed in the chaos job. - Cross-version restore (BACKUP-01, issue #453):
internal/schemafixture’scross_version_restore_test.gotakes a realVACUUM INTOsnapshot of each supported-release fixture and restores the three pieces (database +PROFILE_PHOTO_DIR+ATTACHMENTS_DIR) under the matrix of restoring-release outcomes:M == Nserves the snapshot with no migration;M > Nmigrates it forward on startup and the restored data is compared semantically (MIG-03, issue #438), with every attachment/photo row asserted to resolve to a real file;M < N(a newer snapshot under an older binary) is refused withErrSchemaAheadOfBinary, which names the recovery path. A companion test takes snapshots under concurrent write load and asserts each is a transactionally consistent cut (integrity_check = ok, no foreign-key violations, no torn writes). This is the automated backing for the roll-back-a-bad-release procedure indocs/operations/migration-recovery.md.
Document consistency
The floor (v0.6.0, migration 000031) is defined in backend/database/migrate.go (SupportedUpgradeFloorVersion / SupportedUpgradeFloorTag) and re-exported by internal/schemafixture. schemafixture.TestDocsStateTheFloor asserts this document still names both, so the published policy cannot drift from the code.