Skip to content

Move approval authority to the repository and continue retained worktrees - #12

Merged
Deep-Sixed merged 11 commits into
mainfrom
claude/kind-cannon-6yodm7
Sep 29, 2026
Merged

Deep-Sixed merged 11 commits into
mainfrom
claude/kind-cannon-6yodm7

Conversation

@Deep-Sixed

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

Copy link
Copy Markdown
Owner

Summary

Fixes the review items that decide who may authorize and what happens to filesystem state when ownership moves. PR #9 (the receive transaction boundary) and the scope enforcement in PR #10 are left to those PRs.

Review finding Status here
Agents can approve their own operation (P1) Fixed
Expired reclaim / takeover breaks with an existing local worktree (P1) Fixed
Agent/repo names can escape the worktree directory (P1/P2) Fixed
Verify governance-critical triggers, not just tables/migrations Partly: the new and existing governance triggers are now part of the contract

Approval authority

Before this change, gate.vote() checked only that the voter was a registered agent, and the requester set --threshold (default 1). A worker could therefore request and approve its own push.

  • Roles. Each agent has one role: worker (the default), reviewer, or operator.
  • Repository policy. Each repository owns approval_threshold, its approving roles, and requester_may_vote. The defaults are 1, operator, and no. approve request --threshold is removed; the threshold is recorded on the instance for display only.
  • Voting. Only agents whose role the policy names may vote, yes or no. The requester may not vote unless the policy allows it. The agent a takeover would benefit may never vote on it.
  • Consumption. An agent that voted yes on an operation can never carry it out, whether by pushing or by taking over. The gate refuses this, and so does a database trigger.
  • Re-checked at use time. Consumption recomputes eligible votes from current roles and policy. Raising the threshold or demoting an approver therefore invalidates approvals that no longer meet the policy.
  • Administration. Registering a non-worker, changing a role, and changing policy all require an operator. The exception is bootstrap: while no operator exists, anyone can designate the first one. The last operator cannot be demoted. All of these changes are audited.
  • Database enforcement. Migration 004_approval_authority.sql adds triggers for vote eligibility (on insert and update), approvals naming a registered repository, and consumer ≠ approver. verify_contract now fails if any governance trigger is missing.
  • CLI. New commands agent add --role, agent role <name> <role>, and repo policy <name> [--threshold] [--role]… [--[no-]requester-may-vote]. agent list now shows roles.

Upgrade behavior (fails closed). Existing agents become workers and existing repositories get the default policy. Old approvals, self-approvals included, stop authorizing anything until an eligible operator votes on a new request. The README tells operators to run quorumgit agent role <name> operator after init.

Worktree continuation

A claim that replaces an earlier claim on the same task and branch no longer tries to create a second checkout, which Git would refuse. This covers lease expiry, an approved takeover, and release without --remove-worktree. Instead, trees.continue_or_create_worktree checks the retained checkout's repository, root, and branch (the same check doctor uses, now factored out), moves it to the new claim with any uncommitted work, and records worktree.continued.

  • A retained checkout from a different task is never adopted. The claim is refused with a clear message instead of Git's raw "already checked out" error.
  • A drifted checkout (wrong branch, detached HEAD, or a different repository) is also refused. The claim rolls back entirely.
  • If the recorded directory no longer exists, a fresh worktree is created, as before, and doctor reports the stale record.

Worktree paths

Managed paths are now worktrees/repo-<id>/claim-<id>-<random>. Agent and repository names no longer appear in paths, so a name like ../../x cannot move a checkout outside the managed directory. The random suffix stops a leftover directory from blocking a later claim that reuses the rowid of a rolled-back one.

CLI connection lifecycle

Every CLI command now runs inside store.session(). It keeps the connection context manager's commit/rollback behavior and then closes the connection deterministically. (The Windows 0xC0000005 crashes seen earlier on this PR were a libSQL 0.1.11 double-close bug; PR #14's move to sqlite3 removed it. Details are in the PR comments.)

Interaction with other PRs

Validation

New tests are in tests/test_approval_authority.py, tests/test_worktree_continuation.py, and tests/test_store.py (session commit/rollback/close).

🤖 Generated with Claude Code

https://claude.ai/code/session_01SrukvuPz7kpbore3kkKpQ3

…rees

Approvals previously proved only that the voter was a registered agent, and
the requester chose the threshold (default 1), so a worker could request and
approve its own protected push or takeover.

Authority:
- agents carry a role (worker, reviewer, operator; existing agents upgrade
  as workers)
- each repository owns its approval policy: threshold, approving roles, and
  whether the requester may vote; `approve request --threshold` is removed
- only approving roles may vote, the requester may not by default, and a
  takeover's beneficiary may never vote on it
- an agent that approved an operation cannot consume it (pusher or taker),
  enforced in the gate and by a database trigger
- eligible votes are re-derived from current roles and policy at use time,
  so a tightened policy or a demoted approver invalidates old approvals
- role and policy changes require an operator after bootstrap, the last
  operator cannot be demoted, and both are audited
- the store contract now requires the governance triggers by name

Worktrees:
- a claim that supersedes an earlier claim on the same task and branch
  (lease expiry, approved takeover, release that kept its checkout)
  verifies and transfers the retained worktree instead of failing on Git's
  one-checkout-per-branch rule; drifted or other-task checkouts are refused
- managed worktree paths are built from IDs plus a random suffix, so agent
  and repository names can no longer place a checkout outside the managed
  directory

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SrukvuPz7kpbore3kkKpQ3

Copy link
Copy Markdown
Owner Author

CI note on 1ad1f7e: windows-latest / Python 3.11 failed in test_governed_takeover_continues_incumbent_worktree. The failure is the test's first quorumgit claim subprocess exiting with 3221225477 (0xC0000005, access violation) and empty stdout and stderr. That means the native libSQL extension crashed. It isn't a Python exception or a governance refusal.

Why I don't think this is a logic failure in this PR:

  • Ubuntu 3.11, Ubuntu 3.14 and macOS 3.11 pass all 117 tests.
  • On the same Windows runner, the previous test in the same file, test_reclaim_after_expiry_continues_retained_worktree, made an identical first claim call after identical setup (register repo, add agents, create task, commit), and it passed.
  • The crashing call is a plain first claim. It doesn't reach the takeover or continuation code this PR adds.

I can't reproduce Windows from this environment and haven't found a root cause, so I'm not calling it a flake. I'll re-run the failed job once when the rest of the run finishes; GitHub refused the re-run while macOS 3.14 is still in progress. If the crash comes back, I'll treat it as real and make the CLI's libSQL connection lifecycle explicit (close each connection deterministically, not by garbage collection), then re-test.


Generated by Claude Code

Every CLI command used libSQL's connection context manager, which ends
the transaction but leaves the native connection open, so each one was
torn down implicitly when its last reference disappeared. On Windows CI
the CLI child process intermittently died with 0xC0000005 and no output,
in different commands on each run, as the number of CLI invocations in
the suite grew.

store.session() keeps the same commit-on-success / rollback-on-error
semantics and then closes the connection deterministically. All CLI
commands use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SrukvuPz7kpbore3kkKpQ3

Copy link
Copy Markdown
Owner Author

Follow-up on the Windows crash: the one re-run of windows-latest / Python 3.11 failed again with the same 0xC0000005 and empty output, this time in approve vote (test_cli_votes_by_approval_instance_id, which has passed on Windows on main) and agent role. The claim test that crashed the first time passed. That makes it a real, intermittent crash in the CLI child process, not a flake, and more likely the more CLI subprocesses the suite starts. It doesn't reproduce on Linux (15 back-to-back runs of the CLI-heavy test files all passed).

Pushed 52438c6 as the fix I proposed. libSQL's connection context manager commits or rolls back but never closes, so every CLI command left its native connection to be torn down implicitly. store.session() keeps the same transaction semantics and then closes the connection explicitly. All 25 CLI commands use it. I also added a regression test for the session's commit and rollback behavior. The next Windows run will show whether this is the root cause.


Generated by Claude Code

The Windows job intermittently loses a CLI child process to 0xC0000005
with no output, so the failing call is unknown. faulthandler prints the
Python stack on a native crash, which the next occurrence will capture in
the assertion message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SrukvuPz7kpbore3kkKpQ3
The intermittent Windows 0xC0000005 crashes are a libsql 0.1.11 bug, not
QuorumGit code. faulthandler put the crash in Connection.close(), and
Valgrind on Linux shows why for every connection teardown, on main too:
dropping the inner LibsqlConnection calls sqlite3Close and frees the
handle, then the outer Connection's drop calls sqlite3Close again on the
freed block ("Invalid read ... inside a block of size 848 free'd by
sqlite3Close"). Linux tolerates the stray read; Windows sometimes faults.
0.1.11 is the newest libsql release, so there is no upgrade to take.

CLI processes now retain their connections and end through os._exit
after flushing output (cli.run, used by the console script and
`python -m quorumgit`). store.release() closes normally everywhere else,
and when retaining it first rolls back any open transaction so no write
lock outlives the command. Valgrind reports zero invalid reads for read
and write commands with this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SrukvuPz7kpbore3kkKpQ3
…odm7

# Conflicts:
#	src/quorumgit/store.py
#	tests/test_authorization_binding.py
#	tests/test_gate.py

Copy link
Copy Markdown
Owner Author

Root cause of the Windows 0xC0000005 crashes, for the record: it was a bug in libSQL 0.1.11, not in this PR's code.

  • With faulthandler on, the crash was in Connection.close().
  • Under Valgrind on Linux, every libSQL connection teardown reads freed memory. Dropping the inner LibsqlConnection calls sqlite3Close and frees the handle, then the outer Connection's drop calls sqlite3Close again on it ("Invalid read … inside a block of size 848 free'd by sqlite3Close"). main's code at the time did the same. Linux tolerates the stray read; Windows sometimes faults on it. This PR runs more CLI subprocesses in its tests, which is why the crash started showing up. 0.1.11 is the newest libSQL release, so there was no upgrade to take.

PR #14 has since replaced libSQL with the standard-library sqlite3 module, which removes the bug. b09c847 merges that main into this branch and reverts my libSQL-specific workaround (c5ba900, keeping connections alive and exiting via os._exit) and the faulthandler diagnostic. store.session() stays, rebuilt on sqlite3: the CLI still closes each command's connection deterministically. Trigger-abort tests now expect sqlite3.IntegrityError. Locally: 120 passed, pyright and ruff clean.


Generated by Claude Code

…odm7

# Conflicts:
#	src/quorumgit/cli.py
#	src/quorumgit/trees.py
main now pins ruff 0.16.9 and gates CI on it. Drop three noqa comments
for a rule that is not enabled, sort registry imports, merge a nested
with in the session test, and mark an unused unpacked fixture value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SrukvuPz7kpbore3kkKpQ3
@Deep-Sixed
Deep-Sixed marked this pull request as ready for review September 29, 2026 13:27
@Deep-Sixed
Deep-Sixed merged commit 52d00ce into main Sep 29, 2026
7 checks passed
Deep-Sixed pushed a commit that referenced this pull request Sep 29, 2026
The predecessor Codex_Kernel gate derived its threshold from membership:
a proposal needed floor(2N/3) + 1 votes from the N authorized nodes, so the
bar grew with the approver set. Repository policy (#12) uses a fixed
approval_threshold, which stays at 1 no matter how many operators exist.

`quorumgit repo policy <repo> --quorum` makes an operation require 2/3 + 1
of the agents eligible to approve it: agents holding an approving role,
minus the requester (unless the policy lets it vote) and a takeover's
beneficiary, and at use time minus the consumer. The fixed threshold
remains a floor. Counting only eligible agents keeps those exclusions from
making approval impossible. Because eligibility is re-derived when an
approval is used, adding approvers raises the bar for unused approvals the
same way raising --threshold does. Migration 005 adds the flag; policy
changes remain operator-only and audited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mc44WsAZx9hzZdWEihDk5a
Deep-Sixed pushed a commit that referenced this pull request Sep 29, 2026
main's #12 now owns two things this branch also fixed, more completely:

- Approval authority: agent roles and per-repository policy, requesters
  and takeover beneficiaries barred from voting, and approvers barred
  from consuming. This branch's independent-vote rule, approve-after-
  approved voting, and their tests are dropped in favour of it. The
  push path's re-validation at the reference transaction's `prepared`
  stage now authorizes through main's approved_instance/approval_refusal
  with the pusher as consumer.
- Retained-worktree continuation: main's continue_or_create_worktree
  replaces this branch's inherit_worktree and its duplicate tests.

Everything else in this branch is kept: the reference-transaction
boundary, push-time scope enforcement, the full schema contract, doctor
reconciliation, scope normalization, commit resolution, and handoff
--last-commit precedence. CLI commands use main's store.session, and
migration 003_ref_updates sits between 002 and main's 004.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8BpCkArA34hB1aSyVcnZp
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