Contracts

bookkeeping_transaction.md

Contract: BookkeepingTransaction

Spec source: FR-023, FR-024, FR-025, FR-026 Module: src/specify_cli/coordination/transaction.py

Purpose

The single owner of writes that target the coordination branch (or, in legacy mode, the lane branch). Holds the feature status lock across the entire atomic window: emit → materialize → commit → (rollback on failure) → outbound dispatch → release.

Signature

class BookkeepingTransaction:
    @classmethod
    def acquire(
        cls,
        *,
        repo_root: Path,
        mission_id: str,                  # ULID; canonical identity
        mission_slug: str,                # required to resolve coord worktree path
        mid8: str,                        # required for worktree disambiguation
        destination_ref: str,             # SHORT branch name (C-016)
        operation: str,                   # diagnostic label
    ) -> "BookkeepingTransaction":
        """Construct + lock + pre-flight gate. Returns context manager."""

    def __enter__(self) -> "BookkeepingTransaction": ...
    def __exit__(self, exc_type, exc, tb) -> None: ...

    def append_event(self, event: StatusEvent) -> PendingEventHandle: ...
    def write_artifact(self, path: Path, content: bytes) -> None: ...
    def stage_path(self, path: Path) -> None: ...
    def commit(self, message: str) -> CommitReceipt: ...     # implicit on __exit__ if not called
    def defer_outbound(self, side_effect: Callable[[], None]) -> None: ...

Critical signature note (cross-review correction): An earlier draft of this contract showed acquire(repo_root, mission_id, destination_ref, operation). That was incomplete — the worktree resolution path needs mission_slug + mid8 to compute .worktrees/<slug>-<mid8>-coord/. The corrected signature above is canonical.

Return-type note (cross-review correction): An earlier draft had append_event() returning an EventReceipt that carried commit_sha. That was incoherent — the commit doesn't exist when append_event returns. The corrected types are: PendingEventHandle (event_id only) from append_event(); CommitReceipt (commit_sha, committed_at, destination_ref, worktree_root, event_ids) from commit() / successful __exit__.

Lifecycle invariants

1. acquire() synchronously:

  • Resolves the worktree to operate against:
  • Coordination-branch missions: CoordinationWorkspace.resolve(repo_root, mission_slug, mid8).
  • Legacy missions: the current lane worktree.
  • Acquires the feature status lock (src/specify_cli/locking.py). Blocks if another emitter holds it; respects standard timeout (default 30s).
  • Captures pre_emit_size = os.path.getsize(<worktree>/kitty-specs/<mission>/status.events.jsonl).
  • Constructs a GitChangeSet with the resolved worktree_root, destination_ref, and operation label.
  • Calls WorkflowMutationPolicy.assert_allowed(change_set). If Refused, releases the lock and raises BookkeepingPolicyRefused(verdict) before any write.
  • Returns the transaction object.

2. Inside the with block, the caller may:

  • append_event(event) — appends a JSONL line to status.events.jsonl in the resolved worktree; re-materializes status.json. Idempotent within the transaction (calling twice with the same event_id is an error).
  • write_artifact(path, content) — write a non-event artifact (e.g. decisions/index.json, issue-matrix.md).
  • stage_path(path) — explicitly stage a path that was already modified out-of-band.
  • defer_outbound(side_effect) — register a callable to run after the commit succeeds.
  • commit(message) — explicit commit. Optional; if not called, __exit__ does the commit using a default message derived from operation.

3. __exit__ on no exception:

  • If the caller did not call commit(), perform it now.
  • On commit success: run deferred outbound side effects in registration order. Each failure is logged but does not abort the rest (best-effort fanout per FR-022 with degraded mode).
  • Release the feature status lock.

4. __exit__ on exception:

  • The exception is from append_event, write_artifact, commit(), or a deferred-outbound callable that ran inside the block.
  • Surgical rollback (C-009 prohibits git checkout -- for any rollback path):
  • os.truncate(<worktree>/kitty-specs/<mission>/status.events.jsonl, pre_emit_size) (FR-010).
  • Re-materialize status.json from the truncated event log.
  • For every path written via write_artifact() inside this transaction: restore from the byte snapshot captured at write time. Each write_artifact() call records pre_write_bytes = path.read_bytes() if path.exists() else None before writing the new content. On rollback: if pre_write_bytes is None, path.unlink(missing_ok=True); otherwise path.write_bytes(pre_write_bytes). No git checkout --. (C-009.)
  • For every path passed to stage_path() (already-modified files that the caller wants tracked), the rollback path does nothing — the caller is responsible for any state they wrote outside write_artifact(). Documented contract: only paths flowing through write_artifact() get snapshot/restore semantics.
  • Do NOT run deferred outbound side effects.
  • Release the feature status lock.
  • Re-raise the original exception (or wrap in BookkeepingTransactionFailed if useful for diagnostics).

5. No nested transactions: acquiring a second BookkeepingTransaction for the same mission_id while one is held raises BookkeepingLockTimeout after the lock-acquire timeout.

Error codes

CodeWhen raised
BOOKKEEPING_POLICY_REFUSEDPre-flight policy gate refused (carries underlying Refused verdict)
BOOKKEEPING_LOCK_TIMEOUTFeature status lock could not be acquired within the timeout
BOOKKEEPING_WORKTREE_MISSINGResolution found neither a coordination worktree nor a valid lane worktree
BOOKKEEPING_COMMIT_FAILEDInner safe_commit() raised; rollback ran successfully; original error chained
BOOKKEEPING_ROLLBACK_FAILEDRollback itself failed (rare; lock is still released, exception re-raised loudly)
BOOKKEEPING_DOUBLE_EVENT_IDSame event_id appended twice in one transaction

Diagnostics emitted on commit failure (FR-011)

Tracking commit '<message>' was rejected by <reason> on branch <destination_ref>.
Lane transition <from_lane> → <to_lane> for <wp_id> has been rolled back.
status.events.jsonl restored to pre-emit state.
Next step: <concrete action — e.g. "Fix the pre-commit hook and re-run">.

JSON output mode (FR-014): the same fields surface as keys:

{
  "error_code": "BOOKKEEPING_COMMIT_FAILED",
  "destination_ref": "kitty/mission-foo-01ABCDEF",
  "rejected_message": "<commit message>",
  "rejected_reason": "<git stderr or hook output>",
  "rolled_back_transition": {"wp_id": "WP01", "from_lane": "planned", "to_lane": "claimed"},
  "next_step": "Fix the pre-commit hook and re-run"
}

Test surface

  • Unit acquire/release happy path: lock acquired, no writes, lock released cleanly.
  • Unit pre-flight refusal: policy refuses → lock never acquired (or released immediately); BOOKKEEPING_POLICY_REFUSED raised.
  • Unit append + commit happy path: event appended, status materialized, commit succeeds, lock released, receipt returned.
  • Unit commit failure → rollback: inject a pre-commit hook that always rejects; verify byte-identical pre/post status.events.jsonl (SC-05); verify no outbound side effects ran (SC-09).
  • Unit deferred outbound: register two defer_outbound() callables; commit succeeds; both run in order.
  • Unit deferred outbound on failure: register callable; commit fails; verify callable did NOT run.
  • Unit nested-lock attempt: a second acquire() for the same mission blocks; the second raises BOOKKEEPING_LOCK_TIMEOUT after timeout.
  • Integration multi-process stress: 20 parallel acquire() for the same mission; each runs serially; no interleaved events (SC-12).

Public surface

BookkeepingTransaction.acquire is the only public entry point from this module. The class itself is exposed for type annotations but its __init__ is private (use acquire).

Performance budget

  • acquire() → first append_event() ready: < 100ms on a typical repo (NFR-008 covers the policy gate sub-step).
  • Lock hold time end-to-end (happy path): < 250ms (NFR-010).
  • Rollback path (truncate + re-materialize): < 100ms on a 10MB event log (NFR-002).

cli_status_mediation.md

Contract: CLI status mediation

Spec source: FR-030 Affected CLI surface: spec-kitty agent tasks status, spec-kitty agent context resolve, any read-side query that consumes status.events.jsonl or status.json.

Purpose

Lane worktrees do not contain status.events.jsonl or status.json (sparse-checkout per FR-029). Any read-side query MUST resolve the coordination worktree path and read from there, regardless of where the operator's process is currently running.

Mediation rule

Every read-side CLI command that accesses status data:

1. Accepts an optional --mission <handle> flag (existing CLI plumbing). 2. Resolves the mission via the existing handle resolver: mission_id (ULID) → mid8mission_slug. Ambiguous handles produce MISSION_AMBIGUOUS_SELECTOR (no silent fallback). 3. Resolves the read path: prefer the coordination worktree (.worktrees/<slug>-<mid8>-coord/kitty-specs/<mission>/). If the coordination worktree does not exist (legacy topology), fall back to the primary checkout's view of kitty-specs/<mission>/. 4. Reads status.events.jsonl and status.json from the resolved path. 5. Returns the data; the output format is unchanged from current behavior.

The CLI never reads from the operator's CWD, the lane worktree, or any other location.

Affected commands

CommandRead path resolutionBehavior change
spec-kitty agent tasks statusCoordination worktree (new topology) / primary checkout (legacy)New: reads from coordination worktree
spec-kitty agent context resolveCoordination worktreeNew: returns status snapshot from coordination worktree
spec-kitty agent decision verifyCoordination worktree (decisions/index.json lives there too)New: reads from coordination worktree
spec-kitty agent tasks status --jsonSame as above; output is JSONNo format change
spec-kitty doctorInspects both coordination and lane worktreesNew checks: sparse-checkout drift, missing coordination worktree
spec-kitty agent mission statusCoordination worktreeNew: reads from coordination worktree

Write-side commands (no change to this contract)

Write-side commands (spec-kitty agent action implement, agent action review, agent mission finalize-tasks) go through BookkeepingTransaction, which resolves the coordination worktree as the write target. Their behavior is governed by contracts/bookkeeping_transaction.md.

Behavior in lane worktree

An agent process running inside .worktrees/<slug>-<mid8>-lane-a/ invokes:

spec-kitty agent tasks status --mission <handle>

The CLI resolves the mission, locates the coordination worktree, reads from there, and returns the result. The agent does NOT attempt to read kitty-specs/<mission>/status.json directly from its own CWD (sparse-checkout would make that file missing).

Behavior on legacy missions

Legacy missions have no coordination worktree. The CLI falls back to the primary checkout's view of kitty-specs/<mission>/. The mediation contract is unchanged — the read path resolution simply selects a different worktree.

Error codes

CodeMeaning
STATUS_READ_PATH_NOT_FOUNDNeither coordination worktree nor primary checkout has the mission dir
MISSION_AMBIGUOUS_SELECTORExisting error code from handle resolver

Performance

The CLI mediation path adds at most one git worktree list invocation per command (cached for the process lifetime). Target overhead: < 50ms per command.

Out-of-scope (future tickets)

  • Read-only mirror in the lane: caching a stale snapshot in .spec-kitty/mission-status-snapshot.json inside the lane for faster repeated reads. Mentioned in R-004 as an optimization for a future ticket; not required by this mission.
  • Watch / subscribe APIs: real-time status streams via inotify / fsevents are out of scope.

Test surface

  • Unit handle resolution: ULID, mid8, slug all resolve to the same mission.
  • Unit ambiguous handle: returns structured MISSION_AMBIGUOUS_SELECTOR.
  • Unit read-from-coordination: lane worktree CWD; CLI returns same data as primary checkout would.
  • Unit read-from-primary-fallback (legacy mission): coordination worktree absent; CLI reads from primary checkout view.
  • Unit missing mission: returns STATUS_READ_PATH_NOT_FOUND.
  • Integration: spawn process inside lane worktree, run spec-kitty agent tasks status, verify output matches a process spawned inside the coordination worktree (SC-02).

coordination_workspace.md

Contract: CoordinationWorkspace and lane sparse-checkout policy

Spec source: FR-003, FR-018, FR-024, FR-025, FR-029 Module: src/specify_cli/coordination/workspace.py

Purpose

Manage the lifecycle of the per-mission coordination worktree at .worktrees/<slug>-<mid8>-coord/. Register the sparse-checkout policy that lane worktrees use to exclude status files.

Coordination worktree lifecycle

Creation (FR-003, FR-024)

The coordination worktree MUST exist for every active mission whose topology is the new coordination-branch model (not legacy). It is created at one of two points:

1. spec-kitty agent mission create — creates the coordination branch (kitty/mission-<slug>-<mid8>) but does NOT necessarily create the worktree (no implementation work has started yet). The branch is parented off the canonical target branch. 2. First implement/review invocation for the missionCoordinationWorkspace.resolve() is called by BookkeepingTransaction.acquire(); if the worktree does not exist, it is created via git worktree add <path> <branch>.

Both paths converge on the same end state: .worktrees/<slug>-<mid8>-coord/ is a git worktree checked out to kitty/mission-<slug>-<mid8>.

Idempotency (FR-018)

CoordinationWorkspace.resolve() is idempotent:

  • Worktree already exists and points at the correct branch → return path.
  • Worktree exists but points at a different branch → raise COORDINATION_WORKTREE_BRANCH_MISMATCH (operator must intervene; no auto-recovery).
  • Branch exists but worktree does not → create worktree, return path.
  • Neither branch nor worktree exists → check whether mission is in the new topology. If new topology: create branch + worktree. If legacy topology: raise COORDINATION_WORKTREE_NOT_APPLICABLE (caller falls back to lane worktree).

Teardown

The coordination worktree is removed at:

  • spec-kitty mission close --discardgit worktree remove <path> --force; also delete the coordination branch.
  • spec-kitty merge after successful merge of coordination → target — git worktree remove; coordination branch deleted (FR-016).

Teardown is idempotent (FR-016): calling on a missing worktree is a no-op.

Naming

  • Branch: kitty/mission-<mission_slug>-<mid8> (FR-003, FR-015, C-001).
  • Worktree path: .worktrees/<mission_slug>-<mid8>-coord/ relative to repo root (FR-024).

<mission_slug> and <mid8> are taken verbatim from meta.json; both are already ASCII-sanitized per DIR-010 / DIR-011.

Lane worktree sparse-checkout policy (FR-029)

When the lane allocator creates .worktrees/<slug>-<mid8>-lane-<id>/, it MUST register a sparse-checkout pattern that excludes kitty-specs/<mission>/status.events.jsonl and kitty-specs/<mission>/status.json from the lane worktree.

Implementation steps (executed at lane worktree creation)

Important: In a linked worktree, .git is a file pointing to a per-worktree gitdir, not a directory. Writing literally to .git/info/sparse-checkout fails. Resolve the actual path via git rev-parse --git-path info/sparse-checkout — this returns the correct location for any worktree topology.

git worktree add .worktrees/<slug>-<mid8>-lane-<id> <coordination_branch>
cd .worktrees/<slug>-<mid8>-lane-<id>
git sparse-checkout init --no-cone
# Resolve the actual sparse-checkout file path (handles linked worktrees correctly):
SPARSE_FILE=$(git rev-parse --git-path info/sparse-checkout)
# Include everything (default), then exclude the two status files for this mission:
cat > "$SPARSE_FILE" <<EOF
/*
!kitty-specs/<mission_slug>-<mid8>/status.events.jsonl
!kitty-specs/<mission_slug>-<mid8>/status.json
EOF
git read-tree -mu HEAD

Python equivalent for the Python helper:

gitdir_info = subprocess.check_output(
    ["git", "-C", str(lane_path), "rev-parse", "--git-path", "info/sparse-checkout"],
    text=True,
).strip()
sparse_file = Path(gitdir_info)
sparse_file.parent.mkdir(parents=True, exist_ok=True)
sparse_file.write_text("\n".join([
    "/*",
    f"!kitty-specs/{mission_slug}-{mid8}/status.events.jsonl",
    f"!kitty-specs/{mission_slug}-{mid8}/status.json",
]) + "\n")
subprocess.run(["git", "-C", str(lane_path), "read-tree", "-mu", "HEAD"], check=True)

The --no-cone mode is used because the exclusion list is path-specific (not directory-level).

Alternative: git sparse-checkout set --no-cone <patterns> handles file location automatically. Either approach is acceptable; the rev-parse --git-path form is shown above for clarity about WHY the literal .git/info/ path is wrong.

Verification

A spec-kitty doctor check (added in PR 2) inspects each lane worktree's .git/info/sparse-checkout file. Drift triggers a warning:

Lane worktree .worktrees/<slug>-<mid8>-lane-a/ has missing sparse-checkout
exclusion for kitty-specs/<slug>-<mid8>/status.events.jsonl.
Run `spec-kitty agent worktree repair --mission <slug>` to restore.

Why sparse-checkout (vs. alternatives)

See research.md → R-003. Sparse-checkout preserves backward compatibility (files stay under kitty-specs/<mission>/), is in-tree (no external config to distribute), and is stock git since 2.25.

Minimum git version (RR-01)

This contract requires git >= 2.25 (the version where git sparse-checkout graduated from experimental). The doctor command MUST check the git version on startup and emit a one-line error if older.

Error codes

CodeMeaning
COORDINATION_WORKTREE_BRANCH_MISMATCHWorktree exists but is checked out to a different branch
COORDINATION_WORKTREE_NOT_APPLICABLEMission is on legacy topology; coordination worktree concept does not apply
COORDINATION_WORKTREE_DIRTYWorktree has uncommitted changes that block teardown
LANE_SPARSE_CHECKOUT_INIT_FAILEDgit sparse-checkout init returned non-zero
LANE_SPARSE_CHECKOUT_DRIFTDoctor check found a lane worktree without the expected exclusion

Test surface

  • Unit CoordinationWorkspace.resolve() creates worktree when missing.
  • Unit resolve idempotent when worktree exists at the right branch.
  • Unit branch mismatch raises the structured error.
  • Unit teardown removes worktree and branch.
  • Integration lane creation: lane worktree created; .git/info/sparse-checkout has the expected exclusions; lane worktree's filesystem does NOT contain the status files; primary checkout still contains them.
  • Integration doctor: drift detected when sparse-checkout file is manually edited; warning surfaces; repair restores.
  • Integration min git version: on git < 2.25, the CLI exits with a clear error.

safe_commit_signature.md

Contract: safe_commit() signature and HEAD assertion

Spec source: FR-031, C-015 Module: src/specify_cli/git/commit_helpers.py

Signature

def safe_commit(
    *,
    repo_root: Path,
    worktree_root: Path,
    destination_ref: str,
    message: str,
    paths: tuple[Path, ...],
) -> CommitResult: ...

All parameters are keyword-only. mypy --strict catches missing destination_ref at every typed call site.

Behavior

1. Validate inputs: repo_root is a git repo; worktree_root is a worktree of that repo; paths is non-empty; destination_ref is a short branch name (C-016), never the fully-qualified refs/heads/... form. If destination_ref starts with refs/heads/, raise SafeCommitDestinationRefShape — the caller must normalize at the boundary. 2. Resolve the worktree's HEAD via git -C <worktree_root> symbolic-ref HEAD → raw output is refs/heads/<branch>. Normalize: actual_head = raw_output.removeprefix("refs/heads/"). This is the short form, matching destination_ref. 3. HEAD assertion: if actual_head != destination_ref, raise SafeCommitHeadMismatch(destination_ref, observed_head=actual_head, worktree_root=worktree_root). No commit attempted. Both fields in the error are in short form. 4. Run the existing protected-branch check (_is_protected_branch(destination_ref)). If protected and no documented exception applies, raise ProtectedBranchRefused(destination_ref, message). 5. Stage paths via git -C <worktree_root> add -- <paths>. 6. Run git -C <worktree_root> commit -m <message>. Return CommitResult with the new commit SHA.

No silent fallback. If destination_ref is missing, mypy fails the type check. If HEAD doesn't match, the helper refuses. If the branch is protected, the helper refuses. There is no "infer destination from HEAD" path.

Error codes (stable for scripted detection; NFR-007)

CodeWhen raisedRecovery suggestion in message
SAFE_COMMIT_HEAD_MISMATCHWorktree HEAD does not match destination_ref"Run git -C <worktree_root> checkout <destination_ref> first"
SAFE_COMMIT_PROTECTED_BRANCHdestination_ref is on the protected list (no exception)"Use the coordination worktree at .worktrees/<slug>-<mid8>-coord/"
SAFE_COMMIT_DESTINATION_NOT_FOUNDdestination_ref does not exist in the repo"Did you mean kitty/mission-<slug>-<mid8>?"
SAFE_COMMIT_EMPTY_CHANGESETpaths is emptyProgramming error; pass at least one path
SAFE_COMMIT_NOT_A_WORKTREEworktree_root is not a valid worktree of repo_rootProgramming error; pass the resolved worktree path
SAFE_COMMIT_RECOVERY_FAILEDCaller staging could not be restored or recovery state could not be capturedInspect unrecovered_paths, orphan_stash_ref, and commit_sha

Each error carries: error_code, message, destination_ref, observed_head (when relevant), worktree_root. Recovery failures additionally carry unrecovered_paths, orphan_stash_ref, and commit_sha when a commit was created before recovery failed. JSON-serializable.

CLI surface change

The existing spec-kitty safe-commit <message> <paths...> CLI command gains a required --to-branch <ref> parameter. Without it, the CLI exits non-zero with SAFE_COMMIT_HEAD_MISMATCH. (The CLI does not infer the destination — it requires the operator or wrapping script to declare it.)

CLI-only deprecation env var (NOT a helper fallback) — for backward compatibility during PR 1's rollout window, the CLI emits a deprecation warning if --to-branch is missing AND SPEC_KITTY_INFER_DESTINATION_REF=1 is set; in that mode the CLI layer invokes the existing branch-context resolver, gets the short branch name, and passes it explicitly to safe_commit(destination_ref=resolved). The helper still receives a required, explicit destination_ref; nothing about C-015 is loosened. The env var is removed in the next minor release after PR 1 lands. (Documented in CHANGELOG.) This is scoped per the cross-review: CLI-only explicit resolver, never helper-level inference.

Test surface

  • Unit happy path: worktree on destination_ref, non-protected → commit succeeds, returns SHA.
  • Unit HEAD mismatch: worktree on a different branch → raises SafeCommitHeadMismatch.
  • Unit protected branch: destination_ref=main (protected) → raises ProtectedBranchRefused.
  • Unit empty paths: → raises SAFE_COMMIT_EMPTY_CHANGESET.
  • Type test: a missing-arg call site fails mypy --strict.
  • CLI test: spec-kitty safe-commit without --to-branch exits non-zero.
  • Migration test: each existing call site in the codebase passes destination_ref after PR 1.

Compatibility

Breaking change for any caller of safe_commit() that does not pass destination_ref. All such callers live in the spec-kitty tree (audited and migrated in PR 1). External users invoking spec-kitty safe-commit get a clear migration message via the CLI deprecation path described above.

workflow_mutation_policy.md

Contract: WorkflowMutationPolicy

Spec source: FR-019, FR-020, FR-021, C-012 Module: src/specify_cli/coordination/policy.py

Purpose

Single chokepoint for protected-branch refusal. Called by BookkeepingTransaction.acquire() before any write happens, and called by safe_commit() during the commit attempt as a defense in depth. The policy input is always an explicit destination_ref — never inferred from CWD/HEAD.

Signature

class WorkflowMutationPolicy:
    @staticmethod
    def assert_allowed(change_set: GitChangeSet) -> PolicyVerdict:
        """
        Inspect change_set.destination_ref.
        Return Allowed if the would-be commit is permitted; Refused with a
        stable error_code otherwise.
        """

Behavior

1. Validate inputs. destination_ref is required and non-empty. repo_root is a git repo. 2. Check whether destination_ref exists in the repo. If not → Refused(error_code="DESTINATION_REF_NOT_FOUND", ...). 3. Check whether destination_ref is a remote-tracking branch (refs/remotes/...). If so → Refused(error_code="DESTINATION_REF_NOT_LOCAL", ...). 4. Look up destination_ref against the project's protected-branch list (existing logic in src/specify_cli/git/commit_helpers.py). 5. If protected → Refused(error_code="PROTECTED_BRANCH_REFUSED", next_step=<route to coordination worktree>). 6. Otherwise → Allowed().

The policy is idempotent and side-effect-free. It never modifies repo state, never writes files, never touches the lock.

Error code stability (NFR-007)

The error_code field is stable across releases for scripted detection. Adding new codes is allowed; removing or renaming is breaking. Current codes:

CodeMeaning
PROTECTED_BRANCH_REFUSEDdestination_ref is on the protected branch list
DESTINATION_REF_NOT_FOUNDdestination_ref does not resolve to any ref in the repo
DESTINATION_REF_NOT_LOCALdestination_ref is a remote-tracking branch
DESTINATION_REF_INVALID_SHAPEdestination_ref does not match expected naming (e.g. starts with -)

Operator-facing message format (FR-002)

The Refused.message field carries a human-readable description naming:

  • The rejected commit's intent (from change_set.operation).
  • The destination ref the policy rejected.
  • The recovery route.

Example:

Refusing to record WP01 transitions: destination ref 'main' is on this
project's protected branch list. Bookkeeping commits must target the
coordination branch 'kitty/mission-foo-01ABCDEF'. Re-run the command;
the coordination worktree is auto-resolved.

Integration with existing protected-branch check

The policy wraps but does not replace the existing _is_protected_branch() helper in src/specify_cli/git/commit_helpers.py. The wrapper exists to:

  • Provide a single chokepoint for the workflow paths to call (so audits and architectural tests can target one entry point).
  • Normalize the error shape into PolicyVerdict (the existing helper returns a bool).
  • Make the input contract explicit (GitChangeSet with required destination_ref).

The protected-branch list itself (which branches count as protected) is unchanged by this mission.

Test surface

  • Unit allowed: destination_ref is a non-protected branch → Allowed().
  • Unit protected: destination_ref is on the protected list → Refused(PROTECTED_BRANCH_REFUSED).
  • Unit not found: destination_ref does not exist → Refused(DESTINATION_REF_NOT_FOUND).
  • Unit remote-tracking: destination_ref is refs/remotes/origin/mainRefused(DESTINATION_REF_NOT_LOCAL).
  • Unit invalid shape: destination_ref begins with -Refused(DESTINATION_REF_INVALID_SHAPE).
  • Unit side-effect-free: calling assert_allowed does not modify git state, does not touch files, does not acquire locks.
  • Integration: refusing inside BookkeepingTransaction.acquire() results in BOOKKEEPING_POLICY_REFUSED with the underlying verdict accessible.

Composition with safe_commit()

WorkflowMutationPolicy.assert_allowed() is called twice per write: 1. By BookkeepingTransaction.acquire() before any write (pre-flight; saves the cost of writing-then-rolling-back when the destination is protected). 2. Indirectly inside safe_commit() via the existing protected-branch check (defense in depth, in case a caller bypasses the transaction layer).

The double-call is intentional: the pre-flight catches 99% of refusals cheaply; the helper-level check catches the residual 1% (direct safe_commit() callers from outside the transaction layer).