fix(mcp): bind post-merge moot-lease cleanup to the reconciler capability (Closes #745)

gitea_cleanup_post_merge_moot_lease (#515) posts a terminal `phase: released`
lease marker — a durable mutation of the PR lease ledger — but had no entry in
the canonical task-capability map and no role binding. Entry gated on
gitea.read, apply gated only on gitea.pr.comment, so any profile holding the
comment permission (author, reviewer, merger) reached the mutation path, while
the reconciler could not satisfy the operator-required resolve-exact-task ->
mutation sequence because no cleanup task was resolvable at all. The apply path
also called verify_preflight_purity(remote) with no task/org/repo, skipping the
resolved-task, canonical-root and explicit-target checks its siblings perform,
and passed request org/repo straight into _resolve.

Capability map and router:

- Map `cleanup_post_merge_moot_lease` and the tool-name alias
  `gitea_cleanup_post_merge_moot_lease` to gitea.pr.comment + reconciler.
  Both names carry an identical contract; unknown names keep failing closed on
  the map's KeyError.
- Add both to role_session_router RECONCILER_TASKS and TASK_REQUIRED_ROLE so
  the map and the router cannot disagree (the #723 defect-A class).

Tool enforcement (apply path only):

- Require the session to have resolved exactly the cleanup task; resolving a
  different task, including a sibling reconciler task, does not authorize it.
- Require the reconciler role, checked independently of the permission gate.
- Require gitea.pr.comment.
- Validate explicit org/repo against the canonical repository identity derived
  from the session binding, so request parameters can never redirect the
  mutation, and forward worktree_path/task/org/repo to verify_preflight_purity
  for canonical-root, workspace and anti-stomp binding (#733/#739).
- Require matching dry-run evidence proving lease_moot and cleanup_allowed for
  the same PR, lease session, candidate head and lease marker id, with optional
  caller expectations checked against the live lease.

New post_merge_moot_lease_gate module holds the pure authorization logic and an
append-only dry-run ledger: entries are only ever appended, lookup is
newest-wins, and dry_run_history hands out copies. Live, non-moot, superseded,
mismatched, malformed and foreign-repository leases all fail closed;
already-terminal cleanup stays idempotent.

The read-only apply=false assessment deliberately stays reachable under
gitea.read with no role gate, matching gitea_cleanup_stale_review_decision_lock
and gitea_cleanup_obsolete_reviewer_comment_lease, so an operator can diagnose a
stuck lease from any attached namespace. This choice is documented in the map,
the tool docstring and docs/gitea-execution-profiles.md, and is directly tested.

_delete_branch_repository_binding_block is generalized into
_repository_binding_block(required_permission=...) and retained as a thin
delete-path alias so #733/#739 coverage keeps exercising its permission label.

Tests: new tests/test_issue_745_moot_lease_reconciler_gate.py (39 tests, 35
subtests) covers the map/router contract, alias parity, unknown-name rejection,
role and task gates, dry-run/apply sequencing, superseded and malformed leases,
repository binding, ledger append-only behavior, idempotency, and asserts no
production PR/session/marker is referenced. tests/test_post_merge_moot_lease.py
is updated from the permission-only model to reconciler + dry-run evidence, with
a new negative test pinning that a merger can no longer apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-18 12:59:51 -04:00
co-authored by Claude Opus 4.8
parent b05075fd25
commit b00e09a781
7 changed files with 1187 additions and 14 deletions
+38
View File
@@ -317,6 +317,44 @@ Least-privilege constraints:
canonical names such as `gitea.pr.close` (never bare `pr.close` /
`issue.close`, which the production normalizer rejects or drops).
### Post-merge moot-lease cleanup ownership (`gitea.pr.comment`)
Neutralising a reviewer lease left behind on an already-merged/closed PR is
reconciliation work too. `task_capability_map` maps
`cleanup_post_merge_moot_lease` — and its tool-name alias
`gitea_cleanup_post_merge_moot_lease` — to role `reconciler` with permission
`gitea.pr.comment` (#745). Both names carry the **same** contract.
The permission alone is deliberately not sufficient: author, reviewer and
merger profiles all hold `gitea.pr.comment` for ordinary PR discussion, so the
role gate — not the permission gate — is what keeps the terminal lease marker
reconciler-owned.
`gitea_cleanup_post_merge_moot_lease` splits its two modes on purpose:
- **`apply=false` (assessment) requires only `gitea.read`, with no role gate.**
This matches `gitea_cleanup_stale_review_decision_lock` and
`gitea_cleanup_obsolete_reviewer_comment_lease`, whose assessment paths are
likewise read-gated, so an operator can diagnose a stuck lease from whichever
namespace happens to be attached without switching roles. The dry run
performs no mutation and records append-only evidence in-session.
- **`apply=true` (mutation) requires all of the following**, in order: the
session must have resolved exactly `cleanup_post_merge_moot_lease` (resolving
any other task — including a sibling reconciler task — does not authorize
it); the active role must be `reconciler`; the profile must hold
`gitea.pr.comment`; the explicit `org`/`repo` must agree with the canonical
repository identity, which is derived from the session binding and can never
be overridden by request parameters; and matching dry-run evidence must show
`lease_moot`, `cleanup_allowed`, and the same PR, lease session, candidate
head and lease marker id that are live at apply time.
Everything else fails closed: a live lease on an open PR, an already-terminal
(idempotent) lease, a lease superseded between the dry run and the apply, a
malformed lease missing session/head/marker, and any foreign-repository target.
The cleanup only ever appends a terminal `phase: released` marker
(`blocker: post-merge-moot`) — it never edits or deletes another session's
comment, and it never merges or adopts a lease.
Launch a static `gitea-reconciler` MCP namespace with
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
`reconciler_profile.assess_reconciler_profile` (#304). Use the