Contracts

guard-api.md

Contract — Refuse-Before-Destroy Guard API

Frozen shared contract for IC-1 (must be stable before IC-2–IC-4 branch).

Module: src/specify_cli/git/destructive_guard.py (proposed)

git-plumbing purity: no specify_cli import; the caller injects the churn classifier. Names are indicative; the brownfield scout confirms final placement.

class DestructiveOpRefused(Exception):
    error_code: str            # MERGE_UNSAFE_PRIMARY_OFF_TARGET | *_PRIMARY_DIRTY | *_WORKTREE_DIRTY
    worktree_path: Path | None
    current_branch: str | None
    expected_branch: str | None
    dirty_entries: list[str]
    remediation: str           # actionable, includes the --resume note

def assert_checkout_on_target(repo_root: Path, expected_branch: str, *, env=None) -> None:
    """Raise DestructiveOpRefused(MERGE_UNSAFE_PRIMARY_OFF_TARGET) if HEAD != expected_branch.
    Reuses the rev-parse logic of merge/preflight._enforce_planning_artifact_target_branch."""

def assert_worktree_clean(
    worktree: Path, *, new_sha: str | None = None,
    is_residue,                # injected: coordination.coherence.is_toolchain_generated_churn (partial w/ mission_slug)
    env=None,
    error_code: str = MERGE_UNSAFE_WORKTREE_DIRTY,  # caller picks the refusal vocabulary
) -> None:
    """Raise DestructiveOpRefused(error_code) if ref_advance._dirty_entries(...) is non-empty.
    Default MERGE_UNSAFE_WORKTREE_DIRTY (lane/coord); the merge preflight passes
    MERGE_UNSAFE_PRIMARY_DIRTY for the primary checkout (US1 AC2 / FR-002)."""

def guarded_worktree_remove(
    worktree: Path, *, retain: bool, is_residue, env=None,
) -> "RemoveResult":            # removed | retained_dirty
    """The SHARED removal chokepoint every live destroy site routes through.
    Runs assert_worktree_clean unless `retain`; on clean (or retained-and-kept)
    performs the inline `git worktree remove --force`; on retain+dirty KEEPS the
    worktree and reports retained_dirty. NOT the dead core/vcs/git.py remove_workspace."""

Consumption

_phase_merge_lanes — before any mutation) calls assert_checkout_on_target(main_repo, target) then assert_worktree_clean(main_repo, ...). On the safe path _refresh_primary_checkout_after_merge runs unchanged; on refusal nothing mutates.

(assert_worktree_clean, retention-aware) so refusal is pre-mutation; the live destroy sites (executor lane cleanup, coordination teardown + stale-prune, orchestrator cleanup) all route through guarded_worktree_remove. The dead core/vcs/git.py remove_workspace is NOT touched.

(has_active_merge) + scopes to the merge workspace (get_merge_workspace_path), never repo_root (does not need the guard).

  • IC-2 (#4752): the merge preflight (in the OUTER _run_lane_based_merge, BEFORE
  • IC-3 (#4753): the merge preflight pre-checks every lane + coord worktree
  • IC-4 (#4754): abort_git_merge gates on active spec-kitty merge state

Guarantees

SafeCommitHeadMismatch — C-002).

  • Idempotent, side-effect-free on the safe path.
  • Raises only DestructiveOpRefused for unsafe conditions (never overloads
  • Residue recognized by the injected classifier never triggers a raise (NFR-003).

routing-invariant.md

Contract — Unification Routing Invariant (NFR-006 / IC-5)

A non-vacuous architectural gate (DIRECTIVE_043) proving the defect class is closed by construction, not by reviewer goodwill.

Assertions

1. Destructive commands route through the guarded seam. Outside an explicitly-rationalized allowlist, every git reset --hard, git worktree remove ... --force (user-facing), and git merge --abort on repo_root must go through the WP01 guard / guarded_worktree_remove. guarded_worktree_remove is the removal chokepoint — NOT core/vcs/git.py remove_workspace (a dead adapter, allowlisted as unused). 2. No new parallel dirty predicate. The guard reuses ref_advance._dirty_entries; the test asserts no additional module-level git status --porcelain-parsing "is dirty" predicate was added by this mission. 3. Self-mutation test. A companion test deliberately routes a destructive op around the seam and asserts the gate goes RED (non-vacuity).

Allowlist (built from a LIVE census — see WP05 T018)

Do NOT hand-copy a baseline; the census adjudicates every site with an inline rationale. Provisional classification (verify at implement time):

merge/git_probes.py (guarded by WP03), doctrine/sources/git_source.py (doctrine clone dir, not repo_root), lanes/worktree_allocator.py (alloc rollback to a pre-loop ref).

coordination/workspace.py teardown + stale-prune, orchestrator_api/commands.py.

review/baseline.py (detached temp), merge/workspace.py (scratch, C-006), mission_type.py --discard (intentional), core/vcs/git.py remove_workspace (unused adapter), lanes/worktree_allocator.py fresh-worktree removal (adjudicate: tree known-clean → allow, or route).

  • reset --hard allow: git/ref_advance.py (ref-advance resync),
  • worktree remove --force routed (in-scope): merge/executor.py lane cleanup,
  • worktree remove --force allow: merge/ordering.py, lanes/merge.py,
  • merge --abort on repo_root: gated by WP04.

The allowlist may only shrink after the census freezes it.

Rationale

The surface lens found ~9 forked dirty predicates and 3 unguarded force-remove sites (executor, coord teardown, orchestrator mirror). Without this gate a future change re-forks the authority; with it, a new ungated destructive op fails CI.