Stacked on #17 (base review-loop) because approvals arrive through the comment poller that PR adds. Merge #17 first and this retargets to main.
Completes step 6.
approvals.py — PlanBoard opens one issue per project and posts each plan revision as a comment carrying its rev-N tag for copy-paste. The state store keeps only a pointer, so gate state can be re-derived from Gitea after a state-volume loss, which is the Part H rationale: external truth wins.
Gitea is now a second approval source. Revision binding, the typed word approve, and first-approval-wins behave exactly as over transport, sharing one code path. The allowlist is per channel, because a Gitea login is not a Matrix MXID and holding one says nothing about holding the other.
Outcomes are echoed to the other channel, so the two records cannot silently disagree about what was approved.
A Gitea outage degrades the durability of the record, not the ability to approve: planning proceeds, the operator is told, and Matrix remains a complete channel on its own.
The property your branch protection now makes real
Before you protected main, I tested whether the agent could merge its own PR. It could — HTTP 200. Repo ownership only blocked deletion; in Gitea, write access includes merge. So A4's "no self-advancement past a human gate" was holding purely because our code never called the merge endpoint, which is exactly the advisory guarantee the spec says must be structural.
With protection on, the same probe now returns 405 "Does not have enough approvals", and a direct push to main is refused outright. The live test asserts both, so if the protection is ever relaxed, CI goes red rather than the property quietly evaporating. There is also a unit test that the agent's own login cannot approve its own plan, since it is not on the operator allowlist.
I would suggest the same protection on real project repos, and I am happy to draft a short B2 amendment recording it as a deployment requirement — the spec currently specifies the service account and scoped token but never says the default branch must be protected, which this exercise showed is the load-bearing part.
Also fixed: a race in the process-group test. Killing a process group is asynchronous and the grandchild is an orphan by then, so its exit is not synchronised with our return; the test now waits for death instead of assuming it is instantaneous. It failed once in ten runs before, which is the worst kind of test.
11 new unit tests. 184 unit + 10 integration green locally.
**Stacked on #17** (base `review-loop`) because approvals arrive through the comment poller that PR adds. Merge #17 first and this retargets to `main`.
Completes step 6.
- **`approvals.py`** — `PlanBoard` opens one issue per project and posts each plan revision as a comment carrying its `rev-N` tag for copy-paste. The state store keeps **only a pointer**, so gate state can be re-derived from Gitea after a state-volume loss, which is the Part H rationale: external truth wins.
- **Gitea is now a second approval source.** Revision binding, the typed word `approve`, and first-approval-wins behave exactly as over transport, sharing one code path. The allowlist is per channel, because a Gitea login is not a Matrix MXID and holding one says nothing about holding the other.
- **Outcomes are echoed to the other channel**, so the two records cannot silently disagree about what was approved.
- **A Gitea outage degrades the durability of the record, not the ability to approve**: planning proceeds, the operator is told, and Matrix remains a complete channel on its own.
### The property your branch protection now makes real
Before you protected `main`, I tested whether the agent could merge its own PR. **It could** — HTTP 200. Repo ownership only blocked deletion; in Gitea, write access includes merge. So A4's "no self-advancement past a human gate" was holding purely because our code never called the merge endpoint, which is exactly the advisory guarantee the spec says must be structural.
With protection on, the same probe now returns **405 "Does not have enough approvals"**, and a direct push to `main` is refused outright. The live test asserts both, so **if the protection is ever relaxed, CI goes red** rather than the property quietly evaporating. There is also a unit test that the agent's own login cannot approve its own plan, since it is not on the operator allowlist.
I would suggest the same protection on real project repos, and I am happy to draft a short B2 amendment recording it as a deployment requirement — the spec currently specifies the service account and scoped token but never says the default branch must be protected, which this exercise showed is the load-bearing part.
**Also fixed:** a race in the process-group test. Killing a process group is asynchronous and the grandchild is an orphan by then, so its exit is not synchronised with our return; the test now waits for death instead of assuming it is instantaneous. It failed once in ten runs before, which is the worst kind of test.
11 new unit tests. 184 unit + 10 integration green locally.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
- approvals.py: PlanBoard opens one issue per project and posts each
plan revision as a comment carrying its rev tag. The state store
keeps only a pointer, so gate state can be re-derived from Gitea
after a state-volume loss — external truth wins
- Gitea comments are a second approval source: revision binding, the
typed word approve, and first-approval-wins work exactly as over
transport, with a per-channel allowlist because a Gitea login is
not a Matrix MXID
- Outcomes are echoed to the other channel, so the two records cannot
silently disagree about what was approved
- A Gitea outage degrades durability of the record, not the ability
to approve: planning proceeds and Matrix remains a complete channel
- Live test now asserts the server refuses the agent's own merge and
its push to the protected branch. That property was previously
true only because our code did not attempt it; branch protection
makes it structural, and this test fails if the protection is ever
relaxed
- Fixed a race in the process-group test: killing a group is
asynchronous and the grandchild is an orphan, so its exit is not
synchronised with our return; the test now waits for death
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught the executor recording an empty milestone as a success: the
model answered with prose on its first turn, and the loop reads "no
tool call" as "the plan is done". It had therefore finished a
milestone having touched nothing, and the milestone would have gone
on to publish with no changes.
A completion claim is now challenged once when no tool has been
called yet — the same shape as the planner's nudge — and a model that
persists escalates instead of reporting success. After the first tool
call the claim is taken at face value, as before.
The live milestone test failed roughly one run in five on this; four
consecutive runs pass now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Stacked on #17 (base
review-loop) because approvals arrive through the comment poller that PR adds. Merge #17 first and this retargets tomain.Completes step 6.
approvals.py—PlanBoardopens one issue per project and posts each plan revision as a comment carrying itsrev-Ntag for copy-paste. The state store keeps only a pointer, so gate state can be re-derived from Gitea after a state-volume loss, which is the Part H rationale: external truth wins.approve, and first-approval-wins behave exactly as over transport, sharing one code path. The allowlist is per channel, because a Gitea login is not a Matrix MXID and holding one says nothing about holding the other.The property your branch protection now makes real
Before you protected
main, I tested whether the agent could merge its own PR. It could — HTTP 200. Repo ownership only blocked deletion; in Gitea, write access includes merge. So A4's "no self-advancement past a human gate" was holding purely because our code never called the merge endpoint, which is exactly the advisory guarantee the spec says must be structural.With protection on, the same probe now returns 405 "Does not have enough approvals", and a direct push to
mainis refused outright. The live test asserts both, so if the protection is ever relaxed, CI goes red rather than the property quietly evaporating. There is also a unit test that the agent's own login cannot approve its own plan, since it is not on the operator allowlist.I would suggest the same protection on real project repos, and I am happy to draft a short B2 amendment recording it as a deployment requirement — the spec currently specifies the service account and scoped token but never says the default branch must be protected, which this exercise showed is the load-bearing part.
Also fixed: a race in the process-group test. Killing a process group is asynchronous and the grandchild is an orphan by then, so its exit is not synchronised with our return; the test now waits for death instead of assuming it is instantaneous. It failed once in ten runs before, which is the worst kind of test.
11 new unit tests. 184 unit + 10 integration green locally.
🤖 Generated with Claude Code
67aa82371etod508e9a9b7