From 5a1146fe15326108fcadfa0608f8afeabbbb0399 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 22:29:10 +0000 Subject: [PATCH 1/2] Add a 2/3 + 1 quorum mode to repository approval policy 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 --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 Claude-Session: https://claude.ai/code/session_01Mc44WsAZx9hzZdWEihDk5a --- README.md | 7 +- src/quorumgit/cli.py | 21 +- src/quorumgit/gate.py | 61 +++++- .../migrations/005_approval_quorum.sql | 8 + src/quorumgit/registry.py | 11 +- tests/test_approval_authority.py | 6 +- tests/test_approval_quorum.py | 181 ++++++++++++++++++ tests/test_authorization_binding.py | 6 +- 8 files changed, 286 insertions(+), 15 deletions(-) create mode 100644 src/quorumgit/migrations/005_approval_quorum.sql create mode 100644 tests/test_approval_quorum.py diff --git a/README.md b/README.md index b29fe38..5d393e2 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,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'] +migrations applied: ['001_core.sql', '002_approval_identities.sql', '004_approval_authority.sql', '005_approval_quorum.sql'] contract: ok ``` @@ -199,7 +199,8 @@ 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. -- **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 [--threshold ] [--role ]… [--requester-may-vote | --no-requester-may-vote]`. `approve request` no longer accepts `--threshold`. +- **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 [--threshold ] [--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`, 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. ### Roles and administration @@ -222,7 +223,7 @@ The incumbent's checkout is not duplicated. When a claim supersedes an earlier c | `quorumgit repo add [--protected-ref ]…` | Register a repository | | `quorumgit agent add [--role worker\|reviewer\|operator]` | Register an agent identity (non-workers need an operator once one exists) | | `quorumgit agent role ` | Change an agent's role (operator only, after bootstrap) | -| `quorumgit repo policy [--threshold ] [--role ]… [--[no-]requester-may-vote]` | Show or change a repository's approval policy (changes are operator only) | +| `quorumgit repo policy [--threshold ] [--role ]… [--[no-]requester-may-vote] [--[no-]quorum]` | Show or change a repository's approval policy (changes are operator only) | | `quorumgit task add --repo --title [--objective ]` | Create a task | | `quorumgit claim --branch --scope … [--no-worktree] [--takeover] [--override-overlap] [--lease-hours ]` | Claim a task | | `quorumgit renew [--lease-hours ]` | Extend a live, unexpired lease; expired claims must be acquired again | diff --git a/src/quorumgit/cli.py b/src/quorumgit/cli.py index ff3588f..f4b910b 100644 --- a/src/quorumgit/cli.py +++ b/src/quorumgit/cli.py @@ -125,6 +125,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 ) if changing: policy = registry.set_approval_policy( @@ -134,13 +135,27 @@ 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, ) conn.commit() else: repo = registry.get_repository(conn, args.name) policy = registry.approval_policy(conn, repo["id"]) + approvers = sum( + 1 + for agent in registry.list_agents(conn) + if agent["role"] in policy["roles"] + ) print(f"repository: {args.name}") - print(f"approval threshold: {policy['threshold']}") + if policy["quorum"]: + print( + "approval threshold: 2/3 + 1 of the agents eligible to approve " + f"each operation, at least {policy['threshold']} " + f"({approvers} agent(s) hold an approving role; " + f"{gate.quorum_threshold(approvers)} of {approvers} before exclusions)" + ) + else: + print(f"approval threshold: {policy['threshold']}") print(f"approving roles: {', '.join(policy['roles'])}") print(f"requester may vote: {'yes' if policy['requester_may_vote'] else 'no'}") return 0 @@ -511,6 +526,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)"), ), parent=repo) agent = sub.add_parser("agent").add_subparsers(dest="sub", required=True) diff --git a/src/quorumgit/gate.py b/src/quorumgit/gate.py index 26e15d6..0996bc2 100644 --- a/src/quorumgit/gate.py +++ b/src/quorumgit/gate.py @@ -168,6 +168,49 @@ 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, + 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 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, + 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 + approve this particular approval — excluding the requester (unless the + policy lets it vote), a takeover's beneficiary, and, at use time, the + consuming agent — and never less than the policy's fixed threshold. + Counting only eligible agents keeps those exclusions from making approval + impossible while other approvers exist. + """ + if not policy["quorum"]: + return policy["threshold"] + eligible = _eligible_approver_count(conn, approval, policy, exclude_agent_id) + return max(policy["threshold"], quorum_threshold(eligible)) + + def _authorization_refusal( conn: Connection, approval: dict, consumer: dict | None ) -> str | None: @@ -185,10 +228,14 @@ def _authorization_refusal( "carry it out." ) eligible = _eligible_approvals(conn, approval, policy) - if eligible < policy["threshold"]: + required = required_approvals( + conn, approval, policy, consumer["id"] if consumer is not None else None + ) + 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']}." + f"repository policy now requires {required}{basis}." ) return None @@ -226,7 +273,11 @@ def request_approval( if existing is not None: return _approval_dict(existing) - threshold = approval_policy(conn, repo["id"])["threshold"] + threshold = required_approvals( + conn, + {"requested_by_agent_id": requester["id"], "operation": operation}, + approval_policy(conn, repo["id"]), + ) row = conn.execute( """ INSERT INTO approvals ( @@ -320,7 +371,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 + ): new_status = "approved" else: new_status = "pending" diff --git a/src/quorumgit/migrations/005_approval_quorum.sql b/src/quorumgit/migrations/005_approval_quorum.sql new file mode 100644 index 0000000..7e32ccc --- /dev/null +++ b/src/quorumgit/migrations/005_approval_quorum.sql @@ -0,0 +1,8 @@ +-- Optional quorum mode for a repository's 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. + +ALTER TABLE repositories + ADD COLUMN approval_quorum INTEGER NOT NULL DEFAULT 0 + CHECK (approval_quorum IN (0, 1)); diff --git a/src/quorumgit/registry.py b/src/quorumgit/registry.py index 1dd329b..536aedd 100644 --- a/src/quorumgit/registry.py +++ b/src/quorumgit/registry.py @@ -209,8 +209,8 @@ def get_repository(conn: Connection, name: str) -> dict: def approval_policy(conn: Connection, repository_id: int) -> dict: """The repository-owned rule for who may authorize its operations.""" 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: @@ -227,6 +227,7 @@ def approval_policy(conn: Connection, repository_id: int) -> dict: "threshold": row[0], "requester_may_vote": bool(row[1]), "roles": roles, + "quorum": bool(row[2]), } @@ -238,6 +239,7 @@ def set_approval_policy( threshold: int | None = None, roles: list[str] | None = None, requester_may_vote: bool | None = None, + quorum: bool | None = None, ) -> dict: begin_immediate(conn) repo = get_repository(conn, repository) @@ -269,6 +271,11 @@ def set_approval_policy( "UPDATE repositories SET requester_may_vote = ? WHERE id = ?", (1 if requester_may_vote else 0, repo["id"]), ) + if quorum is not None: + conn.execute( + "UPDATE repositories SET approval_quorum = ? WHERE id = ?", + (1 if quorum else 0, repo["id"]), + ) after = approval_policy(conn, repo["id"]) if after != before: audit.record( diff --git a/tests/test_approval_authority.py b/tests/test_approval_authority.py index 063b4ce..d61117c 100644 --- a/tests/test_approval_authority.py +++ b/tests/test_approval_authority.py @@ -359,7 +359,10 @@ def test_upgrade_leaves_no_agent_with_authority(tmp_path): conn.commit() conn.close() - assert store.migrate(local) == ["004_approval_authority.sql"] + assert store.migrate(local) == [ + "004_approval_authority.sql", + "005_approval_quorum.sql", + ] upgraded = store.connect(local) try: assert registry.get_agent(upgraded, "self-approver")["role"] == "worker" @@ -368,6 +371,7 @@ def test_upgrade_leaves_no_agent_with_authority(tmp_path): "threshold": 1, "requester_may_vote": False, "roles": ["operator"], + "quorum": False, } backfilled = gate.get_approval_by_id(upgraded, approval_id) assert backfilled["repository_id"] == repo["id"] diff --git a/tests/test_approval_quorum.py b/tests/test_approval_quorum.py new file mode 100644 index 0000000..722320c --- /dev/null +++ b/tests/test_approval_quorum.py @@ -0,0 +1,181 @@ +"""Quorum mode: 2/3 + 1 of the agents eligible to approve an operation. + +Roles are global, so these tests use an isolated store where the number of +agents holding each role is known exactly. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +from quorumgit import gate, registry, store +from quorumgit.config import Config +from tests.conftest import register_repo + + +@pytest.fixture() +def quorum_store(tmp_path): + local = Config(data_dir=tmp_path / "quorum-data", agent=None) + store.migrate(local) + conn = store.connect(local) + try: + yield local, conn + finally: + conn.close() + store.destroy(local) + + +def _operators(conn, *names: str) -> None: + """Register operators; the first is bootstrapped, the rest added by it.""" + registry.add_agent(conn, names[0]) + registry.set_agent_role(conn, names[0], "operator", actor=None) + for name in names[1:]: + registry.add_agent(conn, name, role="operator", actor=names[0]) + + +def _op(repo: str, n: int = 0) -> dict: + return {"type": "protected_ref_update", "repository": repo, "n": n} + + +@pytest.mark.parametrize( + ("eligible", "required"), + [(0, 1), (1, 1), (2, 2), (3, 3), (4, 3), (5, 4), (6, 5), (7, 5), (9, 7)], +) +def test_quorum_threshold_is_two_thirds_plus_one(eligible, required): + assert gate.quorum_threshold(eligible) == required + + +def test_quorum_counts_eligible_approvers(quorum_store, tmp_path): + _local, conn = quorum_store + _operators(conn, "a", "b", "c", "d") + registry.add_agent(conn, "worker") + repo = register_repo(conn, tmp_path / "q1") + registry.set_approval_policy(conn, repo, actor="a", quorum=True) + + # Four operators may vote on a worker's request: 3 of 4 are needed. + approval = gate.request_approval(conn, _op(repo), "worker") + assert approval["threshold"] == 3 + assert gate.vote(conn, approval["id"], "a", True)["status"] == "pending" + assert gate.vote(conn, approval["id"], "b", True)["status"] == "pending" + assert gate.vote(conn, approval["id"], "c", True)["status"] == "approved" + gate.consume_approval(conn, approval["id"], _op(repo), agent="worker") + + +def test_requester_exclusion_shrinks_the_eligible_set(quorum_store, tmp_path): + """An operator's own request is decided by the others: 2 of 2, not 3 of 3.""" + _local, conn = quorum_store + _operators(conn, "a", "b", "c") + registry.add_agent(conn, "pusher") + repo = register_repo(conn, tmp_path / "q2") + registry.set_approval_policy(conn, repo, actor="a", quorum=True) + + approval = gate.request_approval(conn, _op(repo), "a") + assert approval["threshold"] == 2 + gate.vote(conn, approval["id"], "b", True) + assert gate.vote(conn, approval["id"], "c", True)["status"] == "approved" + gate.consume_approval(conn, approval["id"], _op(repo), agent="pusher") + + +def test_fixed_threshold_is_a_floor(quorum_store, tmp_path): + _local, conn = quorum_store + _operators(conn, "a", "b", "c", "d") + registry.add_agent(conn, "worker") + repo = register_repo(conn, tmp_path / "q3") + registry.set_approval_policy(conn, repo, actor="a", quorum=True, threshold=4) + approval = gate.request_approval(conn, _op(repo), "worker") + assert approval["threshold"] == 4 + + +def test_new_approver_raises_quorum_for_unused_approvals(quorum_store, tmp_path): + _local, conn = quorum_store + _operators(conn, "a", "b") + registry.add_agent(conn, "worker") + repo = register_repo(conn, tmp_path / "q4") + registry.set_approval_policy(conn, repo, actor="a", quorum=True) + + op = _op(repo) + approval = gate.request_approval(conn, op, "worker") + gate.vote(conn, approval["id"], "a", True) + assert gate.vote(conn, approval["id"], "b", True)["status"] == "approved" + + # Two more operators: 2 of 4 no longer meets 2/3 + 1 (3). + registry.add_agent(conn, "c", role="operator", actor="a") + registry.add_agent(conn, "d", role="operator", actor="a") + assert not gate.is_approved(conn, op) + with pytest.raises(gate.GateError, match=r"now requires 3 \(2/3 \+ 1"): + gate.consume_approval(conn, approval["id"], op, agent="worker") + + +def test_quorum_respects_approving_roles(quorum_store, tmp_path): + _local, conn = quorum_store + _operators(conn, "a", "b") + registry.add_agent(conn, "r1", role="reviewer", actor="a") + registry.add_agent(conn, "r2", role="reviewer", actor="a") + registry.add_agent(conn, "worker") + repo = register_repo(conn, tmp_path / "q5") + registry.set_approval_policy( + conn, repo, actor="a", quorum=True, roles=["operator", "reviewer"] + ) + # Operators and reviewers both count: 4 eligible, so 3 are needed. + approval = gate.request_approval(conn, _op(repo), "worker") + assert approval["threshold"] == 3 + + registry.set_approval_policy(conn, repo, actor="a", roles=["reviewer"]) + other = gate.request_approval(conn, _op(repo, 1), "worker") + assert other["threshold"] == 2 + + +def test_quorum_off_keeps_fixed_threshold(quorum_store, tmp_path): + _local, conn = quorum_store + _operators(conn, "a", "b", "c", "d") + registry.add_agent(conn, "worker") + repo = register_repo(conn, tmp_path / "q6") + approval = gate.request_approval(conn, _op(repo), "worker") + assert approval["threshold"] == 1 + assert gate.vote(conn, approval["id"], "a", True)["status"] == "approved" + + +def test_quorum_change_is_audited_and_operator_only(quorum_store, tmp_path): + _local, conn = quorum_store + _operators(conn, "a") + registry.add_agent(conn, "worker") + repo = register_repo(conn, tmp_path / "q7") + conn.commit() + with pytest.raises(registry.RegistryError, match="requires an operator"): + registry.set_approval_policy(conn, repo, actor="worker", quorum=True) + conn.rollback() + registry.set_approval_policy(conn, repo, actor="a", quorum=True) + row = conn.execute( + "SELECT detail FROM audit_events WHERE event_type = " + "'repository.policy_changed' ORDER BY id DESC LIMIT 1" + ).fetchone() + detail = store.json_loads(row[0], {}) + assert detail["before"]["quorum"] is False + assert detail["after"]["quorum"] is True + + +def test_cli_quorum_policy(quorum_store, tmp_path): + local, conn = quorum_store + _operators(conn, "a", "b", "c") + repo = register_repo(conn, tmp_path / "q8") + conn.commit() + + def cli(*args): + env = {**os.environ, "QUORUMGIT_DATA_DIR": str(local.data_dir)} + env.pop("QUORUMGIT_AGENT", None) + return subprocess.run( + [sys.executable, "-m", "quorumgit", *args], + capture_output=True, text=True, env=env, check=False, + ) + + shown = cli("repo", "policy", repo, "--quorum", "--agent", "a") + assert shown.returncode == 0, shown.stderr + assert "2/3 + 1" in shown.stdout + assert "3 agent(s) hold an approving role" in shown.stdout + + off = cli("repo", "policy", repo, "--no-quorum", "--agent", "a") + assert "approval threshold: 1" in off.stdout diff --git a/tests/test_authorization_binding.py b/tests/test_authorization_binding.py index a19faaa..bd12f38 100644 --- a/tests/test_authorization_binding.py +++ b/tests/test_authorization_binding.py @@ -39,6 +39,7 @@ def _apply_001(cfg: Config): UPGRADE_MIGRATIONS = [ "002_approval_identities.sql", "004_approval_authority.sql", + "005_approval_quorum.sql", ] @@ -203,10 +204,7 @@ def test_contract_rejects_001_only_store_until_all_migrations_apply(tmp_path): conn.commit() conn.close() - message = ( - "Missing required migrations: " - "['002_approval_identities.sql', '004_approval_authority.sql']" - ) + message = f"Missing required migrations: {UPGRADE_MIGRATIONS}" with pytest.raises(store.ContractViolation, match=re.escape(message)): store.verify_contract(cfg) with pytest.raises(store.ContractViolation, match=re.escape(message)): From 1d20eae5002b3a194eedc1fb790e410109f62160 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 22:39:52 +0000 Subject: [PATCH 2/2] Use one quorum requirement for approval status and use Excluding the consuming agent from the eligible set at use time could only lower the requirement, but a still-pending approval is refused before that check runs, so an approval's status and whether it could be used disagreed (review feedback). The quorum is now always 2/3 + 1 of the agents eligible to vote on the approval; whether a given agent may carry it out remains the separate rule that an approver never uses its own approval. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Mc44WsAZx9hzZdWEihDk5a --- src/quorumgit/gate.py | 36 +++++++++++++---------------------- tests/test_approval_quorum.py | 28 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/quorumgit/gate.py b/src/quorumgit/gate.py index 92c44b6..46307fb 100644 --- a/src/quorumgit/gate.py +++ b/src/quorumgit/gate.py @@ -178,41 +178,33 @@ def quorum_threshold(eligible_approvers: int) -> int: return (2 * eligible_approvers) // 3 + 1 -def _eligible_approver_count( - conn: Connection, - approval: dict, - policy: dict, - exclude_agent_id: int | None = None, -) -> int: +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 agent_id != exclude_agent_id - and _vote_refusal(approval, policy, {"id": agent_id, "name": name, "role": role}) + if _vote_refusal(approval, policy, {"id": agent_id, "name": name, "role": role}) is None ) -def required_approvals( - conn: Connection, - approval: dict, - policy: dict, - exclude_agent_id: int | None = None, -) -> int: +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 - approve this particular approval — excluding the requester (unless the - policy lets it vote), a takeover's beneficiary, and, at use time, the - consuming agent — and never less than the policy's fixed threshold. - Counting only eligible agents keeps those exclusions from making approval - impossible while other approvers exist. + 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, exclude_agent_id) + eligible = _eligible_approver_count(conn, approval, policy) return max(policy["threshold"], quorum_threshold(eligible)) @@ -233,9 +225,7 @@ def _authorization_refusal( "carry it out." ) eligible = _eligible_approvals(conn, approval, policy) - required = required_approvals( - conn, approval, policy, consumer["id"] if consumer is not None else None - ) + required = required_approvals(conn, approval, policy) if eligible < required: basis = " (2/3 + 1 of eligible approvers)" if policy["quorum"] else "" return ( diff --git a/tests/test_approval_quorum.py b/tests/test_approval_quorum.py index 722320c..3079b0e 100644 --- a/tests/test_approval_quorum.py +++ b/tests/test_approval_quorum.py @@ -80,6 +80,34 @@ def test_requester_exclusion_shrinks_the_eligible_set(quorum_store, tmp_path): gate.consume_approval(conn, approval["id"], _op(repo), agent="pusher") +def test_status_and_usability_use_the_same_requirement(quorum_store, tmp_path): + """A non-voting operator carrying out the operation does not lower quorum. + + Five operators are eligible on a worker's request, so 4 are needed. With 3 + yes votes the approval is pending for everyone, including an operator who + did not vote; it becomes usable exactly when it becomes approved. + """ + _local, conn = quorum_store + _operators(conn, "a", "b", "c", "d", "e") + registry.add_agent(conn, "worker") + repo = register_repo(conn, tmp_path / "q-consumer") + registry.set_approval_policy(conn, repo, actor="a", quorum=True) + + op = _op(repo) + approval = gate.request_approval(conn, op, "worker") + assert approval["threshold"] == 4 + for voter in ("a", "b", "c"): + approval = gate.vote(conn, approval["id"], voter, True) + assert approval["status"] == "pending" + assert gate.approved_instance(conn, op, consumer="d") is None + with pytest.raises(gate.GateError, match="not consumable"): + gate.consume_approval(conn, approval["id"], op, agent="d") + + assert gate.vote(conn, approval["id"], "e", True)["status"] == "approved" + assert gate.approved_instance(conn, op, consumer="d") is not None + gate.consume_approval(conn, approval["id"], op, agent="d") + + def test_fixed_threshold_is_a_floor(quorum_store, tmp_path): _local, conn = quorum_store _operators(conn, "a", "b", "c", "d")