Contracts
protection-config.md
Contract: .kittify protected-branch configuration + resolver
Configuration schema (additive)
A new top-level protection: block in .kittify/config.yaml. Backward compatible: absent block ⇒ current default behavior.
# .kittify/config.yaml
protection:
# Branches the safe-commit guard refuses direct commits to.
# - key ABSENT entirely -> default {main, master} (+ remote default branch) [unchanged behavior]
# - key present, list NON-empty -> exactly this set is protected
# - key present, list EMPTY ([]) -> NOTHING is protected (owner opts the primary out)
protected_branches:
- main
- release/* # exact names today; glob support is a possible future extension (out of scope)
Resolution rules (normative)
| Config state | Resolved protected_branches |
|---|---|
no protection: block | {main, master} ∪ {remote default branch} — byte-identical to today (NFR-004) |
protection.protected_branches: [a, b] | {a, b} (exactly; no name-default union) |
protection.protected_branches: [] | frozenset() — nothing protected (US2 edge case) |
| malformed value (non-list) | resolver raises a clear config error (fail-closed; no silent default) |
Operator hatch SPEC_KITTY_ALLOW_PROTECTED_BRANCH_COMMITS is unchanged (FR-006) and is resolved onto ProtectionPolicy.operator_hatch_active at the boundary; when active, is_protected() returns False.
Resolver contract
# src/specify_cli/git/protection_policy.py
@dataclass(frozen=True)
class ProtectionPolicy:
protected_branches: frozenset[str]
operator_hatch_active: bool
@classmethod
def resolve(cls, repo_root: Path) -> "ProtectionPolicy": ... # SOLE producer; all I/O here
def is_protected(self, ref: str) -> bool: ... # ref in set and not hatch
(FR-007, NFR-003). git/commit_helpers.protected_branches() is demoted to its private delegate.
commit_helpers.protected_branches); any other module computing a protection decision from a raw protected_branches(repo_root) call or a literal {"main", "master"} set fails CI.
resolve()is the only sanctioned function that reads the protection set from git/filesystem/env- The FR-010 architectural guard allowlists exactly
protection_policy.resolve(+ the demoted
safe_commit integration contract (pillar A boundary)
# safe_commit gains an optional injected policy; resolves-if-None for back-compat.
def safe_commit(*, repo_root: Path, worktree_root: Path, target: CommitTarget,
protection: ProtectionPolicy | None = None, ...) -> CommitResult: ...
safe_commit_cmd caller) materializes the coordination worktree via CoordinationWorkspace.resolve() and routes the commit there — materialize-then-retry, idempotent if already materialized.
(commit_helpers.py:1017-1020) are replaced by protection.is_protected(...).
- When
targetref is protected (protection.is_protected(ref)),safe_commit(or its - When not protected, commit proceeds directly on the target (the documented runbook behavior, FR-005).
- The internal
protected_branches(repo_root)+protected_branches(worktree_root)reads
Compatibility
- No change to the wire/CLI surface for repos without the
protection:block. - No change to
core/commit_guard.evaluateorProtectionState. - Coordination-worktree on-disk layout unchanged (reuses
CoordinationWorkspace).