From 42bb432ff7e0b35c71230b1b53994465fd9e15ec Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:51:43 -0400 Subject: [PATCH 01/10] Compose ref governance and exclude quorum consumer votes --- src/quorumgit/gate.py | 69 +++++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/src/quorumgit/gate.py b/src/quorumgit/gate.py index c2024e1..8b0833f 100644 --- a/src/quorumgit/gate.py +++ b/src/quorumgit/gate.py @@ -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. """ @@ -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)) @@ -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 @@ -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)) @@ -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}) From 33a9e630f538c0cd97e71ef608144f9608da5b4c Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:52:18 -0400 Subject: [PATCH 02/10] Enforce quorum consumer separation in the store --- .../011_quorum_consumer_separation.sql | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/quorumgit/migrations/011_quorum_consumer_separation.sql diff --git a/src/quorumgit/migrations/011_quorum_consumer_separation.sql b/src/quorumgit/migrations/011_quorum_consumer_separation.sql new file mode 100644 index 0000000..bb7ef53 --- /dev/null +++ b/src/quorumgit/migrations/011_quorum_consumer_separation.sql @@ -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; From f714d9a5b314b420f82c67b35f8440ede6547499 Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:52:26 -0400 Subject: [PATCH 03/10] Require eligible-agent policy view --- src/quorumgit/store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quorumgit/store.py b/src/quorumgit/store.py index 3ff5b5a..e7bf075 100644 --- a/src/quorumgit/store.py +++ b/src/quorumgit/store.py @@ -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): From f94b3309ed16ec7decf221288a6b9783c0c4f6e4 Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:52:35 -0400 Subject: [PATCH 04/10] Include quorum consumer migration in upgrade tests --- tests/test_authorization_binding.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_authorization_binding.py b/tests/test_authorization_binding.py index 4f931f9..75a8879 100644 --- a/tests/test_authorization_binding.py +++ b/tests/test_authorization_binding.py @@ -45,6 +45,7 @@ def _apply_001(cfg: Config): "008_operation_policies.sql", "009_approval_quorum.sql", "010_protected_ref_names.sql", + "011_quorum_consumer_separation.sql", ] From 7148cd2f319c68fc1fb57a55d194046bc9233326 Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:52:37 -0400 Subject: [PATCH 05/10] Include quorum consumer migration in upgrade tests --- tests/test_approval_authority.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_approval_authority.py b/tests/test_approval_authority.py index d155806..438cafa 100644 --- a/tests/test_approval_authority.py +++ b/tests/test_approval_authority.py @@ -367,6 +367,7 @@ def test_upgrade_leaves_no_agent_with_authority(tmp_path): "008_operation_policies.sql", "009_approval_quorum.sql", "010_protected_ref_names.sql", + "011_quorum_consumer_separation.sql", ] upgraded = store.connect(local) try: From d48d87c88fd3f2dcb60f3a1e91bec6d5f8afc7d5 Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:53:01 -0400 Subject: [PATCH 06/10] Cover quorum execution without self-authorization --- tests/test_approval_quorum.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_approval_quorum.py b/tests/test_approval_quorum.py index aaa7214..0e0f31c 100644 --- a/tests/test_approval_quorum.py +++ b/tests/test_approval_quorum.py @@ -80,6 +80,29 @@ def test_requester_exclusion_shrinks_the_eligible_set(quorum_store, tmp_path): gate.consume_approval(conn, approval["id"], _op(repo), agent="pusher") +def test_quorum_consumer_vote_does_not_authorize_itself(quorum_store, tmp_path): + """A quorum voter may execute only when the other voters still form quorum.""" + _local, conn = quorum_store + _operators(conn, "a", "b", "c") + repo = register_repo(conn, tmp_path / "q-consumer-voter") + registry.set_approval_policy( + conn, repo, actor="a", quorum=True, requester_may_vote=True + ) + + op = _op(repo) + approval = gate.request_approval(conn, op, "a") + assert approval["threshold"] == 3 + gate.vote(conn, approval["id"], "a", True) + gate.vote(conn, approval["id"], "b", True) + assert gate.vote(conn, approval["id"], "c", True)["status"] == "approved" + + # a's own vote is ignored for authorizing a. The other two operators are + # the complete eligible set once a is excluded, and 2/3 + 1 of 2 is 2. + assert gate.approved_instance(conn, op, consumer="a") is not None + gate.consume_approval(conn, approval["id"], op, agent="a") + assert gate.get_approval_by_id(conn, approval["id"])["status"] == "consumed" + + 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. From 9b8939806a171da4bd8fdb3bdd726f93fabb9d5e Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:53:32 -0400 Subject: [PATCH 07/10] Cover additive protected ref force and delete policies --- tests/test_content_governance.py | 79 +++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/tests/test_content_governance.py b/tests/test_content_governance.py index 269b39a..018f15e 100644 --- a/tests/test_content_governance.py +++ b/tests/test_content_governance.py @@ -359,6 +359,82 @@ def test_new_claimed_branch_is_measured_from_the_default_branch( assert pushed.returncode == 0, pushed.stderr +# -------------------------------------------------------- ref rule composition + + +def test_protected_ref_force_and_delete_keep_specific_policies(conn, tmp_path): + repo_path = tmp_path / "ref-composition" + repo_path.mkdir() + _git(repo_path, "init", "-q", "-b", "main") + base = _edit(repo_path, "README.md", message="base") + + _git(repo_path, "checkout", "-q", "-b", "old") + oldrev = _edit(repo_path, "old.txt", message="old") + _git(repo_path, "checkout", "-q", "-b", "new", base) + newrev = _edit(repo_path, "new.txt", message="new") + + repo_name = f"compose-{uuid.uuid4().hex[:8]}" + registry.add_repository(conn, repo_name, repo_path, protected_refs=["main"]) + ensure_agent(conn, OPERATOR, "operator") + ensure_agent(conn, "requester") + registry.set_approval_policy( + conn, + repo_name, + actor=OPERATOR, + operation_type="force_update", + threshold=2, + ) + registry.set_approval_policy( + conn, + repo_name, + actor=OPERATOR, + operation_type="ref_delete", + threshold=3, + ) + registered = registry.get_repository(conn, repo_name) + git_dir = git_objects.absolute_git_dir(repo_path) + + forced = gate.governed_operations( + conn, + registered, + "refs/heads/main", + oldrev, + newrev, + git_dir, + [], + [], + ) + assert [op["type"] for op in forced] == [ + "protected_ref_update", + "force_update", + ] + protected_approval = gate.request_approval( + conn, forced[0], requested_by="requester" + ) + force_approval = gate.request_approval(conn, forced[1], requested_by="requester") + assert protected_approval["threshold"] == 1 + assert force_approval["threshold"] == 2 + + deleted = gate.governed_operations( + conn, + registered, + "refs/heads/main", + oldrev, + git_objects.zero_oid_like(oldrev), + git_dir, + [], + [], + ) + assert [op["type"] for op in deleted] == [ + "protected_ref_update", + "ref_delete", + ] + delete_approval = gate.request_approval( + conn, deleted[1], requested_by="requester" + ) + assert delete_approval["threshold"] == 3 + + # ----------------------------------------------------------- protected paths @@ -499,7 +575,8 @@ def test_prepare_reports_refusals_without_writing(committed_conn, tmp_path, cfg) deletion = gate.prepare_push(conn, repo_name, "main", clone, None) assert [e["operation"]["type"] for e in deletion["operations"]] == [ - "protected_ref_update" + "protected_ref_update", + "ref_delete", ] assert deletion["paths"] == [] From 8b41ae2fba91476f0ef938bfe4f99f3ffd5daa5d Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:54:03 -0400 Subject: [PATCH 08/10] Document composed ref rules and quorum separation --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9d3885d..3b7f1d9 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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` @@ -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 [--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` (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 @@ -277,6 +277,8 @@ Rules that hold no matter what: Registering a `reviewer` or `operator`, changing a role (`quorumgit agent 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 ` 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. From 384b15cb0f11c5a1a099dee70bf6fd703186d059 Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:54:17 -0400 Subject: [PATCH 09/10] Update agent guide for composed governance rules --- docs/agent-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index fadd5e4..f68fa3b 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -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 @@ -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 --ref ` 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 From c22bb88b4dd7f25fb976b3ed8ac87fb3fd9270e9 Mon Sep 17 00:00:00 2001 From: Deep-Sixed <281237415+Deep-Sixed@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:56:33 -0400 Subject: [PATCH 10/10] Update protected-ref upgrade expectation --- tests/test_protected_refs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_protected_refs.py b/tests/test_protected_refs.py index 9f06c27..2b0267b 100644 --- a/tests/test_protected_refs.py +++ b/tests/test_protected_refs.py @@ -174,7 +174,10 @@ def test_upgrade_expands_short_protected_ref_names(tmp_path): finally: conn.close() - assert store.migrate(local) == ["010_protected_ref_names.sql"] + assert store.migrate(local) == [ + "010_protected_ref_names.sql", + "011_quorum_consumer_separation.sql", + ] upgraded = store.connect(local) try: refs = sorted(