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
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Eight concepts, in the order you meet them:
| **Worktree** | An isolated `git worktree` created per claim. Agents never share a mutable checkout; Git itself refuses to check one branch out twice. A claim that supersedes an earlier claim on the same task and branch continues its retained checkout instead of creating a second one. |
| **Handoff** | A structured continuation record (done / remaining / exact commit / blockers) that transfers work to a successor instead of abandoning it. |
| **Approval** | A sign-off by eligible agents, hash-bound to one exact operation (a specific push, takeover, or deletion), consumed on use. |
| **Approval policy** | Owned by each repository: how many votes an approval needs, which roles may cast them, and whether the requester may vote. The agent asking for permission never chooses its own quorum. |
| **Approval policy** | Owned by each repository: how many votes an approval needs, which roles may cast them, and whether the requester may vote — as a default, optionally overridden per operation type (a force push can need more votes than a takeover). The agent asking for permission never chooses its own quorum. |
| **Content governance** | In the hub model, what a push *actually changes* is derived from the Git objects it carries and checked against the pushing claim's scopes and the repository's protected paths. |

Everything an agent does — claim, renew, checkpoint, hand off, release — writes an audit event in the same database transaction. The audit table is append-only, enforced by a trigger.
Expand All @@ -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']
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']
contract: ok
```

Expand Down Expand Up @@ -260,13 +260,24 @@ Rules that hold no matter what:
- **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.
- **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.
- **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.

```bash
quorumgit repo policy myproject --operation force_update --threshold 2 --agent lead
quorumgit repo policy myproject --operation ref_delete --threshold 2 --agent lead
quorumgit repo policy myproject --operation out_of_scope_push --role reviewer --role operator --agent lead
quorumgit repo policy myproject # default plus every override
quorumgit repo policy myproject --operation force_update --inherit --agent lead
```

### Roles and administration

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 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.

> **Upgrading to content governance.** Migration `005_content_governance.sql` restricts every existing repository to `refs/heads/`. If your agents push tags or other refs, allow those namespaces with `quorumgit repo allow-ref` after running `quorumgit init`. Claimed branches now also enforce their scopes at push time, so a claim declared too narrowly will ask for `out_of_scope_push` approvals.

> **Upgrading.** Migration `004_approval_authority.sql` makes every existing agent a `worker` and gives every existing repository the default policy. Approvals granted under the old rules (including self-approvals) stop authorizing anything until an eligible operator votes on a fresh request. After running `quorumgit init`, designate an operator with `quorumgit agent role <name> operator`.
Expand All @@ -286,7 +297,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> [--threshold <n>] [--role <role>]… [--[no-]requester-may-vote]` | Show or change a repository's approval policy (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]` | 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
45 changes: 38 additions & 7 deletions src/quorumgit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,30 +149,55 @@ def cmd_repo_list(args, cfg) -> int:
return 0


def _describe_policy(policy: dict) -> str:
return (
f"threshold {policy['threshold']}; roles "
f"{', '.join(policy['roles']) or 'none'}; requester may vote: "
f"{'yes' if policy['requester_may_vote'] else 'no'}"
)


def cmd_repo_policy(args, cfg) -> int:
with store.session(cfg) as conn:
changing = (
args.threshold is not None
or args.role
or args.requester_may_vote is not None
or args.inherit
)
if changing:
policy = registry.set_approval_policy(
registry.set_approval_policy(
conn,
args.name,
actor=getattr(args, "agent", None) or cfg.agent,
threshold=args.threshold,
roles=args.role or None,
requester_may_vote=args.requester_may_vote,
operation_type=args.operation,
inherit=args.inherit,
)
conn.commit()
else:
repo = registry.get_repository(conn, args.name)
policy = registry.approval_policy(conn, repo["id"])
repo = registry.get_repository(conn, args.name)
default = registry.approval_policy(conn, repo["id"])
overridden = registry.operation_policy_overrides(conn, repo["id"])
effective = {
operation_type: registry.approval_policy(conn, repo["id"], operation_type)
for operation_type in registry.OPERATION_TYPES
}
print(f"repository: {args.name}")
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'}")
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])}")
return 0
print(f"approval threshold: {default['threshold']}")
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])}")
else:
print("per-operation overrides: none")
with store.session(cfg) as conn:
repo = registry.get_repository(conn, args.name)
print(f"protected refs: {', '.join(repo['protected_refs']) or '-'}")
Expand Down Expand Up @@ -698,6 +723,12 @@ 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("--operation", choices=registry.OPERATION_TYPES,
help="show or change the override for one operation "
"type instead of the repository default"),
sp.add_argument("--inherit", action="store_true",
help="with --operation: remove the override so the "
"type inherits the default again"),
), parent=repo)
add("protect-path", cmd_repo_protect_path, lambda sp: (
sp.add_argument("name"),
Expand Down
40 changes: 36 additions & 4 deletions src/quorumgit/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ def _policy_for(conn: Connection, approval: dict) -> dict:
f"Approval {approval['id']} predates repository approval policy "
"and names no registered repository; request it again."
)
return approval_policy(conn, approval["repository_id"])
return approval_policy(
conn, approval["repository_id"], approval["operation"].get("type")
)


def _vote_refusal(approval: dict, policy: dict, voter: dict) -> str | None:
Expand Down Expand Up @@ -202,7 +204,8 @@ def _authorization_refusal(
if eligible < policy["threshold"]:
return (
f"Approval {approval['id']} has {eligible} eligible approval(s); the "
f"repository policy now requires {policy['threshold']}."
f"repository policy now requires {policy['threshold']}. Eligible "
f"agents can vote on approval {approval['id']} to reach it."
)
return None

Expand Down Expand Up @@ -240,7 +243,7 @@ def request_approval(
if existing is not None:
return _approval_dict(existing)

threshold = approval_policy(conn, repo["id"])["threshold"]
threshold = approval_policy(conn, repo["id"], operation.get("type"))["threshold"]
row = conn.execute(
"""
INSERT INTO approvals (
Expand Down Expand Up @@ -298,16 +301,45 @@ def vote(conn: Connection, approval_id: int, voter: str, approve: bool) -> dict:
approval state. Denial has precedence and terminal states remain final.
Only agents the repository's policy recognizes may vote, in either
direction; the requester may not vote unless the policy allows it.

An approved instance that no longer meets the effective policy (its
threshold was raised, or approvers lost their eligibility) is reopened
for voting rather than left stuck: request_approval() keeps returning
that same live instance, so it must be able to collect the votes the
stricter policy now needs.
"""
begin_immediate(conn)
voter_row = get_agent(conn, voter)
approval = get_approval_by_id(conn, approval_id)
if approval["status"] != "pending":
if approval["status"] not in ("pending", "approved"):
raise GateError(f"Approval {approval_id} is already {approval['status']}.")
policy = _policy_for(conn, approval)
stale = (
approval["status"] == "approved"
and _eligible_approvals(conn, approval, policy) < policy["threshold"]
)
if approval["status"] == "approved" and not stale:
raise GateError(f"Approval {approval_id} is already approved.")
refusal = _vote_refusal(approval, policy, voter_row)
if refusal is not None:
raise GateError(f"Vote refused: {refusal}")
if stale:
conn.execute(
"UPDATE approvals SET status = 'pending', decided_at = NULL "
"WHERE id = ? AND status = 'approved'",
(approval["id"],),
)
audit.record(
conn,
"approval.reopened",
"approval",
approval["id"],
agent=voter,
detail={
"hash": approval["operation_hash"],
"threshold": policy["threshold"],
},
)
conn.execute(
"""
INSERT INTO votes (approval_id, voter, vote, voter_agent_id)
Expand Down
94 changes: 94 additions & 0 deletions src/quorumgit/migrations/008_operation_policies.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
-- Per-operation approval policy.
--
-- A repository's approval policy is its default. It may override the
-- threshold, the approving roles, and whether the requester may vote for one
-- operation type (a force push needing two votes where a lease takeover needs
-- one, say). A NULL column, or an override with no roles of its own, inherits
-- the repository default, so overrides only ever say what differs.

CREATE TABLE operation_approval_policies (
id INTEGER PRIMARY KEY,
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
operation_type TEXT NOT NULL CHECK (operation_type <> ''),
approval_threshold INTEGER CHECK (approval_threshold IS NULL OR approval_threshold >= 1),
requester_may_vote INTEGER CHECK (requester_may_vote IS NULL OR requester_may_vote IN (0, 1)),
UNIQUE (repository_id, operation_type)
);

-- quorumgit-statement
CREATE TABLE operation_approval_roles (
id INTEGER PRIMARY KEY,
policy_id INTEGER NOT NULL
REFERENCES operation_approval_policies(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('worker', 'reviewer', 'operator')),
UNIQUE (policy_id, role)
);

-- quorumgit-statement
-- The effective policy for every approval: the override for its operation
-- type where one says something, the repository default otherwise.
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,
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
DROP TRIGGER votes_require_eligible_voter;

-- quorumgit-statement
CREATE TRIGGER votes_require_eligible_voter
BEFORE INSERT ON votes
WHEN NOT EXISTS (
SELECT 1
FROM approvals ap
JOIN approval_effective_policy p ON p.approval_id = ap.id
JOIN agents v ON v.id = NEW.voter_agent_id
WHERE ap.id = NEW.approval_id
AND (p.requester_may_vote = 1
OR ap.requested_by_agent_id IS NOT NEW.voter_agent_id)
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 = v.role)
ELSE EXISTS (SELECT 1 FROM repository_approval_roles rr
WHERE rr.repository_id = p.repository_id AND rr.role = v.role)
END
)
BEGIN
SELECT RAISE(ABORT, 'approval voter is not eligible under repository policy');
END;

-- quorumgit-statement
DROP TRIGGER vote_updates_require_eligible_voter;

-- quorumgit-statement
CREATE TRIGGER vote_updates_require_eligible_voter
BEFORE UPDATE OF vote, voter, voter_agent_id ON votes
WHEN NOT EXISTS (
SELECT 1
FROM approvals ap
JOIN approval_effective_policy p ON p.approval_id = ap.id
JOIN agents v ON v.id = NEW.voter_agent_id
WHERE ap.id = NEW.approval_id
AND (p.requester_may_vote = 1
OR ap.requested_by_agent_id IS NOT NEW.voter_agent_id)
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 = v.role)
ELSE EXISTS (SELECT 1 FROM repository_approval_roles rr
WHERE rr.repository_id = p.repository_id AND rr.role = v.role)
END
)
BEGIN
SELECT RAISE(ABORT, 'approval voter is not eligible under repository policy');
END;
Loading
Loading