Status: Accepted
Date: 2026-10-04
Deciders: Stijn Dejongh (owner). Operator brief for #5457.
Technical Story: #5457, Mission upgrade-project-global-state-01M44538. Related: #2385, #2392, #4972, #4892.
Context and Problem Statement
spec-kitty upgrade run on a project with a Mission in flight wrote the project's generated bookkeeping once per checkout: in the repository root checkout and again in every live lane and coordination worktree. Each copy was committed on that worktree's own branch. MigrationRunner._upgrade_worktrees treated every directory under .worktrees/ as a project of its own, kept a separate ProjectMetadata record per worktree, and stamped a fresh applied_at on every migration record in every copy. On 4.0.0rc5 and later the same run also committed an identical .gitattributes line on every branch.
The Mission's integration checks then refused a file the operator never touched (paths A, B and C of #5457):
- Path A.
consolidaterefused the second lane:Lane lane-b is stale: overlapping files ['.gitattributes', '.kittify/metadata.yaml']. - Path B.
consolidaterefused the squash into a non-primary target:TARGET_BRANCH_CONTENT_CONFLICTwithconflicting_path: .kittify/metadata.yaml. - Path C. On a coordination Mission, review start and implement resume failed with
LANE_AUTO_REBASE_FAILED: no classifier rule matched .../.kittify/metadata.yaml.
The printed git merge remedy was a no-op: consolidate rolls the Mission branch back to its pre-run tip after the refusal, so the lane already contains that tip.
The earlier per-worktree design could not converge. #2385 and #2392 ensured that every upgrade write ends in a commit, so no checkout is left dirty. #4972 aligned last_upgraded_at for a version-only bump. Neither could make two copies byte-identical, because the per-record applied_at, the set of recorded migrations and the notes still differ between checkouts.
Decision
Upgrade writes project-global state in the repository root checkout and no longer in any integrating worktree. A lane's own copy is not brought forward while the Mission is in flight: no step merges the merge target branch into the Mission branch, so a lane keeps its pre-upgrade copy until consolidation, where the merge target branch's copy survives.
Write placement.
spec-kitty upgradeskips integrating worktrees._is_integrating_worktree(src/specify_cli/upgrade/runner.py) is true for a worktree whose checked-out branch the branch-naming authority recognises as akitty/mission-…mission, lane or coordination branch, and for a worktree whose branch cannot be read (detached HEAD fails safe)._worktrees_to_upgradeleaves those worktrees out, so nothing is written, stamped or committed in them. A directory without a.gitentry, and a worktree on any other branch, keep today's behaviour: upgrade still writes, stamps and commits there (see Residuals).A declared set of target-owned bookkeeping. ("Target-owned" is defined in the glossary: the copy on the merge target branch is the authoritative one. The identifiers were first named
primary_owned; they were renamed before release because that read as the PRIMARY partition or the primary branch, and it meant neither.) The state contract (src/specify_cli/state/contract.py) carriesStateSurface.target_owned, true only for a tracked, project-root surface that Spec Kitty fully generates and marks "do not edit".target_owned_paths()derives the path set andis_target_owned_pathmatches an exact, normalised path relative to the repository root, never a basename. Today exactly one surface is target-owned:.kittify/metadata.yaml..gitattributes,.gitignoreand.kittify/config.yamlare operator-editable, so a work package may legitimately change them and they are not in the set (the #4933 and #4978 lesson: basename exemptions lost data).A fixed resolution side per integration merge site. A conflict confined to a target-owned path resolves as follows. No site uses
-X oursor-X theirs(#4892).Site Code Side for a target-owned conflict Stale-lane check check_lane_staleness(lanes/stale_check.py)not counted (no merge) Lane → mission merge _merge_branch_into(lanes/consolidation.py), viaresolve_target_owned_conflictsand_complete_merge_after_target_owned_resolutionstage 2: the mission branch Mission → target _run_squash_mergeand_merge_branch_into(lanes/consolidation.py)stage 2: the target Lane sync (auto-rebase) _resolve_managed_artifact_conflicts(lanes/auto_rebase.py), ruleRULE_ID_TARGET_OWNED(R-TARGET-OWNED-BOOKKEEPING)stage 3: the incoming coordination or mission branch Dependency-lane merge _merge_dependency_lane_tips(lanes/worktree_allocator.py), via_complete_merge_after_target_owned_resolutionstage 2: the dependent lane Recorded planning-commit merge on implement resume _merge_recorded_planning_commit(lanes/worktree_allocator.py)stage 2: the lane Every resolved side is either the copy closer to the merge target branch, or a lane copy that is never authored and is resolved again at the next integration. The Mission → target
--strategy mergeleg reconciles the derivedstatus.jsononly when it also resolved a target-owned path; a conflict onstatus.jsonalone still refuses there.The resolver breaks ties; it is not an ownership invariant. A lane's upgraded copy still merges cleanly onto a Mission branch or target that did not change the file.
The stale check ignores content-identical overlaps.
_filter_benign_overlapsdrops target-owned paths and any overlapping path whose tree entry (mode and object id, or absence) is identical at the lane tip and the Mission tip. Equal end states merge trivially. A failed probe cannot prove identity, so every candidate stays stale.
A Mission already caught in the broken state heals on its next consolidate, review or implement, with no manual edit, history rewrite or destructive command. The operator runbook is docs/operations/upgrade-with-live-lanes-recovery.md.
Considered Options
- Align the per-worktree copies. Rejected. This is the #2385, #2392 and #4972 design. The per-record
applied_at, the migration record set and the notes cannot be made byte-identical across checkouts. - Refuse upgrade while lanes are live. Rejected. It blocks the main 3.2.x to 4.0 route for every team that upgrades mid-Mission.
- A git merge driver for
.kittify/metadata.yaml. Rejected. The refusals are raised by Spec Kitty's own checks (stale check, squash gate, classifier), which a driver does not reach, and a driver needs per-clone configuration. - Per-hunk classifier rules. Rejected. The file is wholly generated, so a whole-file resolution side is correct and a hunk-level rule adds no safety.
- Skip every linked worktree. Not chosen here. Commands in any linked worktree read
.kittifyfrom the repository root checkout, so the argument for skipping applies to all of them, but it changes behaviour for worktrees on ordinary branches (#2385, #4972). Follow-up: #5747. - Declare the fact in the dirty-churn classifier (
coherence) instead of the state contract. Rejected. That classifier answers "may this dirty file be ignored", a different question, and a dirty copy must still block. - Skip integrating worktrees, declare target-owned bookkeeping, fix the resolution side per site (chosen). It removes the defect at its source for Mission worktrees and recovers Missions already affected.
Consequences
- Upgrade leaves no integrating worktree dirty and adds no upgrade commit to a lane, mission or coordination branch (the #2385 and #2392 invariant holds trivially).
- Commands run inside a worktree resolve
.kittifyfrom the repository root checkout, so a lane that keeps its pre-upgrade copy is harmless at runtime. - Residuals:
- Lanes keep their pre-upgrade
.gitignoreand.gitattributesuntil they integrate. See the amendment to ADR 2026-07-07-1. - Ignored per-checkout surfaces are no longer refreshed in integrating worktrees.
- A pre-
kitty/legacy branch (NNN-slug[-WP##]) is not recognised as integrating, so its worktree keeps today's behaviour. --strategy rebaseis not covered by the merge-site resolution above.- Worktrees on other branches are still upgraded. A linked worktree whose branch is not a
kitty/mission-…branch (a feature or landing branch) still gets its own.kittify/metadata.yamlcommit and every worktree-running migration. When that branch reaches the merge target branch through a pull request, no Spec Kitty resolver is on the path. Follow-up: #5747. - Upgrade run from inside a lane worktree. The skip covers the sibling worktrees of the checkout upgrade runs in. Run with a lane worktree as the current directory, upgrade still treats it as the project and commits
.kittify/metadata.yaml,.gitattributesand.kittify/config.yamlon the lane branch. Follow-up: #5747. - Skipped worktrees are not listed. Upgrade does not name the integrating worktrees it left alone.
.gitattributeson an already-affected Mission. When the merge target branch's.gitattributesdiffers from the lanes' (any edit on the target after the Mission was cut, plus the line the earlier upgrade appended on each branch),consolidatestill refuses withTARGET_BRANCH_CONTENT_CONFLICTon.gitattributes. The file is operator-editable, so it is deliberately not target-owned.- Tracked generated tool files in lanes. Generated command and skill copies in a lane worktree are no longer refreshed by upgrade until the lane integrates.
- A project nested below the repository root (
sub/.kittify/metadata.yaml) is not matched and gets no recovery. - For a genuine overlap between two lanes on an operator-editable file, the printed stale remedy is still a no-op after the in-run rollback. This Mission removes the upgrade-induced trigger only. The general stale-remedy wording is a defect. Follow-up: #5711.
- Migrations with
runs_on_worktrees=Truethat rewrite tracked Mission state no longer reach coordination or lane branches. For example,m_3_1_1_normalize_status_jsonrewriteskitty-specs/*/status.json, and now does so only in the repository root checkout and in worktrees that upgrade still visits.status.jsonis a derived snapshot that is re-materialized from the event log, so the impact is low today. A future migration of tracked Mission state must not rely on the worktree pass to reach in-flight branches. Follow-up: #5712.
- Lanes keep their pre-upgrade
- This decision adds no writer of
.kittify/metadata.yaml. The file already has several (ProjectMetadata.save(), the schema stamp inupgrade/runner.py,migration/runner.py,migration/backfill_identity.pyandinit; see #5229), and nothing ties the state contract's path to them: if the file moved, the target-owned declaration would go inert without a failing test.