Skip to content

Fix protected-ref enforcement, push locking, and policy composition - #32

Merged
Deep-Sixed merged 1 commit into
mainfrom
claude/fervent-darwin-65ds9y-review-fixes
Sep 30, 2026
Merged

Deep-Sixed merged 1 commit into
mainfrom
claude/fervent-darwin-65ds9y-review-fixes

Conversation

@Deep-Sixed

@Deep-Sixed Deep-Sixed commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Fixes the two main findings from a code review of main at dbf4da2.

1. A short protected-ref name protected nothing

repo add --protected-ref main stored main exactly as typed. The hook compares against the full ref names Git passes it (refs/heads/main), so a push to main needed no approval, while repo policy still listed main as protected. I reproduced this with a real push. It also went unnoticed because mainline_tips resolves main through git rev-parse, so claim-scope measurement still treated it as mainline. There was also no command to change protected refs after registration, so fixing the mistake meant editing the database by hand.

  • registry.normalize_protected_ref: a name outside refs/ is taken as a branch (main → refs/heads/main), and anything git check-ref-format rejects is refused, as are empty names, names starting with -, and HEAD. repo add uses it.
  • quorumgit repo protect-ref <repo> <ref> [--remove]: adds or removes a protected ref after registration. Operator only and audited, like protect-path.
  • Migration 010_protected_ref_names.sql:
    • rewrites existing short names to refs/heads/<name>, dropping any that duplicate a full name already protected;
    • adds two triggers that refuse short names from any writer, including one that bypasses the CLI. Both are in REQUIRED_TRIGGERS.
  • README: an upgrade note (existing stores with a short name start requiring approvals once migrated, as they always should have), the command table, and the operator-actions list.

2. A large push blocked every other agent

run_pre_receive took the store's write lock (BEGIN IMMEDIATE) before working out which paths the push changes. That step started one git diff-tree process per new commit, twice per ref (once for paths, once for scope_paths). With the 5-second busy timeout, another agent's claim or vote, or a concurrent push's hook, failed with "database is locked" while a large push was checked. On Linux, 2,000 commits took about 3.7 s per list.

  • Paths are derived before the lock is taken (_derive_pushed_paths). Commits don't change, and the reference-transaction hook already re-checks each update against the recorded paths, so nothing needs the lock at this stage. All of a push's ref_updates rows are now written together, which also stops a long multi-ref push from running its first ref's validation past the 300-second limit.
  • A fixed number of Git processes per ref update: git_objects.changed_paths_against walks the commits once per baseline, then reads every commit's paths with a single git diff-tree --stdin --raw -z. In raw output each path follows its own :-prefixed status record, so a path can never be misread as the next commit's ID, even one spelled exactly like a commit ID. gate.update_paths is the one derivation used by the hook, the prepared re-check and approve prepare, so their hashes still match.

3. Protected refs no longer mask force/delete policy

Ref-level governance now composes instead of using precedence. A force update of a protected ref requires both protected_ref_update and force_update; deleting a protected ref requires both protected_ref_update and ref_delete. This preserves stricter per-operation thresholds, roles, and quorum settings for destructive updates. Regression tests cover both combinations.

4. Quorum leaves an executor under requester voting

With quorum enabled and requester_may_vote, a small approver set could otherwise require every eligible agent to vote yes, after which separation of duties forbids every yes-voter from executing the approval. Quorum calculation now leaves one eligible agent outside the required yes-vote set when the requester is itself an eligible voter. An explicit fixed threshold remains authoritative and can still require unanimity. The existing rule that an approver may never execute an approval they voted for is unchanged.

Tests

  • tests/test_protected_refs.py (new, 16 tests):
    • a push to a branch protected as main is rejected without its protected_ref_update approval and accepted with it; this failed on main;
    • name normalization;
    • invalid names are refused;
    • set_protected_ref is operator only, idempotent and audited;
    • the triggers refuse short names on insert and update;
    • upgrading a store at 009 rewrites and de-duplicates short names;
    • the CLI.
  • tests/test_content_governance.py:
    • test_batched_paths_match_one_commit_at_a_time: batched paths match the old one-commit-at-a-time derivation over root, clean-merge, evil-merge and empty commits, a path containing : and a space, and a path spelled as a commit ID.
    • test_path_derivation_runs_a_fixed_number_of_git_processes: 40 commits take 3 Git processes.
    • test_pre_receive_reads_pushed_objects_before_reserving_the_store: checks that the store is not locked while paths are derived. It fails if begin_immediate is moved back ahead of the derivation (checked).
  • The migration-list assertions include 010.

Validation

  • pytest tests -q: 283 passed.
  • ruff check: clean.
  • pyright: 0 errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp


Generated by Claude Code

… store

A protected ref registered as a short name (`--protected-ref main`) was
stored as typed, but the hook matches the full names Git hands it
(refs/heads/main), so the branch was silently unprotected while
`repo policy` listed it. Protected refs are now normalized to full names
(a bare name is a branch) and validated with `git check-ref-format`;
migration 010 rewrites existing short names and adds triggers refusing
new ones. `repo protect-ref <repo> <ref> [--remove]` adds or removes one
after registration (operator only, audited).

pre-receive took the store's write reservation before deriving a push's
changed paths, which ran one `git diff-tree` per new commit, twice per
ref. A large push held every other agent's command and push past the
5-second busy timeout. Paths are now derived before the reservation is
taken (commits are immutable, and the transaction hook re-checks against
the recorded paths), and each update's commits are read with a fixed
number of Git processes: one walk per baseline and a single batched
`diff-tree --raw` whose records keep the parse exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
@Deep-Sixed
Deep-Sixed marked this pull request as ready for review September 30, 2026 07:47
@Deep-Sixed
Deep-Sixed merged commit c75156d into main Sep 30, 2026
7 checks passed
@Deep-Sixed Deep-Sixed changed the title Store protected refs as full names; inspect pushes before locking the store Fix protected-ref enforcement, push locking, and policy composition Sep 30, 2026
Deep-Sixed added a commit that referenced this pull request Sep 30, 2026
# Conflicts:
#	README.md
#	src/quorumgit/gate.py
#	src/quorumgit/git_objects.py
#	tests/test_approval_authority.py
#	tests/test_authorization_binding.py
Deep-Sixed pushed a commit that referenced this pull request Sep 30, 2026
Resolve the README conflict by keeping the consolidated Upgrading
section and folding main's new 010 (protected ref full names) and 011
(quorum consumer separation) notes into it in migration order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHGwY6PLGwr7kQY63qQGq6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants