Context
When a new IGNORED StateSurface is added to src/specify_cli/state/contract.py,
fresh spec-kitty init immediately covers new projects via
GitignoreManager.protect_all_agents() — which derives its runtime entries
from get_runtime_gitignore_entries() at module load. But existing projects
never receive the entry until they run spec-kitty upgrade; those projects
continue to commit machine-local artefacts until the next upgrade.
Three separate incidents exposed this gap before this ADR was written:
| Issue | Surface added | PR adding surface | PR adding backfill |
|---|---|---|---|
| #2369 | .kittify/encoding-provenance/ |
unknown | #2370 |
| #2384 | .kittify/migrations/, .kittify/logs/ |
#2384 | #2388 |
| #2412 | .agents/skills/<skill_name>/, .kittify/skills-manifest.json |
#2412 | #2423 |
Each time, the defect was discovered independently, and an ad-hoc backfill migration was authored to remedy existing projects. The three migrations are structurally identical: detect the missing entries, add them, record the migration so repeat runs skip it. Without a standing pattern the same mistake will recur on the fourth new surface.
Why not a live-contract migration?
An alternative would be a single "live contract" migration that always checks
whether every current IGNORED surface is present in .gitignore and adds
missing entries. This approach was considered and rejected:
- Non-determinism under concurrent development: a live migration re-runs
whenever any new surface is added, making the migration's behaviour
change retroactively for projects that already ran it. Upgrade runners
record migrations by
migration_id; a live migration would be recorded once and never re-fire, creating silent coverage gaps as new surfaces land. - Hard-to-audit change sets: a migration that adds N entries in a single apply() call makes it impossible to attribute which surface caused which gitignore entry to appear, complicating reversal or review.
- Regression risk for
runs_on_worktrees: worktree-scoped upgrade runs replay migrations flaggedruns_on_worktrees=True. A live migration would need its own idempotency logic to avoid re-running on every worktree upgrade as the surface list grows. - Frozen migrations are the established pattern: every other migration in
src/specify_cli/upgrade/migrations/is frozen — it detects a concrete historical gap and closes it once. Introducing a live migration would be the only dynamic one in the registry, requiring special-case handling.
Decision
One frozen backfill migration per IGNORED-surface addition is the standing
pattern. Whenever a new GitClass.IGNORED surface is added to the state
contract and its gitignore entry is not already guaranteed by an existing
migration, a companion migration MUST be authored in the same PR.
The migration:
- Has a
migration_idof the form"X.Y.Z_<slug>_gitignore_backfill". - Declares
target_versionthat matches the release that introduces the surface. - Does NOT override
runs_on_worktrees; inherits theTruedefault so lane worktrees receive the same gitignore protection on upgrade. - Implements
_EQUIVALENT_ENTRIEScovering all reasonable variant forms of the entry (trailing-slash, no-slash, parent-directory wholesale) for idempotency against hand-edited gitignores. - Uses
GitignoreManagerfor the write so section headers are consistent. - Registers automatically via
auto_discover_migrations()— no manualMIGRATIONSlist edit required.
The companion ADR (this document) is not required for every backfill — it is the single reference that all future backfills cite in their docstrings or PR descriptions.
Consequences
- Authors receive a clear checklist (see below) instead of rediscovering the pattern.
- The three existing backfill migrations
(
m_3_2_3_encoding_provenance_gitignore_backfill,m_3_2_4_runtime_dirs_gitignore_backfill,m_3_2_5_agents_skills_gitignore_backfill) are the reference implementations. - A live migration or a blanket "always re-sync everything" approach remains explicitly out of scope.
- Any future work that introduces a second mutable class of surfaces (e.g., surfaces that should always be tracked) should author its own ADR rather than extending this one.
Author Checklist
When adding a GitClass.IGNORED surface to state/contract.py:
- [ ] Contract: add
StateSurfacewithgit_class=GitClass.IGNOREDinsrc/specify_cli/state/contract.py. Include anotesfield that cross-references any visually similar TRACKED surfaces to prevent naming confusion. - [ ] Init coverage: confirm
get_runtime_gitignore_entries()emits the new entry. The testtests/specify_cli/test_gitignore_contract.py::test_contract_runtime_entries_include_skill_projection_surfacesshows the assertion pattern. - [ ] Manager test: add a
test_gitignore_manager_protects_<surface>test that callsGitignoreManager(tmp_path).protect_all_agents()and asserts the surface path is git-ignored. - [ ] Backfill migration: create
src/specify_cli/upgrade/migrations/m_<X_Y_Z>_<slug>_gitignore_backfill.pyfollowing the structure ofm_3_2_5_agents_skills_gitignore_backfill.py. Include_EQUIVALENT_ENTRIESfor idempotency. Do NOT overrideruns_on_worktrees— theTruedefault is correct. - [ ] Migration tests: cover
apply(),detect(), equivalence variants, idempotency, dry-run, end-to-end viaMigrationRunner, and the worktree composition path (include_worktrees=True). - [ ] Ratchet baseline: bump
category_1_auto_discovered_migrationsintests/architectural/_baselines.yamland update the justification comment. - [ ] Dead-module allowlist: add the new migration to the allowlist in
tests/architectural/test_no_dead_modules.py. - [ ] Repo .gitignore: add the entry to the repo root
.gitignoreunder the# Project specificor# Added by Spec Kitty CLI (auto-managed)section so the repo itself does not accumulate the surface.
References
src/specify_cli/state/contract.py—GitClass,StateSurface,get_runtime_gitignore_entries()src/specify_cli/gitignore_manager.py—RUNTIME_PROTECTED_ENTRIES,GitignoreManager.protect_all_agents()- Reference implementations:
- Contract tests:
tests/specify_cli/test_gitignore_contract.py - Migration tests:
tests/specify_cli/upgrade/migrations/test_m_3_2_5_agents_skills_gitignore.py - PR #2370 (provenance backfill), PR #2388 (runtime-dirs backfill), PR #2423 (agents-skills backfill — defect-class trigger for this ADR)