Migrations

September 2, 2026 · View on GitHub

Domain: distribution

Overview

The migration system lets kit releases ship step-by-step instructions that an LLM agent can follow to bring a project's fab/ files in sync with the kit engine they run on. Migrations handle evolving config.yaml schemas, .status.yaml formats, naming conventions, and other project-level artifacts that live outside src/kit/.

Requirements

Dual-Version Model

Two VERSION files track the relationship between the installed engine and the project's file format:

  • $(fab kit-path)/VERSION — engine version (ships inside .kit/, replaced on each fab upgrade-repo run)
  • fab/.kit-migration-version — local project version (lives outside .kit/, NOT replaced on upgrades)

Both files contain a bare semver string (MAJOR.MINOR.PATCH), no prefix, no trailing content.

Migration Directory

$(fab kit-path)/migrations/ ships with the kit and contains migration instruction files. The directory exists even if empty for the first release (.gitkeep).

Migration File Format

Migration files are named {FROM}-to-{TO}.md where FROM and TO are full semver strings. A migration applies when FROM <= fab/.kit-migration-version < TO.

Each migration file follows this structure:

# Migration: {FROM} to {TO}

## Summary
{What changed and why migration is needed.}

## Pre-check
{Conditions to verify before applying.}

## Changes
{Ordered list of changes to apply.}

## Verification
{Steps to confirm migration succeeded.}

Migration files are pure markdown instructions (Constitution I — Pure Prompt Play). They contain no executable scripts — an LLM agent reads and applies them.

Range-Based Applicability

Migration ranges are determined by the release author, not by version bump type. Any release (patch, minor, or major) can ship a migration file if it changes project-level files. Wide-range migrations (e.g., 0.2.0-to-0.4.0.md) cover multiple intermediate releases.

Migration file ranges MUST NOT overlap. Overlap detection is owned by the binary (fab migrations-status), which surfaces conflicting filename pairs in its overlaps field; both /fab-setup migrations and fab upgrade-repo refuse to apply (or stamp) when overlaps is non-empty.

Binary-Owned Discovery — fab migrations-status

Discovery (the scan/parse/validate-non-overlap/sort + the applicability walk) lives in the fab-kit binary, implemented in src/go/fab-kit/internal/migrations.go:

  • parseMigrationFilename(name) — matches {FROM}-to-{TO}.md, parses both parts as semver, returns false for non-matching names (.gitkeep, README.md, malformed).
  • DiscoverMigrations(migrationsDir, local, engine) — scans the dir, detects overlaps (A.From < B.To && B.From < A.To), sorts by FROM ascending, walks the discovery loop, and returns a DiscoverResult{Local, Engine, Applicable, GapSkips, Overlaps}. It reuses the existing parseSemver/compareSemver helpers in semver.go (split out of sync.go in 260612-tb6f; no new semver dependency). The convenience predicate "migrations needed" is len(Applicable) > 0.

fab migrations-status [--json] exposes this as a queryable command (registered in the router's fabKitArgs allowlist so it routes to fab-kit). It resolves fab/.kit-migration-version locally, then reads the engine VERSION and migration directory from a validated absolute FAB_KIT_PATH when set or from the local-then-remote version cache otherwise. An invalid override fails loudly without cache fallback. The command runs DiscoverMigrations and reports local/engine versions, the ordered applicable list, gap-skips, and overlaps; --json emits {local, engine, applicable:[{from,to,file}], gap_skips, overlaps}. Exit code is 0 on a clean query, including no-op and overlap results. It exits 3 (internal.ExitNotManaged) outside a fab-managed repo and 1 on genuine errors such as an invalid override, missing version file, or unreadable directory. The command is read-only and never writes fab/.kit-migration-version.

/fab-setup migrations Subcommand

The migration runner, a subcommand of /fab-setup. It:

  1. Runs fab migrations-status --json (binary-owned discovery — no manual scan/parse/validate/sort in skill prose)
  2. STOPs and reports if overlaps is non-empty
  3. Surfaces any gap_skips lines, then applies each file in applicable sequentially (FROM ascending, already chained by the binary)
  4. Reads each migration file and executes its Pre-check/Changes/Verification (application stays LLM-driven per Constitution I)
  5. Writes the migration's TO to fab/.kit-migration-version after each successful migration

Only discovery moved into the binary; application of each migration instruction file remains an LLM activity in the skill.

Discovery loop (now implemented in DiscoverMigrations):

  1. Find first migration where FROM <= current < TO → append to Applicable, set current = TO
  2. If no match but a later migration exists with FROM > current → record a gap-skip, advance current to that FROM
  3. If no match and no later migrations → done (empty Applicable = no-op; fab upgrade-repo self-stamps the engine version in this case)

Failure handling: stops immediately on failure, fab/.kit-migration-version reflects last successful migration, suggests re-running /fab-setup migrations.

Two-Step Update Flow

fab upgrade-repo (shim subcommand) handles the mechanical .kit/ swap. /fab-setup migrations (skill subcommand) handles intelligent migration execution. They are separate operations — the shim handles download/swap (no LLM needed), the skill handles reading and applying instructions (LLM needed).

Brew-Install Migration

A migration file for the transition to the system shim model. The migration:

  1. Prerequisite gate: Verify fab (system shim) is on PATH. If not, instruct: "Install fab-kit first: brew tap sahil87/tap && brew install fab-kit"
  2. Add fab_version: Write fab_version: "{version}" to fab/project/config.yaml (set to the current $(fab kit-path)/VERSION)
  3. Clean .envrc: Remove the PATH_add src/kit/bin line if present
  4. Clean bin/: Remove fab, fab-go, wt, idea — only .gitkeep remains

Scenarios:

  • Migration on existing repo — adds fab_version, cleans .envrc, removes binaries; subsequent fab invocations work via system shim
  • Migration without shim installed — stops at prerequisite gate with install instructions

2.5.5-to-2.6.0 Re-Baseline Migration (freeze-on-write log.md)

src/kit/migrations/2.5.5-to-2.6.0.md (tayp) transitions existing projects onto freeze-on-write log.md generation (the schema/code side is in pipeline/schemas.md § Freeze-on-Write log.md Generation; the normative spec is fkf.md §6.4). As of 2.6.0 fab memory-index reads the existing log.md back and appends-only rather than re-projecting from scratch, so it is deterministic across git history rewrites. Existing projects carry log.md files generated under the old pure-projection model — already stale relative to live git after any squash-merge — so the first freeze-on-write run would freeze the stale-but-committed lines. The migration IS the fix: a one-time fab memory-index --rebuild (the destructive re-projection — clean baseline from current git) followed by a commit; from that commit on, every fab memory-index run is append-only stable.

  • Binary pre-check (the upgrade-ordering guard). This is the precedent for a migration whose Pre-check gates on a binary capability, not just project-file state. The standard upgrade ordering MUST be respected — the new binary lands first (brew upgrade fab-kit), then /fab-setup migrations applies — because applying with an older binary would fail on the unknown --rebuild flag. The Pre-check probes fab memory-index --help for --rebuild; if absent it aborts with no partial rewrite (Aborted: this migration needs fab ≥ 2.6.0 (the --rebuild flag). Upgrade the binary first: brew upgrade fab-kit.). A project with no docs/memory/ directory skips it entirely.
  • No fab/ data change. Unlike the .status.yaml-schema migrations above (1.8.0-to-1.9.0, 1.9.7-to-1.10.0, 2.4.2-to-2.5.0), this migration ships no .status.yaml schema change and no fab/ data change — it only regenerates docs/memory/ log.md files (and the indexes, which --rebuild also rewrites) and commits them. The re-baseline commit is the last churn the repo sees from the non-determinism issue.
  • Idempotent (Constitution III). Re-running --rebuild + commit on an already-clean tree is a no-op diff (nothing to commit), and the --rebuild pre-check still passes. After the baseline, fab memory-index --check exits 0 or 1, never 2 (a freshly re-projected tree is provably never destructive-loss).
  • Version bump. src/kit/VERSION is bumped to 2.6.0 (the migration's target version) — a behavior change to a shipped CLI warrants a minor bump, matching the catalog's 2.4.2-to-2.5.0 / 2.2.0-to-2.3.0 feature-migration convention.

2.6.6-to-2.7.0 Re-Baseline Migration (drop the index Last Updated column)

src/kit/migrations/2.6.6-to-2.7.0.md (ugde) re-baselines every docs/memory/**/index.md onto the two-column domain-index form. As of 2.7.0 (ugde) fab memory-index renders no third Last Updated column on domain / sub-domain indexes — the index is a pure function of content (file names + descriptions + structure), with no git dates. The old date cell was a live git log projection, which is HEAD/branch-relative, so concurrent PRs churned the cells back and forth on merge (the loom PR #1846 "lots of date-only changes" symptom); dropping the column makes the index genuinely branch-independent and idempotent (Constitution III). No capability is lost — dated, change-attributed history already lives in each folder's freeze-on-write log.md. Existing projects carry index.md files generated under the old three-column renderer, so the fix is a one-time re-baseline: run fab memory-index once with the new binary to rewrite every index.md to the two-column form, then commit. That re-baseline commit is the last churn the repo sees from the date column.

  • Rendered-output binary pre-check — the second output-probe precedent. Like 2.5.5-to-2.6.0, this migration's Pre-check gates on a binary capability under the same upgrade ordering — the new binary lands first (brew upgrade fab-kit), then /fab-setup migrations applies (an older binary would re-write the indexes back to three columns). Where 2.5.5-to-2.6.0 probed a --help flag (--rebuild present?), this one probes the rendered output: it runs fab memory-index in a throwaway temp project and checks the generated index.md for a Last Updated header. If present (or the probe index is absent), it aborts with no partial rewrite of the real tree (Aborted: this migration needs fab >= 2.7.0 (the two-column memory index). Upgrade the binary first: brew upgrade fab-kit.). A project with no docs/memory/ directory skips it entirely.
  • No fab/ data change. Like 2.5.5-to-2.6.0, this migration ships no .status.yaml schema change and no fab/ data change — it only regenerates docs/memory/ index.md files (and the append-only log.md files, which do not change shape) and commits them.
  • Idempotent (Constitution III). Re-running fab memory-index + commit on an already two-column tree is a no-op diff (nothing to commit), and the two-column pre-check still passes. After the baseline, fab memory-index --check exits 0 or 1, never 2 (a re-baselined tree is provably never destructive-loss); the --check exit-code contract is unchanged.
  • Version bump. src/kit/VERSION is bumped to 2.7.0 (the migration's target version) — a behavior change to a shipped CLI warrants a minor bump, matching the catalog's 2.5.5-to-2.6.0 / 2.4.2-to-2.5.0 feature-migration convention.

2.7.1-to-2.8.0 Backfill Migration (detect & fill test_paths)

src/kit/migrations/2.7.1-to-2.8.0.md (5qf5) backfills test_paths in existing projects' fab/project/config.yaml, mirroring the new Config Create-Mode detection (see setup.md § Config Create-Mode Detects & Fills test_paths). test_paths drives the /git-pr impact breakdown's test/impl split, ships language-specific with no kit default, and most projects never set it — so the split silently does nothing. The migration has two effects: (a) refresh the scaffold's test_paths example comment block so users keep an editing reference even when the key stays empty, and (b) detect + fill test_paths from on-disk marker files via the same anchored marker→ecosystem table the create-mode skill uses (Go/Python/JS-TS/Java-Kotlin/.NET; Rust & unrecognized → empty, since inline #[cfg(test)] tests are not glob-addressable).

  • Config-only, no binary/.status.yaml change. Unlike the re-baseline migrations above (2.5.5-to-2.6.0, 2.6.6-to-2.7.0), this one needs no binary capability pre-checkimpact.go already consumes any non-empty test_paths verbatim, and detection is pure prompt logic (Constitution I). It is the config-field-add shape, like 1.9.1-to-1.9.2 (true_impact_exclude) and 2.2.0-to-2.3.0 (agent.tiers): Summary / Pre-check / Changes / Verification, atomic write.
  • Idempotent + value-preserving (Constitution III). Pre-check skips entirely when config.yaml is absent. The comment-block refresh is sentinel-guarded on the # Examples (uncomment/adapt the line for your stack): line (re-run no-op). The fill happens only when test_paths is absent or empty — a user's hand-set non-empty value is preserved unchanged (only the comment block refreshes). Report lines mirror the create-mode notes (detected ecosystem + patterns, or "no test convention detected → left empty").
  • Version bump. src/kit/VERSION is bumped to 2.8.0 (the migration's target version) — an additive config-feature change is a minor bump, matching the catalog's feature-migration convention.

2.9.2-to-2.10.0 Pointer Migration (surface fab config reference)

src/kit/migrations/2.9.2-to-2.10.0.md (6nke) backfills the one-line config-reference pointer comment into existing projects' fab/project/config.yaml:

# Full reference of all available options: fab config reference

The visible command is fab config explain; reference survives as an invisible Cobra alias, which is what keeps this shipped pointer and every historical migration instruction functional without rewriting history.

New projects get this line from the scaffold; the migration surfaces it to projects already on fab-kit so an existing config also names the schema-discovery command (fab config reference, today the alias spelling of fab config explain — see configuration.md § Schema Discovery). The line is prepended as the file's header, matching the scaffold placement so migrated and newly-scaffolded configs converge.

  • Config-only, no binary/.status.yaml change — the same shape as 2.7.1-to-2.8.0. Like 1.9.1-to-1.9.2 (true_impact_exclude), 2.2.0-to-2.3.0 (agent.tiers), and 2.7.1-to-2.8.0 (test_paths): Summary / Pre-check / Changes / Verification, atomic write. It needs no binary capability pre-check (unlike the 2.5.5-to-2.6.0 / 2.6.6-to-2.7.0 re-baselines) — the pointer is a plain comment, and the command it names (fab config reference, new at the time and now the alias spelling of fab config explain) requires no project-file change to work.
  • Idempotent + value-preserving (Constitution III). Pre-check skips entirely when config.yaml is absent (Skipped: fab/project/config.yaml not present.). It is sentinel-guarded on the pointer line itself — the migration is skipped when that exact line already appears anywhere in the file (Skipped: config reference pointer already present.), so re-running is a complete no-op. All existing keys, values, comments, and formatting are preserved verbatim below the new header line (atomic temp+rename write).
  • Version bump. src/kit/VERSION is bumped to 2.10.0 (the migration's target version). The current VERSION was 2.9.2 (ahead of the last migration 2.7.1-to-2.8.0), so the range starts at the real current VERSION to chain cleanly; an additive config-feature change is a minor bump, matching the catalog's feature-migration convention.

2.11.0-to-2.12.0 Announce Migration (opt-in per-tier spawn_command)

src/kit/migrations/2.11.0-to-2.12.0.md (24ec) announces the new opt-in per-tier spawn_command (the cross-harness stage-dispatch knob — see configuration.md § agent tiers) to existing projects by inserting a short, fully-commented reference note under the config's existing agent: block, ending with a pointer to fab config reference for the canonical documentation. When no agent: block exists, it appends one with only the commented note (no spawn_command line — the binary falls back to its default spawn command when absent). The note documents the load-bearing semantic: a tier spawn_command is INDEPENDENT of agent.spawn_command (which opens whole agent sessions) — there is NO fallback from a tier to agent.spawn_command; PRESENT → CLI dispatch, ABSENT → native Agent-tool dispatch (default).

  • Config-only, no binary/.status.yaml change — the same shape as 2.9.2-to-2.10.0. Like 1.9.1-to-1.9.2 (true_impact_exclude), 2.2.0-to-2.3.0 (agent.tiers), 2.7.1-to-2.8.0 (test_paths), and 2.9.2-to-2.10.0 (the config-reference pointer): Summary / Pre-check / Changes / Verification, atomic write. It follows the 2.2.0-to-2.3.0 precedent (comment-sentinel idempotency, insert under agent:) and, like 2.9.2-to-2.10.0, needs no binary capability pre-check for the comment itself (unlike the 2.5.5-to-2.6.0 / 2.6.6-to-2.7.0 re-baselines) — the note is a plain comment. Note, however, that the field it documents required the then-widened binary (TierProfile.SpawnCommand + the historical resolve-agent spawn= line) — that is the version-gating point of shipping the note in this slot: the migration is a documentation announcement, but a tier spawn_command only does anything on fab ≥ 2.12.0. Current stage dispatch consumes fab agent <stage> -o yaml; this paragraph records the older migration's own surface.
  • Idempotent + value-preserving (Constitution III). Pre-check skips entirely when config.yaml is absent (Skipped: fab/project/config.yaml not present.). It is sentinel-guarded on the # agent.tiers.<tier>.spawn_command reference-comment line — the migration is skipped when that marker already appears (Skipped: agent.tiers spawn_command reference already present.), so re-running is a complete no-op. The note stays commented outyq '.agent.tiers' is unchanged by the migration (still null unless the user had already configured tiers); all other keys, values, comments, and formatting are preserved verbatim (including any existing commented agent.tiers reference block from 2.2.0-to-2.3.0).
  • Slot note — 3a took 2.10.1-to-2.11.0. This is the next slot after 3a's 2.10.1-to-2.11.0.md (PR #457, artifact-write hook removal), which had already bumped VERSION to 2.11.0 on this branch. Per the range-based-applicability rule, the slot's from is the real current VERSION (2.11.0), not the intake's originally-proposed 2.10.1-to-2.11.0 (already claimed).
  • Version bump. src/kit/VERSION is bumped to 2.12.0 (the migration's target version) — an additive config-feature change is a minor bump, matching the catalog's feature-migration convention.

2.12.1-to-2.13.0 Restructure Migration (agent config v3 — providers & role tiers)

src/kit/migrations/2.12.1-to-2.13.0.md (tykw) restructures fab/project/config.yaml for the providers/role-tiers rework — three coordinated, config-only schema changes (see configuration.md § providers and § agent):

  1. Providers extracted. agent.spawn_command moves to a new top-level providers.claude.session_command (verbatim value move); each per-tier spawn_command moves to providers.<name>.dispatch_command, with the tier pointing at that provider by name. A non-claude agent.spawn_command (templated, or otherwise not a plain claude … invocation) cannot be auto-attributed — it is relocated under providers.UNNAMED_PROVIDER.session_command and the migration halts and asks the user to name the provider.
  2. Five role tiers. agent.tiers keys thinking/doing/fast become default/operator/doing/review/fast; tier values become {provider, model, effort}. A thinking override maps to review (its only dispatched stage); doing/fast overrides carry over field-by-field; provider: claude is added on tiers that set a model/effort (documented style). An absent agent.tiers is left absent — fab-kit's built-in defaults apply (no synthesized five-tier block for a project that never overrode a tier).
  3. review_tools retired. The review_tools block is removed; when every key was true (or the block was empty) it is a silent no-op delete (absent = enabled). When any key was explicitly false, the block is deleted AND a fab/project/code-review.md § Review Tools section is seeded recording the disabled tools (creating code-review.md if absent).
  • Config-only, no .status.yaml change. Summary / Pre-check / Changes / Verification, atomic write — the same shape as the config-restructure migrations before it. Unlike the 2.9.2-to-2.10.0 / 2.11.0-to-2.12.0 comment-only announces, this migration rewrites live keys, but it still ships as a markdown instruction file (Constitution: user-data restructuring is a migration, not an ad-hoc script). The load-bearing semantics are preserved: absence of a dispatch_command still signals native Agent-tool dispatch (NO fallback to session_command), and unset tier fields inherit from the project's default tier.
  • Idempotent + value-preserving (Constitution III). Pre-check skips entirely when config.yaml is absent, and is sentinel-guarded on the top-level providers: key (the migration's own output) — a config already carrying providers: is on the v3 shape, so re-running is a complete no-op (Skipped: providers: block already present (agent config v3).). All unrelated keys/values/comments are preserved verbatim, and relocated command strings keep their exact value (no re-quoting).
  • Version bump. src/kit/VERSION is bumped to 2.13.0 (a new command + schema change is a minor bump). fab-kit's own fab/project/config.yaml is updated to the target v3 shape in the same change.

2.13.1-to-2.13.2 Backfill Migration (providers config template)

src/kit/migrations/2.13.1-to-2.13.2.md (fyn5) backfills the v2.13.1 providers config template (#467) into existing projects' fab/project/config.yaml. #467 pre-filled three providers in the scaffold template — claude live, codex/gemini as commented starter blocks, plus an expanded explanatory header and claude's commented dispatch_command line — but scaffold files are copy-if-absent (fab sync), so existing projects never picked these up, and no migration ever targeted 2.13.0→2.13.1. The installed base thus permanently diverges from the shipped template: users on migrated configs never discover multi-provider support or claude CLI dispatch (see configuration.md § providers). The migration surfaces the template with three comment-only additions: (1) the providers explanatory header (including the per-provider-notes paragraph), (2) claude's commented dispatch_command line, and (3) the commented codex/gemini starter blocks. No live key is added, removed, or modified — a user who wants codex/gemini uncomments and adapts.

  • Config-only, no binary/.status.yaml change — the same comment-backfill shape as 2.9.2-to-2.10.0 / 2.11.0-to-2.12.0. Like 1.9.1-to-1.9.2 (true_impact_exclude), 2.2.0-to-2.3.0 (agent.tiers), 2.7.1-to-2.8.0 (test_paths), 2.9.2-to-2.10.0 (config-reference pointer), and 2.11.0-to-2.12.0 (per-tier spawn_command note): Summary / Pre-check / Changes / Verification, atomic write. It needs no binary capability pre-check (unlike the 2.5.5-to-2.6.0 / 2.6.6-to-2.7.0 re-baselines) — the added content is entirely comments, and the codex/gemini/dispatch grammar they document already works on the 2.13.x binary (no new binary behavior is required for the comments to be valid).
  • Three-gate Pre-check. (1) Skip entirely when fab/project/config.yaml is absent (Skipped: fab/project/config.yaml not present.). (2) STOP when no top-level providers: key exists — the config has not run 2.12.1-to-2.13.0 (which introduces the block); for projects migrating from ≤ 2.12.1 the chained /fab-setup migrations flow runs 2.12.1-to-2.13.0 first (FROM-ascending), so this is normally hit only by a direct-file invocation or a hand-set fab/.kit-migration-version. (3) Sentinel: skip when the config already carries a codex/gemini provider — live (codex: / gemini: mapping keys) or as the commented starter marker (# codex: / # gemini:, the marker this migration writes) — Skipped: codex/gemini provider template already present. for the commented-marker case, with distinct Skipped: codex/gemini provider already configured — leaving config untouched. wording for the live-key case (comment-sentinel precedent: 2.2.0-to-2.3.0, 2.11.0-to-2.12.0).
  • Header refresh/insert + no-claude skip path. The header step keys on the per-provider-notes detection line (# Per-provider notes (kept out of the blocks below so uncommenting a whole block): if present the header is current and untouched; if absent it either replaces a pre-#467 header (distinctive old line # dispatch; ABSENT → native Agent-tool dispatch). The two are NOT merged.) or inserts the full v2.13.1 header above providers: when none exists (a 2.12.1-to-2.13.0-migrated bare-block config). The claude dispatch_command line is appended after claude's session_command only when claude carries no dispatch_command — live or commented; a live one (e.g. relocated by 2.12.1-to-2.13.0's per-tier spawn_command extraction) means the piece is skipped, and the Verification step mirrors the same gate. It replaces the old # no dispatch_command → … note when present; when no claude: provider exists (a provider under a different name, or UNNAMED_PROVIDER from the 2.12.1-to-2.13.0 halt-and-ask path) that piece is likewise skipped while the codex/gemini blocks are still appended.
  • Indent adaptation. The scaffold is 2-space indented; go-yaml-written configs (fab-kit's own) are 4-space. The migration detects the file's mapping indent from the existing providers: block children and emits all commented lines so that stripping the leading # from every line of a block yields valid YAML at the file's own indent. It ships both the 2-space scaffold blocks and a 4-space worked example (proven by fab-kit's own hand-patched config).
  • Idempotent + value-preserving (Constitution III). All live keys, values, and unrelated comments are preserved verbatim — the migration only inserts comment lines, so yq '.providers' (and .agent, and every other top-level key) is semantically identical before and after. Re-running is a complete no-op (the sentinel trips on the now-present # codex:/# gemini: marker). The migration's 4-space worked example is proven against fab-kit's own fab/project/config.yaml (already on the backfilled shape), doubling as the worked example.
  • Version bump. src/kit/VERSION is bumped 2.13.12.13.2 — a patch, since the backfill is comment-only with no binary change (patch-target precedent: 1.9.1-to-1.9.2). FROM is the real current VERSION (2.13.1) per the 2.9.2-to-2.10.0 chaining precedent; projects at local 2.13.0 reach it via a gap-skip to 2.13.1 then apply.

2.14.0-to-2.15.0 Restructure Migration (fab_versionfab/.fab-version)

src/kit/migrations/2.14.0-to-2.15.0.md (j0qm) relocates the project-pinned engine version out of fab/project/config.yaml into a new plain-text sibling fab/.fab-version (one line, bare semver + newline), and deletes the fab_version: key (with any stale comment line) from config.yaml. This is the user-data-restructure half of j0qm (Change 3 of the config-upgrade effort): for the single-writer invariant to hold — every write of an existing config.yaml routes through internal/configupgrade, whole-file upgrade and surgical set/unset alike, and no setFabVersion comment-clobbering masher exists — the one machine-managed field that masher owned (fab_version) has to live outside the file (see configuration.md § fab_version and kit-architecture.md). This migration is the only version source a migrated repo needs: it moves the pin into fab/.fab-version, which is now the sole source both reader stacks read (fab-kit router pinned-version resolution, fab-go preflight staleness). The one-compat-window config.yaml fab_version: fallback that the two stacks originally carried alongside this migration was removed in code by 260719-kq7v, so .fab-version is authoritative and a never-migrated repo hard-fails router resolution (recovery: fab upgrade-repo).

  • Config restructure, no .status.yaml change. Summary / Pre-check / Changes / Verification, atomic temp+rename write — the config-restructure shape, like 2.12.1-to-2.13.0. Unlike the comment-only announces (2.9.2-to-2.10.0 / 2.11.0-to-2.12.0 / 2.13.1-to-2.13.2) it moves live data (the fab_version value + key), so it ships as a migration per the constitution's user-data-restructure rule, not an ad-hoc script.
  • Upgrade-repo-first sentinel / order-independence. Because fab upgrade-repo already stamps fab/.fab-version (j0qm) and auto-runs fab config upgrade (which strips the now-unregistered fab_version key on its A-field pass), the field may already be gone before /fab-setup migrations reaches this file. The migration is sentinel-guarded and idempotent: when fab/.fab-version is already present AND the fab_version: key is already absent from config.yaml, it is a complete no-op — so it is order-independent with the upgrade-repo auto-run and re-running is safe (Verification: .fab-version present with the moved value, fab_version: key absent, YAML still parses, re-run is a no-op).
  • No binary pre-check needed. The .fab-version readers ship in the same 2.15.0 binary the migration targets (that binary also still read the legacy config.yaml key, so the pre-move state resolved either way), so no capability gate is required (unlike the 2.5.5-to-2.6.0 / 2.6.6-to-2.7.0 re-baselines).
  • The comment-backfill migration pattern is RETIRED going forward. Historical comment-backfill migrations (2.9.2-to-2.10.0, 2.11.0-to-2.12.0, 2.13.1-to-2.13.2) are left untouched — they remain shipped history. But the pattern of shipping a migration to repair or backfill config.yaml comments/scaffolding is not needed anymore: fab config upgrade now regenerates the managed fence's commented scaffold on every upgrade-repo, so new/renamed/removed advertise-flagged fields surface automatically without a bespoke migration. The fence itself needs no migration step — it appears on the first fab config upgrade run (auto-run by the next upgrade-repo).
  • Machine-level scaffold adoption is not a project migration. ~/.fab-kit/config.yaml is one system file shared by every repo on the machine, while this migration pipeline runs once per project. Its legacy unfenced scaffold is therefore normalized by the idempotent fab config upgrade --system path (also warmed by bare fab setup), not by a src/kit/migrations/ file that would target the same machine file N times.
  • Version bump. src/kit/VERSION is bumped 2.14.02.15.0 — a minor (a new subcommand + user-data restructure), matching the fence worked example's kit 2.15.0 stamp. The migration is named 2.14.0-to-2.15.0 per the DiscoverMigrations naming rule.

2.15.1-to-2.15.2 Verify+Commit Migration (un-ignore fab/.fab-version)

src/kit/migrations/2.15.1-to-2.15.2.md (8ken) un-ignores and commits fab/.fab-version so fresh worktrees, clones, and CI have a version source. As of 2.15.0 (j0qm) the project-pinned engine version lives only in fab/.fab-version (config.yaml's fab_version: key was deleted), and the design says that file is committed — but the .fab-* line added 2026-03-11 for the root runtime files (.fab-status.yaml/.fab-backend/.fab-runtime.yaml) is an unanchored gitignore pattern: with no slash it matches at any directory depth, so it also swallowed fab/.fab-version. The file could never be committed, so every fresh checkout had no version source and fab sync/wt init fail-loud (no fab version found in fab/.fab-version or config.yaml). The fix is a !fab/.fab-version negation in .gitignore plus the commit the binary cannot make; because the shipped scaffold fragment now carries the negation and lineEnsureMerge re-applies the fragment on every fab sync (not only at init — see kit-architecture.md and setup.md), a repo's .gitignore self-heals on its next fab upgrade-repo/fab sync, reducing this migration's job to verification + the commit.

  • This is the FIRST migration that commits. Every prior migration either restructured project files in place (config-restructure / re-baseline) or added comments — none ran git commit. This one must, because a binary cannot commit for the user (git identity / hooks / staging are the user's), and the whole point is to land fab/.fab-version in git. The commit is pathspec-scopedgit commit -m "…" -- .gitignore fab/.fab-version — so unrelated changes the user happens to have staged when /fab-setup migrations runs are never swept into the migration commit (the load-bearing safety property of a committing migration).
  • User-data restructure, not comment-only. It edits the user's .gitignore (adds one line) and commits a file, so it ships as a migration per the constitution's user-data-restructure rule (like 2.12.1-to-2.13.0 / 2.14.0-to-2.15.0), not the comment-only-announce shape (2.9.2-to-2.10.0 / 2.11.0-to-2.12.0 / 2.13.1-to-2.13.2), and NOT the retired comment-backfill pattern.
  • Two-part Pre-check (skip-safe + idempotency sentinel). (1) Skip entirely and silently when not a git repository OR fab/.fab-version absent — a non-git checkout cannot commit anyway, and a repo without the file predates version stamping and reaches this migration only after 2.14.0-to-2.15.0 has run. (2) Sentinel: skip when fab/.fab-version is not-ignored AND committedgit check-ignore -q fab/.fab-version exits non-zero (not ignored) AND git ls-files --error-unmatch fab/.fab-version exits 0 (committed). Re-running is a complete no-op. The sentinel also handles the sync-first order: fab upgrade-repo/fab sync merges the fixed fragment (self-healing the negation) before /fab-setup migrations runs, so the check-ignore half already holds and the migration only lands the commit.
  • Fragment-self-heal rationale — negation arrives via every sync. The migration deliberately does NOT own the negation as its primary job: the scaffold fragment carries !fab/.fab-version, and the fragment is merged by lineEnsureMerge on every fab sync, so the negation self-heals independently of whether the migration runs. The migration adds the negation itself only as a fallback for a repo whose sync predates the fixed fragment (still ignored at migration time) — then it inserts !fab/.fab-version on the line after .fab-* (or appends at end if no .fab-* / no .gitignore), touching no other line.
  • Hand-crafted last-match-wins ordering edge. gitignore is last-match-wins: if a user's .gitignore has the negation but a later .fab-* line follows it, the file is re-ignored despite the negation being present. The migration handles this specific case by moving the !fab/.fab-version line below the last .fab-* line so the negation wins — the one case where it reorders rather than appends.
  • Version bump. src/kit/VERSION is bumped 2.15.12.15.2 — a patch (a pure fix, no schema change), matching the migration name (FROM=released 2.15.1, TO=next 2.15.2).

2.15.7-to-2.15.8 Worktree-Sweep Migration (stale fab hook settings across every worktree)

src/kit/migrations/2.15.7-to-2.15.8.md (weoh) sweeps every worktree's .claude/settings.local.json for stale fab hook entries, cleaning the per-checkout copies the two prior settings migrations (2.10.1-to-2.11.0.md, 2.13.6-to-2.14.0.md §1) could never reach. The fab hook command family was removed outright in 2.14.0 (agent-state divestment, ioku — see runtime-agents.md) with no deprecation shim, so any lingering hook entry now errors every time it fires: Claude Code invokes fab hook <x>, cobra prints an unknown command "hook" for "fab" message, and the user sees a non-blocking PostToolUse:{Write,Edit} (or SessionStart/Stop/UserPromptSubmit) hook-error warning on every file write / session event — harmless but relentless noise.

  • Why the two prior settings migrations don't cover it — the version-gate/gitignored-state mismatch. Both 2.10.1-to-2.11.0.md (the two fab hook artifact-write PostToolUse entries) and 2.13.6-to-2.14.0.md §1 (the three session-scoped entries) edit only the checkout in which /fab-setup migrations runs. Migration applicability is gated on fab/.kit-migration-version — a committed, repo-wide file — so once a migration runs in one checkout and the version bump merges, no sibling checkout ever re-runs it. Meanwhile .claude/settings.local.json is gitignored, per-checkout state: pre-2.14.0, fab sync's syncHooks step minted the hook entries into every checkout it ran in (and wt createwt init runs fab sync in every new worktree). Result: every worktree synced before 2.14.0 carries its own stale copy that no version-gated migration will ever touch again. This is the precise gap the change closes.
  • The main checkout is the live poison. Claude Code resolves project settings through worktrees to the main repository root (per the official Claude Code docs: .claude/settings.local.json is read at the root of the git repository, resolved through worktrees to the main checkout, so one file covers sessions started in any subdirectory or worktree). A stale main checkout therefore poisons every worktree session — including worktrees created after 2.14.0 whose own settings files are clean. This is why the sweep MUST cover the main checkout — which is the first entry git worktree list --porcelain reports. (Pre-v2.1.211 Claude Code read the starting directory's file directly, so the per-worktree copies matter too; hooks are re-read live by a file watcher, so cleanup takes effect without a session restart.)
  • All-worktrees sweep — the 2.13.6-to-2.14.0 §2 shape, extended to settings edits. The Pre-check enumerates all worktrees via git worktree list --porcelain (main = first entry) and, in a non-git directory, handles only the current directory and skips the sibling sweep — directly mirroring 2.13.6-to-2.14.0.md §2, which already enumerated worktrees that way to delete .fab-runtime.yaml everywhere. This change applies the same worktree discipline to the settings edits its own §1 (and 2.11.0's) missed.
  • Target set = prefix + legacy shims. A hook action is stale when its command either starts with the prefix fab hook (a prefix match, NOT an enumeration of the four known subcommands — the whole family is gone, so any fab hook <x> errors) or matches the legacy script-shim forms carried by the prior migrations' target sets (bash "$CLAUDE_PROJECT_DIR"/fab/.kit/hooks/on-<script>.sh / bash fab/.kit/hooks/on-<script>.sh — the on-*.sh scripts do not exist, so these also fail if fired).
  • Sentinel-guarded, idempotent, preserve-non-fab-hooks discipline. Sentinel: when no <worktree>/.claude/settings.local.json carries a target entry (under any event), skip entirely — Skipped: no stale fab hook entries found in any worktree. — so re-running is a complete no-op. Per cleaned worktree, in every event array under hooks (any event), it drops each target action; an entry mixing a target action with an unrelated custom command keeps the custom command (the preserve-non-fab-hooks discipline from 2.10.1-to-2.11.0.md §1 / 0.46.0-to-1.1.0.md §1); an entry whose hooks[] becomes empty is removed; an emptied event array is left empty (or its key omitted); the hooks object is never deleted; all non-hook top-level keys (permissions, model, …) are preserved verbatim. Writes are atomic (temp file in the same directory + rename), and one line prints per cleaned worktree (Removed stale fab hook entries from <worktree-path>/.claude/settings.local.json.).
  • No binary pre-check, no fab//.status.yaml change, no commit — unlike 2.15.1-to-2.15.2. The logic is pure prompt / JSON-edit (no new flag or command needed), so it needs no binary capability pre-check (unlike the 2.5.5-to-2.6.0 / 2.6.6-to-2.7.0 re-baselines). It makes no .status.yaml change and no fab/ data change. And, unlike 2.15.1-to-2.15.2 (the first committing migration, which committed fab/.fab-version), it runs no commit: the edited .claude/settings.local.json files are gitignored, so nothing here lands in git.
  • Version bump. src/kit/VERSION is bumped 2.15.72.15.8 — a patch (a pure fix with no schema or binary change; patch-target precedent: 1.9.1-to-1.9.2, 2.13.1-to-2.13.2, 2.15.1-to-2.15.2). FROM is the real current released VERSION (2.15.7) per the chaining precedent; if another in-flight change had claimed the slot first, it would re-slot per the 2.11.0-to-2.12.0 slot-note precedent (FROM = real current VERSION at apply time).

2.16.19-to-2.17.0 Agent-Schema Migration (agent.tiersagent.profiles; flat provider fill → profiles.default)

src/kit/migrations/2.16.19-to-2.17.0.md rewrites the agent config to the roles/profiles schema: agent.tiers:agent.profiles: (key rename, values verbatim), and providers.<name>.model/.effortproviders.<name>.profiles.default.{model,effort}. It sweeps both scopes — the project's fab/project/config.yaml and the system ~/.fab-kit/config.yaml — since both keys are scope: both and the worktree/system-sweep lesson says a migration touching one location strands the other.

  • Why a migration rather than the registry carry. The binary reads both legacy spellings (agent.tiers per role, and the flat fill as an alias for profiles.default), so nothing breaks before the migration runs. But fab config upgrade's renamed_from carry is a top-level-key operation — it rewrites a column-0 key: token and preserves the block below it — and agent.tiersagent.profiles is a rename inside the agent: block, which it skips by design. So the migration is what rewrites the file, and it is a user-data restructure, which the constitution requires ship this way.
  • Two shapes it warns about instead of guessing. (1) An agent.tiers.default carrying model/effort: the old map re-based every unset field from its default tier, and agent.profiles has no cross-role inheritance, so the migration names the two possible replacements (agent.session/agent.workers for a provider intent, providers.<name>.profiles.default for a model/effort intent) and carries the values verbatim. (2) A flat providers.claude.model/.effort: inert while it sits below claude's per-role fills, but the default role's own fill once folded — where it wins, changing that one role's resolution while the other five are untouched.
  • Live keys only, sentinel-guarded, idempotent. A target key must be a live YAML key (commented lines are not targets), and a model:/effort: under providers.<name>.profiles.<role>: is the NEW shape, not a target. When neither file carries a target key the migration skips entirely, so re-running is a complete no-op. Pure YAML editing: no .status.yaml change, no binary capability pre-check, and no commit (the project config is committed by the user in their own change; the system config is not in git).
  • Version bump. src/kit/VERSION is bumped 2.16.192.17.0 — a minor (a config-schema change plus the new agent.session/agent.workers keys). FROM is the real current released VERSION per the chaining precedent; the slot was re-numbered from 2.16.18-to-2.17.0 when v2.16.19 released mid-implementation, exactly as the 2.11.0-to-2.12.0 slot note prescribes.

2.17.3-to-2.18.0 Dispatch-Mode Migration

src/kit/migrations/2.17.3-to-2.18.0.md migrates the retired dispatch.watchable boolean in both fab/project/config.yaml and ~/.fab-kit/config.yaml:

  • Only live keys outside the managed reference fence are candidates.
  • watchable: true becomes mode: pane; watchable: false is removed; an absent key remains absent so the built-in native default applies.
  • When both spellings are live, the explicit mode value wins and the legacy key is removed.
  • Unrelated values/comments are preserved and writes are atomic and idempotent.
  • The runtime has no read-time alias for watchable, so migration is the sole compatibility bridge.
  • src/kit/VERSION advances from 2.17.2 to 2.18.0.

2.18.1-to-2.19.0 Provider-Command-Fields Migration (session_commandinteractive_command; dispatch_commandheadless_command)

src/kit/migrations/2.18.1-to-2.19.0.md renames the two provider command fields — providers.<name>.session_commandinteractive_command and providers.<name>.dispatch_commandheadless_command — in both fab/project/config.yaml and ~/.fab-kit/config.yaml. Pure key renames: every value, comment, and the file's indentation style is carried verbatim.

  • Why a migration rather than the registry carry. The 2.19.0 binary reads both spellings — per field, a non-empty new spelling wins and the deprecated spelling is the silent fallback — so nothing breaks before the migration runs. But fab config upgrade's renamed_from carry is a top-level-key operation and this rename lives inside providers.<name>: blocks, so it is skipped by design; the providers row's renamed_from is informational for --json consumers only. The migration is what rewrites the file.
  • Per-field independence, duplicate-drop warning. The two renames are independent per field (a half-migrated provider gets exactly one key rewritten). An entry already carrying the new spelling alongside the old drops the deprecated duplicate with a warning — the binary prefers the new spelling, so resolution is unchanged.
  • Live keys only, sentinel-guarded, idempotent. Commented lines (including the managed fence) are never targets; when neither file carries a target key the migration skips entirely. The dispatch.mode value headless, the dispatch.* block, and the fab dispatch command family keep their names — only the two field names move. The write surface (fab config set/unset/explain/show dotted keys) accepts the new spellings only; old spellings refuse as unknown-key.
  • Version bump. src/kit/VERSION is bumped 2.18.12.19.0 — a minor, per the 2.16.19-to-2.17.0 rename precedent.
  • Earlier catalog entries describe the pre-rename spellings, and correctly so. The 2.12.1-to-2.13.0 and 2.13.1-to-2.13.2 sections above describe files that write session_command / dispatch_command, because a shipped migration is an instruction for an older upgrade and is never rewritten (§ Design Decisions → "A Shipped Migration File Is a Historical Record"). A config stepping through the chain therefore arrives carrying the old spellings, and this migration is the step that moves it to the new ones. Elsewhere in memory those relocations are cited by the key path the value occupies today (e.g. configuration.md § Schema Discovery names agent.spawn_commandproviders.claude.interactive_command) — the catalog reads a migration file, the schema docs read the live config.

2.19.4-to-2.20.0 Retirement Migration (delete the retired branch_prefix key)

src/kit/migrations/2.19.4-to-2.20.0.md (h1eu) deletes the retired branch_prefix key from a live fab/project/config.yaml and, as hygiene, from ~/.fab-kit/config.yaml. branch_prefix was an optional prefix that only fab batch switch applied when naming a worktree branch; as of 2.20.0 the key is retired outright — struct field, accessor, registry row, and both scope-table entries are gone — and fab batch switch names branches by the change folder name, the one branch-naming convention /git-branch, /fab-new, and docs/specs/naming.md share (see kit-architecture.md § Batch Commands and configuration.md § Design Decisions → "One Branch Namespace — No Config Key Varies a Branch Name").

  • A retirement with no destination. Unlike review_tools (→ fab/project/code-review.md § Review Tools, 2.12.1-to-2.13.0) or agent.spawn_command (→ providers.claude.interactive_command, via 2.12.1-to-2.13.0 then 2.18.1-to-2.19.0), the behavior this key configured has no home anywhere in the system, so nothing is carried forward: the migration removes the line plus any adjacent documenting comment. When the value was non-empty it warns, naming the orphan <value><folder> branches as the user's to keep, rename, or delete — a prefixed branch holds none of its change's commits, which is the bug the retirement fixes.
  • Nothing breaks before it runs. The key is unregistered as of 2.20.0, so a branch_prefix: left on disk is an inert unknown key the loader ignores silently. The migration's job is to stop a dead key from reading like a live setting, not to preserve behavior.
  • Both scopes swept, for different reasons. branch_prefix was scope: project, so only the project file could carry it effectively — a copy in ~/.fab-kit/config.yaml was already pruned with fab: warning: ignoring project-scoped field "branch_prefix" on every load. Sweeping the system file is therefore hygiene rather than a fix, per the 2.15.7-to-2.15.8 lesson that a migration touching one location strands the other. The derived environment override FAB_BRANCH_PREFIX went with the registry row it was derived from; environment variables are not project files, so no step targets it.
  • Live keys only, sentinel-guarded, idempotent. Skip entirely when neither config exists; skip when neither carries a live column-0 branch_prefix: (Skipped: branch_prefix already absent (retired key).), so re-running is a complete no-op. Commented lines are never targets — in particular the managed fence, whose # branch_prefix — … advert block disappears on its own when fab config upgrade regenerates the fence from the registry (the retired comment-backfill pattern, per 2.14.0-to-2.15.0). Atomic temp+rename write, no .status.yaml change, no binary capability pre-check (pure YAML editing), and no commit.
  • Version slot. FROM is the released 2.19.4, TO the next minor 2.20.0, per the DiscoverMigrations naming rule and the 2.16.19-to-2.17.0 chaining precedent — a config-schema removal is a minor. The src/kit/VERSION bump to 2.20.0 is release-owned: unlike the entries above, whose Summaries state the bump as made, this file's Summary states it as pending at release, because the authoring change deliberately left src/kit/VERSION alone.

2.22.0-to-2.23.0 Restructure Migration (strip the blanket agent-dir ignores; regenerate per-target manifests)

src/kit/migrations/2.22.0-to-2.23.0.md (jjg0) removes the six directory-level ignores the scaffold fragment historically line-ensured into every fab project's root .gitignore (/.agents, /.claude, /.cursor, /.opencode, /.codex, /.kimi — plus /.gemini, which fab-kit's own checkout carried), so a fab-managed project can commit its own content under those directories. As of 2.23.0 the fragment ships no directory token, and fab's deployed skill copies are ignored by the per-target generated .gitignore manifest (see kit-architecture.md § Agent Skill Deployment).

  • Exact-token removal, never a sweep. The removal set is the seven tokens the fragment (or the dev repo's own .gitignore) ever shipped, matched under the same directory-token equivalence the dedup uses (leading slash optional, trailing / or /* tolerated), plus the # Optional - ignore agent specific folders header when it immediately precedes a removed block. ! negation lines and every other line are untouched, and every removed line is printed so the user can re-add anything intentional.
  • Regenerate, then sweep once. After the strip, the migration runs fab sync (which writes the per-target manifests), lists each fired target's entries absent from its new manifest, and deletes only on user confirmation — the one non-mechanical step, because a manifest-less sync run deliberately prunes nothing. It then verifies git status --porcelain shows no fab-deployed skill path as untracked.
  • Worktree-aware. The root .gitignore edit is tracked, but the manifests are per-checkout sync output, so the migration prints the worktree note and offers to run fab sync in each sibling from git worktree list --porcelain (the per-checkout sweep discipline — see the Design Decision below).
  • Version slot. FROM 2.22.0, TO 2.23.0 — minor (a behaviour change to sync plus a migration; no .status.yaml schema change). Sentinel-guarded and idempotent: a re-run finds no historical tokens and existing manifests, and is a complete no-op.

Version Drift Detection

  • fab upgrade-repo: after sync, runs DiscoverMigrations against the target version's cached migrations/ dir and the current fab/.kit-migration-version (mechanical relevance check, not string inequality). Three terminal cases:
    • Overlap → warns naming the conflicting files + "Run '/fab-setup migrations' to resolve."; does NOT stamp.
    • Applicable non-empty → prints Run '/fab-setup migrations' to update project files ({LOCAL} -> {TARGET}), styled bold+yellow (\033[1;33m…\033[0m) when os.Stdout is a character device and plain when piped/redirected (TTY detection is dependency-free via os.ModeCharDevice); does NOT stamp (the skill owns the write after applying).
    • Applicable empty (no overlap) → silently writes the target version to fab/.kit-migration-version (no migration line printed), stopping the drift that occurred when only the skill ever advanced the local version.
    • fab/.kit-migration-version missing → preserves the existing init-guidance behavior.
  • /fab-status: displays ⚠ Version drift: local {X}, engine {Y} — run /fab-setup migrations when versions differ
  • release.sh: warns when no migration targets the new release version; warns on overlapping migration ranges

fab/.kit-migration-version Creation

Handled by fab-kit sync during structural bootstrap:

  • New project (no config.yaml): copies engine version from $(fab kit-path)/VERSION
  • Existing project (has config.yaml, no fab/.kit-migration-version): writes 0.1.0 (base version) so /fab-setup migrations runs all migrations
  • Already exists: preserves existing value

Design Decisions

Range-Based Migration Applicability

Decision: Migration files define a FROM-TO version range. A migration applies when FROM <= fab/.kit-migration-version < TO. Any release can ship a migration file. The release author decides — the system does not impose rules based on bump type. Why: Avoids hardcoding assumptions about which version types need migrations. Allows sparse migration files (no empty placeholders). Supports wide-range migrations covering multiple intermediate releases. Rejected: Minor-only stepping (forced empty migration files), exact-version chaining (unbroken linked list, maintenance burden).

Two-Step Update Flow

Decision: fab upgrade-repo (shim subcommand) handles mechanical swap; /fab-setup migrations (skill subcommand) handles intelligent migration. Why: Migrations are LLM instruction files. The shim handles download/cache/swap (no LLM needed); the skill handles reading and applying instructions (LLM needed). Preserves Constitution I. Rejected: Single combined script — would require embedding LLM invocation in shell or making migration files executable (violates pure prompt play).

Warning-Only Release Validation

Decision: release.sh warns but does not block releases without a migration file targeting the new version. Why: Not every release changes project-level files. Blocking would create friction with empty boilerplate migration files. Rejected: Hard block — too restrictive.

Existing Projects Get Base Version

Decision: fab-kit sync assigns 0.1.0 to existing projects (detected via config.yaml presence) so /fab-setup migrations applies all needed migrations from the beginning. Why: Existing projects predate the migration system. Starting from 0.1.0 ensures the full migration chain runs. New projects get the engine version since their config is freshly generated. Rejected: Assigning engine version to all — would skip needed migrations for existing projects.

A Shipped Migration File Is a Historical Record

Decision: A migration file that has shipped is never edited by a later change — not to track a rename, not to modernize its prose, not to fix a key name it writes. A rename that invalidates an older migration's vocabulary ships its own migration on top; the older file keeps naming the keys that were current when it was written. The same rule covers completed change artifacts under fab/changes/ and planning records under fab/plans/: transient records of a moment, swept by no repo-wide rename. Why: Applicability is a range (FROM <= fab/.kit-migration-version < TO), so a user upgrading from an old version still runs the old file verbatim — rewriting its keys would make it emit a shape its own TO-version binary never expected, and the chain would land the user on a config no migration in between ever produced. The file's job is to move a config from one historical shape to the next, which requires it to speak that era's vocabulary. This is also what makes a repo-wide rename sweep bounded and reviewable: the sweep class is live kit text, specs, and memory — never the migration catalog's inputs. Rejected: Sweeping historical migrations along with everything else (breaks the chain for any user not already on HEAD, and silently rewrites the record of what each release actually did); adding a compatibility shim inside the old file so it can emit either shape (a migration would then need to know about renames that postdate it); leaving the newer rename unmigrated and relying on read-time aliases alone (a silent behavior-change window, and the alias never gets to retire). Introduced by: 260809-n1he-rename-provider-command-fields

A Migration Touching Per-Checkout Gitignored State Must Sweep All Worktrees

Decision: A migration that edits per-checkout, gitignored state (e.g. .claude/settings.local.json) MUST enumerate and sweep all worktrees via git worktree list --porcelain (main checkout included, as the first entry) — never edit only the checkout in which /fab-setup migrations runs. Why: Migration applicability is gated on fab/.kit-migration-version, which is committed and repo-wide — once a migration runs in one checkout and the version bump merges, no sibling checkout ever re-runs it. But per-checkout gitignored state is minted independently in each worktree (pre-2.14.0, fab sync's syncHooks did this on every wt init). So a current-checkout-only edit against gitignored state permanently strands every other checkout: the gate says "done" repo-wide while the stale state survives untouched everywhere else. 2.10.1-to-2.11.0 and 2.13.6-to-2.14.0 §1 both made this mistake with the fab hook settings entries, which is exactly why 2.15.7-to-2.15.8 (weoh) had to re-sweep them across all worktrees. The precedent for the correct shape already existed in the same catalog: 2.13.6-to-2.14.0 §2's runtime-file sweep enumerated worktrees this way to delete .fab-runtime.yaml everywhere — this decision generalizes that runtime-file discipline to settings edits (and any other per-checkout gitignored artifact). Rejected: Current-checkout-only editing of gitignored state (what 2.11.0 and 2.13.6-to-2.14.0 §1 did — the committed version gate then prevents any sibling checkout from ever being reached again). Re-minting a new committed version bump per stranded checkout (impossible — the gate is a single repo-wide file). Introduced by: 260718-weoh-sweep-worktree-hook-settings

Machine-Level Config Gets No Migration File

Decision: The system config's adoption path lives inside fab config upgrade --system itself, with no src/kit/migrations/ file, despite fab/project/code-quality.md requiring a migration for "restructuring existing user data". Why: Migrations are applied per project by /fab-setup migrations. ~/.fab-kit/config.yaml is machine-level and shared across every project on the host, so a per-project migration would run N times against one file — wrong cardinality and non-idempotent in intent. The migration rule is scoped to project-local user data. Rejected: A migration file (wrong vehicle for machine-level state); a standalone one-shot repair subcommand (a second mechanism to keep in sync with the reconciliation engine — exactly the drift --check exists to catch). Introduced by: 260830-m4ai-config-upgrade-system-scaffold