Data Model: Worktree-Owned Root for Mission Create/Next

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

This mission has no persisted business data model (it is a CLI/runtime-topology fix). This document instead models the checkout-ownership domain concepts the implementation must reason about precisely — entities, their attributes, and the relationships/validation rules between them, derived from the code inventory in research.md.

Entities

Checkout

A single git working directory — either the primary checkout or a linked worktree.

AttributeTypeDescription
pathabsolute pathThe checkout's working directory root.
git_common_dirabsolute pathResult of git rev-parse --git-common-dir run from path, resolved to absolute. Shared by every checkout of the same repository.
toplevelabsolute pathResult of git rev-parse --show-toplevel run from path.
is_primaryboolTrue iff path == git_common_dir.parent (the checkout that owns .git as a real directory, not a gitdir: pointer file).
gitdir_pointer_targetabsolute path \None

Existing code that computes these attributes (research D-2, D-4): is_worktree_context(), _is_worktree_of(), locate_project_root(), resolve_canonical_repo_root() — each recomputes a subset independently; the plan should route new validation through _is_worktree_of's comparison rather than adding a sixth implementation.

OwnershipClaim

The explicit, per-invocation declaration that a Checkout should be treated as the mission's write root.

AttributeTypeDescription
claimed_checkoutCheckoutThe checkout the caller has explicitly named (new — does not exist today).
resolved_primaryCheckoutThe canonical common-repository checkout, resolved independently of the claim (via the existing locate_project_root/get_main_repo_root family).
validation_resultOwnershipValidationResult (enum, below)Outcome of comparing claimed_checkout against resolved_primary's topology.
opted_inboolWhether the caller supplied the new explicit-ownership affordance at all. When False, today's existing fail-closed default behavior (research D-1) applies unchanged — no OwnershipClaim is constructed.

OwnershipValidationResult (enum)

The five possible validation outcomes (spec.md Key Entities, User Story 2's acceptance scenarios):

ValueMeaningExisting precedent
OWNEDclaimed_checkout's git_common_dir matches resolved_primary's git_common_dir, and claimed_checkout is not nested inside any other worktree per the registry. Accepted._is_worktree_of() returning True, plus a new nested-worktree negative check (research D-4).
UNOWNED_NO_OPT_INCaller did not supply the ownership affordance; today's existing refusal (mission create) or ambient behavior (next) applies unchanged.Existing is_worktree_context() / require_main_repo behavior — UNCHANGED (C-001, FR-004).
NESTEDclaimed_checkout's path is a descendant of another worktree's root per git worktree list --porcelain. Refused.New — built on coordination/surface_resolver.read_worktree_registry()'s raw entries (research D-4), not its .worktrees-literal _enclosing_worktree_root().
FOREIGN_OR_MISMATCHEDclaimed_checkout's git_common_dir does not match resolved_primary's. Refused, common-dir named in the error._is_worktree_of() returning False on a common-dir mismatch.
BROKEN_POINTERclaimed_checkout's .git file exists but its gitdir: target is missing/unreadable, or any underlying git subprocess failed. Refused (fail-closed, NFR-004).New — must not raise an unstructured KeyError/FileNotFoundError; wrap as a named error.

PerCheckoutRuntimeState

Runtime bookkeeping that must be rooted under the OwnershipClaim.claimed_checkout rather than resolved_primary once ownership is OWNED (research D-6).

AttributeTypeDescription
feature_runs_pathpath<claimed_checkout>/.kittify/runtime/feature-runs.json (today always <resolved_primary>/... — FR-007 fix target).
merge_runtime_dirpath<claimed_checkout>/.kittify/runtime/merge/<mission_id>/ (today always <resolved_primary>/...).

Explicitly excluded from this entity (research D-6): the cross-worktree status lock (status/locking.py's spec-kitty-locks/<mission_slug>.status.lock) stays rooted at the shared git_common_dir by design — it is a different concern (serializing convergent writes to shared planning artifacts, not per-checkout isolation) and is out of scope for relocation.

Relationships

Checkout (1) ──is_common_dir_peer_of──> Checkout (0..N)   [same git_common_dir; e.g. primary + N linked worktrees]
OwnershipClaim ──references── claimed_checkout: Checkout
OwnershipClaim ──references── resolved_primary: Checkout
OwnershipClaim ──produces── validation_result: OwnershipValidationResult
OwnershipClaim (validation_result=OWNED) ──scopes── PerCheckoutRuntimeState
OwnershipClaim (validation_result=OWNED) ──scopes── safe_commit(repo_root=resolved_primary, worktree_root=claimed_checkout)

Validation Rules (drive FR-003/FR-005/FR-006/NFR-004 directly)

1. An OwnershipClaim is only constructed when the caller opts in explicitly (opted_in=True); absent that, behavior is byte-identical to today (C-001/C-002/FR-004). 2. resolved_primary MUST be computed independently of claimed_checkout (via the existing ambient-collapse resolvers) — never derived FROM the claim — so a claim cannot self-certify. 3. Common-dir comparison (OWNED vs FOREIGN_OR_MISMATCHED) MUST use the fail-closed comparator (_is_worktree_of's internal logic: any None from a git subprocess call → refuse), never the non-fail-closed status/locking._git_common_dir fallback pattern (NFR-004). 4. Nested-worktree detection (NESTED) MUST be evaluated using the raw git worktree list --porcelain registry entries' paths (ancestor/descendant path comparison), not the .worktrees-literal _enclosing_worktree_root() helper (C-006). 5. Every non-OWNED result MUST raise/return a structurally distinguishable error (FR-011) — not a single generic string match on "worktree" (the current UX hint in mission_create.py:_print_worktree_navigation_hint string-matches this way and must not be the ONLY signal for the new refusal classes). 6. PerCheckoutRuntimeState paths are only used when validation_result == OWNED; otherwise runtime state continues to resolve against resolved_primary exactly as today.