Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 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']
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']
contract: ok
```

Expand Down Expand Up @@ -261,8 +261,9 @@ Rules that hold no matter what:
- **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.
- **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]`. `approve request` no longer accepts `--threshold`.
- **Policy can differ per operation type.** A repository's policy is its default; it may override the threshold, the approving roles, or whether the requester may vote 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.
- **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.
- **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
quorumgit repo policy myproject --operation force_update --threshold 2 --agent lead
Expand Down Expand Up @@ -297,7 +298,7 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c
| `quorumgit repo add <name> <path> [--protected-ref <ref>]… [--protected-path <glob>]…` | Register a repository |
| `quorumgit agent add <name> [--role worker\|reviewer\|operator]` | Register an agent identity (non-workers need an operator once one exists) |
| `quorumgit agent role <name> <role>` | Change an agent's role (operator only, after bootstrap) |
| `quorumgit repo policy <name> [--operation <type> [--inherit]] [--threshold <n>] [--role <role>]… [--[no-]requester-may-vote]` | Show or change a repository's default approval policy, or its override for one operation type (changes are operator only); also shows protected refs, protected paths, and allowed ref namespaces |
| `quorumgit repo policy <name> [--operation <type> [--inherit]] [--threshold <n>] [--role <role>]… [--[no-]requester-may-vote] [--[no-]quorum]` | Show or change a repository's default approval policy, or its override for one operation type (changes are operator only); also shows protected refs, protected paths, and allowed ref namespaces |
| `quorumgit repo protect-path <name> <glob> [--remove]` | Require an approval for any push changing matching paths (operator only) |
| `quorumgit repo allow-ref <name> <prefix> [--remove]` | Allow pushes to a ref namespace such as `refs/tags/` (operator only) |
| `quorumgit task add --repo <name> --title <t> [--objective <o>]` | Create a task |
Expand Down
40 changes: 35 additions & 5 deletions src/quorumgit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,23 @@ def cmd_repo_list(args, cfg) -> int:
return 0


def _describe_policy(policy: dict) -> str:
def _approver_count(agents: list[dict], policy: dict) -> int:
return sum(1 for agent in agents if agent["role"] in policy["roles"])


def _describe_threshold(policy: dict, approvers: int) -> str:
if not policy["quorum"]:
return str(policy["threshold"])
return (
"2/3 + 1 of the agents eligible to approve each operation, at least "
f"{policy['threshold']} ({approvers} agent(s) hold an approving role; "
f"{gate.quorum_threshold(approvers)} of {approvers} before exclusions)"
)


def _describe_policy(policy: dict, approvers: int) -> str:
return (
f"threshold {policy['threshold']}; roles "
f"threshold {_describe_threshold(policy, approvers)}; roles "
f"{', '.join(policy['roles']) or 'none'}; requester may vote: "
f"{'yes' if policy['requester_may_vote'] else 'no'}"
)
Expand All @@ -163,6 +177,7 @@ def cmd_repo_policy(args, cfg) -> int:
args.threshold is not None
or args.role
or args.requester_may_vote is not None
or args.quorum is not None
or args.inherit
)
if changing:
Expand All @@ -173,6 +188,7 @@ def cmd_repo_policy(args, cfg) -> int:
threshold=args.threshold,
roles=args.role or None,
requester_may_vote=args.requester_may_vote,
quorum=args.quorum,
operation_type=args.operation,
inherit=args.inherit,
)
Expand All @@ -184,18 +200,28 @@ def cmd_repo_policy(args, cfg) -> int:
operation_type: registry.approval_policy(conn, repo["id"], operation_type)
for operation_type in registry.OPERATION_TYPES
}
agents = registry.list_agents(conn)
print(f"repository: {args.name}")
if args.operation is not None:
state = "override" if args.operation in overridden else "inherits default"
print(f"{args.operation} ({state}): {_describe_policy(effective[args.operation])}")
policy = effective[args.operation]
print(
f"{args.operation} ({state}): "
f"{_describe_policy(policy, _approver_count(agents, policy))}"
)
return 0
print(f"approval threshold: {default['threshold']}")
approvers = _approver_count(agents, default)
print(f"approval threshold: {_describe_threshold(default, approvers)}")
print(f"approving roles: {', '.join(default['roles'])}")
print(f"requester may vote: {'yes' if default['requester_may_vote'] else 'no'}")
if overridden:
print("per-operation overrides:")
for operation_type in overridden:
print(f" {operation_type}: {_describe_policy(effective[operation_type])}")
policy = effective[operation_type]
print(
f" {operation_type}: "
f"{_describe_policy(policy, _approver_count(agents, policy))}"
)
else:
print("per-operation overrides: none")
with store.session(cfg) as conn:
Expand Down Expand Up @@ -723,6 +749,10 @@ def add(name, fn, configure=None, parent=None):
"the current set)"),
sp.add_argument("--requester-may-vote", dest="requester_may_vote",
action=argparse.BooleanOptionalAction, default=None),
sp.add_argument("--quorum", action=argparse.BooleanOptionalAction,
default=None,
help="require 2/3 + 1 of the eligible approvers "
"(never fewer than --threshold)"),
sp.add_argument("--operation", choices=registry.OPERATION_TYPES,
help="show or change the override for one operation "
"type instead of the repository default"),
Expand Down
54 changes: 49 additions & 5 deletions src/quorumgit/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,41 @@ def _eligible_approvals(
)


def quorum_threshold(eligible_approvers: int) -> int:
"""Integer-stable 2/3 + 1 quorum over the eligible approvers."""
return (2 * eligible_approvers) // 3 + 1


def _eligible_approver_count(conn: Connection, approval: dict, policy: dict) -> 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})
is None
)


def required_approvals(conn: Connection, approval: dict, policy: dict) -> 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).
"""
if not policy["quorum"]:
return policy["threshold"]
eligible = _eligible_approver_count(conn, approval, policy)
return max(policy["threshold"], quorum_threshold(eligible))


def _authorization_refusal(
conn: Connection, approval: dict, consumer: dict | None
) -> str | None:
Expand All @@ -201,10 +236,12 @@ def _authorization_refusal(
"carry it out."
)
eligible = _eligible_approvals(conn, approval, policy)
if eligible < policy["threshold"]:
required = required_approvals(conn, approval, policy)
if eligible < required:
basis = " (2/3 + 1 of eligible approvers)" if policy["quorum"] else ""
return (
f"Approval {approval['id']} has {eligible} eligible approval(s); the "
f"repository policy now requires {policy['threshold']}. Eligible "
f"repository policy now requires {required}{basis}. Eligible "
f"agents can vote on approval {approval['id']} to reach it."
)
return None
Expand Down Expand Up @@ -243,7 +280,11 @@ def request_approval(
if existing is not None:
return _approval_dict(existing)

threshold = approval_policy(conn, repo["id"], operation.get("type"))["threshold"]
threshold = required_approvals(
conn,
{"requested_by_agent_id": requester["id"], "operation": operation},
approval_policy(conn, repo["id"], operation.get("type")),
)
row = conn.execute(
"""
INSERT INTO approvals (
Expand Down Expand Up @@ -316,7 +357,8 @@ def vote(conn: Connection, approval_id: int, voter: str, approve: bool) -> dict:
policy = _policy_for(conn, approval)
stale = (
approval["status"] == "approved"
and _eligible_approvals(conn, approval, policy) < policy["threshold"]
and _eligible_approvals(conn, approval, policy)
< required_approvals(conn, approval, policy)
)
if approval["status"] == "approved" and not stale:
raise GateError(f"Approval {approval_id} is already approved.")
Expand Down Expand Up @@ -366,7 +408,9 @@ def vote(conn: Connection, approval_id: int, voter: str, approve: bool) -> dict:
assert denials is not None
if denials[0] > 0:
new_status = "denied"
elif _eligible_approvals(conn, approval, policy) >= policy["threshold"]:
elif _eligible_approvals(conn, approval, policy) >= required_approvals(
conn, approval, policy
):
Comment thread
Deep-Sixed marked this conversation as resolved.
Resolved
new_status = "approved"
else:
new_status = "pending"
Expand Down
16 changes: 16 additions & 0 deletions src/quorumgit/migrations/009_approval_quorum.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Optional quorum mode for approval policy. When on, the number of eligible
-- yes votes required is 2/3 + 1 of the agents eligible to approve that
-- particular approval (never less than approval_threshold), instead of the
-- fixed approval_threshold alone.
--
-- The repository default is on or off; a per-operation override may turn it
-- on or off for one operation type, and NULL there inherits the default.

ALTER TABLE repositories
ADD COLUMN approval_quorum INTEGER NOT NULL DEFAULT 0
CHECK (approval_quorum IN (0, 1));

-- quorumgit-statement
ALTER TABLE operation_approval_policies
ADD COLUMN approval_quorum INTEGER
CHECK (approval_quorum IS NULL OR approval_quorum IN (0, 1));
44 changes: 35 additions & 9 deletions src/quorumgit/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,8 @@ def _validate_operation_type(operation_type: str) -> str:

def _default_policy(conn: Connection, repository_id: int) -> dict:
row = conn.execute(
"SELECT approval_threshold, requester_may_vote FROM repositories "
"WHERE id = ?",
"SELECT approval_threshold, requester_may_vote, approval_quorum "
"FROM repositories WHERE id = ?",
(repository_id,),
).fetchone()
if row is None:
Expand All @@ -305,15 +305,16 @@ def _default_policy(conn: Connection, repository_id: int) -> dict:
"threshold": row[0],
"requester_may_vote": bool(row[1]),
"roles": roles,
"quorum": bool(row[2]),
}


def operation_policy_overrides(conn: Connection, repository_id: int) -> dict:
"""Per-operation overrides as stored: None or [] means "inherit"."""
overrides: dict[str, dict] = {}
for policy_id, operation_type, threshold, requester_may_vote in conn.execute(
"SELECT id, operation_type, approval_threshold, requester_may_vote "
"FROM operation_approval_policies WHERE repository_id = ? "
for policy_id, operation_type, threshold, requester_may_vote, quorum in conn.execute(
"SELECT id, operation_type, approval_threshold, requester_may_vote, "
"approval_quorum FROM operation_approval_policies WHERE repository_id = ? "
"ORDER BY operation_type",
(repository_id,),
).fetchall():
Expand All @@ -322,6 +323,7 @@ def operation_policy_overrides(conn: Connection, repository_id: int) -> dict:
"requester_may_vote": (
None if requester_may_vote is None else bool(requester_may_vote)
),
"quorum": None if quorum is None else bool(quorum),
"roles": [
r[0]
for r in conn.execute(
Expand Down Expand Up @@ -356,6 +358,9 @@ def approval_policy(
else override["requester_may_vote"]
),
"roles": override["roles"] or policy["roles"],
"quorum": (
policy["quorum"] if override["quorum"] is None else override["quorum"]
),
}


Expand All @@ -374,6 +379,7 @@ def set_approval_policy(
threshold: int | None = None,
roles: list[str] | None = None,
requester_may_vote: bool | None = None,
quorum: bool | None = None,
operation_type: str | None = None,
inherit: bool = False,
) -> dict:
Expand All @@ -394,7 +400,9 @@ def set_approval_policy(
if inherit:
raise RegistryError("--inherit applies only to an --operation override.")
before = _default_policy(conn, repo["id"])
_set_default_policy(conn, repo["id"], threshold, wanted, requester_may_vote)
_set_default_policy(
conn, repo["id"], threshold, wanted, requester_may_vote, quorum
)
after = _default_policy(conn, repo["id"])
detail: dict = {"before": before, "after": after}
else:
Expand All @@ -403,7 +411,7 @@ def set_approval_policy(
before = overrides.get(operation_type)
_set_operation_override(
conn, repo["id"], operation_type, threshold, wanted,
requester_may_vote, inherit,
requester_may_vote, quorum, inherit,
)
after = operation_policy_overrides(conn, repo["id"]).get(operation_type)
detail = {"operation_type": operation_type, "before": before, "after": after}
Expand All @@ -425,6 +433,7 @@ def _set_default_policy(
threshold: int | None,
roles: list[str] | None,
requester_may_vote: bool | None,
quorum: bool | None,
) -> None:
if threshold is not None:
conn.execute(
Expand All @@ -447,6 +456,11 @@ def _set_default_policy(
"UPDATE repositories SET requester_may_vote = ? WHERE id = ?",
(1 if requester_may_vote else 0, repository_id),
)
if quorum is not None:
conn.execute(
"UPDATE repositories SET approval_quorum = ? WHERE id = ?",
(1 if quorum else 0, repository_id),
)


def _set_operation_override(
Expand All @@ -456,13 +470,19 @@ def _set_operation_override(
threshold: int | None,
roles: list[str] | None,
requester_may_vote: bool | None,
quorum: bool | None,
inherit: bool,
) -> None:
if inherit:
if threshold is not None or roles is not None or requester_may_vote is not None:
if (
threshold is not None
or roles is not None
or requester_may_vote is not None
or quorum is not None
):
raise RegistryError(
"--inherit removes the override; it cannot be combined with "
"--threshold, --role, or --requester-may-vote."
"--threshold, --role, --requester-may-vote, or --quorum."
)
conn.execute(
"DELETE FROM operation_approval_policies "
Expand Down Expand Up @@ -494,6 +514,12 @@ def _set_operation_override(
"WHERE id = ?",
(1 if requester_may_vote else 0, policy_id),
)
if quorum is not None:
conn.execute(
"UPDATE operation_approval_policies SET approval_quorum = ? "
"WHERE id = ?",
(1 if quorum else 0, policy_id),
)
if roles is not None:
conn.execute(
"DELETE FROM operation_approval_roles WHERE policy_id = ?", (policy_id,)
Expand Down
Loading
Loading