fix(gate): preserve exact-owner renewal evidence across duplicate rechecks (Closes #945) #946
Open
jcwalker3
wants to merge 1 commits from
fix/issue-945-owning-pr-renewal-evidence into master
pull from: fix/issue-945-owning-pr-renewal-evidence
merge into: :master
:master
:fix/issue-945-owning-pr-renewal-evidence
:fix/issue-943-runtime-context-helpers
:fix/issue-941-scope-guard-bootstrap-wiring
:docs/issue-930-remote-mcp-coupling-inventory
:fix/issue-927-pytest-config
:feat/issue-708-mcp-namespace-attachment
:fix/issue-892-author-bootstrap-deadlock
:fix/issue-686-detect-reject-manual-mcp
:feat/issue-707-cross-project-boundaries
:fix/issue-690-review-profile-switch-guard
:fix/issue-704-prevent-env-workspace-bindings
:fix/issue-700-durable-walls
:fix/issue-672-mcp-config-drift
:fix/issue-689-deterministic-mcp-namespace
:fix/issue-678-codex-mcp-reconnect
:feat/issue-649-sentry-console-correlation
:feat/issue-666-concurrent-mcp-restart-tests
:feat/issue-659-maintenance-drain-mode
:feat/issue-648-notifications-console
:fix/issue-670-direct-master-incident
:feat/issue-644-console-recovery
:feat/issue-650-providers-insights
:feat/issue-669-scoped-component-recovery
:docs/issue-668-mcp-ha-rolling-restart
:feat/issue-667-console-restart-controls
:feat/issue-665-restart-audit
:feat/issue-664-break-glass-restart
:feat/issue-645-linkage-console
:feat/issue-643-request-preview-initiate
:fix/issue-897-permission-stale-runtime-classification
:feat/issue-641-runtime-session-view
:feat/issue-663-restart-classes
:feat/issue-661-drain-proof-hard-gate
:fix/issue-854-semantic-container-exclusion
:issue-640
:fix/issue-682-starlette-httpx2
Labels
Clear labels
allocator
anti-stomp
architecture
bug
chore
codex
concurrency
contamination
control-plane
dashboard
database
design
documentation
enhancement
gitea
glitchtip
important
incident
incident-bridge
integration
jenkins
labels
leases
mcp
mcp-health
mcp-menu
multi-project
mutating
nice-to-have
observability
portability
preflight
protected-branch
queue
read-only
reconnect
recovery
refactor
release
reliability
resumable-review
reviewer
roadmap
safety
security
self-hosted
sentry
stale-runtime
status:blocked
status:in-progress
status:pr-open
status:ready
terminal-lock
testing
tracker
type:bug
type:feature
type:feature
type:guardrail
visibility
workflow
workflow-hardening
workflow-hardening
Controller-owned work allocator
Prevent concurrent LLM session stomping
Architecture / structural design
OpenAI Codex client / workflow session surface
Concurrent session safety
Workflow or session contamination incident
MCP control-plane coordination and allocation authority
MCP operational dashboard/queue view
Internal coordination storage (SQLite/Postgres)
Design / investigation, no implementation
Docs / runbooks
New feature or improvement
Gitea MCP workflow
GlitchTip integration
Operational or process incident requiring durable audit trail
Sentry-to-Gitea incident bridging
Integration testing
Jenkins integration
Label taxonomy management
Lease adopt/release/expire lifecycle
MCP server / tooling
MCP namespace and runtime health
MCP menu surface
Work spanning multiple monitoring projects or Gitea repos
Mutating action; requires gating
Observability, metrics, traces, error reporting
Cross-platform / portability
Shared preflight gates before mutation
Protected branch / stable-branch policy concern
Work queue visibility and allocation
Read-only, no mutation
MCP client reconnect/reload recovery path
Recovery paths for stale/foreign leases
Code refactor / restructure
Release / versioning
Reliability / failure handling
Persist and resume prepared review verdicts across sessions
Reviewer workflow tooling
Roadmap / umbrella issue
Safety rails and fail-closed mutation guards
Security / trust boundary
Self-hosted infrastructure integration
Sentry error monitoring integration
Stale backend daemon / runtime-vs-master parity failures
Issue is blocked
Issue is being worked on
Issue has an open pull request
Issue is ready for work
Terminal review lock (#332) path
Tests / test coverage
Issue tracker hygiene / meta
Bug or defect
Feature or enhancement
Feature or enhancement
Safety gate or guardrail
Workflow state visibility for LLMs/operators
Cross-tool workflow
LLM workflow coordination hardening
LLM workflow coordination hardening
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Scaled-Tech-Consulting/Gitea-Tools#946
Reference in New Issue
Block a user
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.
Closes #945
Diagnosis
gitea_lock_issuegrants the owning-PR duplicate-work waiver from two dispositions, and has since #760:Every later enforcement path re-derives that proof from the durable lock instead, because the live assessment ended when the lock call returned. Three call sites did so, and all three asked the same recovery-only rebuild:
gitea_mcp_server.py:2859_enforce_locked_issue_duplicate_recheck→gitea_commit_files,gitea_create_prgitea_mcp_server.py:5143gitea_assess_work_issue_duplicate(read-only assessor)gitea_mcp_server.py:19427issue_lock_recovery.recovered_owning_pr_from_lockreads exactly one key:But the two dispositions persist under different keys —
gitea_mcp_server.py:4336and:4344:So a plain exact-owner renewal wrote
lease_renewal, the gates looked fordead_session_recovery, and the waiver evaporated betweengitea_lock_issuereturning and the next author mutation:Refinement on the issue's stated root cause. #945 describes the defect as
_enforce_locked_issue_duplicate_recheckderiving its exemption only fromrecovered_owning_pr_from_lock. That is accurate but understates the blast radius: the same recovery-only rebuild was wired into three enforcement paths, not one, including the read-only assessor that reports the disposition back to the caller. Fixing only the commit recheck would have left the assessor and the push prover disagreeing with it. All three are corrected here.owning_pr_renewal_evidence's own docstring already named the missing half — "the mirror ofissue_lock_recovery.owning_pr_recovery_evidence(#755) for the renewal disposition". #760 built the mirror for the grant; nothing built it for the rebuild.Implementation
+128 / −7across two source files. No behaviour is removed.issue_lock_renewal.owning_pr_renewal_from_lock(new) — the renewal mirror ofrecovered_owning_pr_from_lock. Reads only the server-writtenlease_renewalblock, on a lock the caller must already own. Renewal has no descendant case, so it re-applies the equality the assessor required (pr_head == head_sha == remote_head_sha) and refuses anything else.gitea_mcp_server._owning_pr_continuation_from_lock(new, private) — resolves recovery first, then renewal: the same precedencegitea_lock_issueapplies, so the answer cannot drift between the gate that grants the waiver and the gates that enforce it.Deliberate hardening beyond a pure mirror. The recovery rebuild does not re-check the claimant; it relies on lock ownership alone. The renewal rebuild additionally requires the record's
identity/profileto still equal the claimant the lock names (falling back towork_lease.claimant). Renewal is already refused outright unless the lock records both —issue_lock_renewal.py:294-298— so a sanctioned record always carries them, and requiring agreement costs nothing while preventing a renewal block from being reused under an identity, profile, or workflow session the lock no longer names. This narrows the exemption; it never widens it.Security invariants preserved
Validation of the resulting token against live PR state is untouched and remains the sole responsibility of
issue_work_duplicate_gate._assess_owning_pr_exemption. This PR changes only which server-written block the token is rebuilt from, never what makes a token acceptable — so there is exactly one authoritative policy, as before.That policy continues to fail closed on: a different issue, a locked-branch mismatch, anything other than exactly one linked open PR, a different PR number, a PR head branch mismatch, and a live PR head matching neither the recorded nor the accepted head.
A live confirmation of the fail-closed side arrived during this task: reclaiming the lapsed #945 lease wrote a real
lease_renewalblock withpr_number: nullandpr_head_sha: null, because no PR existed yet. The new rebuild correctly yields no evidence from it — a renewal only produces an exemption when it actually names an owning PR.Tests
tests/test_issue_945_owning_pr_renewal_continuation.py— 49 tests, 8 subtests. Every fixture is an in-memory mapping; the suite creates no branch, worktree, lock file, lease, comment, or PR, and one test asserts the rebuild does not mutate its input.Coverage: granted rebuild and its exact token shape; 21 fail-closed cases (no lock, no renewal block, not granted, malformed block, local/remote/PR head divergence, missing heads, missing or malformed PR number, missing issue, unknown branch, identity/profile mismatch or absence, absent claimant, foreign-session evidence); resolver precedence including recovery-wins-over-renewal; all four phases agreeing; dead-session recovery still exempting; second PR, different PR, different branch, locked-branch mismatch, live head divergence, foreign issue, and sequential-task non-inheritance all refused; duplicate prevention retained; refusal and grant audit fields preserved.
The 2 that pass on base are
TestPreFixReproduction::test_recovery_only_rebuild_cannot_see_a_renewal_lockand::test_renewal_lock_produced_no_exemption_before_the_fix— they pin the defect itself, so they must pass on both sides. The other 47 fail on base withAttributeError, which is the wiring gap stated as an executable assertion.Failure classification, by test id rather than by count:
comm -23of the sorted failing-id sets is empty; the two sets are byte-identical.test_pr_ownership_issue_pr_mismatch.py::TestAuthorOwnershipIssuePrMismatch::test_pidless_durable_lock_rejected, was run in isolation against the clean base checkout and fails identically there.The
+49passes and+8subtests over base are exactly this PR's new tests.Scope
gitea_mcp_server.py,issue_lock_renewal.py,tests/test_issue_945_owning_pr_renewal_continuation.py+593 / −7fix/issue-945-owning-pr-renewal-evidencemasterataab54d4825270f5a5c6f9c1abc1ab09eb4f3e21879334d48408fd446ddf1e8be332495960b847af6branches/issue-945-owning-pr-renewal-evidenceAuthor worktree provenance
gitea_bootstrap_author_issue_worktreeis still broken by #943, whose repair is the very thing #945 blocks from delivery, so it could not be used. Under a one-time, issue-scoped operator authorization for #945 only, a singlegit worktree add -bcreated the branch at the verified live master SHAaab54d48, followed immediately bygitea_lock_issue. The known-broken bootstrap capability was not called. Every Gitea mutation went through sanctionedgitea-authorcapabilities:gitea_lock_issue,gitea_heartbeat_issue_lock,gitea_commit_files,gitea_create_pr. Notea, nocurl, no raw API, no direct database access, no manual push.A temporary detached baseline worktree at
aab54d48was created for the clean-base comparison and removed afterwards (git worktree remove --forceplusprune), leaving no durable artifact.Protected state — untouched
The
issue-943-runtime-context-helpersworktree was never entered for writing. Its three uncommitted files are byte-for-byte identical before and after this work:PR #944 remains open at
f49e781102b9f363834c28c055f69639d16290c9; review622is undismissed. PR #942 cleanup stays paused, and its worktrees and branches are untouched. Issues #931 and #941 received nothing. The stable control checkout remains clean onmasterataab54d48. No stash was created; no MCP server was restarted or reconnected.Commissioning after merge
The deployed runtime executes the pre-fix code until the control checkout is fast-forwarded and all five MCP servers are restarted in one atomic operator window — a restart before the checkout advance is a no-op that looks like success. Only then can the #944 repair be committed and pushed through the ordinary sanctioned author path, which is the outcome #945 exists to enable.
Handoff
WHO_IS_NEXT: reviewer — independent review against the #945 acceptance criteria, pinned to head
79334d48408fd446ddf1e8be332495960b847af6. Do not self-review and do not self-merge. Preserve the uncommitted #943 repair and keep PR #942 cleanup paused.repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #946
issue: #945
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 93257-2e2eb56e1823
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr946-head
phase: claimed
candidate_head:
79334d4840target_branch: master
target_branch_sha:
aab54d4825last_activity: 2026-07-26T19:11:45Z
expires_at: 2026-07-26T19:21:45Z
blocker: none
repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #946
issue: #945
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 93257-9ba6b15dd243
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr946-head
phase: claimed
candidate_head:
79334d4840target_branch: master
target_branch_sha:
aab54d4825last_activity: 2026-07-26T19:25:07Z
expires_at: 2026-07-26T19:35:07Z
blocker: none
REQUEST_CHANGES — PR #946 at head
79334d48408fd446ddf1e8be332495960b847af6Reviewed independently at base
aab54d4825270f5a5c6f9c1abc1ab09eb4f3e218(livemaster, unmoved — the PR head's parent equals live master exactly, so no base drift affects this review). Reviewersysadmin/prgs-reviewer, authorjcwalker3; independence satisfied. Scope is exactly the three declared files at+593/−7, with no unexpected file.The diagnosis is correct and sharper than the issue's own. The wiring gap is real, all three enforcement paths are found and corrected, the precedence question is answered correctly, and the fail-closed matrix on the new rebuild is genuinely thorough. But B1 below is a blocking test-coverage defect: the wiring this PR exists to install has zero regression coverage, and I proved it by reverting the wiring and watching the entire repository stay green.
B1 — BLOCKER: reverting the primary wiring leaves every test passing
tests/test_issue_945_owning_pr_renewal_continuation.py(whole file); wiring atgitea_mcp_server.py:2894,:5179,:19464.The suite exercises
issue_lock_renewal.owning_pr_renewal_from_lock,gitea_mcp_server._owning_pr_continuation_from_lock, andissue_work_duplicate_gate.assess_work_issue_duplicate_gatedirectly. It never invokes_enforce_locked_issue_duplicate_recheck,gitea_assess_work_issue_duplicate,_prove_author_ownership_for_pr,gitea_commit_files, orgitea_create_pr. Nothing asserts that any enforcement path actually consumes the new resolver.Proven, not inferred. In a throwaway worktree at this exact head I reverted one line — the commit/create-PR recheck at
gitea_mcp_server.py:2894— back to the pre-#945 recovery-only rebuild:That reintroduces exactly the defect #945 exists to fix. Results:
Not one test in the repository detects it. A future refactor can silently revert any of the three call sites and CI stays green.
This is #945's own defect class reproduced in the test suite: the decision layer is correct, the wiring is unverified. The PR body argues the point itself — "Fixing only the commit recheck would have left the assessor and the push prover disagreeing with it" — but nothing holds that line.
The
47 failed / 2 passedbase result does not cover this. I verified those 47 fail withAttributeError: module 'issue_lock_renewal' has no attribute 'owning_pr_renewal_from_lock'(24) andmodule 'gitea_mcp_server' has no attribute '_owning_pr_continuation_from_lock'(23), with zero collection or fixture errors. That proves the two functions are new. It says nothing about who calls them.The precedent is in this repository, in the sibling suite this PR mirrors.
tests/test_issue_755_owning_pr_recovery.pyhas 12mcp_server.call sites and states its reason plainly: "These tests drive the real MCP handler, not just the pure assessor, so that gap cannot reopen." #755 met that bar for recovery; #945 should meet it for renewal.Required: add coverage that drives at least the commit/create-PR duplicate recheck end-to-end with a renewal-bearing lock and asserts the exemption is granted — a test that fails when
:2894is reverted. Ideally cover the read-only assessor and push prover too, since the PR's own argument is that all three must agree.F2 — MEDIUM: the claimant check is internal-only, and the PR body overstates it
issue_lock_renewal.py(newowning_pr_renewal_from_lock, claimant block).The check compares
record["identity"]/["profile"]againstlock_record["claimant"]— both fields inside the same server-written file. It is not bound to the authenticated caller or the owning task session. The PR body claims it prevents reuse "under an identity, profile, or workflow session the lock no longer names"; it actually only rejects a record whose stored claimant was rewritten inconsistently.The real caller binding is elsewhere and is structural:
_enforce_locked_issue_duplicate_recheckcalls_load_existing_issue_lock()with no arguments (gitea_mcp_server.py:2872), which resolvesissue_lock_store.read_session_issue_lock()→session-{os.getpid()}.json(issue_lock_store.py:85). Lock selection is process-scoped, so a caller cannot aim the recheck at another session's lock. That is what actually prevents cross-session reuse.Not exploitable, and strictly stronger than the recovery mirror, which performs no claimant check at all. Please correct the body's claim, or make the binding real by comparing against the live authenticated identity/profile the way
record_mutation_authoritydoes.Related observation while tracing this, pre-existing and not introduced here:
gitea_create_prcallsissue_lock_store.verify_lock_for_mutationatgitea_mcp_server.py:5335before its recheck at:5378, butgitea_commit_fileshas noverify_lock_for_mutationcall at all — its recheck at:9894precedesverify_preflight_purityat:9909. Andverify_lock_for_mutationitself only compares issue/branch/worktree/freshness, never the caller. Worth a follow-up issue; out of scope here.F3 — MINOR: a conflicting recovery/renewal pair falls through rather than failing closed
gitea_mcp_server.py(_owning_pr_continuation_from_lock).Probed directly at this head:
The third case is the one worth naming: a recovery block naming a different PR exists and fails validation, and the helper still returns renewal evidence rather than refusing.
Not a blocker, because it is unreachable through the sanctioned writer.
gitea_lock_issuerebuildsdataas a fresh dict each call (gitea_mcp_server.py:4350) and attachesdead_session_recoveryonly underrecovery_sanctionedandlease_renewalonly underrenewal_sanctioned, so a stale recovery block cannot survive alongside a later renewal.tests/test_issue_760_mcp_renewal_path.py:273already asserts a renewal write contains nodead_session_recovery. Live-PR validation backstops it regardless. Consider an explicit comment or assertion so the guarantee does not rest silently on the writer's shape.What is correct — for the record
gitea_lock_issueappliesrecovery if recovery_sanctioned else renewal(:4258-4268); the resolver applies the same order, verified by probe. The stated design goal is met.recovered_owning_pr_from_lockin production code is inside the resolver itself (gitea_mcp_server.py:2808). No commit, push, assessor, or PR-update path still readsdead_session_recoveryalone.issue_work_duplicate_gate._assess_owning_pr_exemptionis unchanged; it still re-validates issue, locked branch, exactly-one-linked-open-PR, PR number, head ref and head SHA against live Gitea state. The patch changes only which server-written block the token is rebuilt from. An open PR alone still grants nothing.None. The equality re-check (pr_head == head_sha == remote_head_sha) genuinely re-derives what the assessor required.AttributeErrorat call time, 0 collection/fixture errors, and the 2 base-passing tests are the defect-pinning reproductions that must pass on both sides.branches/:The single targeted failure,
test_pr_ownership_issue_pr_mismatch.py::TestAuthorOwnershipIssuePrMismatch::test_pidless_durable_lock_rejected, reproduces on base in isolation. The+49passes and+8subtests are exactly this PR's new tests. The suite creates no durable branch, worktree, lock, lease, comment or PR.Canonical PR State
STATE: PR #946 is open at head
79334d4840and has received one formal REQUEST_CHANGES review from sysadmin at that exact head. One blocking finding (B1) plus one medium (F2) and one minor (F3) are open. The branch introduces no test regression against baseaab54d4825.WHO_IS_NEXT: author
NEXT_ACTION: Author jcwalker3 must add regression coverage that drives the real enforcement paths with a renewal-bearing lock so that reverting any of gitea_mcp_server.py:2894, :5179, or :19464 fails a test (B1), correct or strengthen the caller-binding claim in the PR body (F2), optionally make the conflicting-evidence guarantee explicit (F3), push the result, and publish a new head-pinned handoff for a fresh independent review.
NEXT_PROMPT:
WHAT_HAPPENED: An independent review at the exact head read all three changed files, traced the evidence flow from owning_pr_renewal_evidence through the new rebuild, the shared resolver, and all three enforcement paths into issue_work_duplicate_gate._assess_owning_pr_exemption, and probed precedence and fall-through behaviour directly against the patched modules. The wiring, precedence and fail-closed matrix are correct. Reverting the primary wiring in a throwaway worktree at the same head left the new suite at 49 passed and the full suite byte identical to the PR's own result, proving no test protects the fix. Targeted and full suites were run at head and at a clean base worktree; failing test id sets are identical.
WHY: #945 exists because a correct decision layer was never wired into the paths that enforce it. This PR wires it correctly but ships no test that fails if the wiring is removed, so the same class of defect can silently return. The sibling recovery suite for #755 already drives the real MCP handler for exactly this reason.
ISSUE: #945
HEAD_SHA:
79334d4840REVIEW_STATUS: REQUEST_CHANGES posted at
79334d4840by sysadminMERGE_READY: no
BLOCKERS: test coverage blocker
VALIDATION: New #945 suite at head: 49 passed, 8 subtests. Targeted 24-file sweep at head: 1 failed, 514 passed, 32 subtests in 32.49s; at clean base worktree
aab54d48: 1 failed, 465 passed, 24 subtests in 32.13s; the single failure test_pidless_durable_lock_rejected reproduces on base in isolation. Full suite at head: 28 failed, 5574 passed, 6 skipped, 1002 subtests in 149.90s. Full suite at base: 28 failed, 5525 passed, 6 skipped, 994 subtests in 148.91s. Failing test id sets identical, so no regression originates from this branch. Wiring-revert probe at the same head: new suite 49 passed, full suite 28 failed/5574 passed with an identical failing id set, demonstrating the absent coverage. Base reproduction of the new suite fails with AttributeError at call time on both new symbols, 0 collection or fixture errors.LAST_UPDATED_BY: sysadmin / prgs-reviewer / gitea-reviewer namespace, reviewer lease session 93257-9ba6b15dd243
repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #946
issue: #945
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 93257-9ba6b15dd243
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr946-head
phase: released
candidate_head:
79334d4840target_branch: master
target_branch_sha:
aab54d4825last_activity: 2026-07-26T19:30:14Z
expires_at: 2026-07-26T19:40:14Z
blocker: manual-release
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.