M3.3, the trust dial (L3, D5; closes#65). This finishes Phase 3. A project can now let the agent merge its PRs once an operator has approved them.
Configuration: each project gets trust: {merge: operator | approved}. The default is operator, which is today's behaviour.
How approved works. The harness merges, never the model, during the PR watch, when all of these hold:
an approving review from an account in gitea.operators is on the PR's current commit;
its checks pass. No checks at all counts as passing; a pending check waits, as agreed;
Gitea says it's mergeable.
The item then goes on as it does after any merge: the next milestone, or closing after the final one. The thread gets "Merged PR #N after don's approval." Each reason to hold off (failed checks, conflicts, a refused merge) is said once per commit, not on every watch.
The hard wall: Gitea enforces it
Protection:
approvals come from an operators-only whitelist, so the agent can't approve;
stale approvals are dismissed, so an approval doesn't carry over to a later commit;
the agent is on the merge whitelist, and direct pushes are still refused.
Verified by behaviour at start, for each approved project: a throwaway branch and PR, made through the API (never the workspace, which may hold an item's work), whose merge must be refused. They're then closed and deleted. If the merge isn't refused, the operator merges until a restart, and the agent says why.
Why behaviour and not the settings: a collaborator can't read branch protection (403), and must never be able to set it (D4).
Repos: test repos (make_repo) and the bring-up repo get the protection. The existing bring-up repo picks it up on its next scripts/bringup-repo.sh reset.
Deferred: the operator-side setup script and a label check, #130.
Tests
Unit (test_loop_trust.py):
an approved PR is merged and the item goes on;
the operator merges by default;
neither an approval of an older commit nor a non-operator's approval counts;
pending checks wait, and failed ones are said once;
protection is checked at start; unprotected means the operator merges;
a refused merge is said once.
The hard wall on a real Gitea (test_trust_live.py): refused unapproved, merged once approved, and refused again after a commit lands on an approved PR.
E2E (test_an_approved_pr_is_merged_by_the_agent, @pytest.mark.merge("approved")): the operator only approves, and the agent merges and closes the item.
The property walk: half the seeds use merge: approved, with operator, other-account and stale approvals.
New property merges_only_approved: the harness asks Gitea to merge only what an operator approved as it is.
It catches a mutant that ignores which commit was approved (seeds 0 and 6).
Config and client:trust.merge parsing; approvals, checks, the head commit, API branches.
Results, run locally:
Unit: 719 passed.
Integration: 18 passed, plus 16 opt-in skips.
Full e2e: 9 of 9. The new test needed one fix: an intake tick before driving. It then passed on its own run.
**M3.3, the trust dial** (L3, D5; closes #65). This finishes Phase 3. A project can now let the agent merge its PRs once an operator has approved them.
**Configuration:** each project gets `trust: {merge: operator | approved}`. The default is `operator`, which is today's behaviour.
**How `approved` works.** The harness merges, never the model, during the PR watch, when all of these hold:
- an approving review from an account in `gitea.operators` is on the PR's **current** commit;
- its checks pass. No checks at all counts as passing; a pending check waits, as agreed;
- Gitea says it's mergeable.
The item then goes on as it does after any merge: the next milestone, or closing after the final one. The thread gets *"Merged PR #N after don's approval."* Each reason to hold off (failed checks, conflicts, a refused merge) is said once per commit, not on every watch.
**The hard wall: Gitea enforces it**
- **Protection:**
- approvals come from an **operators-only whitelist**, so the agent can't approve;
- **stale approvals are dismissed**, so an approval doesn't carry over to a later commit;
- the agent is on the merge whitelist, and direct pushes are still refused.
- **Verified by behaviour at start**, for each `approved` project: a throwaway branch and PR, made through the **API** (never the workspace, which may hold an item's work), whose merge must be refused. They're then closed and deleted. If the merge isn't refused, the operator merges until a restart, and the agent says why.
- **Why behaviour and not the settings:** a collaborator can't read branch protection (403), and must never be able to set it (D4).
- **Repos:** test repos (`make_repo`) and the bring-up repo get the protection. The existing bring-up repo picks it up on its next `scripts/bringup-repo.sh reset`.
- **Deferred:** the operator-side setup script and a label check, #130.
**Tests**
- **Unit** (`test_loop_trust.py`):
- an approved PR is merged and the item goes on;
- the operator merges by default;
- neither an approval of an older commit nor a non-operator's approval counts;
- pending checks wait, and failed ones are said once;
- protection is checked at start; unprotected means the operator merges;
- a refused merge is said once.
- **The hard wall on a real Gitea** (`test_trust_live.py`): refused unapproved, merged once approved, and refused again after a commit lands on an approved PR.
- **E2E** (`test_an_approved_pr_is_merged_by_the_agent`, `@pytest.mark.merge("approved")`): the operator only approves, and the agent merges and closes the item.
- **The property walk:** half the seeds use `merge: approved`, with operator, other-account and stale approvals.
- New property `merges_only_approved`: the harness asks Gitea to merge only what an operator approved as it is.
- It catches a mutant that ignores which commit was approved (seeds 0 and 6).
- **Config and client:** `trust.merge` parsing; approvals, checks, the head commit, API branches.
**Results, run locally:**
- **Unit:** 719 passed.
- **Integration:** 18 passed, plus 16 opt-in skips.
- **Full e2e:** 9 of 9. The new test needed one fix: an intake tick before driving. It then passed on its own run.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
A project's trust.merge is operator (the default) or approved (L3, D5,
#65). Set to approved, the harness, never the model, merges a PR in the
PR watch once an approving review from a gitea.operators account is on
its current commit, its checks pass (none at all counts; pending
waits) and it's mergeable; the item then goes on as after any merge.
Each reason to hold off (failed checks, conflicts, a refused merge) is
said once per commit.
Gitea enforces it: protection requires approvals from an operators-only
whitelist and dismisses stale ones. At start, for each approved
project, the agent checks by behaviour that its own unapproved merge is
refused, with a throwaway branch and PR made through the API (never the
workspace); if it isn't refused, the operator merges until a restart.
- gitea: approvals, combined check status, a PR's head commit and
mergeability, API-made branches and their deletion.
- Test and bring-up repositories get the protection.
- Tests: the decision (unit), the hard wall on a real Gitea
(test_trust_live), an e2e test where the operator only approves, and
a walk property, merges_only_approved, checked against a mutant that
ignores which commit was approved.
Closes#65
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
M3.3, the trust dial (L3, D5; closes #65). This finishes Phase 3. A project can now let the agent merge its PRs once an operator has approved them.
Configuration: each project gets
trust: {merge: operator | approved}. The default isoperator, which is today's behaviour.How
approvedworks. The harness merges, never the model, during the PR watch, when all of these hold:gitea.operatorsis on the PR's current commit;The item then goes on as it does after any merge: the next milestone, or closing after the final one. The thread gets "Merged PR #N after don's approval." Each reason to hold off (failed checks, conflicts, a refused merge) is said once per commit, not on every watch.
The hard wall: Gitea enforces it
approvedproject: a throwaway branch and PR, made through the API (never the workspace, which may hold an item's work), whose merge must be refused. They're then closed and deleted. If the merge isn't refused, the operator merges until a restart, and the agent says why.make_repo) and the bring-up repo get the protection. The existing bring-up repo picks it up on its nextscripts/bringup-repo.sh reset.Tests
test_loop_trust.py):test_trust_live.py): refused unapproved, merged once approved, and refused again after a commit lands on an approved PR.test_an_approved_pr_is_merged_by_the_agent,@pytest.mark.merge("approved")): the operator only approves, and the agent merges and closes the item.merge: approved, with operator, other-account and stale approvals.merges_only_approved: the harness asks Gitea to merge only what an operator approved as it is.trust.mergeparsing; approvals, checks, the head commit, API branches.Results, run locally:
🤖 Generated with Claude Code
https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE