Skip to content
Merged
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Then initialize the store once:
```bash
$ quorumgit init
store: /home/you/.quorumgit/quorumgit.db
migrations applied: ['001_core.sql', '002_approval_identities.sql', '004_approval_authority.sql', '005_content_governance.sql', '006_ref_updates.sql', '007_ref_update_scope_paths.sql', '008_operation_policies.sql', '009_approval_quorum.sql', '010_protected_ref_names.sql']
migrations applied: ['001_core.sql', '002_approval_identities.sql', '004_approval_authority.sql', '005_content_governance.sql', '006_ref_updates.sql', '007_ref_update_scope_paths.sql', '008_operation_policies.sql', '009_approval_quorum.sql', '010_protected_ref_names.sql', '011_quorum_consumer_separation.sql']
contract: ok
```

Expand Down Expand Up @@ -204,7 +204,7 @@ push → identify pusher → branch reservations → allowed ref namespace
```
- **Allowed ref namespaces.** Every repository accepts pushes to `refs/heads/` only. Tags, notes, and custom refs are refused until an operator opts in: `quorumgit repo allow-ref myproject refs/tags/ --agent lead` (`--remove` withdraws it). Branch claims, reservations, and scopes apply to `refs/heads/`; protected refs and protected paths apply to every allowed namespace.

One push can need several approvals — a direct push to protected `main` that edits a protected path needs both `protected_ref_update` and `protected_path_update`. They are all-or-nothing: the hook lists every missing approval at once, and consumes none unless all are usable.
One push can need several approvals — a direct push to protected `main` that edits a protected path needs both `protected_ref_update` and `protected_path_update`. Ref-level rules compose too: force-updating a protected ref needs both `protected_ref_update` and `force_update`, while deleting one needs both `protected_ref_update` and `ref_delete`. They are all-or-nothing: the hook lists every missing approval at once, and consumes none unless all are usable.

### Planning a push: `approve prepare`

Expand Down Expand Up @@ -259,10 +259,10 @@ Rules that hold no matter what:
- **Consumption is single-use under concurrency.** Two simultaneous pushes racing for one approval produce exactly one accepted push — the loser is rejected, not silently allowed.
- **Votes bind to one approval instance.** A delayed vote for an older denied or consumed instance cannot decide a newer request with the same operation hash. Requesters, voters, consumers, and pushers must name registered agents.
- **A consumed approval is spent, not blacklisted.** Consumption moves the approval to a terminal `consumed` state (with `consumed_at`) rather than reusing `denied`, and only one *live* (`pending` or `approved`) approval may exist per operation hash. The same operation can therefore be requested and approved again later as a new approval instance — which matters for takeovers, whose payload is stable and legitimately repeatable. What is never possible is one approval authorizing twice.
- **Nobody authorizes themselves.** Only agents whose role the repository's policy names may vote, in either direction. The requester may not vote unless the policy says so, the agent a takeover would benefit may not vote on it, and an agent that voted to approve an operation can never be the one that carries it out — pushes and takeovers by an approver are rejected, and a database trigger refuses the consumption even for writers that bypass the CLI.
- **Nobody's own vote authorizes their own action.** Only agents whose role the repository's policy names may vote, in either direction. The requester may not vote unless the policy says so, and the agent a takeover would benefit may not vote on it. Under a fixed-threshold policy, an approver still cannot carry out the operation. Under quorum policy, an approver may carry it out only when the other currently eligible yes votes still satisfy the policy after that consumer is removed from both the vote count and the quorum denominator. The same rule is enforced by the database trigger.
- **Authority is checked when it is used, not only when it is granted.** Consumption re-derives eligible votes from current roles and policy, so raising the threshold or demoting an approver invalidates approvals that no longer meet it. Such an approval is reopened by the next vote on it, so eligible agents can bring it up to the new requirement instead of requesting it again.
- **The repository owns the policy.** New repositories require one vote from an `operator`, and the requester may not vote. Change it with `quorumgit repo policy <repo> [--threshold <n>] [--role <role>]… [--requester-may-vote | --no-requester-may-vote] [--quorum | --no-quorum]`. `approve request` no longer accepts `--threshold`.
- **Quorum mode scales the threshold with the approvers.** With `--quorum` (on the default or on one operation type's override), an operation needs **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 never fewer than `--threshold`. For example, with four operators a worker's request needs 3 of the 4, while an operator's own request is decided by the other three and needs all 3 of them. Because eligibility is counted when the approval is used, adding approvers raises the bar for approvals not yet used, just as raising `--threshold` does.
- **Quorum mode scales the threshold with the approvers.** With `--quorum` (on the default or on one operation type's override), an operation needs **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 never fewer than `--threshold`. For example, with four operators a worker's request needs 3 of the 4, while an operator's own request is decided by the other three and needs all 3 of them. At consumption time, if the consumer also cast a yes vote, that vote is excluded and quorum is recomputed over the other eligible approvers; the consumer therefore never helps authorize itself. Because eligibility is counted when the approval is used, adding approvers raises the bar for approvals not yet used, just as raising `--threshold` does.
- **Policy can differ per operation type.** A repository's policy is its default; it may override the threshold, the approving roles, whether the requester may vote, or quorum mode for any of `protected_ref_update`, `force_update`, `ref_delete`, `out_of_scope_push`, `protected_path_update`, and `lease_takeover`. Fields an override leaves unset keep following the default, and `--inherit` removes the override. The effective policy is enforced by the same database triggers, and re-derived at consumption like any other policy change.

```bash
Expand All @@ -277,6 +277,8 @@ Rules that hold no matter what:

Registering a `reviewer` or `operator`, changing a role (`quorumgit agent role <name> <role>`), and changing repository policy are themselves authority decisions, so each requires an operator acting through `--agent` or `QUORUMGIT_AGENT`, and each is audited. The single exception is bootstrap: while no operator exists, the first one can be designated by anyone. The last remaining operator cannot be demoted, so a store never falls back into bootstrap mode by accident.

> **Upgrading quorum consumer separation.** Migration `011_quorum_consumer_separation.sql` keeps fixed-threshold separation unchanged and makes quorum consumption exclude a consumer's own yes vote from both the counting votes and the quorum denominator. This prevents a small quorum from becoming impossible to execute while preserving independent authorization.
>
> **Upgrading to full protected ref names.** Protected refs are matched against the full names Git hands the hooks, so one registered as a short name (`--protected-ref main`) used to protect nothing. Migration `010_protected_ref_names.sql` rewrites short names to the branch they name (`main` becomes `refs/heads/main`); from then on pushes to those branches need their `protected_ref_update` approval, as intended. Check `quorumgit repo policy <repo>` after running `quorumgit init`.

> **Upgrading to per-operation policy.** Migration `008_operation_policies.sql` adds no overrides, so every operation keeps following the repository default until an operator sets one.
Expand Down
4 changes: 2 additions & 2 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This is the short version of how a coding agent should behave in a repository go
## Identity

- Always act as your own registered agent: set `QUORUMGIT_AGENT` (or pass `--agent`) once per session and never change it to another agent's name. Identity is asserted, not authenticated — QuorumGit trusts you to be who you say you are.
- Never vote on an approval you requested, benefit from, or intend to carry out. The repository's policy refuses most of these; don't go looking for the ones it doesn't.
- Never vote on an approval you requested, benefit from, or intend to carry out. The repository's policy refuses most of these; under quorum policy a consumer's own yes vote is explicitly excluded from authorizing that consumer, but agents should still keep approval and execution separate when possible.

## The work loop

Expand All @@ -21,7 +21,7 @@ This is the short version of how a coding agent should behave in a repository go
- **Plan before you push.** `quorumgit approve prepare --repo <repo> --ref <branch>` shows the paths your push changes and every approval it needs, with the exact hashes the hook will demand. If it says `approvals required: none`, push.
- If approvals are needed, first ask whether the push should be different: changes outside your scopes usually mean the work belongs to another task, or your claim should have declared more. Only when the change is right, run `prepare … --request` and wait for an eligible reviewer or operator to vote.
- Fetch before planning. A plan is bound to the hub's current ref value; if the hub moves, re-plan.
- Never force-push, delete refs, or push to protected refs as a workaround. Each needs its own approval, and each approval authorizes one exact update once.
- Never force-push, delete refs, or push to protected refs as a workaround. Each rule needs its own approval, and the rules compose: a force push to a protected ref needs both `protected_ref_update` and `force_update`; deleting a protected ref needs both `protected_ref_update` and `ref_delete`. Each approval authorizes one exact update once.
- A rejected push is information, not an obstacle: read the reason and act on it. Don't retry the same push hoping for a different answer.

## Secrets
Expand Down
69 changes: 47 additions & 22 deletions src/quorumgit/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
changes outside the pushing claim's scopes and changes to protected paths are
approval-governed just like protected refs, force pushes, and deletions. Who may authorize is owned by the operation's repository:
its approval policy names the threshold, the roles whose votes count, and
whether the requester may vote. An agent that approved an operation may never
be the one that carries it out. Enforcement is fail-closed: any hook error
whether the requester may vote. A consumer's own vote never counts toward
authorizing that consumer; fixed-threshold policies still require a separate
approver. Enforcement is fail-closed: any hook error
rejects the push.
"""

Expand Down Expand Up @@ -189,33 +190,43 @@ def quorum_threshold(eligible_approvers: int) -> int:
return (2 * eligible_approvers) // 3 + 1


def _eligible_approver_count(conn: Connection, approval: dict, policy: dict) -> int:
def _eligible_approver_count(
conn: Connection,
approval: dict,
policy: dict,
exclude_agent_id: int | None = None,
) -> int:
"""Registered agents who could cast a counting vote on this approval."""
rows = conn.execute("SELECT id, name, role FROM agents").fetchall()
return sum(
1
for agent_id, name, role in rows
if _vote_refusal(approval, policy, {"id": agent_id, "name": name, "role": role})
if agent_id != exclude_agent_id
and _vote_refusal(approval, policy, {"id": agent_id, "name": name, "role": role})
is None
)


def required_approvals(conn: Connection, approval: dict, policy: dict) -> int:
def required_approvals(
conn: Connection,
approval: dict,
policy: dict,
exclude_agent_id: int | None = None,
) -> int:
"""Eligible yes votes this approval needs under the current policy.

With quorum mode on, the requirement is 2/3 + 1 of the agents eligible to
vote on this particular approval — excluding the requester (unless the
policy lets it vote) and a takeover's beneficiary — and never less than
the policy's fixed threshold. Counting only eligible agents keeps those
exclusions from making approval impossible while other approvers exist.
The same requirement decides votes and is re-checked at use time, so an
approval's status never disagrees with whether it can be used; who may
use it is a separate rule (an approver can never carry out its own
approval).
the policy's fixed threshold. During consumption, a quorum approver who is
also the consumer is excluded from both the yes-vote count and the quorum
denominator, so its own vote never authorizes its own action.
"""
if not policy["quorum"]:
return policy["threshold"]
eligible = _eligible_approver_count(conn, approval, policy)
eligible = _eligible_approver_count(
conn, approval, policy, exclude_agent_id=exclude_agent_id
)
return max(policy["threshold"], quorum_threshold(eligible))


Expand All @@ -224,24 +235,36 @@ def _authorization_refusal(
) -> str | None:
"""Why an approved instance cannot authorize this consumer now, or None."""
policy = _policy_for(conn, approval)
consumer_voted = False
if consumer is not None:
approved_by_consumer = conn.execute(
consumer_voted = conn.execute(
"SELECT 1 FROM votes WHERE approval_id = ? AND voter_agent_id = ? "
"AND vote = 1",
(approval["id"], consumer["id"]),
).fetchone()
if approved_by_consumer is not None:
).fetchone() is not None
if consumer_voted and not policy["quorum"]:
return (
f"{consumer['name']} approved this operation and may not also "
"carry it out."
"carry it out under a fixed-threshold policy."
)
eligible = _eligible_approvals(conn, approval, policy)
required = required_approvals(conn, approval, policy)

excluded = consumer["id"] if consumer is not None and consumer_voted else None
eligible = _eligible_approvals(
conn, approval, policy, exclude_agent_id=excluded
)
required = required_approvals(
conn, approval, policy, exclude_agent_id=excluded
)
if eligible < required:
basis = " (2/3 + 1 of eligible approvers)" if policy["quorum"] else ""
own_vote = (
f" {consumer['name']}'s own yes vote is excluded from its authorization."
if consumer is not None and consumer_voted
else ""
)
return (
f"Approval {approval['id']} has {eligible} eligible approval(s); the "
f"repository policy now requires {required}{basis}. Eligible "
f"repository policy now requires {required}{basis}.{own_vote} Eligible "
f"agents can vote on approval {approval['id']} to reach it."
)
return None
Expand Down Expand Up @@ -437,8 +460,10 @@ def approved_instance(
) -> dict | None:
"""The approved instance for this exact operation, if it can be used now.

With a consumer, the instance must also be usable by that agent: an agent
that approved an operation cannot be the one that carries it out.
With a consumer, the instance must also be usable by that agent. Under
quorum policy, a consumer's own yes vote is excluded from both the vote
count and quorum denominator; fixed-threshold policy still forbids an
approver from carrying out the operation.
"""
try:
approval = get_approval(conn, operation_hash(operation))
Expand Down Expand Up @@ -606,7 +631,7 @@ def governed_operations(
)
if refname in repo["protected_refs"]:
operations.append({"type": "protected_ref_update", **base})
elif deletion:
if deletion:
operations.append({"type": "ref_delete", **base})
elif forced:
operations.append({"type": "force_update", **base})
Expand Down
96 changes: 96 additions & 0 deletions src/quorumgit/migrations/011_quorum_consumer_separation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
-- Quorum consumers may have voted, but their own vote must never authorize
-- their own action. Fixed-threshold policy keeps the original strict
-- separation rule. Under quorum policy, consumption is allowed only when the
-- other currently eligible yes votes still satisfy the quorum computed over
-- the other currently eligible approvers.
--
-- Add the effective quorum bit to the policy view, expose the current eligible
-- voter set as a view, and tighten the engine trigger to the same rule the
-- Python gate applies.

DROP VIEW approval_effective_policy;

-- quorumgit-statement
CREATE VIEW approval_effective_policy AS
SELECT
ap.id AS approval_id,
COALESCE(op.requester_may_vote, r.requester_may_vote) AS requester_may_vote,
COALESCE(op.approval_threshold, r.approval_threshold) AS approval_threshold,
COALESCE(op.approval_quorum, r.approval_quorum) AS approval_quorum,
op.id AS override_id,
EXISTS (
SELECT 1 FROM operation_approval_roles orr WHERE orr.policy_id = op.id
) AS override_has_roles,
r.id AS repository_id
FROM approvals ap
JOIN repositories r ON r.id = ap.repository_id
LEFT JOIN operation_approval_policies op
ON op.repository_id = r.id
AND op.operation_type = json_extract(ap.operation, '$.type');

-- quorumgit-statement
CREATE VIEW approval_eligible_agents AS
SELECT ap.id AS approval_id, a.id AS agent_id
FROM approvals ap
JOIN approval_effective_policy p ON p.approval_id = ap.id
JOIN agents a
WHERE (p.requester_may_vote = 1 OR ap.requested_by_agent_id IS NOT a.id)
AND NOT (
json_extract(ap.operation, '$.type') = 'lease_takeover'
AND json_extract(ap.operation, '$.to_agent') = a.name
)
AND CASE WHEN p.override_has_roles
THEN EXISTS (
SELECT 1 FROM operation_approval_roles orr
WHERE orr.policy_id = p.override_id AND orr.role = a.role
)
ELSE EXISTS (
SELECT 1 FROM repository_approval_roles rr
WHERE rr.repository_id = p.repository_id AND rr.role = a.role
)
END;

-- quorumgit-statement
DROP TRIGGER approvals_consumer_is_not_approver;

-- quorumgit-statement
CREATE TRIGGER approvals_consumer_is_not_approver
BEFORE UPDATE OF status ON approvals
WHEN NEW.status = 'consumed'
AND EXISTS (
SELECT 1 FROM votes
WHERE approval_id = NEW.id
AND voter_agent_id = NEW.consumed_by_agent_id
AND vote = 1
)
AND NOT (
(SELECT approval_quorum
FROM approval_effective_policy
WHERE approval_id = NEW.id) = 1
AND
(
SELECT count(*)
FROM votes v
JOIN approval_eligible_agents e
ON e.approval_id = v.approval_id
AND e.agent_id = v.voter_agent_id
WHERE v.approval_id = NEW.id
AND v.vote = 1
AND v.voter_agent_id IS NOT NEW.consumed_by_agent_id
) >= max(
(SELECT approval_threshold
FROM approval_effective_policy
WHERE approval_id = NEW.id),
(
2 * (
SELECT count(*)
FROM approval_eligible_agents
WHERE approval_id = NEW.id
AND agent_id IS NOT NEW.consumed_by_agent_id
)
) / 3 + 1
)
)
BEGIN
SELECT RAISE(ABORT, 'an approver cannot consume its own approval');
END;
2 changes: 1 addition & 1 deletion src/quorumgit/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@

# Views the governance triggers read from; a store missing one would pass the
# table and trigger checks yet fail on the next vote.
REQUIRED_VIEWS = ("approval_effective_policy",)
REQUIRED_VIEWS = ("approval_effective_policy", "approval_eligible_agents")


class StoreError(RuntimeError):
Expand Down
Loading
Loading