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 eachfab upgrade-reporun)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 aDiscoverResult{Local, Engine, Applicable, GapSkips, Overlaps}. It reuses the existingparseSemver/compareSemverhelpers insemver.go(split out ofsync.goin 260612-tb6f; no new semver dependency). The convenience predicate "migrations needed" islen(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:
- Runs
fab migrations-status --json(binary-owned discovery — no manual scan/parse/validate/sort in skill prose) - STOPs and reports if
overlapsis non-empty - Surfaces any
gap_skipslines, then applies each file inapplicablesequentially (FROM ascending, already chained by the binary) - Reads each migration file and executes its Pre-check/Changes/Verification (application stays LLM-driven per Constitution I)
- Writes the migration's
TOtofab/.kit-migration-versionafter 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):
- Find first migration where
FROM <= current < TO→ append toApplicable, set current = TO - If no match but a later migration exists with
FROM > current→ record a gap-skip, advance current to that FROM - If no match and no later migrations → done (empty
Applicable= no-op;fab upgrade-repoself-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:
- Prerequisite gate: Verify
fab(system shim) is on PATH. If not, instruct:"Install fab-kit first: brew tap sahil87/tap && brew install fab-kit" - Add
fab_version: Writefab_version: "{version}"tofab/project/config.yaml(set to the current$(fab kit-path)/VERSION) - Clean
.envrc: Remove thePATH_add src/kit/binline if present - Clean
bin/: Removefab,fab-go,wt,idea— only.gitkeepremains
Scenarios:
- Migration on existing repo — adds
fab_version, cleans.envrc, removes binaries; subsequentfabinvocations 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 migrationsapplies — because applying with an older binary would fail on the unknown--rebuildflag. The Pre-check probesfab memory-index --helpfor--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 nodocs/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.yamlschema change and nofab/data change — it only regeneratesdocs/memory/log.mdfiles (and the indexes, which--rebuildalso 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--rebuildpre-check still passes. After the baseline,fab memory-index --checkexits 0 or 1, never 2 (a freshly re-projected tree is provably never destructive-loss). - Version bump.
src/kit/VERSIONis bumped to2.6.0(the migration's target version) — a behavior change to a shipped CLI warrants a minor bump, matching the catalog's2.4.2-to-2.5.0/2.2.0-to-2.3.0feature-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 migrationsapplies (an older binary would re-write the indexes back to three columns). Where2.5.5-to-2.6.0probed a--helpflag (--rebuildpresent?), this one probes the rendered output: it runsfab memory-indexin a throwaway temp project and checks the generatedindex.mdfor aLast Updatedheader. 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 nodocs/memory/directory skips it entirely. - No
fab/data change. Like2.5.5-to-2.6.0, this migration ships no.status.yamlschema change and nofab/data change — it only regeneratesdocs/memory/index.mdfiles (and the append-onlylog.mdfiles, 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 --checkexits 0 or 1, never 2 (a re-baselined tree is provably never destructive-loss); the--checkexit-code contract is unchanged. - Version bump.
src/kit/VERSIONis bumped to2.7.0(the migration's target version) — a behavior change to a shipped CLI warrants a minor bump, matching the catalog's2.5.5-to-2.6.0/2.4.2-to-2.5.0feature-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.yamlchange. 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-check —impact.goalready consumes any non-emptytest_pathsverbatim, and detection is pure prompt logic (Constitution I). It is the config-field-add shape, like1.9.1-to-1.9.2(true_impact_exclude) and2.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.yamlis 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 whentest_pathsis 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/VERSIONis bumped to2.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.yamlchange — the same shape as2.7.1-to-2.8.0. Like1.9.1-to-1.9.2(true_impact_exclude),2.2.0-to-2.3.0(agent.tiers), and2.7.1-to-2.8.0(test_paths): Summary / Pre-check / Changes / Verification, atomic write. It needs no binary capability pre-check (unlike the2.5.5-to-2.6.0/2.6.6-to-2.7.0re-baselines) — the pointer is a plain comment, and the command it names (fab config reference, new at the time and now the alias spelling offab config explain) requires no project-file change to work. - Idempotent + value-preserving (Constitution III). Pre-check skips entirely when
config.yamlis 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/VERSIONis bumped to2.10.0(the migration's target version). The current VERSION was2.9.2(ahead of the last migration2.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.yamlchange — the same shape as2.9.2-to-2.10.0. Like1.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), and2.9.2-to-2.10.0(the config-reference pointer): Summary / Pre-check / Changes / Verification, atomic write. It follows the2.2.0-to-2.3.0precedent (comment-sentinel idempotency, insert underagent:) and, like2.9.2-to-2.10.0, needs no binary capability pre-check for the comment itself (unlike the2.5.5-to-2.6.0/2.6.6-to-2.7.0re-baselines) — the note is a plain comment. Note, however, that the field it documents required the then-widened binary (TierProfile.SpawnCommand+ the historicalresolve-agentspawn=line) — that is the version-gating point of shipping the note in this slot: the migration is a documentation announcement, but a tierspawn_commandonly does anything on fab ≥ 2.12.0. Current stage dispatch consumesfab agent <stage> -o yaml; this paragraph records the older migration's own surface. - Idempotent + value-preserving (Constitution III). Pre-check skips entirely when
config.yamlis absent (Skipped: fab/project/config.yaml not present.). It is sentinel-guarded on the# agent.tiers.<tier>.spawn_commandreference-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 out —yq '.agent.tiers'is unchanged by the migration (stillnullunless the user had already configured tiers); all other keys, values, comments, and formatting are preserved verbatim (including any existing commentedagent.tiersreference block from2.2.0-to-2.3.0). - Slot note — 3a took
2.10.1-to-2.11.0. This is the next slot after 3a's2.10.1-to-2.11.0.md(PR #457, artifact-write hook removal), which had already bumped VERSION to2.11.0on this branch. Per the range-based-applicability rule, the slot'sfromis the real current VERSION (2.11.0), not the intake's originally-proposed2.10.1-to-2.11.0(already claimed). - Version bump.
src/kit/VERSIONis bumped to2.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):
- Providers extracted.
agent.spawn_commandmoves to a new top-levelproviders.claude.session_command(verbatim value move); each per-tierspawn_commandmoves toproviders.<name>.dispatch_command, with the tier pointing at that provider by name. A non-claudeagent.spawn_command(templated, or otherwise not a plainclaude …invocation) cannot be auto-attributed — it is relocated underproviders.UNNAMED_PROVIDER.session_commandand the migration halts and asks the user to name the provider. - Five role tiers.
agent.tierskeysthinking/doing/fastbecomedefault/operator/doing/review/fast; tier values become{provider, model, effort}. Athinkingoverride maps toreview(its only dispatched stage);doing/fastoverrides carry over field-by-field;provider: claudeis added on tiers that set a model/effort (documented style). An absentagent.tiersis left absent — fab-kit's built-in defaults apply (no synthesized five-tier block for a project that never overrode a tier). review_toolsretired. Thereview_toolsblock is removed; when every key wastrue(or the block was empty) it is a silent no-op delete (absent = enabled). When any key was explicitlyfalse, the block is deleted AND afab/project/code-review.md§ Review Tools section is seeded recording the disabled tools (creatingcode-review.mdif absent).
- Config-only, no
.status.yamlchange. Summary / Pre-check / Changes / Verification, atomic write — the same shape as the config-restructure migrations before it. Unlike the2.9.2-to-2.10.0/2.11.0-to-2.12.0comment-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 adispatch_commandstill signals native Agent-tool dispatch (NO fallback tosession_command), and unset tier fields inherit from the project'sdefaulttier. - Idempotent + value-preserving (Constitution III). Pre-check skips entirely when
config.yamlis absent, and is sentinel-guarded on the top-levelproviders:key (the migration's own output) — a config already carryingproviders: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/VERSIONis bumped to2.13.0(a new command + schema change is a minor bump). fab-kit's ownfab/project/config.yamlis 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.yamlchange — the same comment-backfill shape as2.9.2-to-2.10.0/2.11.0-to-2.12.0. Like1.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), and2.11.0-to-2.12.0(per-tierspawn_commandnote): Summary / Pre-check / Changes / Verification, atomic write. It needs no binary capability pre-check (unlike the2.5.5-to-2.6.0/2.6.6-to-2.7.0re-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.yamlis absent (Skipped: fab/project/config.yaml not present.). (2) STOP when no top-levelproviders:key exists — the config has not run2.12.1-to-2.13.0(which introduces the block); for projects migrating from ≤ 2.12.1 the chained/fab-setup migrationsflow runs2.12.1-to-2.13.0first (FROM-ascending), so this is normally hit only by a direct-file invocation or a hand-setfab/.kit-migration-version. (3) Sentinel: skip when the config already carries acodex/geminiprovider — 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 distinctSkipped: 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 aboveproviders:when none exists (a2.12.1-to-2.13.0-migrated bare-block config). The claudedispatch_commandline is appended after claude'ssession_commandonly when claude carries nodispatch_command— live or commented; a live one (e.g. relocated by2.12.1-to-2.13.0's per-tierspawn_commandextraction) means the piece is skipped, and the Verification step mirrors the same gate. It replaces the old# no dispatch_command → …note when present; when noclaude:provider exists (a provider under a different name, orUNNAMED_PROVIDERfrom the2.12.1-to-2.13.0halt-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 ownfab/project/config.yaml(already on the backfilled shape), doubling as the worked example. - Version bump.
src/kit/VERSIONis bumped2.13.1→2.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 the2.9.2-to-2.10.0chaining precedent; projects at local2.13.0reach it via a gap-skip to2.13.1then apply.
2.14.0-to-2.15.0 Restructure Migration (fab_version → fab/.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.yamlchange. Summary / Pre-check / Changes / Verification, atomic temp+rename write — the config-restructure shape, like2.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 (thefab_versionvalue + 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-repoalready stampsfab/.fab-version(j0qm) and auto-runsfab config upgrade(which strips the now-unregisteredfab_versionkey on its A-field pass), the field may already be gone before/fab-setup migrationsreaches this file. The migration is sentinel-guarded and idempotent: whenfab/.fab-versionis already present AND thefab_version:key is already absent fromconfig.yaml, it is a complete no-op — so it is order-independent with theupgrade-repoauto-run and re-running is safe (Verification:.fab-versionpresent with the moved value,fab_version:key absent, YAML still parses, re-run is a no-op). - No binary pre-check needed. The
.fab-versionreaders ship in the same 2.15.0 binary the migration targets (that binary also still read the legacyconfig.yamlkey, so the pre-move state resolved either way), so no capability gate is required (unlike the2.5.5-to-2.6.0/2.6.6-to-2.7.0re-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 backfillconfig.yamlcomments/scaffolding is not needed anymore:fab config upgradenow regenerates the managed fence's commented scaffold on everyupgrade-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 firstfab config upgraderun (auto-run by the nextupgrade-repo). - Machine-level scaffold adoption is not a project migration.
~/.fab-kit/config.yamlis 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 idempotentfab config upgrade --systempath (also warmed by barefab setup), not by asrc/kit/migrations/file that would target the same machine file N times. - Version bump.
src/kit/VERSIONis bumped2.14.0→2.15.0— a minor (a new subcommand + user-data restructure), matching the fence worked example'skit 2.15.0stamp. The migration is named2.14.0-to-2.15.0per theDiscoverMigrationsnaming 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 landfab/.fab-versionin git. The commit is pathspec-scoped —git commit -m "…" -- .gitignore fab/.fab-version— so unrelated changes the user happens to have staged when/fab-setup migrationsruns 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 (like2.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-versionabsent — a non-git checkout cannot commit anyway, and a repo without the file predates version stamping and reaches this migration only after2.14.0-to-2.15.0has run. (2) Sentinel: skip whenfab/.fab-versionis not-ignored AND committed —git check-ignore -q fab/.fab-versionexits non-zero (not ignored) ANDgit ls-files --error-unmatch fab/.fab-versionexits 0 (committed). Re-running is a complete no-op. The sentinel also handles the sync-first order:fab upgrade-repo/fab syncmerges the fixed fragment (self-healing the negation) before/fab-setup migrationsruns, 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 bylineEnsureMergeon everyfab 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-versionon 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
.gitignorehas 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-versionline below the last.fab-*line so the negation wins — the one case where it reorders rather than appends. - Version bump.
src/kit/VERSIONis bumped2.15.1→2.15.2— a patch (a pure fix, no schema change), matching the migration name (FROM=released2.15.1, TO=next2.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 twofab hook artifact-writePostToolUse entries) and2.13.6-to-2.14.0.md§1 (the three session-scoped entries) edit only the checkout in which/fab-setup migrationsruns. Migration applicability is gated onfab/.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.jsonis gitignored, per-checkout state: pre-2.14.0,fab sync'ssyncHooksstep minted the hook entries into every checkout it ran in (andwt create→wt initrunsfab syncin 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.jsonis 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 entrygit worktree list --porcelainreports. (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 viagit worktree list --porcelain(main = first entry) and, in a non-git directory, handles only the current directory and skips the sibling sweep — directly mirroring2.13.6-to-2.14.0.md§2, which already enumerated worktrees that way to delete.fab-runtime.yamleverywhere. 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
commandeither starts with the prefixfab hook(a prefix match, NOT an enumeration of the four known subcommands — the whole family is gone, so anyfab 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— theon-*.shscripts do not exist, so these also fail if fired). - Sentinel-guarded, idempotent, preserve-non-fab-hooks discipline. Sentinel: when no
<worktree>/.claude/settings.local.jsoncarries 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 underhooks(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 from2.10.1-to-2.11.0.md§1 /0.46.0-to-1.1.0.md§1); an entry whosehooks[]becomes empty is removed; an emptied event array is left empty (or its key omitted); thehooksobject 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.yamlchange, no commit — unlike2.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 the2.5.5-to-2.6.0/2.6.6-to-2.7.0re-baselines). It makes no.status.yamlchange and nofab/data change. And, unlike2.15.1-to-2.15.2(the first committing migration, which committedfab/.fab-version), it runs no commit: the edited.claude/settings.local.jsonfiles are gitignored, so nothing here lands in git. - Version bump.
src/kit/VERSIONis bumped2.15.7→2.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 the2.11.0-to-2.12.0slot-note precedent (FROM = real current VERSION at apply time).
2.16.19-to-2.17.0 Agent-Schema Migration (agent.tiers → agent.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/.effort → providers.<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.tiersper role, and the flat fill as an alias forprofiles.default), so nothing breaks before the migration runs. Butfab config upgrade'srenamed_fromcarry is a top-level-key operation — it rewrites a column-0key:token and preserves the block below it — andagent.tiers→agent.profilesis a rename inside theagent: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.defaultcarrying model/effort: the old map re-based every unset field from itsdefaulttier, andagent.profileshas no cross-role inheritance, so the migration names the two possible replacements (agent.session/agent.workersfor a provider intent,providers.<name>.profiles.defaultfor a model/effort intent) and carries the values verbatim. (2) A flatproviders.claude.model/.effort: inert while it sits below claude's per-role fills, but thedefaultrole'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:underproviders.<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.yamlchange, 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/VERSIONis bumped2.16.19→2.17.0— a minor (a config-schema change plus the newagent.session/agent.workerskeys). FROM is the real current released VERSION per the chaining precedent; the slot was re-numbered from2.16.18-to-2.17.0when v2.16.19 released mid-implementation, exactly as the2.11.0-to-2.12.0slot 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: truebecomesmode: pane;watchable: falseis removed; an absent key remains absent so the built-innativedefault applies.- When both spellings are live, the explicit
modevalue 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/VERSIONadvances from2.17.2to2.18.0.
2.18.1-to-2.19.0 Provider-Command-Fields Migration (session_command → interactive_command; dispatch_command → headless_command)
src/kit/migrations/2.18.1-to-2.19.0.md renames the two provider command fields — providers.<name>.session_command → interactive_command and providers.<name>.dispatch_command → headless_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'srenamed_fromcarry is a top-level-key operation and this rename lives insideproviders.<name>:blocks, so it is skipped by design; theprovidersrow'srenamed_fromis informational for--jsonconsumers 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.modevalueheadless, thedispatch.*block, and thefab dispatchcommand family keep their names — only the two field names move. The write surface (fab config set/unset/explain/showdotted keys) accepts the new spellings only; old spellings refuse as unknown-key. - Version bump.
src/kit/VERSIONis bumped2.18.1→2.19.0— a minor, per the2.16.19-to-2.17.0rename precedent. - Earlier catalog entries describe the pre-rename spellings, and correctly so. The
2.12.1-to-2.13.0and2.13.1-to-2.13.2sections above describe files that writesession_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 namesagent.spawn_command→providers.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) oragent.spawn_command(→providers.claude.interactive_command, via2.12.1-to-2.13.0then2.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_prefixwasscope: project, so only the project file could carry it effectively — a copy in~/.fab-kit/config.yamlwas already pruned withfab: warning: ignoring project-scoped field "branch_prefix"on every load. Sweeping the system file is therefore hygiene rather than a fix, per the2.15.7-to-2.15.8lesson that a migration touching one location strands the other. The derived environment overrideFAB_BRANCH_PREFIXwent 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 whenfab config upgraderegenerates the fence from the registry (the retired comment-backfill pattern, per2.14.0-to-2.15.0). Atomic temp+rename write, no.status.yamlchange, no binary capability pre-check (pure YAML editing), and no commit. - Version slot. FROM is the released
2.19.4, TO the next minor2.20.0, per theDiscoverMigrationsnaming rule and the2.16.19-to-2.17.0chaining precedent — a config-schema removal is a minor. Thesrc/kit/VERSIONbump to2.20.0is 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 leftsrc/kit/VERSIONalone.
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 foldersheader 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 verifiesgit status --porcelainshows no fab-deployed skill path as untracked. - Worktree-aware. The root
.gitignoreedit is tracked, but the manifests are per-checkout sync output, so the migration prints the worktree note and offers to runfab syncin each sibling fromgit worktree list --porcelain(the per-checkout sweep discipline — see the Design Decision below). - Version slot. FROM
2.22.0, TO2.23.0— minor (a behaviour change to sync plus a migration; no.status.yamlschema 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, runsDiscoverMigrationsagainst the target version's cachedmigrations/dir and the currentfab/.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) whenos.Stdoutis a character device and plain when piped/redirected (TTY detection is dependency-free viaos.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-versionmissing → preserves the existing init-guidance behavior.
/fab-status: displays⚠ Version drift: local {X}, engine {Y} — run /fab-setup migrationswhen versions differrelease.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, nofab/.kit-migration-version): writes0.1.0(base version) so/fab-setup migrationsruns 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