Phase 0 Research: Worktree-Owned Root for Mission Create/Next

Mission: worktree-owned-root-3328-01KZRG01 | Issue: #3328

This document records the concrete code-level findings that drive the plan. Every finding was verified by reading the actual source at the cited path/line on branch fix/worktree-owned-root-3328-v2 (based on main@88c992dd3), not inferred from documentation. See research/evidence-log.csv and research/source-register.csv for the full audit trail.

D-1: Two independent, materially different worktree-detection mechanisms gate different commands

Decision driver: the plan must unify (or explicitly reconcile) both, not add a third parallel mechanism.

  • mission create refuses via create_mission_core() (src/specify_cli/core/mission_creation.py:309-314): if not allow_worktree_context and is_worktree_context(cwd): raise MissionCreationError(...). is_worktree_context() (src/specify_cli/core/paths.py:281-328) is git-topology-generic — it follows the .git file's gitdir: pointer, so it recognizes ANY linked worktree, not only .worktrees/<name> ones.
  • next, merge, implement are gated by @require_main_repo (src/specify_cli/core/context_validation.py:190-216) → detect_execution_context() (lines 65-111), which recognizes only the literal .worktrees path segment (line 76-77: if ".worktrees" in cwd.parts:). A generic linked worktree at an arbitrary path is invisible to this detector.
  • Evidence: is_worktree_context is exercised by tests/runtime/test_paths_unit.py (submodule/separate-git-dir/bare-repo/external-worktree cases); detect_execution_context/require_main_repo by tests/agent/test_context_validation_unit.py.
  • Implication for design: FR-003 (git-topology ownership validation) must be built on the is_worktree_context/_is_worktree_of family (generic, gitdir-pointer-based), NOT detect_execution_context (path-literal, narrower). The new explicit-ownership affordance should be added as a parameter both mission create and next consult, resolved through one shared validation function — not by extending detect_execution_context's literal check.

D-2: Every existing root-resolver collapses worktree → primary; this is correct for reads, wrong (today, unaddressed) for explicit-ownership writes

  • locate_project_root() (src/specify_cli/core/paths.py:182-267), get_main_repo_root() (lines 451-493), resolve_canonical_root() (lines 392-448), and charter/resolution.py:resolve_canonical_repo_root() (lines 66-136) are FOUR independent implementations that all follow the worktree .git gitdir pointer back to the primary checkout.
  • This is deliberate and tested as correct for read-only status/dossier surfaces: tests/contract/test_canonical_root_when_in_worktree.py, tests/unit/workspace/test_root_resolver.py (test_worktree_returns_canonical_main_repo, test_coord_worktree_feature_dir_is_not_rewritten_to_primary). The plan must NOT regress these tests — the fix is additive (a new explicit-ownership path), not a change to the default collapse behavior.
  • get_status_read_root() (lines 560-611) is the one existing precedent for a worktree-scoped (non-collapsing) resolver, explicitly documented as "READ paths only... For write paths... continue to use get_main_repo_root()" (docstring lines 577-578). This is useful precedent/naming inspiration for the new write-path resolver this mission adds, but it cannot be reused directly (it is read-only by contract).
  • assert_worktree_supported() (lines 614-635) is a dead stub: "NOT called by any active command" per its own docstring. Not reusable as-is; may be a starting skeleton for the new refusal helper if its shape fits.

D-3: allow_worktree_context is a test-only escape hatch fenced by an architectural AST test — it must NOT become, or be confused with, the new affordance

  • Definition + only production gate: src/specify_cli/core/mission_creation.py:217,256,313.
  • Every other reference is test code: tests/_factories/__init__.py, tests/_factories/test_make_mission_parity.py, tests/core/test_mission_create_activation_gate.py, tests/specify_cli/upgrade/test_upgrade_provisions_mission_type_activations.py, tests/architectural/test_resolution_activation_foundation.py.
  • The fence: tests/architectural/test_no_production_worktree_guard_bypass.py AST-scans src/*/.py and fails the build if any file passes allow_worktree_context=True. Its own docstring states the intended resolution path explicitly: "If the cwd-vs-target guard is ever reshaped to validate the resolution target instead... delete this guard with that change." This confirms the plan's direction (add validated target-resolution, not relax the boolean) is the one the codebase's own tests anticipate.
  • Decision: the new affordance is a distinct, named parameter (e.g., an explicit worktree-root argument that is independently git-topology-validated), not a reuse or loosening of allow_worktree_context. NFR-003 requires the existing AST fence to keep passing unmodified, or — only if the guard itself is restructured per its own documented intent — the fence is deliberately updated in the SAME change, never silently weakened.

D-4: The generic (non-.worktrees-literal) topology validator already exists — commit_helpers._is_worktree_of — but only fires inside safe_commit, at commit time

  • src/specify_cli/git/commit_helpers.py:609-634. Compares git rev-parse --show-toplevel and git rev-parse --git-common-dir between the candidate worktree and the candidate repo root. Generic: works for any linked worktree.
  • Currently invoked only as step 3 of safe_commit()'s validation chain (commit_helpers.py:904+), i.e., after mission-create has already decided whether to proceed. There is no pre-flight call to this (or an equivalent) at the point mission create/next decide whether to accept worktree ownership.
  • Decision: FR-003/FR-005/FR-006 reuse this exact predicate (or a thin wrapper around it) as the pre-flight ownership-validation primitive, rather than writing a fifth independent common-dir comparison. This directly satisfies C-006 (generic linked-worktree recognition) since _is_worktree_of already has no .worktrees-literal dependency.
  • Nested-worktree detection: _is_worktree_of alone does not distinguish "sibling worktree of the same repo" from "worktree nested inside another worktree's own checkout directory" — both share the same common-dir. FR-005 (refuse nested topology) requires an additional check: is the invoking worktree's own root path a descendant of ANY other worktree's root (per git worktree list --porcelain)? This is where coordination/surface_resolver.read_worktree_registry() (src/specify_cli/coordination/surface_resolver.py:231-262) is directly reusable — it already shells git worktree list --porcelain and fails closed (WorktreeRegistryUnavailable) on git failure, satisfying NFR-004. Its own _enclosing_worktree_root() helper (lines 286-297) is .worktrees-literal and NOT reusable as-is for the nested check (C-006 gap), but the registry data it parses (read_worktree_registry) is topology-generic and IS reusable — the nested check should be rewritten against the raw registry entries' paths (prefix/ancestor comparison), not against _enclosing_worktree_root.

D-5: safe_commit's repo_root/worktree_root split already exists and works — mission-create just never exercises it

  • safe_commit(*, repo_root: Path, worktree_root: Path, ...) (src/specify_cli/git/commit_helpers.py:904-913). Docstring (958-960): worktree_root "May equal repo_root when the primary checkout is the worktree."
  • coordination/transaction.py's BookkeepingTransaction is the ONE existing production call site where repo_root != worktree_root in practice (resolves the coordination worktree as worktree_root, keeps repo_root as primary) — comment at lines 331-335 confirms this is the intended, working pattern.
  • mission_creation.py:_commit_feature_file() (lines 155, 191-198) always calls safe_commit(repo_root=repo_root, worktree_root=repo_root, ...) — same value both times. FR-009 requires threading the explicitly-owned checkout as worktree_root here, with repo_root resolved to the canonical common-repository root — mirroring the BookkeepingTransaction pattern, not inventing a new one.

D-6: Runtime state (feature-runs.json, merge-lock dirs) is keyed purely by whatever repo_root the caller passes — every current caller passes the ambient primary

  • src/runtime/next/runtime_bridge_io.py:143-145 (_feature_runs_path) and src/specify_cli/merge/workspace.py:32-34 (get_merge_runtime_dir) both take repo_root as a plain parameter with no worktree awareness of their own.
  • next_cmd.py:91-94 always resolves repo_root = locate_project_root() (collapses to primary). FR-007 requires next to pass the explicitly-owned checkout as repo_root to these functions when ownership is asserted, so .kittify/runtime/ for an owned-checkout mission lives under that checkout, not the primary's.
  • The cross-worktree status lock (src/specify_cli/status/locking.py) is INTENTIONALLY shared at the git common-dir (module docstring: "Parallel agents may run from separate worktrees, but they still converge on the same planning repo paths, so these writes need an inter-process lock") — this is explicitly OUT of scope for relocation (see Key Entities in spec.md). Do not conflate this lock with the per-checkout runtime state FR-007 addresses.
  • status/locking.py:_git_common_dir() (lines 35-56) is NOT fail-closed (falls back to repo_root/".git" on any git failure) — this is acceptable for its existing shared-lock use case but must NOT be reused as the fail-closed common-dir comparison FR-003/NFR-004 require; use commit_helpers._is_worktree_of's internal comparison (which IS fail-closed: any None from _run_git_text returns False, refusing) instead.

D-7: No ADR currently documents "checkout ownership" as a named architectural decision — this mission is the first to establish it

  • Searched docs/adr/3.x/ and docs/architecture/: the nearest-adjacent ADRs (2026-06-24-2-write-branch-resolution-primary-anchor.md, 2026-06-19-1-coord-empty-surface-fallback.md, 2026-06-03-2-executioncontext-owner-and-committarget.md, 2026-04-03-1-execution-lanes-own-worktrees-and-mission-branches.md) all address branch/ref resolution and coordination-worktree ownership (worktrees Python creates for execution lanes), not validation of an invoking worktree an agent already created independently via git worktree add.
  • docs/architecture/git-workflow.md documents the "infrastructure git (Python owns) vs content git (agents own)" split and the primary-branch auto-commit behavior, but nothing about validating an invoking checkout's ownership.
  • Decision: the plan phase should record a new ADR for this ownership-validation mechanism (ADR numbering per docs/adr/README.md convention), since it establishes a new architectural primitive multiple future issues (#3128) will build on.

D-8: Issue relationship map (confirms scope boundary, not a design input)

IssueRelationshipScope boundary this mission respects
#3129Parent design issue ("shared root" diagnosis, shadow-workspace sketch)This mission does NOT adopt the shadow-workspace redesign (C-003); it is the "narrow implementation child" #3129's own comment names.
#3128Sibling — fail-closed caller-vs-declared-workspace guardActivates AFTER ownership exists (compares invoking checkout against meta.json/lanes.json). This mission establishes ownership AT create/advance time. No overlap in what each writes/reads, but #3128's guard will consume this mission's ownership-validation primitive as a building block.
#1907Cross-linked — editable-install / stale-worktree-base hazardSupplies the packaging hazard this mission's FR-012/C-004 (immutable-artifact validation) must avoid; #1907 itself is a separate dev-tooling ticket, not implemented here.
SaaS #836 / draft PR #864Downstream blocked consumer#864's own PR body states: "the fake installed-CLI concurrency test proves environment/state routing only; deployed Spec Kitty 3.2.6 still rejects real mission creation inside linked worktrees" — i.e., SaaS #836 is explicitly waiting on THIS mission's FR-001/FR-002/FR-012 before its own acceptance can proceed with real (non-mocked) evidence.

Open Questions / Risks Carried Into Planning

1. SPECIFY_REPO_ROOT precedence vs. the new explicit-ownership flag: locate_project_root() treats SPECIFY_REPO_ROOT as authoritative even without a .kittify/ marker (lines 224-228). If both are set and disagree, the plan must state which wins. Recommendation carried to plan.md: the explicit-ownership flag should take precedence for the SPECIFIC command invocation that supplies it (it is a narrower, more specific declaration than a process-wide env var), but this needs an explicit test (tests/specify_cli/core/test_paths.py precedence tests are the existing regression net to extend). 2. Naming of the new affordance: candidates include --owned-checkout <path> (explicit, self-documenting) vs. reusing positional repo_root semantics. Plan phase must pick one name and thread it consistently through mission create and next CLI surfaces. 3. ADR numbering: confirm the next available ADR slot under docs/adr/3.x/ at plan time (this repo's ADR cadence is date-prefixed; avoid collision with concurrent missions — zeitgeist showed multiple active sessions in this repo during research). 4. Concurrent-repo activity: zeitgeist_presence showed other active sessions on main and other branches during this research session; origin/main advanced from 972c0351e to 88c992dd3 between mission creation and this research phase. The plan and tasks phases must be written to tolerate a moving main (rebase-safe, not tied to a specific SHA) since implementation will happen later in a separate lane/worktree.