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_mergegates on active spec-kitty merge state
Guarantees
SafeCommitHeadMismatch — C-002).
- Idempotent, side-effect-free on the safe path.
- Raises only
DestructiveOpRefusedfor 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 --hardallow:git/ref_advance.py(ref-advance resync),worktree remove --forcerouted (in-scope):merge/executor.pylane cleanup,worktree remove --forceallow:merge/ordering.py,lanes/merge.py,merge --abortonrepo_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.