Skip to content

ADR 0085: Migrations validate before schema stamping and refuse newer configs

Status: accepted

Context

§

The migration chain is discern's upgrade safety story: a project records [meta].schema_version runs every pending step, then stamps the project as current. ADR 0014 already says the migrated result must be validated before stamping, but the implementation did not enforce that boundary. A comment-preserving TOML edit could leave discern.toml syntactically invalid or schema-invalid, while upgrade still exited successfully and stamped the current schema. Every later command would then fail on the user's now-current but unreadable config.

The same boundary was missing in the other direction. A project written by a newer binary records a schema version greater than this binary supports. The old binary has no migration to run, because pendingMigrations(newer, older) is empty, so it treated the install as up to date and a full upgrade stamped the recorded version down. That silently converted "you need a newer binary" into "your current config is malformed," pushing the user toward deleting valid newer keys.

Guideline compilation has a different failure model. ADR 0065 deliberately made per-artifact guidance failures non-fatal so one blocked generated file does not prevent the rest of setup or refresh from completing. Config validity is not the same kind of work: a bad discern.toml breaks the command surface itself, so it cannot share that soft-failure semantics.

Decision

§

upgrade proves the migrated discern.toml parses and validates against the current schema before it recompiles guidance or stamps [meta].schema_version. If validation fails returns a hard invalid_migrated_config failure and leaves the recorded schema untouched. The migration's partial file edits may still be present, but the project is not marked current; a fixed, idempotent migration can be re-run from the same recorded version.

A recorded schema greater than this binary's SCHEMA_VERSION is a hard forward skew refusal on every migration-status surface: upgrade, upgrade --check, and upgrade all return schema_version_too_new with the user-facing message "this project needs a newer discern — re-run the installer." They do not compute a pending set, do not report "up to date," and do not stamp the schema down.

The explicit no: guideline refresh failures remain non-fatal after config validity is proven. Config validity is a prerequisite for stamping; generated guidance is a refresh artifact whose failures are reported but isolated.

Consequences

§
  • A migration bug can no longer create a stamped brick. The user may still need a fixed binary to repair a partially edited config, but discern does not hide the failure behind a current schema number.
  • Older binaries fail accurately on newer installs. The recovery advice is to update discern, not to delete config that may be valid for the newer schema.
  • Migration tests now include an executable validity net: migration outputs are parsed, full upgrade-corpus entries validate with zero schema issues, and a deliberately invalid synthetic migration pins the hard-failure behavior.
  • The cost is that upgrade can now fail after migration steps have written files but before guidance refresh or schema stamping. This matches the existing idempotent-chain design: the recorded schema remains behind, so the repaired chain replays rather than skipping the failed step.

Alternatives considered

§
  • Warn but do not stamp. Rejected because a warning still exits through a successful-looking path and asks callers to infer that the install is not current. A config that cannot be read is the hard failure the migration system exists to prevent.
  • Validate only TOML syntax. Rejected because schema-invalid TOML is still unusable to the current engine. The proof must be the same parser and schema every later command will use.
  • Let newer configs pass as "up to date." Rejected because an older binary cannot know that no migration is needed. A monotonic schema version is only meaningful if forward skew refuses instead of pretending compatibility.
choose openEsc close