Status: Accepted (2026-10-17).
Date: 2026-10-17
Deciders: Stijn Dejongh (operator).
Technical Story: epic #2933 (explicit, machine-readable migration ledger); spike #5230
(decide ledger location/authority, declared ordering, selection, drift, visibility, adoption,
and the module/directory placement). Builds directly on #6233 / PR #6350 (clone_local_effect
on BaseMigration, the per-clone reselection seam) and its ADR amendment. Related: ADR
2026-10-04-4 (upgrade writes
project-global state once), ADR 2026-10-07-1.
Reader: a maintainer working on the spec-kitty upgrade migration subsystem, or authoring
a new migration.
Supersedes / extends: ADR 2026-07-07-1, specifically its Amendment 2026-10-17 (#6233), which introduced the per-clone reselection seam as an interim fix and explicitly deferred "the explicit, machine-readable clone-local ledger that would record the effect's presence directly" to epic #2933. This ADR is that deferred design.
Context and Problem Statement
The upgrade subsystem records which migrations have run in the migrations.applied block of
the tracked, pushed .kittify/metadata.yaml (ProjectMetadata.applied_migrations,
src/specify_cli/upgrade/metadata.py:71). Two authorities decide whether a migration runs:
- The version window —
MigrationRegistry.get_applicableselects a migration whenfrom_v < target <= to_v(src/specify_cli/upgrade/registry.py:119). - The applied record — the runner skips a migration already recorded applied
(
src/specify_cli/upgrade/runner.py:357,ProjectMetadata.has_migrationmetadata.py:276).
This design has accumulated three structural faults:
The ledger is the wrong authority for clone-local effects (#6233). A migration whose effect lives outside version control —
.git/info/exclude,git config --local,.git/hooks— records into the shared, pushedmetadata.yaml. The record propagates to every clone, but the effect does not. A pulled clone inherits the record without the effect and skips the migration forever. PR #6350 patched this withclone_local_effect(src/specify_cli/upgrade/migrations/base.py:122) plus a version-window reselection branch (registry.py:175 _clone_local_selected_below_window) and a skip-gate override (base.py:135 reselect_when_recorded). That is a workaround for the authority being in the wrong file.The version window strands migrations. Anything below
from_vis never reconsidered — the very reason the clone-local reselection branch and the symlink fail-closed path (runner.py:366-393) had to be bolted on. A migration that failed, or that a pulledmetadata.yamlreset, can be stranded past the window.metadata.yamlis a multi-writer surface (#5229). The applied list shares the file with the version stamp, the schema-capabilities stamp (canonical_schema_capabilitiesmetadata.py:364), the environment block,project_uuidand operator keys. The merge-preservingsave(metadata.py:184-241,_merge_onto_disk :348) mitigates the second-writer hazard; it does not remove it. A list that grows on every migration is the part most in need of leaving that contested file.Execution order has an implicit tiebreak.
get_allsorts byVersion(target_version)(registry.py:79-80); migrations that share atarget_version(several exist, e.g. twom_0_13_0_*, twom_2_1_2_*) fall back to filename import order fromauto_discover_migrations(src/specify_cli/upgrade/migrations/__init__.py:46) — an unstable, undeclared tiebreak.
Spike #5230 asked for a decisive design across six deliverables, led by a verdict on where the ledger and its machinery live, defended against the enforced modularity SSOT.
Decision Drivers
- Correctness for clone-local effects without per-site compensation logic.
- Remove the applied record from the
metadata.yamlmulti-writer surface. - Make execution order and "what has run" explicit, auditable, and the single authority.
- Change no architectural layer rule and no wheel package set.
Decision
0. Module / directory placement — stays in specify_cli/upgrade
The ledger and all its machinery stay in src/specify_cli/upgrade/. A new module
src/specify_cli/upgrade/ledger.py owns the ledger data, its read/write, and its authority.
It consumes two root-layer primitives downward across the existing, legal boundary: atomic
writes via specify_cli/core/atomic.py:53 atomic_write (itself a thin wrapper over
kernel.atomic), and content hashing via kernel.content_digest.sha256_digest
(src/kernel/content_digest.py:19).
SSOT defense. The enforced chain is
kernel <- charter <- {glossary, runtime, mission_runtime} <- specify_cli
(tests/architectural/conftest.py:88-118 landscape; tests/architectural/test_layer_rules.py:83-85
_DEFINED_LAYERS; pyproject.toml [tool.hatch.build.targets.wheel].packages). Every reader of
applied-migration state is already in specify_cli and none is below it: registry.py,
runner.py, detector.py:44, compat.py, core/version_checker.py, the doctor family
(cli/commands/_*_doctor.py), cli/commands/upgrade.py, and migrations that read other
migrations' applied state (m_3_2_6_*, m_4_0_0rc6_charter_pack_cutover.py,
m_3_1_1_charter_rename.py, all via has_migration). The whole subsystem — BaseMigration,
MigrationRegistry, MigrationRunner, VersionDetector — is specify_cli.
Placing the ledger in kernel (or a new sibling package) is rejected: it would create
consumer-less public surface in the dependency-free root layer and would force a new
_DEFINED_LAYERS entry plus a new pyproject.toml wheel package (the meta-tests
test_no_unregistered_src_packages test_layer_rules.py:421 and
test_all_defined_layers_match_at_least_one_module :460 require it), for zero benefit. The
genuinely root-layer mechanics (atomic write, hashing, locking) already live in kernel and are
consumed downward exactly as metadata.py:14 already consumes atomic_write. No layer rule
and no wheel package set change. Adding a file inside specify_cli touches none of the three
enforced authorities.
1. Ledger location & authority — two partitions
The explicit ledger is two append-only JSONL files, partitioned by whether a migration's
effect is tracked or clone-local, and it becomes the execution authority for "already
applied", replacing both metadata.yaml.applied_migrations and the version window.
| Tracked ledger | Per-clone ledger | |
|---|---|---|
| Path | .kittify/migration-ledger.jsonl (tracked) |
.kittify/migrations/clone-local.jsonl (untracked) |
| Authority for | migrations with clone_local_effect == False |
migrations with clone_local_effect == True |
| Propagates on clone/pull | yes — correct; a tracked effect travels with the commit | no — correct; the effect (.git/info/exclude, git config --local, .git/hooks) does not travel |
| Record shape | {id, applied_at, result, identity, content_hash, cli_version} |
same, plus clone_local: true |
This maps onto a split the repository already has. .kittify/metadata.yaml is tracked;
.kittify/migrations/, .kittify/logs/, .kittify/encoding-provenance/ and the per-clone
sentinel files .kittify/legacy-warning-shown-* are untracked per-clone state. The tracked
ledger is a new tracked sibling of metadata.yaml (confirmed not git-ignored). The per-clone
ledger lives inside the already-IGNORED .kittify/migrations/ surface
(src/specify_cli/state/contract.py:308, back-compat ignore #4928; git check-ignore .kittify/migrations/clone-local.jsonl confirms it is ignored), so it needs no new gitignore
entry and is untracked by construction.
clone_local_effect (base.py:122) stops being a reselection trigger and becomes a routing
key: it decides which ledger a record is written to and read from. Because a clone-local
record lives only in the per-clone ledger, a freshly pulled clone has no such record, so the
migration is pending by plain ledger-diff (§3), applies, and records into this clone's
per-clone ledger.
This supersedes the #6233 machinery. registry.py:175 _clone_local_selected_below_window
and the sub-window reselection it drives are removed. base.py:135 reselect_when_recorded
collapses to "is this id in the applicable ledger for its partition?" detect()
(e.g. m_4_0_0rc7_exclude_spec_kitty.py:detect) remains, as the idempotency guard only — a
belt-and-suspenders check before apply() re-runs, never the authority for selection.
Atomic write, single-writer. The ledger is a full-file atomic rewrite of the JSONL on each
append, consistent with metadata.py:240 and VersionStamp (metadata.py:466/491), not
O_APPEND. The subsystem is single-writer: upgrade runs in one process, worktrees
sequentially (runner.py _upgrade_worktrees), and integrating worktrees are skipped (ADR
2026-10-04-4). If concurrent cross-process upgrade ever becomes real, wrap the append in
kernel.locks (src/kernel/locks.py:151); this is the named extension point, deliberately
not built now.
Rejected: keeping the applied record inside metadata.yaml. It is the #5229 multi-writer
surface; a YAML block is a whole-document read-modify-write per record; and it cannot represent
the tracked/clone-local partition without a per-record scope flag inside a tracked file — which
re-creates the #6233 propagation bug for the clone-local rows.
2. Declared ordering — a checked-in order manifest
A single checked-in order manifest (a changelog-style list of migration_ids in canonical
execution order) is the authority for order. Version(target_version) is demoted to a
validation cross-check (the manifest must be version-monotonic save for explicitly annotated
ties). A build-time check (a new tests/architectural/ gate) enforces: every registered
migration_id appears in the manifest exactly once (orphan / missing-entry check); every
manifest entry resolves to a registered migration (dangling-entry check); the order is
version-monotonic except where annotated; and at most one runs_first migration leads (already
guarded registry.py:61-67). Touchpoints: the sort in registry.py:79-80 get_all becomes a
manifest-index lookup; discovery (__init__.py:18) is unchanged.
Rejected: per-migration after:/before: DAG edges. They need a runtime topological sort
and cycle detection, and the total order is not readable at a glance. A single ordered list is
auditable in one read and the exactly-once / resolves checks are trivial. The runs_first flag
remains the escape hatch for the rare "must lead regardless of version" case.
3. Selection — ledger-diff replaces the version window
Selection becomes declared minus applied:
pending = [m for m in manifest_order if m.id not in applicable_ledger(m)], then filtered by
detect() / can_apply, where applicable_ledger(m) is the per-clone ledger when
m.clone_local_effect else the tracked ledger. The version window
(registry.py:119 from_v < target <= to_v) is retired as the applied authority;
target_version survives only as the ordering/monotonicity reference (§2).
Consequences:
- Stranded / skipped migrations. Ledger-diff re-selects any declared migration whose id is
not a
successrecord, independent of the version stamp. The symlink fail-closed strand (runner.py:366-393) and the pulled-clone strand (#6233) both resolve for free. - Retired pre-3.0 modules (#2322).
SupersededMigration(base.py:186,detect()==False) and neutralised no-ops (tests/specify_cli/upgrade/test_neutralised_migrations.py) stay in the manifest. Ledger-diff selects them;detect()==Falseyields askippedrecord; the skipped record keeps them from re-firing. Behaviour is identical to today, now anchored on the ledger rather than the version stamp. - Legacy-id aliases. The legacy-id normalization map (
metadata.py:18 _LEGACY_MIGRATION_ID_MAP, applied today bynormalize_and_save_legacy_idsmetadata.py:158) is applied to ledger ids on read, so a constitution-era id and its charter-era id resolve to one record.
4. Drift detection — audit/report only, never auto-reapply
Each applied record carries an author-declared identity (defaulting to migration_id) and a
content_hash = sha256_digest(<migration module source bytes>)
(kernel.content_digest.sha256_digest). On a later run, a recorded migration whose current
content_hash differs from the stored one is flagged as drift and surfaced read-only
(§5). Drift is never acted on automatically.
This matches established practice for code migrations: Alembic and Django omit per-migration
checksums precisely because migrations are immutable once shipped, and editing a shipped
migration is itself the error (DIRECTIVE_044: do not edit shipped migrations). The hash does
not gate or re-run anything; it is the audit that makes a violation of that rule visible.
Rejected: checksum-mismatch → auto re-apply — it would fight detect() idempotency and
could re-run destructive apply() against a project that already carries the old effect.
5. Visibility — one pending-source, two read surfaces
A new doctor sub-surface spec-kitty doctor migrations
(src/specify_cli/cli/commands/_migration_ledger_doctor.py, following the existing doctor
family shape) and an upgrade --list-applied flag (cli/commands/upgrade.py) render: the
declared set (manifest), the applied set (both ledgers), the pending set, the
tracked/clone-local partition, and drift flags. Both consume the single pending-source
VersionDetector.applicable_migrations (detector.py:49) — the same method the compat planner
and --dry-run already delegate to — so the reported pending set can never diverge from the
applied set.
6. Adoption / backfill — hard cutover, detect-gated for clone-local
Transition is a hard cutover. A one-way backfill migration reads the existing
ProjectMetadata.applied_migrations (normalizing legacy ids, metadata.py:171) and writes
records into the new ledgers, then metadata.yaml.applied_migrations is removed as the
authority — no dual-write mirror. Backfill rules:
- Tracked-effect records backfill unconditionally into the tracked ledger.
- Clone-local records are detect-gated. The existing
metadata.yamlrecord for aclone_local_effectmigration is ambiguous — it was written into the shared file (the #6233 bug), so it does not prove the effect is present on this clone. A clone-local record is backfilled into the per-clone ledger only whendetect()confirms the effect is present on this checkout; ifdetect()reports it absent, nothing is written and ledger-diff re-applies it (safe, becauseapply()is idempotent).
The backfill is ordered first in the manifest so every later selection in the same run sees a
populated ledger. The new tracked ledger file is added to the state contract as a
GitClass.TRACKED surface (src/specify_cli/state/contract.py), and the repository's own
.gitignore must not ignore it.
Consequences
Positive.
- Clone-local correctness falls out of the partition with no per-site reselection logic; the #6233 compensation code is deleted.
- The applied record leaves the
metadata.yamlmulti-writer surface; the runner stops being a second writer of the growing list there (it still stampsversion/schema_version), shrinking the #5229 hazard. - "What has run" and "in what order" are explicit, auditable, and the single authority.
- Stranded/skipped migrations and the retired pre-3.0 modules are handled uniformly by ledger-diff; no version-window special cases.
- No architectural layer rule and no wheel package set change.
Negative / accepted costs.
- Back-compat (accepted ruling). After the hard cutover, an older CLI reading a migrated
project no longer finds applied-migration state in
metadata.yamland loses applied-migration visibility. This is a documented, accepted consequence — the new CLI is the authority; there is no dual-write mirror. - A new tracked
.kittify/file (migration-ledger.jsonl) is added to the state contract. - One-time at adoption, a clone-local migration whose effect
detect()cannot confirm on a given checkout re-runs its idempotentapply()once.
Touchpoints
src/specify_cli/upgrade/migrations/base.py:122clone_local_effect(becomes routing key),:135reselect_when_recorded(collapses to a ledger lookup),:74BaseMigration,:186SupersededMigration.src/specify_cli/upgrade/registry.py:79-80get_allsort → manifest index;:82-135get_applicable→ ledger-diff;:175_clone_local_selected_below_window(removed);:198_runs_first_selected_outside_window;:212_order_runs_first;:61-67one-runs_first guard.src/specify_cli/upgrade/runner.py:206-310upgrade;:337-393_apply_migration(skip gate:357, symlink fail-closed:366-393).src/specify_cli/upgrade/metadata.py:71applied_migrations(removed as authority),:184-241savemerge-preserving (#5229),:276has_migration,:287record_migration,:18_LEGACY_MIGRATION_ID_MAP,:158normalize_and_save_legacy_ids,:410VersionStamp.src/specify_cli/upgrade/detector.py:44detect_version,:49applicable_migrations(single pending-source).src/specify_cli/upgrade/migrations/__init__.py:18auto_discover_migrations(discovery unchanged; manifest is the order layer above it).src/specify_cli/core/atomic.py:53atomic_write;src/kernel/content_digest.py:19sha256_digest;src/kernel/locks.py:151/241(lock extension point, not built now).src/specify_cli/state/contract.py:308.kittify/migrations/IGNORED surface (per-clone ledger home); newGitClass.TRACKEDsurface for.kittify/migration-ledger.jsonl.- Reference clone-local migrations:
m_4_0_0rc7_exclude_spec_kitty.py,m_0_13_1_exclude_worktrees.py,m_3_1_1_event_log_merge_driver.py,m_4_0_0rc5_install_lane_tip_recorder.py,_merge_driver_seeding.py. - Layer SSOT:
tests/architectural/conftest.py:88-118,tests/architectural/test_layer_rules.py:83-85,pyproject.toml [tool.hatch.build.targets.wheel].packages. - Idempotency gates to extend:
tests/upgrade/test_clone_local_migration_idempotent.py,tests/upgrade/test_clone_local_selection.py.
Follow-up child issues (spike #5230 output, epic #2933)
- #A — Ledger module + dual-partition data model.
src/specify_cli/upgrade/ledger.py; tracked.kittify/migration-ledger.jsonl+ per-clone.kittify/migrations/clone-local.jsonl; append-only JSONL viakernel.atomic;clone_local_effectas routing key. (Deliverable 1.) - #B — Declared-ordering manifest + build-time check. Order manifest file + new
tests/architectural/gate (exactly-once / resolves / version-monotonic). (Deliverable 2.) - #C — Ledger-diff selection; retire the version-window authority and the #6233 reselection
machinery. Rewrite
registry.get_applicable/get_all; delete_clone_local_selected_below_window; collapsereselect_when_recorded; keepdetect()as idempotency guard. (Deliverable 3; depends on #A, #B.) - #D — Drift identity/checksum on apply.
identity+content_hash(kernel.content_digest) stored per record; audit/report only. (Deliverable 4; depends on #A.) - #E — Visibility surface.
doctor migrations+upgrade --list-appliedviadetector.applicable_migrations. (Deliverable 5; depends on #A, #C.) - #F — Adoption backfill (hard cutover, detect-gated for clone-local). Backfill from
metadata.yaml.applied_migrations, then remove it as authority; no dual-write mirror; add the tracked ledger tostate/contract.py. (Deliverable 6; depends on #A.) - #G — Docs / ADR. Land this ADR; update the 2026-07-07-1 cross-reference; update the
CLAUDE.mdmigration-authoring guidance and the ADR 2026-07-07-1 author checklist to the ledger model.
References
- ADR 2026-07-07-1 (IGNORED-surface backfill pattern; this ADR supersedes its Amendment 2026-10-17).
- ADR 2026-10-04-4 (upgrade writes project-global state once, skips integrating worktrees).
- Epic #2933 (explicit migration ledger); spike #5230; #6233 / PR #6350 (
clone_local_effect); #5229 (metadata.yaml second-writer); #2322 (retired pre-3.0 modules).
Amendment 2026-10-18 (closeout findings)
Two design facts were discovered and ruled during the mission's closeout. They are recorded here so this ADR matches shipped behaviour; the Decision body above is unchanged.
1. Init-seed baseline (finding-1 remediation)
Problem found. §3 retires the version window and makes selection a plain ledger-diff
(pending = declared − applied). A fresh spec-kitty init wrote no ledger, so the first
spec-kitty upgrade on that brand-new project diffed an empty ledger against the full
declared set and selected all ~114 historical migrations. Replaying them against an
already-current project is not idempotent: m_3_2_0rc35_activate_builtin_mission_types and the
charter-bundle fold disagree over mission_type_activations (the rc35 migration adds it to
config.yaml; the fold relocates it into charter.yaml), so the first upgrade mutated config
and auto-committed — breaking NFR-002 (upgrade idempotency) on a second run.
Decision / shipped behaviour. A fresh spec-kitty init now seeds the ledger via
src/specify_cli/upgrade/init_seed.py (seed_migration_ledger_for_init), consuming the
ledger.py API — it does not re-implement ledger I/O. It records every migration a
brand-new, current project has effectively already satisfied, so the first upgrade selects
nothing. This is the Liquibase "changelog-sync for a new project" baseline: a one-time init
stamp, explicitly not a revived version-window selection authority (§3 stands).
The seed is partition-correct (§1): a tracked-effect migration (clone_local_effect == False) is seeded unconditionally into the tracked ledger; a clone-local migration
(clone_local_effect == True) is seeded into the per-clone (gitignored) ledger only when its
effect is already present on this checkout (detect() reports the old state absent). Because
the per-clone ledger never travels, a pulled clone still has an empty per-clone partition and
re-applies the clone-local effect — the #6233 guarantee is preserved, not undone, by the seed.
The seed is idempotent (migration_settled skips a migration already recorded success).
The disagreement itself was also closed at its root: m_3_2_0rc35_activate_builtin_mission_types
now short-circuits when mission_type_activations already lives in the resolved activation
write target — it reads the single authority
charter.activation.pack_manager.resolve_activation_write_target (which the fold points at
charter.yaml post-fold), so the rc35 migration and the provisioning predicate agree on
"already present" and the migration is a no-op there.
2. "Recorded elsewhere" vs "pending" for a fresh clone (finding-2)
Clarification. §1 deliberately keeps no shared clone-local declaration marker in the
tracked ledger (a per-record scope flag / dual-write was rejected because it would re-create the
#6233 propagation bug). A consequence, confirmed at closeout: for a genuine freshly-pulled
clone, the spec-kitty migrate ledger report state for a clone-local step is pending
("not yet applied" / not yet in place on this checkout), not "recorded elsewhere". The
per-clone ledger is gitignored and never travels, so a fresh clone has an empty per-clone
partition; assemble_report finds no matching record (applied == False) and the surface
classifies the step as pending (src/specify_cli/cli/commands/migrate_cmd.py
_ledger_step_state).
The recorded-elsewhere state (_STATE_RECORDED_ELSEWHERE) remains valid only for the
distinct real case of a clone that applied the clone-local effect and then lost it —
i.e. a per-clone record exists (applied == True) but detect() reports the effect absent now
(in_place_here is False). Acceptance scenario C was re-authored to assert the pending
state for the fresh-clone case accordingly.
3. Incidental notes
- Visibility surface command name. The shipped read surface is
spec-kitty migrate ledger(read-only;--jsonemits the same report), rather than thespec-kitty doctor migrationsspelling named in §5. Both consume the single pending-source (VersionDetector.applicable_migrations), so the §5 no-divergence property holds; only the command path differs. - R4 mirror strip location. The retirement of the stale on-disk
migrations:block frommetadata.yaml(§6, no dual-write mirror) ships inmetadata.py::save(), gated on ledger adoption (_ledger_adopted), rather than inside the backfill migration — so asave()that runs before the backfill never strips the block prematurely, and once the ledger owns the fact the dead second authority is removed on the next write.
4. Amendment 2026-10-20 (#6539): the requires_git_repository ClassVar
- Contract.
BaseMigration.requires_git_repository: ClassVar[bool] = False. A migration that resolves the canonical repo root (charter.resolution.resolve_canonical_repo_root, directly or throughcharter.activation.*) sets it. Outside a git repository the runner skips it beforedetect(), warns "not inside a git repository", and writes no ledger record, so it stays selected and applies on a run inside a repository. "Inside" means exactly what the resolver accepts (a project in a repository subdirectory counts).upgrade --dry-run --verbosereports such a migration as skipped through the same probe. apply()failure. ANotInsideRepositoryErrorraised fromapply()is no longer a skip: anapply()may already have written state. It is a recordedfailed(aruns_firstmigration records nothing, as for any failure), the run stops and exits non-zero. Thedetect()andcan_apply()catches stay: they run before any write. A repository probe that cannot run (GitCommonDirUnavailableError: git missing or unusable) is neither inside nor outside: it is a recorded failure at all three sites, andupgrade --dry-run --verbosesays it would fail.- Accepted drift (C-002). Adding the ClassVar changes the
content_hashof each migration that declares it, which surfaces as audit-only drift (it is never re-selected). Migrations that declare it:3.2.0rc35_unified_bundle. The executed audittests/specify_cli/upgrade/test_requires_git_repository_audit.pyfails for any migration that reaches the resolver without declaring the flag.
Amendment 2026-10-20 (#6431: adoption baseline, requested-target cap)
Three refinements, ruled by the operator on 2026-10-20 after the nightly found the first ledger-era upgrade replaying older migrations (#6431, #6369). The Decision body above is unchanged except where this amendment says so.
1. §6 adoption also settles the version floor (reverses the 2026-10-18 metadata-only ruling)
Problem found. Pre-ledger spec-kitty init stamped spec_kitty.version and wrote
migrations: applied: []; the retired version window then never selected a migration at or
below that version. So the version stamp, not the applied list, was what settled a
project's older migrations, and the list of a real pre-ledger project is empty or partial.
The metadata-only backfill (closeout ruling 2026-10-18) left all those migrations pending,
and the first ledger-era upgrade replayed them against a current project:
2.0.6_consistency_sweep rewrote Mission meta.json, 3.0.0_canonical_context rewrote
status.events.jsonl (undoing a forced lane transition), and the upgrade auto-committed.
Decision. On first adoption only, until the backfill
(m_4_0_0rc8_backfill_migration_ledger.py, unreleased when amended) is itself recorded as
applied, it also records as success every registered migration whose target_version is below
both the recorded spec_kitty.version and the backfill's own 4.0.0rc8, and that neither
applied_migrations nor a ledger partition mentions. A migration AT the recorded version stays
pending, as the retired window still selected it when its detect() held. Adoption is keyed on the backfill's own
record, not on a ledger file existing: a gitignored per-clone ledger survives a checkout of a
pre-ledger branch and is not adoption. The bound below 4.0.0rc8 exists because no CLI without
the ledger shipped a migration at that version, while an rc8 build that predates the ledger
stamped 4.0.0rc8. The records are written with one atomic write per partition, so an
interrupted adoption leaves none of them. Partition routing is the same as the listed
records (§1): tracked unconditionally, clone-local only when its effect is present here (see
3). A listed skipped or failed record keeps its result, so §3's "a failed migration is
retried" still holds. An unparseable recorded version stamps nothing. This is the adoption
counterpart of the init seed (Amendment 2026-10-18 §1), a one-time stamp. It does not revive
the selection window: once the backfill is recorded, nothing is stamped from the version.
The backfill declares the new BaseMigration.ledger_adoption ClassVar. While it is unsettled,
get_applicable treats the ids it will settle (adoption_settled_ids) as already settled, so
upgrade --dry-run, the plan preview, the compat planner and migrate ledger list exactly what
the real run applies (§5's single pending-source promise).
2. §3 the run's requested target stays an upper bound
§3 retired the version window as the applied authority. Its upper bound
(target <= to_v) was the scope of the run the operator asked for, not an applied fact, and
retiring it let spec-kitty upgrade --target X apply migrations declared for later
versions and then stamp the project as X. MigrationRegistry.get_applicable now selects
only migrations whose target_version <= the requested target. Selection is still a
ledger-diff, and a migration above the target is out of scope for that run: it is neither
selected nor settled. An unparseable target caps nothing. A ledger_adoption migration is
never capped: an upgrade to an older target that skipped adoption would still record the
migrations it ran, and those records would then hide the adoption for good. A default upgrade
(target = CLI version) is unaffected. target_version is therefore both the ordering cross-check (§2)
and this cap. The lower bound stays retired.
3. A clone-local effect is "present" only inside a git repository
The init seed and the adoption backfill record a clone-local migration without running it
only when its effect is present on this checkout. Both now ask one predicate,
ledger.clone_local_effect_in_place: a git repository exists and detect() reports
nothing to do. Without a repository, detect() of such a migration reports "nothing to do"
only because there is nowhere to put the effect yet, so an init outside git used to seed
the step as done, and the first upgrade after git init never installed it. A detect()
error reads as "not in place", leaving the migration pending.
Amendment 2026-10-22 (#6582: the lead block, the charter-pack cutover after its prerequisites)
§2 allowed a single # runs-first: lead, and §6 gave it to the ledger backfill. That moved the
charter-pack cutover (charter_pack_cutover, spec FR-012 of mission
charter-pack-cutover-01M491G6) from the head of the run, where its retired front-pull used to
put it, back to its 4.0.0rc6 version slot. About forty pending migrations then ran ahead of it
on a pre-cutover project, so a refused collision ("moves nothing") left .gitattributes merge
driver lines behind, and FR-012 ("runs before every other pending migration") no longer held.
Decision (operator ruling, 2026-10-22). The manifest's # runs-first: annotation marks an
entry of the lead block: a contiguous prefix of annotated entries that run first, in the order
listed, regardless of their target_version. The block holds the cutover and the two migrations
it depends on:
4.0.0rc8_backfill_migration_ledgerleads, as §6 requires.3.2.0rc35_charter_bundle_v2follows. The cutover parses.kittify/charter/synthesis-manifest.yamlwith the current model, which accepts only schema2/3; only this migration upgrades av1bundle. Without it ahead, a pre-3.2.0rc35 project with av1manifest failed at the cutover on every run (found in pre-PR review; pinned bytest_fr012_v1_synthesis_manifest_upgrades_through_the_cutover). It changes only the charter bundle under.kittify/charter/and is detect-gated.charter_pack_cutover. It cannot lead ahead of the backfill: its first-application check (_is_first_application, which gates the one-time activation-list resets) reads the ledger, so on a pre-ledger project that already applied the cutover it would redo those resets against deliberate[]lists. The shipped migration is not edited (DIRECTIVE_044), so ordering is the fix.
FR-012 is read as "the cutover runs before every other pending project migration, after the migrations it depends on": the backfill only writes ledger records and the bundle upgrade only brings the charter bundle to the schema the cutover reads.
Residual. On a constitution-era project (.kittify/constitution/, before 3.1.1_charter_rename)
the bundle upgrade now runs before the rename, finds no .kittify/charter/, and records skipped.
Later migrations in the same run bring the bundle current: the resulting .kittify/ tree equals
the one the version-slot order produced. The step stays a re-skipped, pending row in
spec-kitty migrate ledger, which is §3's behaviour for a not-needed migration.
Assumption (legacy .specify/ layout). The cutover now runs before 0.2.0_specify_to_kittify,
so on a project that still has .specify/ the cutover finds no .kittify/ inventory and records
skipped in that run. This costs nothing: the CLI creates .kittify/ before the migrations run, so
0.2.0_specify_to_kittify itself skips (".kittify/ already exists - manual merge required") with
or without this order, and a pre-0.2 project never reaches the cutover's inputs through the rename.
A project whose .kittify/ is brought to the legacy shape by hand converges on its next
spec-kitty upgrade, because a skipped record does not settle the cutover. Checked with a
throwaway .specify/ project (two runs), not pinned by a test.
The build-time gate (tests/architectural/test_migration_order_manifest.py) now requires every
# runs-first: entry to sit in the lead block and checks version monotonicity over the entries
after it; the gate's lead_block_length is the one definition. The
runs_first ClassVar is unchanged: it governs reselection, not order, and at most one migration
sets it.