Context
Part of the review-stack architecture audit (parent epic). The audit traced every code path that can
call closePullRequest() and found two populations: the unified planAgentMaintenanceActions planner
(src/settings/agent-actions.ts:587-1244, 9 signal sources, sound design), and 5 hand-written
functions in src/queue/processors.ts that call closePullRequest() directly, bypassing the planner
entirely (documented as deliberate — procedural-integrity enforcement, not a merit verdict):
closeDraftDodgeAttemptIfBlocked — processors.ts:12775-12944, close at :12902
recloseDisallowedReopenIfNeeded — :12983-13166, close at :13139
closeReviewEvasionSelfCloseIfActive — :13205-13435, close at :13339
closeReviewEvasionDraftConversionIfActive — :13455-13643, close at :13563
closeRepeatedDraftCyclingIfDetected — :13670-13879, close at :13783
The bug
Two of the five are missing the close-autonomy-class check the other three correctly implement:
processors.ts:12839-12842 — closeDraftDodgeAttemptIfBlocked calls
resolveAgentPermissionReadiness({ autonomy: settings.autonomy, installationPermissions: draftDodgeInstallationPermissions }) without actionClass: "close". Per
requiredAgentActionPermissions (src/settings/agent-execution.ts:109-121), omitting actionClass
checks the union of every acting autonomy class's write-permission grant, not specifically close's.
processors.ts:6169-6176 — the call site gates only on isAgentConfigured(settings.autonomy)
(src/settings/autonomy.ts:39-41: true if any class is acting), never on
resolveAutonomy(settings.autonomy, "close").
processors.ts:12983-13166 (recloseDisallowedReopenIfNeeded) has no
resolveAgentPermissionReadiness call at all, gating only on the same loose
isAgentConfigured(reopenSettings.autonomy) at :13042.
Contrast with the three correct siblings, which explicitly do
const closeAutonomy = resolveAutonomy(settings.autonomy, "close"); if (closeAutonomy !== "auto") { deny }
at :13231-13232, :13483-13484, :13701-13702, and correctly pass actionClass: "close" at
:13283, :13531, :13749.
Impact
A repo that enables any other autonomy class (e.g. assign: "auto") but deliberately leaves close
unconfigured (documented deny-by-default) can still have PRs auto-closed by the draft-dodge and
reopen-reclose paths. This is a live authorization bypass, not a hypothetical.
Root cause
The 5 close-enforcement paths duplicate ~150-230 lines of near-identical scaffolding each (~980 lines
total: lock claim/release, mode resolution, permission readiness, live freshness re-check, the close
call, the audit-event write) instead of sharing one helper — the classic "same check hand-duplicated N
times, N-2 copies drift" pattern.
Fix
- Add
actionClass: "close" to the draft-dodge readiness call and an explicit
closeAutonomy !== "auto" deny to both draft-dodge and reopen-reclose (the immediate security fix).
- Extract one
enforceDeterministicClose(env, { repoFullName, pr, settings, deliveryId, eventType, lockLabel, isStillJustified, closeComment }) helper owning lock claim/release, mode resolution, the
close-autonomy check, scoped permission readiness, live freshness re-check, the close call, and the
audit-event write — leaving each of the 5 call sites only its ~10-30 lines of actually-distinct
justification logic. Estimated 600-700 line reduction, and makes the omission structurally impossible
to repeat a sixth time.
Acceptance criteria
Context
Part of the review-stack architecture audit (parent epic). The audit traced every code path that can
call
closePullRequest()and found two populations: the unifiedplanAgentMaintenanceActionsplanner(
src/settings/agent-actions.ts:587-1244, 9 signal sources, sound design), and 5 hand-writtenfunctions in
src/queue/processors.tsthat callclosePullRequest()directly, bypassing the plannerentirely (documented as deliberate — procedural-integrity enforcement, not a merit verdict):
closeDraftDodgeAttemptIfBlocked—processors.ts:12775-12944, close at:12902recloseDisallowedReopenIfNeeded—:12983-13166, close at:13139closeReviewEvasionSelfCloseIfActive—:13205-13435, close at:13339closeReviewEvasionDraftConversionIfActive—:13455-13643, close at:13563closeRepeatedDraftCyclingIfDetected—:13670-13879, close at:13783The bug
Two of the five are missing the
close-autonomy-class check the other three correctly implement:processors.ts:12839-12842—closeDraftDodgeAttemptIfBlockedcallsresolveAgentPermissionReadiness({ autonomy: settings.autonomy, installationPermissions: draftDodgeInstallationPermissions })withoutactionClass: "close". PerrequiredAgentActionPermissions(src/settings/agent-execution.ts:109-121), omittingactionClasschecks the union of every acting autonomy class's write-permission grant, not specifically
close's.processors.ts:6169-6176— the call site gates only onisAgentConfigured(settings.autonomy)(
src/settings/autonomy.ts:39-41: true if any class is acting), never onresolveAutonomy(settings.autonomy, "close").processors.ts:12983-13166(recloseDisallowedReopenIfNeeded) has noresolveAgentPermissionReadinesscall at all, gating only on the same looseisAgentConfigured(reopenSettings.autonomy)at:13042.Contrast with the three correct siblings, which explicitly do
const closeAutonomy = resolveAutonomy(settings.autonomy, "close"); if (closeAutonomy !== "auto") { deny }at
:13231-13232,:13483-13484,:13701-13702, and correctly passactionClass: "close"at:13283,:13531,:13749.Impact
A repo that enables any other autonomy class (e.g.
assign: "auto") but deliberately leavescloseunconfigured (documented deny-by-default) can still have PRs auto-closed by the draft-dodge and
reopen-reclose paths. This is a live authorization bypass, not a hypothetical.
Root cause
The 5 close-enforcement paths duplicate ~150-230 lines of near-identical scaffolding each (~980 lines
total: lock claim/release, mode resolution, permission readiness, live freshness re-check, the close
call, the audit-event write) instead of sharing one helper — the classic "same check hand-duplicated N
times, N-2 copies drift" pattern.
Fix
actionClass: "close"to the draft-dodge readiness call and an explicitcloseAutonomy !== "auto"deny to both draft-dodge and reopen-reclose (the immediate security fix).enforceDeterministicClose(env, { repoFullName, pr, settings, deliveryId, eventType, lockLabel, isStillJustified, closeComment })helper owning lock claim/release, mode resolution, theclose-autonomy check, scoped permission readiness, live freshness re-check, the close call, and theaudit-event write — leaving each of the 5 call sites only its ~10-30 lines of actually-distinct
justification logic. Estimated 600-700 line reduction, and makes the omission structurally impossible
to repeat a sixth time.
Acceptance criteria
closeDraftDodgeAttemptIfBlockedandrecloseDisallowedReopenIfNeededcorrectly deny whencloseautonomy is not"auto", matching the 3 correct siblings.enforceDeterministicClosehelper.assign: "auto"butcloseunconfigured must NOT have a PRauto-closed via any of the 5 paths.