Files
msd-core/docs/adr/0008-installer-migration-module.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

5.7 KiB

Installer Migration Module owns install-time upgrade safety

  • Status: Accepted
  • Date: 2026-05-11

We decided to introduce an explicit Installer Migration Module for install-time file moves, removals, config rewrites, and user-data preservation. Installer upgrade behavior must be represented as versioned migration records that produce a dry-run plan before applying changes.

Decision

  • Add an Installer Migration Module as the owner for upgrade migrations.
  • Keep the existing installer materialization pipeline, but move cleanup and feature-retirement behavior into migration records over time.
  • Track applied migrations in an install-state file next to the existing file manifest.
  • Treat the existing file manifest as the managed-file ownership baseline.
  • Treat user-owned artifacts as a single shared policy consumed by preservation and manifest writing.
  • Require migrations to plan first, then apply through a shared executor that owns backup, rollback, and reporting.
  • Default ambiguous or unknown files to preserve; destructive changes need managed-file evidence or explicit user choice.
  • Support dry-run output using the same planner used by apply mode.
  • Include a first-time baseline scanner for legacy installs that need classification before destructive migrations can be trusted.
  • Treat the runtime configuration contract registry in docs/installer-migrations.md as the source of truth for migrations that touch host runtime config.

Runtime Contract Decision

Every migration that rewrites runtime config, moves an invocation surface, or retires a generated runtime artifact must cite the registry row in docs/installer-migrations.md. If the migration changes where a runtime loads commands, skills, agents, hooks, or rules, the PR must update both the registry and docs/ARCHITECTURE.md.

The registry records what MSD installs, where it installs it, when migrations may touch it, who owns the surrounding config, and why the shape matches the host runtime. When upstream docs do not publish an API or docs version, the checked date is the drift sentinel. A later upstream docs or CLI release that changes command, skill, agent, hook, or rule loading requires a new registry snapshot before migration work proceeds.

Consequences

  • Retiring features requires an explicit migration instead of a hidden cleanup block.
  • The installer can remove stale MSD-owned artifacts without guessing about user files.
  • Locally modified managed files get a consistent backup path before removal or replacement.
  • Future rollback work can become runtime-neutral instead of Codex-specific.
  • Migration authors must define ownership evidence, conflict behavior, runtime scope, and non-interactive behavior.
  • Migration authors must also define which runtime contract they are relying on and whether the upstream documentation is versioned.
  • The installer gains another state file, so tests must cover missing, legacy, and checksum-mismatch state.

Scope

The first implementation should extract manifest/user-owned helpers, add install-state persistence, add migration planning, and port one existing orphan cleanup into the migration runner. It should not rewrite every runtime installer branch in the first pass.

The detailed module contract lives in docs/installer-migrations.md.

Amendment (2026-05-11): Authoring guard enforcement

The Installer Migration Authoring Guard Module validates migration records and planned actions before planning can proceed. Records must declare title, description, introduction version, explicit install scopes, destructive status, and a plan function. Destructive or config-rewrite actions must include ownership evidence, and runtime config rewrites must cite the runtime configuration contract registry.

Amendment (2026-08-07): Non-recursive empty-directory removal primitive

Migration 003's docblock records, as an intentional consequence of this ADR, that the framework has no recursive directory-removal primitive: every action targets a single file by relPath, and an emptied directory shell is left behind for the user (or a future migration) to clean up. #3023 exposed a case where that is not enough: pi reserves the directory NAME hooks/ for its own deprecated-extension check, which warns on the path's mere existence regardless of contents. Leaving an emptied hooks/ shell behind would keep the warning firing forever, defeating the retirement.

We added remove-empty-dir, a new action type, rather than relaxing the "never remove directories" posture generally:

  • It calls fs.rmdirSync only — never fs.rmSync, { recursive: true }, or { force: true }. A non-empty directory fails the underlying syscall and is treated as a successful no-op (skipped-not-empty), not swept.
  • Emptiness is re-checked immediately before the call, not trusted from planning time, so a file that survived an earlier action in the same run (a failed removal, or a legitimately preserved unknown file) keeps the directory alive.
  • The target must not be a symlink, and its realpath must resolve strictly inside — and never equal — the config directory's own realpath.
  • Any unexpected failure degrades to left-in-place, matching every sibling action type's non-throwing posture.

Recursive directory removal remains deliberately absent. This primitive only retires a directory NODE once every file inside it has already been individually classified and actioned by other, ordinary file-level actions in the same migration — it is not a shortcut for sweeping a subtree in one step, and a migration author who wants that should still enumerate files individually per migration 003's and 009's pattern.

See docs/installer-migrations.md#action-types (remove-empty-dir) and src/installer-migrations/009-pi-retire-reserved-hooks-dir.cts.