Repository navigation
Fix protected-ref enforcement, push locking, and policy composition - #32
Merged
Merged
Conversation
… 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
marked this pull request as ready for review
September 30, 2026 07:47
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the two main findings from a code review of
mainatdbf4da2.1. A short protected-ref name protected nothing
repo add --protected-ref mainstoredmainexactly as typed. The hook compares against the full ref names Git passes it (refs/heads/main), so a push tomainneeded no approval, whilerepo policystill listedmainas protected. I reproduced this with a real push. It also went unnoticed becausemainline_tipsresolvesmainthroughgit 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 outsiderefs/is taken as a branch (main→refs/heads/main), and anythinggit check-ref-formatrejects is refused, as are empty names, names starting with-, andHEAD.repo adduses it.quorumgit repo protect-ref <repo> <ref> [--remove]: adds or removes a protected ref after registration. Operator only and audited, likeprotect-path.010_protected_ref_names.sql:refs/heads/<name>, dropping any that duplicate a full name already protected;REQUIRED_TRIGGERS.2. A large push blocked every other agent
run_pre_receivetook the store's write lock (BEGIN IMMEDIATE) before working out which paths the push changes. That step started onegit diff-treeprocess per new commit, twice per ref (once forpaths, once forscope_paths). With the 5-second busy timeout, another agent'sclaimorvote, 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._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'sref_updatesrows are now written together, which also stops a long multi-ref push from running its first ref's validation past the 300-second limit.git_objects.changed_paths_againstwalks the commits once per baseline, then reads every commit's paths with a singlegit 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_pathsis the one derivation used by the hook, thepreparedre-check andapprove 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_updateandforce_update; deleting a protected ref requires bothprotected_ref_updateandref_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):mainis rejected without itsprotected_ref_updateapproval and accepted with it; this failed onmain;set_protected_refis operator only, idempotent and audited;009rewrites and de-duplicates short names;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 ifbegin_immediateis moved back ahead of the derivation (checked).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