The stable-control runtime ADR (docs/architecture/mcp-stable-control-runtime-policy-adr.md)
established the policy but had no runtime enforcement: a daemon relaunched from a
feature worktree still holds production credentials and will happily mutate real
issues. This adds the enforcement layer (acceptance criteria 6-11).
stable_control_runtime.py:
- classify_runtime_mode(): stable-control | dev-test | unknown, inferred from the
process root and checkout branch, with an explicit GITEA_MCP_RUNTIME_MODE
declaration for packaged layouts that have no git checkout.
- build_runtime_report(): runtime mode, git SHA, branch, checkout path, process
root, active workspace, repo binding, profile, identity, dirty files,
alignment, and real_mutations_allowed.
- assess_runtime_mutation_gate(): fails closed on dev-test targeting production,
unknown runtime, dirty stable checkout, dev-worktree launch, and unsafe
process-root/workspace alignment.
- Post-transport-flap re-proving tracked per namespace, so proving the author
namespace never implies reviewer, merger, or reconciler (#584).
- assess_promotion_record(): promotion must record previous and promoted SHAs
plus health, identity, profile, workspace, capability, and rollback proof.
Server wiring:
- _profile_operation_gate() consults the runtime gate alongside the #420 parity
gate. gitea.read is never blocked, so an operator can still diagnose a sick
runtime.
- The gate reads a startup snapshot rather than shelling out per mutation, for
the same reason parity uses a startup baseline: the runtime a process serves
from is fixed when it loads its code. Enforcement is decided from how the
process was loaded, so per-test production simulation cannot switch it on.
- gitea_get_runtime_context() reports the live runtime under
stable_control_runtime and points at the promotion runbook when blocked.
Docs and tooling:
- docs/stable-runtime-promotion-runbook.md: operator promotion procedure,
required record fields, per-namespace re-proving, rollback.
- scripts/promote-stable-runtime: read-only helper that emits and validates a
promotion record; it never restarts anything.
- Five canonical [THREAD STATE LEDGER] examples: runtime healthy, transport flap
recovered, namespace not yet re-proven, promotion completed, rollback required.
- ADR section 5 follow-ups marked landed; runbooks cross-link the new runbook.
Validation: 47 new tests in tests/test_stable_control_runtime.py. Full suite
3827 passed / 6 skipped / 2 failed; both failures are pre-existing on master
059ee77 (verified in a clean baseline worktree: identical 2 failures,
3780 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Remediates review 479/481 findings F1 and F2 on PR #767.
F1: issue #607 AC4 requires that an existing linked Gitea issue receives a
recurrence comment when Sentry events continue. That path did not exist.
This adds:
- incident_bridge.incident_recurred() — true only when event_count or
last_seen genuinely advanced, compared against the pre-upsert link row, so
an unchanged rescan stays silent and the exactly-once property holds.
- incident_bridge.build_recurrence_comment_body() — reuses the same
redact_text path as build_gitea_issue_body, so redaction is not
re-implemented.
- A comment_issue_fn hook on reconcile_incident, invoked only on
OUTCOME_UPDATED with genuinely new events. The durable incident_links row
is written first, so a comment failure degrades to "link updated, comment
withheld" without corrupting the mapping or creating a duplicate issue.
- _incident_recurrence_comment_fn() in gitea_mcp_server.py, routing through
the sanctioned gitea_create_issue_comment path named in issue #607. It
returns None on dry runs and when the profile lacks gitea.issue.comment, so
the AC8 dry-run default and disabled-mode safety hold.
- comment_issue_fn threading through sentry_incident_bridge.watchdog(), with
the per-issue recurrence_comment outcome recorded in the scan result.
F2: observation_from_issue never populates a fingerprint, so the "deduped by
Sentry issue id and fingerprint" claim was unsupported. The
gitea_sentry_reconcile_issue and gitea_sentry_watchdog docstrings now state
the real dedupe basis: provider identity (provider, base URL, org, project,
Sentry issue id).
Tests: five new AC4 cases in tests/test_sentry_incident_bridge.py covering a
comment on the second scan, dry-run silence, no-new-events silence, link
durability when the comment fails, and comment redaction. Focused suite
43 passed (was 38).
Refs #607
Permit fail-closed recovery when a clean local head is a strict
descendant of the recorded PR/remote head, with server-side ancestry
proof. Propagate recovery evidence through commit, push, and PR
duplicate gates so an owning PR is not re-blocked as competing work.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Adds the read half of the inbound observability path: pull unresolved issues
and events from the self-hosted Sentry API, normalize them into #612
observations, and reconcile them into durable Gitea issues.
Design: the existing #612 incident_bridge already owns dedupe, linking,
redaction, and issue creation on the #613 incident_links substrate, so this
change adds only what was genuinely missing - a Sentry API read layer,
observation mapping, a policy gate, and a watchdog. No second linking store is
introduced, which is what makes the mapping survive restarts (AC6).
New module sentry_incident_bridge.py:
* list_issues() with Link-header cursor pagination and statsPeriod windowing
* get_issue_events() returning sanitized recent + latest event
* observation_from_issue() mapping onto the #612 observation contract
* should_bridge_issue() policy gate (unresolved + event-count threshold)
* watchdog() scanning and reconciling, dry-run by default
* HTTP access injected as http_fn so the surface is testable without Sentry
New MCP tools: gitea_sentry_list_issues, gitea_sentry_get_issue_events,
gitea_sentry_reconcile_issue, gitea_sentry_link_gitea_issue,
gitea_sentry_watchdog.
Safety:
* SENTRY_AUTH_TOKEN is read from the environment only and never returned,
logged, or stored; config projections cannot carry it by construction
* missing config fails closed as not_configured, and a missing token fails
closed as missing_token before any HTTP call is made
* apply=true requires both MCP_SENTRY_ISSUE_BRIDGE_ENABLED and issue-create
permission; Sentry outages create nothing
* secrets are redacted and absolute local paths reduced to a category token
before any value can reach a Gitea issue body
* raw Sentry incidents are never assignable control-plane work items
Tests: 38 new cases covering create, update/recurrence, dedupe, resolved-issue
non-reopen, redaction, pagination, missing token, unavailable server,
self-hosted base URL, restart persistence, policy gates, and the five tool
wrappers.
Full suite: 3733 passed, 6 skipped. The 2 remaining failures
(test_issue_702_review_findings_f1_f6, test_reconciler_supersession_close) were
verified to fail identically on unmodified master at fcf6981 and are unrelated
to this change.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_011w1WGVV3duWEf45SJRJ1DL
Read-only MCP tool gitea_workflow_dashboard plus mcp-menu entry so operators
and LLMs can see PR/issue queues, leases, terminal locks, blockers, and exact
next-safe prompts without reconstructing state from comments. Never assigns
work or presents blocked/terminal-locked items as safe.
The author allocator could select an ineligible issue for two independent
reasons, both fixed here.
Defect 1 — candidate truncation before ranking. The loader fetched the
complete open inventory via api_get_all, then sliced it to `limit` items
before constructing any WorkCandidate. Because every status:ready issue
ties at priority 20 and the tie breaks on lowest number, the slice — not
the ranking — decided the winner, so the same query returned different
answers at different `limit` values. Candidate construction now consumes
the full listing; `limit` bounds the reported skip list only, and any
such truncation is reported explicitly rather than silently.
Defect 2 — dependency state inferred from body substrings. Only
"blocked on #" and "downstream of #" were recognized, so the repository's
canonical `Depends: #N, #N` field never set dependency_unmet and
dependency-blocked issues were emitted as eligible. The new
allocator_dependencies module parses the canonical declaration into
structured references and resolves each against live issue state: open
means unmet, closed means met, and unavailable evidence fails closed. The
complete open listing proves openness without extra calls; anything
absent from it is confirmed by a cached targeted lookup instead of being
assumed closed. The legacy "blocked on #" marker still parses.
A failed listing now marks the inventory incomplete and the tool fails
closed instead of ranking a partial set.
Selection already advanced past a skipped candidate; with dependencies
resolved correctly that fall-through now actually engages, and is covered
by a regression.
Tests: 35 new cases across parser, resolver, loader, and an MCP-level
gitea_allocate_next_work regression, including limit-invariant selection
over a 73-candidate inventory and a guard proving pre-ranking truncation
would change the winner. No issue number is special-cased.
Remediates review #473 (REQUEST_CHANGES) on PR #759 / issue #757 AC3/AC4.
The bootstrap compared SHAs only inside:
if remote_tip and local_tip and remote_tip != local_tip:
so agreement was assumed whenever either tip was unknown. A missing local
HEAD, an unresolvable live master, or a resolver exception silently granted
the control-checkout exemption instead of blocking it. An unknown tip is
missing evidence, not proof of equivalence.
Changes:
* assess_create_issue_bootstrap now blocks unless BOTH tips are known and
equal, with distinct reasons for missing local HEAD, unknown live master,
and resolver failure. Adds a remote_master_sha_error parameter so a failed
resolution is reported as missing evidence rather than absence of a
constraint.
* Both normalized SHAs and a derived base_tips_verified flag are recorded on
the assessment. normalize_sha() treats only case and surrounding whitespace
as equivalent spellings of a commit.
* bootstrap_permits_control_checkout re-derives the comparison from the
recorded tips instead of trusting base_tips_verified, so a hand-built or
truncated assessment cannot assert agreement it never proved.
* _create_issue_bootstrap_assessment captures the resolver exception and
forwards it, replacing the silent except -> None.
One shared assessment still serves both the #274 and #604 guards, and
_BOOTSTRAP_UNSET is preserved. No MCP tool signature gains a bootstrap
argument (AC6). No issue or PR number is special-cased (AC10).
Tests: 16 new assertions across two classes covering missing local, missing
remote, both missing, empty/whitespace tips, mismatch, resolver exception at
the assessment site, and resolver exception through the real
verify_preflight_purity path; plus predicate rejection of stripped tips, a
forged base_tips_verified flag, and a missing flag. All 16 fail against the
sources at adc61255 and pass after this change. The valid non-create_issue
lock_issue test is retained unchanged.
Validation: focused #757 suite 51 passed (31 subtests); affected guard and
preflight suites 217 passed (67 subtests); full suite 3637 passed, 2 failed,
6 skipped, 431 subtests. Both failures (test_issue_702 F1 worktree recovery;
test_reconciler_supersession_close org/repo forwarding) are the documented
pre-existing baseline failures on bde5c5fb and are not caused by this change.
Refs #757
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_015KRvvrFtaM5FEvQ6LvDJMH
The sanctioned create_issue bootstrap from #749/#750 was unreachable in
production. Two guards assessed the same workspace for the same task and
reached opposite conclusions: the #274 branches-only guard consulted the
bootstrap and permitted a clean canonical control checkout, then the #604
anti-stomp preflight -- which never consulted it -- rejected that same
checkout as wrong_worktree.
The defect was wiring, not policy: the bootstrap decision was computed in
one guard and discarded, while the other re-derived a conflicting answer
from a lower-level assessor with no notion of the bootstrap phase.
Fix: one computation site, one interpretation site.
* create_issue_bootstrap.bootstrap_permits_control_checkout() is the single
predicate both guards use to interpret an assessment. It is fail-closed by
construction: missing, malformed, refused, incomplete, or contradictory
evidence returns False and leaves the ordinary block in force. It also
verifies the assessment describes the exact workspace and canonical root
being guarded, so a stale or foreign assessment cannot be reused.
* _create_issue_bootstrap_assessment() computes the assessment once per
preflight from inspected repository state. verify_preflight_purity threads
that single result into both guards.
* The #604 assessor accepts the assessment and waives ONLY the wrong-worktree
verdict. Root checkout, repo, role, stale runtime, lease, head-SHA,
workflow-hash, and contamination checks are evaluated independently and
still apply.
Evidence is server-derived only and travels an internal path: no MCP tool
signature gains a bootstrap argument, and no caller-controlled boolean can
manufacture eligibility. Behavior is unchanged for callers that supply no
evidence, and for every non-create_issue author mutation.
No issue or PR number is special-cased in production behavior.
Tests: new tests/test_issue_757_bootstrap_guard_agreement.py (38 tests, 26
subtests) covering the shared predicate, the narrow waiver, guard agreement
across the full workspace-state matrix, non-forgeable eligibility, and an
end-to-end native gitea_create_issue run with the #604 gate LIVE. All 38
fail against unfixed sources; the e2e reproduces the production error text
verbatim ("Anti-stomp preflight (#604) blocked mutation [wrong_worktree]").
tests/test_reconciler_close_workspace_guard.py: one case asserted that
create_issue stays blocked on the control checkout, which only held because
the bootstrap-blind #604 guard was overriding #750 -- it encoded the defect.
Re-pointed to lock_issue, which is issue-backed and legitimately still
requires a branches/ worktree. Its teardown now restores the module-level
preflight task/role so test order cannot leak resolved state.
Full suite: 3624 passed, 2 failed, 6 skipped (426 subtests).
Baseline at bde5c5fb on a clean detached worktree: 3586 passed, 2 failed,
6 skipped (400 subtests). The same 2 failures reproduce identically on
pristine master and are unrelated to this change
(test_issue_702_review_findings_f1_f6 F1 worktree recovery;
test_reconciler_supersession_close org/repo forwarding).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
#753 added the dead-session author-lock recovery assessor, but no sanctioned
recovery could ever reach the lock write. A dead-session lock is by construction
a lock for work that already has an open PR, and the #400 duplicate-work gate
blocked unconditionally on any linked open PR. gitea_lock_issue computed
recovery_sanctioned, then discarded it one gate later:
open PR #750 already covers issue #749 (fail closed)
Because the recovered lock was never persisted, it stayed non-live, so
_prove_author_ownership_for_pr found no live author lock and
gitea_update_pr_branch_by_merge failed closed too. Both blockers had a single
cause, so only the first one is fixed here.
issue_lock_recovery.owning_pr_recovery_evidence distils a granted assessment
into the minimum evidence the duplicate gate needs: issue, branch, owning PR
number, and head. It returns None unless the outcome is RECOVERY_SANCTIONED and
the assessment's own evidence agrees that local head == remote head == PR head,
so a partial or hand-built assessment cannot authorize anything.
The duplicate gate takes that evidence and re-checks every element against the
live PR list it was handed: exactly one linked open PR, matching number, head
branch, head SHA, and locked branch. Only that exact self-owned PR is exempt.
A different PR, several linked PRs, a different branch or head, or evidence for
another issue all keep failing closed, with a diagnostic naming which element
disagreed. The competing-branch, claim-lease and commit/push/create_pr arms are
untouched, and with no evidence the gate behaves exactly as before.
Ownership proof needed no change: once the recovered lock persists under the
live session pid, _prove_author_ownership_for_pr matches it as before.
Out of scope: the PHASE_COMMIT/PHASE_PUSH/PHASE_CREATE_PR rechecks still block
on a linked open PR for every author, recovered or not. That is pre-existing
#400 behavior, unrelated to lock recovery, and #755 does not cover it.
Tests
- tests/test_issue_755_owning_pr_recovery.py (new, 31 tests) drives the real
mcp_server.gitea_lock_issue handler, not only the pure assessor: sanctioned
recovery relocks, persists a live lease with truthful dead_session_recovery
provenance, and satisfies _prove_author_ownership_for_pr; competing PR,
multiple linked PRs, mismatched head/branch/worktree, dirty worktree, live
prior pid, and a fresh claim with no prior lock all stay blocked.
- Neutralizing owning_pr_recovery_evidence regresses all three success tests to
the exact production error above, so the coverage is load-bearing.
- Focused suites (755, 753, duplicate gates, lock registration, provenance) —
102 passed.
- Lock/ownership/worktree/handoff suites — 172 passed, 16 subtests.
- Full suite tests/ — 3529 passed, 6 skipped, 2 failed, 365 subtests.
- The same 2 failures reproduce on a pristine detached worktree at
08f67007c5 (3498 passed, 6 skipped, 2 failed):
test_issue_702_review_findings_f1_f6 F1 recovery-before-probe and
test_reconciler_supersession_close org/repo forwarding. Pre-existing.
- git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017BNsk3KUuFchaZyPJsCxjk
A durable author issue lock records the PID of the MCP session that took it.
When that process exits, assess_lock_freshness marks the lock stale (live=False)
even while its lease is still within TTL, so every ownership check that needs a
live lock fails closed -- including gitea_update_pr_branch_by_merge.
Re-taking the lock was unreachable for real work. assess_issue_lock_worktree
requires the worktree to be base-equivalent to master/main/dev, and a branch
that already carries commits is ahead of its base by construction. The existing
assess_expired_lock_reclaim affordance does not apply either: it is only
consulted once the lease has expired, so a dead PID under an unexpired lease
never reaches it. assess_own_branch_adoption already speaks of "lock recovery",
but it runs after the base-equivalence gate and so was never reached.
This adds issue_lock_recovery, a pure assessor that grants a narrow waiver only
when every element of durable ownership still matches exactly and the recorded
process is demonstrably dead: same remote/org/repo/issue, same branch (and the
worktree actually on it), same registered worktree, clean worktree, local head
== remote head == open PR head, same claimant identity/profile, no competing
live lock or lease, and no ambiguous branch claims. A malformed or incomplete
lock record can never prove ownership.
The waiver suppresses base-equivalence and nothing else. Cleanliness and every
other precondition still apply, and brand-new issue claims keep the full
requirement. A refused assessment never raises: it withholds the waiver and
lets the pre-existing guard fail closed exactly as before, so recovery can only
ever add permission, never remove a guard. Refusal reasons are appended to the
block message so a caller sees the exact missing evidence.
A completed recovery is recorded on the lock as dead_session_recovery with the
prior and replacement session PIDs, heads, and claimant, so the takeover is
auditable and never looks like an original claim. Rebinding sets the live
session PID, so the recovered lock satisfies verify_lock_for_mutation and the
downstream PR update paths.
Validation:
* new tests/test_issue_753_dead_pid_lock_recovery.py -- 33 passed
* issue-lock, adoption, store, provenance, registration, duplicate-gate,
worktree, create-issue-guard suites -- 120 passed
* MCP server, commit payloads, handoff ledger, PR ownership, branch cleanup
suites -- 286 passed
* full suite -- 3498 passed, 6 skipped, 2 failed
* the same 2 failures reproduce identically on pristine master 0425bf9a
(test_issue_702_review_findings_f1_f6 F1 recovery-before-probe and
test_reconciler_supersession_close org/repo forwarding), so they are
pre-existing and unrelated
* git diff --check clean
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01L9jMhtvTjm5EajofqqaR3F
`gitea_assess_pr_sync_status` could permanently block a merge-ready PR whose
head commit had no status contexts.
Gitea's combined commit-status endpoint reports `state: pending` both when a
check is executing and when the status-context collection is empty. The
wrapper took that `state` verbatim, and `checks_required` defaulted to True
with no production path ever setting it, so "no CI configured" was
indistinguishable from "CI is running" and waiting could never resolve it.
Root cause spans the whole dataflow, not one call site:
* the wrapper read only the combined `state` and never counted contexts;
its `checks_status="none"` fallback was unreachable for any truthy state
* `pr_sync_status.assess_pr_sync_status` defaulted `checks_required=True`
* the MCP wrapper exposed no derived `checks_required` and never passed one
* the branch-protection reader fetched the payload carrying
`enable_status_check` / `status_check_contexts` and discarded both
Changes:
* `pr_sync_status.py`: add `classify_commit_checks` plus explicit
CHECKS_* classifications (success / failure / pending / none /
not_required / missing_required / unknown). The combined state is
recorded for observability but is never evidence that CI is executing.
Aggregation is fail-closed and newest-wins per context.
* `pr_sync_status.py`: rewrite the merge_now checks gate to consume the
derived `checks_required`, distinguish the new classifications with
precise blocker reasons, and fail closed on unrecognized vocabulary.
The previous gate let any value outside its fixed vocabulary fall
through to merge_now.
* `gitea_mcp_server.py`: add `_branch_protection_policy` deriving both the
current-base rule and the status-check requirement from one live read;
`_branch_protection_requires_current_base` is retained as a thin
accessor with unchanged semantics. Add `_commit_checks_snapshot` which
returns the context collection alongside the combined state.
* `gitea_mcp_server.py`: wire the derived `checks_required` into the
production assessment path and report `checks_evidence`.
`checks_required` is always derived from live evidence and is deliberately
not a caller-supplied input, so no session can declare checks optional
without proof. Live classification also outranks a caller-supplied
`checks_status`, which can no longer mask a real required-check failure.
An unreadable protection policy or status collection stays fail-closed.
Approval, current-head, current-base, mergeability, conflict, role, lease
and merge-authorization gates are unchanged.
Tests: `tests/test_issue_751_checks_assessor.py` (40 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Provide a narrow phase-scoped bootstrap so gitea_create_issue can run from
a clean canonical control checkout when no issue number—and therefore no
issue-backed worktree—can exist yet. Dirty roots, non-base branches, base
races, foreign workspaces, and all post-creation author mutations remain
fail-closed under the ordinary branches-only guard.
Closes#749.
Reviewer and merger PR leases minted a fixed 120-minute expiry (#407 AC6/AC7)
and derived staleness from two further activity bands: stale at 30 minutes,
reclaimable at 60. A session that died — daemon crash, transport flap, client
restart — therefore kept a PR blocked for an hour before anyone could reclaim
it, and two hours before manual cleanup was sanctioned. #718 records the
resulting deadlock: a merge stalled behind a reviewer lease that had stopped
being live long before it stopped being authoritative.
The flaw was using a long fixed expiry as a proxy for "the owner is probably
still alive" instead of making the owner continuously prove liveness.
Sliding window
--------------
`LEASE_TTL_MINUTES = 10` now governs acquisition, and every write of the lease
marker re-derives `expires_at` from the moment of the write, so each heartbeat
slides the window forward. An actively heartbeating session is never evicted
and has no maximum lifetime; a dead one releases its hold within one TTL.
`LEASE_RENEWAL_MINUTES` is named separately from the acquisition TTL. Renewal
previously had no seam at all: the heartbeat slid the expiry only as a side
effect of re-defaulting the acquisition constant, so the two durations could
not be reasoned about or tuned independently. `format_lease_body` now takes an
explicit `ttl_minutes`, and the heartbeat passes the renewal window rather than
relying on that default.
Merger leases acquire through the same lease-body formatter, so they inherit
the identical window by construction rather than by a parallel constant.
Removal of the reclaim tier
---------------------------
`classify_lease_freshness` no longer returns `reclaimable`. Beyond being an
extra waiting tier, that band is unreachable under a sliding TTL: a heartbeat
stamps `last_activity` and `expires_at` together, so a lease idle for a full
TTL is necessarily already expired. Expiry is now the only takeover gate.
No reclaim path is lost. `find_active_reviewer_lease` already ignores expired
markers, so an expired foreign lease never gated acquisition; and the
`foreign_expired` classification carries the same
`NEXT_ACTION_RELEASE_EXPIRED_LEASE` the retired `foreign_reclaimable` did. The
two updated tests in `test_reviewer_pr_lease.py` assert exactly that: the
classification label changes, the sanctioned next action does not.
`STALE_WARNING_MINUTES` drops to 5 — half the window — so the warning still
fires while the owner can heartbeat and recover.
Diagnostics
-----------
Adds `lease_seconds_remaining`, and the heartbeat tool now returns
`ttl_minutes`, `expires_at`, and `seconds_remaining`, so an operator can
distinguish "held and live" from "held and dying" instead of only seeing that
a lease exists. All additions are additive; no existing key changed.
Also removes `pr_work_lease.DEFAULT_REVIEWER_LEASE_TTL_MINUTES`, a duplicate of
the reviewer TTL with no readers anywhere in the tree, which could only drift.
Legacy markers minted under the old 120-minute TTL still parse and are judged
against their own recorded `expires_at`, so no lease is retroactively expired
by this change.
Full suite: 3425 passed, 6 skipped, 2 failed. Both failures
(`test_issue_702_review_findings_f1_f6`, `test_reconciler_supersession_close`)
reproduce identically on clean master b05075fd25 and are pre-existing.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QxXHZ7rqXtLgTusngaWgKZ
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]>
#706 introduced the immutable `canonical_repository_root` and #739/#740 routed
three consumption paths through it. The paths #740 left behind all shared one
shape: the *filesystem* guards had been migrated to the canonical root, but
*repository identity* still bottomed out in `_local_git_remote_url`, which ran
`git remote get-url` with `cwd=PROJECT_ROOT` — always the Gitea-Tools install
checkout. The two halves of a single assessment therefore described two
different repositories.
For a namespace bound to another repository this inverts the guards rather than
merely weakening them: an operation naming the genuinely bound target is
rejected, while one naming Gitea-Tools is accepted.
Central helper
--------------
Adds `_canonical_local_git_root()` as the single place a target-repository root
is resolved: the configured canonical root when one is declared, else
`PROJECT_ROOT`. A configured-but-invalid root is never silently replaced by the
install checkout. Single-repository behaviour is byte-for-byte unchanged.
Defects fixed
-------------
* `_local_git_remote_url` now runs in the canonical target root, which corrects
every downstream identity consumer at once (`_resolve`, the #530 remote/repo
guard, the anti-stomp org/repo fill, `_workspace_repository_slug`).
* `_verify_role_mutation_workspace` omitted `configured_canonical_root`, so the
#274 branches-only / worktree-membership guards validated
`Gitea-Tools/branches/` instead of the bound target. It now threads the root
exactly as `_resolve_namespace_mutation_context` does.
* `_resolve` derived omitted coordinates by looking a remote up *by name*. A
target checkout commonly names its remote `origin` rather than `prgs`, so the
lookup returned None and the coordinates fell through to the remote-wide
default target — an unrelated repository. It now prefers
`_canonical_repository_slug`, which probes candidate remote names.
* Explicit `org`/`repo` short-circuit the #530 match check, so caller
coordinates bypassed validation entirely. They may now only *confirm* a
canonical binding, never override it, and fail closed in both directions.
* Reconciler ancestry, fetch, worktree-inventory and cleanup call sites, the
author worktree derivation in `gitea_lock_issue`/`gitea_create_pr`, and the
`control_clean` porcelain probe now use the canonical target root.
* `gitea_config`: v2-`environments` silently *dropped* `canonical_repository_root`
during flattening, so such a namespace fell back to the install root — a
fail-open. It is now validated and propagated. The v1 path validates it too,
so all three loaders behave identically.
Preserved as installation-scoped
--------------------------------
Server parity (`master_parity_gate`), workflow/schema/skill loading, self-code
hashing and stale-runtime detection, and `mirror_refs.sh` lookup remain anchored
to `PROJECT_ROOT`. The `server_implementation` vs `target_repository` parity
dimensions from #740 stay separately labelled, and only the server dimension
gates mutations.
Tests
-----
`tests/test_issue_741_canonical_root_consumers.py` (51 tests, 52 subtests) drives
a role-by-operation matrix over author/reviewer/merger/reconciler against:
correct cross-repository target, wrong repository, wrong worktree, explicit
matching and mismatched coordinates, missing/invalid canonical root, immutable
first-bind, and request-override attempts — plus both cross-repository
directions and all three config loaders. Real git repositories, no network, no
branch deletion, no merge.
The module reinstalls the genuine `_local_git_remote_url` per test: an autouse
conftest fixture permanently reassigns it to a working-directory-independent
stub, which is precisely the behaviour under test.
Full suite: 3408 passed, 6 skipped, 2 failed. Both failures
(`test_issue_702_review_findings_f1_f6`, `test_reconciler_supersession_close`)
reproduce identically on clean master c908ed6050 and are pre-existing.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AYGdWAwuA6UNDc9c3CGipU
Second author remediation on PR #743. Fixes the full-ledger defect surfaced by
the first remediation.
Root cause: pr_work_lease.find_active_reviewer_lease iterated the reviewer
markers newest-first and used `continue` on a terminal marker. On a realistic
append-only ledger (claimed -> validating -> terminal) it therefore stepped
over the terminal marker and returned the older claim of the very chain that
marker had just ended. reviewer_pr_lease got strict newest-wins under #577;
pr_work_lease never did, so the two modules disagreed for released, blocked,
done, and abandoned alike, and merger owner finalization did not leave "no
active lease" for pr_work_lease consumers (conflict-fix acquire, PR sync
inventory).
Fix: a claim is skipped when a later marker terminates its own chain. Chain
identity is (repo, pr_number, candidate_head, session_id, reviewer_identity,
profile) via the new _reviewer_chain_key; _chain_terminated_after scans only
markers appended after the candidate. Consequences:
- a valid terminal marker ends its matching earlier claim (no resurrection);
- a foreign-session, wrong-repo, wrong-PR, wrong-head, wrong-identity, or
wrong-profile terminal marker cannot cancel another session's active lease,
which strict newest-wins alone would have allowed;
- a malformed terminal marker has no provable chain key, so it cancels nothing
and cannot hide a valid active claim;
- expiry, freshness, phase sets, ownership and integrity checks, both parsers,
and find_active_conflict_fix_lease are untouched;
- history stays append-only; no marker is deleted or rewritten.
Reviewer lease markers carry no token field, so token-fingerprint validation
remains where it already lives (session provenance, merger finalization) and is
not weakened here.
Tests: new TestFullLedgerNewestWins in tests/test_merger_lease_finalization.py
covers claimed->released/blocked/done/abandoned, claimed->validating->terminal,
foreign-session and mismatched repo/PR/head/identity/profile terminals, the
malformed terminal marker, a newer active chain surviving an older terminated
chain, newest-valid-chain selection across multiple histories, all-chains-
terminated, single-marker parity between the two modules across eight phases,
expired-claim/freshness non-regression, and the real ledger written by
gitea_release_merger_pr_lease reading as terminal in both modules with the
prior marker preserved. TestCrossModuleTerminalPhaseAgreement's scope-limited
placeholder test is replaced by a real both-modules full-ledger assertion.
Verified 10 of the new tests fail at the prior head 7ae5f3a and pass here.
Validation: focused TestFullLedgerNewestWins 14 passed / 21 subtests;
tests/test_merger_lease_finalization.py 59 passed / 42 subtests; targeted
pr_work_lease + reviewer/merger lease + adoption + provenance + acquire/release
+ anti-stomp + capability-map + decision-lock + report-validator suites 309
passed / 41 subtests; full suite 3326 passed, 6 skipped, 2 failed. Both
failures (test_issue_702_review_findings_f1_f6 F1 recovery, reconciler
supersession close) reproduce identically on clean baseline master a8d2087 and
are pre-existing #737 org/repo-forwarding drift. git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Addresses REQUEST_CHANGES review 460 on PR #743 @ 22d0fdd.
reviewer_pr_lease._TERMINAL_PHASES gained "abandoned" for owner-session merger
finalization, but pr_work_lease._TERMINAL_REVIEWER_PHASES did not. The two
modules parse the same append-only lease markers, so the same comment read as
terminal through reviewer_pr_lease and active through pr_work_lease, leaving
the conflict-fix acquire and PR-sync inventory readers with a stale active
lease after an abandoned finalization.
Fix: add "abandoned" to pr_work_lease._TERMINAL_REVIEWER_PHASES, with a comment
recording that the two sets must stay mirrored.
Reproduced before the fix (single abandoned marker):
reviewer_pr_lease active=False, pr_work_lease active=True
After: both False; released/blocked/done unchanged in both modules.
Tests: new TestCrossModuleTerminalPhaseAgreement in
tests/test_merger_lease_finalization.py proves the abandoned marker is inactive
in pr_work_lease, that both modules agree for released/blocked/done/abandoned,
that claimed/validating stay active in both, that the two phase sets are
mirrored, that the default 'released' finalization outcome and reason are
unchanged, and that finalization appends without rewriting or deleting the
prior marker.
Separately pinned, NOT fixed here: on a claim-then-terminal ledger,
pr_work_lease.find_active_reviewer_lease skips the terminal marker and walks
back to the older claim, so it still reports an active lease. That newest-wins
gap is pre-existing on clean baseline a8d2087 for released, blocked, and done
alike — the #577 fix landed in reviewer_pr_lease only — and is out of scope for
this bounded remediation. test_abandoned_matches_preexisting_terminal_phases_on_full_ledger
pins that "abandoned" introduces no behavior of its own, so all four phases can
be reconciled in one separate change.
Validation: focused TestCrossModuleTerminalPhaseAgreement 7 passed / 9 subtests;
tests/test_merger_lease_finalization.py 45 passed / 20 subtests; related
pr_work_lease + reviewer/merger lease + adoption + provenance + capability-map +
anti-stomp + report-validator suites 261 passed / 41 subtests; full suite 3312
passed, 6 skipped, 2 failed. Both failures (test_issue_702_review_findings_f1_f6
F1 recovery, reconciler supersession close) reproduce identically on clean
baseline master a8d2087 and are pre-existing #737 org/repo-forwarding drift.
git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Root cause (confirmed on PR #740, session 33780-7168cbeeba58, marker 12354):
merger_lease_adoption.SANCTIONED_PROVENANCE_SOURCES already contained
SOURCE_ACQUIRE_MERGER, so the membership check passed, but
is_sanctioned_session_lease() branched only for SOURCE_ADOPT and for
{SOURCE_ACQUIRE, SOURCE_HEARTBEAT} and fell through to `return False` for a
lease minted by gitea_acquire_merger_pr_lease. assess_mutation_lease_gate()
therefore appended "in-session lease lacks sanctioned provenance" and
gitea_merge_pr fail-closed on the merger's own freshly acquired lease.
describe_session_lease_proof() likewise had no branch for that source and
reported the lease as lease_proof_kind=unsanctioned. Separately, no
merger-role owner-session operation existed to end that lease, so a failed
merger lease could only expire.
A. Acquired merger provenance
is_sanctioned_session_lease() now accepts SOURCE_ACQUIRE_MERGER, but only via
the new assess_acquired_merger_lease_integrity(), which fails closed unless
the in-session record is complete and self-consistent: comment marker present
and matching between provenance and session, non-empty session id, holder
identity, merger profile, repository, valid PR number, and a normalized
40-hex candidate_head. describe_session_lease_proof() reports the explicit
kind sanctioned_acquire_merger. Manual _SESSION_LEASE seeding, forged or
incomplete records, mismatched fields, and unknown provenance all remain
rejected; reviewer-acquire, reviewer-heartbeat, and merger-adoption paths are
untouched.
B. Merger owner-session finalization
New native tool gitea_release_merger_pr_lease terminally releases or abandons
a merger's own comment-backed lease when the merge does not occur. It is
merger-only (gitea.read + gitea.pr.comment + gitea.pr.merge; reviewer profiles
lack pr.merge) with an explicit capability-map task release_merger_pr_lease /
gitea_release_merger_pr_lease bound to gitea.pr.comment + role merger, and it
is listed as role-exclusive in the resolver. assess_merger_lease_finalization()
verifies exact session ownership, repository, PR, candidate head vs live head,
profile, identity, marker presence on the thread, and the native runtime token
fingerprint recorded at acquisition; foreign-session release, reviewer-profile
use, and any mismatch fail closed. Finalization appends a terminal lease marker
and never edits or deletes ledger history; an already-terminal lease returns
already_terminal and posts nothing. The PR, approval, decision lock, branch,
and worktree are all preserved. gitea_release_reviewer_pr_lease is unchanged
and is not repurposed for merger sessions.
"abandoned" is added to reviewer_pr_lease._TERMINAL_PHASES; without it an
abandoned marker would fall through the generic non-empty phase branch and
keep re-arming the lease as active.
Tests: new tests/test_merger_lease_finalization.py (38 tests, 11 subtests)
covers all twelve acceptance criteria — provenance sanctioning, explicit proof
kind, merge-gate acceptance, manual-seed and unknown-provenance rejection,
profile/role/session/head/repository/fingerprint mismatches, owner release,
foreign-session refusal, reviewer refusal, append-only idempotent
finalization, no surviving lease after finalization, and no regression in
adoption or reviewer-lease behavior. The defect was reproduced on clean
baseline a8d2087 first (is_sanctioned=False, proof_kind=unsanctioned, no
finalization path).
Validation: targeted lease/capability suites 272 passed. Full suite
3305 passed, 6 skipped, 2 failed — both failures
(test_issue_702_review_findings_f1_f6 F1 recovery, reconciler supersession
close) reproduce identically on clean baseline master a8d2087 in a throwaway
worktree and are pre-existing #737 org/repo-forwarding drift, unrelated to
this change.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
#706 routed the #274 filesystem guards and the session repository slug through
the configured canonical_repository_root. Three consumption paths were left
deriving from the Gitea-Tools installation checkout.
F1 — gitea_get_runtime_context did not normalize its `remote` argument, while
gitea_whoami did. On a prgs-hosted namespace whose first native call was the
runtime-context path, the 'dadeschools' argument default was pinned: identity
resolved against the wrong host, and because first-bind is first-write-wins a
later correct gitea_whoami(remote="prgs") could not repair the binding. It now
calls _effective_remote before any host lookup, identity resolution, or session
seeding. Explicit remotes pass through untouched and a dadeschools-hosted
profile still resolves to dadeschools.
F2 — _delete_branch_repository_binding_block derived its expected slug from
_workspace_repository_slug, which reads _local_git_remote_url in PROJECT_ROOT
(always Gitea-Tools). For a cross-repository namespace this inverted the guard:
the genuinely bound target was rejected and Gitea-Tools was accepted. The
canonical-root branch already present in _trusted_session_repository is
extracted as _canonical_repository_slug and shared by both call sites. A
configured-but-unresolvable root now fails closed instead of falling back to
the installation identity; unconfigured namespaces keep the install-derived
default unchanged.
F3 — gitea_assess_master_parity measures Gitea-Tools server implementation
parity only. That is intentional and is preserved: startup_head, current_head,
in_parity, stale, and restart_required keep their existing meaning and values,
and only the server dimension gates mutations. Two separately labelled
dimensions are added — server_implementation (installation root and commit) and
target_repository (canonical root, repository slug, checkout head, last-known
remote master head, staleness) — so cross-repository commissioning evidence can
distinguish them. The target assessment makes no network call: the remote side
is read from the existing remote-tracking ref, and an unfetched target is
reported indeterminate rather than guessed at.
Verified intentional and left unchanged: the #274 workspace-membership,
author-mutation-worktree, and branches-only guards already validate against the
configured canonical root; explicit org/repo may confirm but never authorize a
binding; and the four-role repository-specific configuration surface already
passes audit and bind-time validation with a wrong root failing closed.
Tests: 31 new cases in tests/test_cross_repo_canonical_consumption.py using
real git repositories and no network. Full suite 3298 passed / 6 skipped
against a clean-baseline 3267; the 2 failures
(test_issue_702_review_findings_f1_f6, test_reconciler_supersession_close)
reproduce identically on unmodified master a8d2087 and are pre-existing.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013jUxaLVLkPHuTQwAzFzztW
Addresses REQUEST_CHANGES review 457 on PR #736.
Root cause: _trusted_session_repository derived the session org/repository
solely from _workspace_repository_slug -> _local_git_remote_url, which runs
`git remote get-url` in PROJECT_ROOT (the Gitea-Tools install checkout). A
cross-repository namespace with a configured canonical_repository_root then
pinned the Gitea-Tools identity while #274 filesystem membership bound the
external target, so _enforce_canonical_repository_root failed closed on a
self-inflicted identity mismatch and the mcp-control-plane / eagenda
namespaces stayed blocked end-to-end.
Fix: when a canonical_repository_root is configured (env over profile) and
passes existence / git-toplevel / resolvable-remote-identity validation, the
session repository slug is derived from repository_identity_slug(configured
root) instead of the install remote. The derived identity is still authorized
by the profile allowed_repositories allowlist (never self-authorizing); the
canonical filesystem root and org/repo identity remain immutable for the
session; invalid / non-git / unresolvable / unallowlisted / forged / drift
cases fail closed; unconfigured single-repo Gitea-Tools namespaces keep the
install-derived slug unchanged.
Tests: new tests/test_issue_706_f1_seed_identity_integration.py drives the
real _seed_session_context -> _enforce_canonical_repository_root path with two
real temporary git repositories (install=Gitea-Tools, configured target=
mcp-control-plane): env + profile config, reviewer + merger role kinds, and
fail-closed cases (nonexistent / non-git / unallowlisted / forged identity
drift). Reproduces the F1 block before the fix; green after.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Session-bound author mutations (create_issue, lock_issue, create_pr, etc.)
called verify_preflight_purity without org/repo, so anti-stomp filled the
remote-wide REMOTES default (prgs → Timesheet) and false-positived wrong_repo
against a Gitea-Tools checkout even when callers passed explicit coordinates.
Mirror _resolve: prefer workspace-derived identity for omitted coordinates on
session-bound tasks; keep delete_branch's explicit-target contract (#733).
Also forward org/repo at author mutation call sites. Add cross-repo regression
coverage. Do not change REMOTES["prgs"]["repo"].
The MCP server derived the canonical repository root from the install
checkout the server script lives in (PROJECT_ROOT), so any namespace that
ran the server against an external repository (e.g. eagenda-author, or the
mcp-control-plane reviewer/merger namespaces) failed every mutation: the
branches-only / worktree-membership guards (#274) compared the task
workspace against the Gitea-Tools .git directory it can never belong to.
Separate the two concepts:
- PROJECT_ROOT stays the immutable code/install root.
- A new, optional, namespace-scoped canonical_repository_root binds the
session to the working root of the target repository.
Implementation:
- canonical_repository_root.py: resolve the binding from profile/env
(GITEA_CANONICAL_REPOSITORY_ROOT overrides the profile field), validate
existence + git identity, fail closed on missing/conflicting/forged
bindings. Preserves the single-repo default when unconfigured.
- session_context_binding.py: pin canonical_repository_root into the
immutable #714 session context; drift fails closed.
- namespace_workspace_binding.py: route the configured target root through
resolve_namespace_mutation_context so #274 / membership guards evaluate
against the target repository.
- gitea_mcp_server.py: seed + enforce the binding in the mutation preflight
(both purity-order and FORCE_PRODUCTION_GUARDS paths); validate repository
identity and git common-directory membership.
- gitea_config.py: validate the optional absolute-path profile field.
- gitea_auth.py: surface the config-sourced field through get_profile.
No weakening of root-checkout, remote/repository, or anti-stomp guards; the
single-repo Gitea-Tools path is unchanged.
Tests: two distinct real git repositories/worktrees prove cross-repository
bindings resolve, enforce membership, and fail closed on forged/conflicting
identity and simultaneous prgs/mdcps isolation.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
gitea_delete_branch accepted explicit org/repo but did not propagate them
through the anti-stomp preflight. During a deletion targeting
Scaled-Tech-Consulting/Gitea-Tools, preflight resolved the remote-wide prgs
default (Scaled-Tech-Consulting/Timesheet) and failed closed with wrong_repo
before any deletion — because verify_preflight_purity was called without
org/repo and the shared #604 anti-stomp resolution fell back to the REMOTES
default.
Fix (delete_branch only; REMOTES untouched):
1. Forward the explicit org/repo into verify_preflight_purity so the shared
#604 anti-stomp resolution validates the *targeted* repository instead of
the remote-wide default. Explicit Scaled-Tech-Consulting/Gitea-Tools now
propagates through role/workspace verification, verify_preflight_purity,
anti-stomp repository resolution, and the final native deletion request.
2. Add a workspace-derived repository-binding gate
(_delete_branch_repository_binding_block) that validates explicit
coordinates against the immutable, workspace-aligned repository identity.
Wrong, substituted, or unverified coordinates fail closed — independent of
the anti-stomp remote/repo guard, which by the #530 contract trusts explicit
caller intent. REMOTES defaults are never consulted as an authorization
scope.
Preserved: reconciler-only ownership of gitea.branch.delete; author/reviewer/
merger denial; protected and preservation/evidence gates; missing coordinates
still fail closed via the anti-stomp default resolution.
Regression matrix (tests/test_issue_733_delete_branch_repo_forwarding.py):
Timesheet-default + explicit Gitea-Tools permits an eligible deletion and
forwards the coordinates; wrong/substituted/unverified coordinates fail closed;
author/reviewer/merger remain denied; protected and preservation branches
remain blocked; anti-stomp resolution marks explicit coordinates authoritative
and fails closed on the remote-wide default for omitted coordinates; the
task-capability and role-routing maps stay consistent.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UVDxVKANhuGYzZgayDU3QS
Mutation tools (create_issue, create_pr, delete_branch, issue-lock) returned
reason_code=internal_error / retryable=false with no class, message, HTTP
status, or actionable detail. The #699/#701 tool-error boundary intentionally
collapses every non-typed exception to a fixed "Internal tool error" and drops
the class and message, so a mutation failure is undiagnosable — and the #695
runtime guard blocks standalone reproduction. The true failing stage was
therefore invisible by two independent mechanisms.
Make the failure observable and actionable without weakening the secret-free
contract:
- Boundary (internal_error path ONLY): capture a safe exception class (a Python
type identifier, never instance text) plus a strictly-redacted `detail`
(token/Authorization/Bearer credentials, JSON/kv secret values incl. short
passwords, raw URLs/hostnames, and absolute filesystem paths all stripped;
whitespace collapsed; length-bounded) and an optional `mutation_stage`. Typed
auth/authz/network/config/http paths are unchanged and still emit no detail.
- Convert raw exceptions into typed structured errors via a safe
`gitea_reason_code` attribute contract: server raisers may self-declare a
known reason and the boundary emits it (fixed message) instead of an opaque
internal_error. Pre-flight order violations now raise `_PreflightOrderError`
(a RuntimeError subclass → preflight_order_violation).
- Add a `_mutation_stage` context manager tagging escaping exceptions with the
stage name (preflight_purity / resolve_auth / api_*), applied to
delete_branch, create_issue, create_pr.
Fail-closed identity/repo/worktree/role/permission/lock/lease/ancestry gates
are untouched; only error *reporting* changed. New suite
test_mutation_error_diagnostics.py (18 tests) pins the redaction, class capture,
stage tagging, typed conversion, and adversarial no-leak contract. Existing
boundary suite (25), core server (209), and preflight (92) suites stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Remote post-merge branch deletion was blocked because the capability
resolver classified delete_branch as an author-role task while
gitea.branch.delete is granted only to prgs-reconciler. No configured
profile could satisfy both the permission gate and the role gate, so
every deletion failed closed with performed=false.
Re-home delete_branch from the author role to the reconciler role in
both single-source-of-truth maps:
- task_capability_map.TASK_CAPABILITY_MAP["delete_branch"].role
- role_session_router: TASK_REQUIRED_ROLE + AUTHOR_TASKS/RECONCILER_TASKS
resolve_task_capability("delete_branch") now resolves to prgs-reconciler.
In gitea_delete_branch, the reconciler is now the delete-capable role and
performs raw deletion through the existing reconciliation-cleanup-phase
authorization gate (operator approval + safe_to_delete proof) plus the
preservation/protected guards; the prior reconciler->cleanup redirect is
removed as it would orphan that guarded flow. Author, reviewer, and
merger stay denied by the permission gate and the required-role gate.
gitea_cleanup_merged_pr_branch remains a separate reconciler-owned path
with independent merged/ancestry/ownership verification.
Tests: reconciler resolves + performs eligible deletion; author/reviewer/
merger denied even when holding the permission; protected/preservation
branches remain fail-closed; both role maps asserted in agreement.
Updated pre-existing tests that encoded the superseded author-delete /
#687 reconciler-redirect contract. Full suite: 3190 passed, 6 skipped,
1 pre-existing unrelated failure (test_issue_702 create_pr stale-binding).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UracxyRHQw49rZBaexHdft
_verify_role_mutation_workspace dropped org/repo, so anti-stomp resolved bare
remote=prgs to Timesheet and failed closed wrong_repo on Gitea-Tools. Forward
explicit org/repo for merger/reviewer lease acquire, adopt, review, merge, and
lease release. Add regression for merger acquire forwarding.
Fixes#718
Refs #723
gitea_acquire_merger_pr_lease previously continued when GET /pulls returned
an empty head SHA. Fail closed so exact-head scoping never proceeds with an
unresolvable live head. Add regression coverage for empty head payloads.
Fixes#718
Refs #723
Prevent approved PRs from stalling when master advances. Add
gitea_assess_pr_sync_status and gitea_update_pr_branch_by_merge with
head/base pinning, author-only updates, conflict handoff, and approval
invalidation after head changes. Wire task capability map, sequential
controller routing in review-merge workflow, and hermetic AC tests.