1
0
Эх сурвалжийг харах

fix: dismiss prior reviews when delegating approval

Turtle 1 долоо хоног өмнө
parent
commit
60d26ac4ab

+ 2 - 2
.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md
-2026-09-15-pr-approval-delegation.md: 9a1ba54b59301492eba492e82395a5af23d22328
-2026-09-15-pr-approval-delegation.zh.md: 49128102413037713545f5ab7afe096abe8c8fc1
+2026-09-15-pr-approval-delegation.md: c55c8b79414e1a7c4920791fea911212d7a8a235
+2026-09-15-pr-approval-delegation.zh.md: 113ee93b246e4c71dca2bf4e9f788ce29ec98084

+ 5 - 3
.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md

@@ -10,9 +10,9 @@ A reviewer may trust another reviewer to decide a particular PR while retaining
 
 ## Decision
 
-The [approval policy](../../../../.github/review-ownership/README.md#delegating-points) accepts `/delegate @username` in PR conversation comments. Each eligible sender's points follow the named recipient's effective approval, once, using the sender's weight. Delegation applies only to that PR and only between write-capable accounts other than the PR author. Received points cannot be forwarded. Delegation does not clear either account's blocking review; [blocking-review policy](2026-09-09-blocked-weighted-approvals-remain-pending.md) remains authoritative.
+The [approval policy](../../../../.github/review-ownership/README.md#delegating-points) accepts `/delegate @username` in PR conversation comments. Each eligible sender's points follow the named recipient's effective approval, once, using the sender's weight. Delegation applies only to that PR and only between write-capable accounts other than the PR author. Received points cannot be forwarded. The command dismisses the sender's prior approvals and change requests through GitHub. Other effective blocking reviews still follow [blocking-review policy](2026-09-09-blocked-weighted-approvals-remain-pending.md).
 
-The latest surviving command in comment creation order determines the recipient. A self-delegation or a subsequently submitted review restores the sender's own decision. Comment-only reviews also reclaim the points; pending reviews do not. Equal timestamps favor the review, and subsequent dismissal cannot revive the delegation. A fresh command after the review can delegate again. Edits and deletions recompute from current comments, including restoration of older commands. The trusted publisher subscribes to all PR conversation comment changes, resolves the live PR head, and reads comments as API data. Creating or editing a still-active eligible command requests review from the recipient unless already requested; other requested reviewers remain unchanged. Drafts do not request reviews. Request failures fail evaluation. It preserves the separate [review-workflow validation](2026-09-10-approval-review-workflow-identity.md).
+The latest surviving command in comment creation order determines the recipient. A self-delegation or a subsequently submitted review restores the sender's own decision. Comment-only reviews also reclaim the points; pending reviews do not. Equal timestamps favor the review, and subsequent dismissal cannot revive the delegation. A fresh command after the review can delegate again. Edits and deletions recompute from current comments, including restoration of older commands. The trusted publisher subscribes to all PR conversation comment changes, resolves the live PR head, and reads comments as API data. Creating or editing a still-active eligible command dismisses the sender's existing decision reviews, then requests review from the recipient unless already requested; other requested reviewers remain unchanged. Scores refresh after dismissal, which retains the old submission timestamps and cannot cancel delegation. Removing the recipient's approval removes counted points while retaining delegation for their next approval. Drafts do not dismiss or request reviews. Dismissal and request failures fail evaluation. It preserves the separate [review-workflow validation](2026-09-10-approval-review-workflow-identity.md).
 
 ## Alternatives considered
 
@@ -22,10 +22,12 @@ The latest surviving command in comment creation order determines the recipient.
 
 **Forward received points through delegation chains.** This would let a recipient transfer another person's points to an account that person did not name.
 
+**Only suppress the sender's old decision in the score.** GitHub would retain the old approval or blocking review. Dismissing it keeps native review state consistent with the handoff.
+
 **Keep delegation after the sender reviews.** A submitted review expresses the sender's own decision, so continuing to use someone else's approval would disregard it.
 
 **Persist commands separately from comments.** This would require additional storage and reconciliation for edits and deletions; current comments already provide an inspectable record.
 
 ## Consequences
 
-Delegation preserves the sender's [production ownership weight](2026-09-11-production-blame-approval-weight.md) and the independent author-credit rule. Each evaluation needs complete comment history and current participant permissions; missing history fails evaluation. Editing an older command does not change its priority, and deleting a newer command may reactivate an older one. Policy tests cover score conservation, review-driven revocation, permission filtering, review requests, blockers, pagination, and failure publication; workflow tests pin trusted execution and shared per-PR concurrency. Native stale-review and latest-push requirements remain GitHub's responsibility.
+Delegation preserves the sender's [production ownership weight](2026-09-11-production-blame-approval-weight.md) and the independent author-credit rule. Each evaluation needs complete comment history and current participant permissions; missing history fails evaluation. Editing an older command does not change its priority, and deleting a newer command may reactivate an older one. Policy tests cover score conservation, review-driven revocation, prior-review dismissal, permission filtering, review requests, blockers, pagination, and failure publication; workflow tests pin trusted execution and shared per-PR concurrency. Native stale-review and latest-push requirements remain GitHub's responsibility.

+ 5 - 3
.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md

@@ -10,9 +10,9 @@ Status: implemented
 
 ## Decision
 
-[批准策略](../../../../.github/review-ownership/README.md#delegating-points) 接受 PR 普通评论中的 `/delegate @username`。每位符合资格的委托人,其分数跟随指定受托人的有效批准,按委托人的权重计入一次。委托仅适用于当前 PR,且双方都必须具有写权限,不能是 PR 作者。收到的分数不能继续转交。委托不会清除任一方的阻塞评审;[阻塞评审策略](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md) 仍是权威规则
+[批准策略](../../../../.github/review-ownership/README.md#delegating-points) 接受 PR 普通评论中的 `/delegate @username`。每位符合资格的委托人,其分数跟随指定受托人的有效批准,按委托人的权重计入一次。委托仅适用于当前 PR,且双方都必须具有写权限,不能是 PR 作者。收到的分数不能继续转交。命令会通过 GitHub 撤销委托人原有的批准和变更请求。其他有效阻塞评审仍遵循[阻塞评审策略](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md)。
 
-按评论创建顺序,最新且仍存在的命令决定受托人。委托给自己或随后提交评审会恢复使用委托人本人的评审决定。仅评论的评审也会收回分数;尚未提交的评审不会。时间戳相同时以评审为准,随后撤销评审也不会重新启用委托。评审之后的新命令可以再次委托。编辑和删除会根据当前评论重算,包括恢复较早的命令。可信发布器订阅所有 PR 普通评论变更,解析实时 PR 头提交,并将评论作为 API 数据读取。创建或编辑仍然有效且符合资格的命令时,会请求受托人进行评审;如果已经请求,则不重复请求,其他 requested reviewers 保持不变。草稿 PR 不发起评审请求。请求失败会使评估失败。独立的[评审工作流验证](2026-09-10-approval-review-workflow-identity.zh.md) 保持不变。
+按评论创建顺序,最新且仍存在的命令决定受托人。委托给自己或随后提交评审会恢复使用委托人本人的评审决定。仅评论的评审也会收回分数;尚未提交的评审不会。时间戳相同时以评审为准,随后撤销评审也不会重新启用委托。评审之后的新命令可以再次委托。编辑和删除会根据当前评论重算,包括恢复较早的命令。可信发布器订阅所有 PR 普通评论变更,解析实时 PR 头提交,并将评论作为 API 数据读取。创建或编辑仍然有效且符合资格的命令时,会先撤销委托人已有的决定性评审,再请求受托人进行评审;如果已经请求,则不重复请求,其他 requested reviewers 保持不变。撤销后刷新分数;撤销保留旧提交时间戳,不会取消委托。移除受托人的批准只会移除计入的分数,委托仍然保留,供其下次批准使用。草稿 PR 不撤销或请求评审。撤销或请求失败会使评估失败。独立的[评审工作流验证](2026-09-10-approval-review-workflow-identity.zh.md) 保持不变。
 
 ## Alternatives considered
 
@@ -22,10 +22,12 @@ Status: implemented
 
 **沿委托链继续转交收到的分数。** 这会允许受托人将他人的分数转给未经原委托人指定的账户。
 
+**只在分数中忽略委托人的旧决定。** GitHub 会保留原有批准或阻塞评审。撤销旧评审使原生评审状态与委托一致。
+
 **委托人提交评审后仍保留委托。** 已提交的评审表达了委托人本人的决定,继续使用他人的批准会忽略该决定。
 
 **在评论之外单独持久化命令。** 这需要额外存储,并协调编辑与删除;当前评论已经提供可检查的记录。
 
 ## Consequences
 
-委托保留委托人的[生产代码归属权重](2026-09-11-production-blame-approval-weight.zh.md)和独立的作者信用规则。每次评估都需要完整评论历史和参与者的当前权限;历史缺失会使评估失败。编辑较早命令不会改变其优先级,删除较新命令可能重新启用较早命令。策略测试覆盖分数守恒、评审触发的收回、权限过滤、评审请求、阻塞评审、分页和失败状态发布;工作流测试约束可信执行及每个 PR 共享的并发分组。过期评审和最新推送要求仍由 GitHub 原生规则负责。
+委托保留委托人的[生产代码归属权重](2026-09-11-production-blame-approval-weight.zh.md)和独立的作者信用规则。每次评估都需要完整评论历史和参与者的当前权限;历史缺失会使评估失败。编辑较早命令不会改变其优先级,删除较新命令可能重新启用较早命令。策略测试覆盖分数守恒、评审触发的收回、旧评审撤销、权限过滤、评审请求、阻塞评审、分页和失败状态发布;工作流测试约束可信执行及每个 PR 共享的并发分组。过期评审和最新推送要求仍由 GitHub 原生规则负责。

+ 3 - 3
.github/review-ownership/README.md

@@ -34,17 +34,17 @@ The publisher runs when a pull request opens, synchronizes, reopens, becomes rea
 
 ## Delegating points
 
-Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Creating or editing an active command also requests review from its recipient unless that user is already requested. Other requested reviewers remain unchanged. The command does not submit or dismiss a GitHub review; drafts do not request reviews.
+Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Creating or editing an active command dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews before requesting review from the recipient unless that user is already requested. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal publishes an error and prevents the review request; scores are refreshed after successful dismissal.
 
 Both accounts must currently have write or admin permission, and neither may be the PR author. Commands involving ineligible accounts are ignored. A sender's newest surviving syntactically valid command wins, in comment creation order; editing an older comment does not move it after newer comments. Submitting any review after delegation automatically takes the points back, including an approval, change request, or comment-only review. Pending reviews do not count; a later dismissal does not restore the delegation. When timestamps are equal, the review takes precedence. A new command after the review can delegate again; editing an older command cannot reactivate it. Post `/delegate @your-own-username` to restore your own review decision. Editing or deleting a command recomputes delegation from remaining comments, so deleting the latest command can restore an older one. Quoted commands, code blocks, review bodies, inline review comments, and commands mixed with other text do not count. Surrounding whitespace is allowed; account matching is case-insensitive.
 
-Each sender contributes at most once, using their own fixed weight or production-line ownership. A delegate can receive points from multiple senders, but can transfer only their own points: delegated points are not forwarded through another delegation. Author credit cannot be delegated. Every write-capable reviewer's effective `CHANGES_REQUESTED` still blocks the PR, including the sender's. Dismissing or replacing the delegate's approval removes the delegated points. Logs identify each counted score owner and their delegate. Comment history failures or the 3,000-record limit fail evaluation instead of accepting a partial history.
+Each sender contributes at most once, using their own fixed weight or production-line ownership. A delegate can receive points from multiple senders, but can transfer only their own points: delegated points are not forwarded through another delegation. Author credit cannot be delegated. Other reviewers' effective `CHANGES_REQUESTED` decisions still block the PR. The sender's old reviews are dismissed through GitHub, so their old change request stops blocking only after dismissal succeeds. That dismissal retains the original submission timestamp and does not cancel delegation; only a review submitted by the sender at or after the command automatically reclaims their points. Dismissing or replacing the delegate's approval removes the counted points but leaves the delegation active for the delegate's next approval. Logs identify each counted score owner and their delegate. Comment history failures or the 3,000-record limit fail evaluation instead of accepting a partial history.
 
 <a id="security"></a>
 
 ## Security
 
-All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. Pull-request write permission allows requesting the validated delegate as a reviewer. Only a created or edited comment that is still the active eligible command can request review; deletion, review events, and superseded or revoked commands cannot. A failed request fails the publisher and writes an error status.
+All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. Pull-request write permission allows dismissing the validated sender's prior decision reviews and requesting the delegate as a reviewer. Only a created or edited comment that is still the active eligible command can request review; deletion, review events, and superseded or revoked commands cannot. A failed dismissal or request fails the publisher and writes an error status.
 
 Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
 

+ 24 - 3
.github/review-ownership/check-approval.mjs

@@ -169,7 +169,7 @@ function timestamp(value, subject) {
 /**
  * Evaluate approval points from current reviews and repository permissions.
  * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
- * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands, and null author credit means history was not evaluated.
+ * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[]}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated.
  */
 export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) {
   const pull = pullRequestFromEvent(event)
@@ -207,6 +207,9 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
     const delegatedTo = writers.has(target) ? target : undefined
     if (delegatedTo) activeDelegations.push({
       login: participants.get(key), delegatedTo: participants.get(delegatedTo), commentId: delegation.commentId,
+      reviewIds: reviews.filter(review => review.user?.login.toLowerCase() === key
+        && ['APPROVED', 'CHANGES_REQUESTED'].includes(review.state.toUpperCase()))
+        .map(review => positiveInteger(review.id, 'delegated review ID')),
     })
     if (!approved.has(delegatedTo ?? key)) continue
     approvals.push({
@@ -271,6 +274,20 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
   let result
   try {
     result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
+    const delegation = activeCommentDelegation(event, result)
+    if (delegation?.reviewIds.length) {
+      for (const reviewId of delegation.reviewIds) {
+        const dismissed = await api(`/repos/${pull.repository}/pulls/${pull.number}/reviews/${reviewId}/dismissals`, {
+          method: 'PUT',
+          body: { message: `This is by automated Angry Turtle Cyborg, not a human. @${delegation.login} delegated approval to @${delegation.delegatedTo} via /delegate.`, event: 'DISMISS' },
+        })
+        if (!isRecord(dismissed) || dismissed.id !== reviewId || dismissed.state !== 'DISMISSED') {
+          throw new Error('delegated review dismissal was not confirmed')
+        }
+        write(`Dismissed @${delegation.login}'s review ${reviewId} for delegation to @${delegation.delegatedTo}.`)
+      }
+      result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
+    }
     await requestDelegatedReview(event, result, api, write)
   } catch (error) {
     await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
@@ -349,9 +366,13 @@ export async function approvalEventFromComment({ event, api }) {
   return resolved
 }
 
+function activeCommentDelegation(event, result) {
+  if (!isRecord(event.issue) || !['created', 'edited'].includes(event.action) || !isRecord(event.comment)) return undefined
+  return result.delegations.find(({ commentId }) => commentId === event.comment.id)
+}
+
 async function requestDelegatedReview(event, result, api, write) {
-  if (!isRecord(event.issue) || !['created', 'edited'].includes(event.action) || !isRecord(event.comment)) return
-  const delegation = result.delegations.find(({ commentId }) => commentId === event.comment.id)
+  const delegation = activeCommentDelegation(event, result)
   if (!delegation) return
   const path = `/repos/${result.pull.repository}/pulls/${result.pull.number}/requested_reviewers`
   const requested = await api(path)

+ 89 - 2
.github/review-ownership/check-approval.test.mjs

@@ -30,7 +30,7 @@ const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
   },
 })
 
-const review = (login, state, submitted_at = '2026-09-14T00:00:00Z') => ({ user: { login }, state, submitted_at })
+const review = (login, state, submitted_at = '2026-09-14T00:00:00Z', id = 10) => ({ user: { login }, state, submitted_at, id })
 
 test('loads the repository approval score policy', () => {
   const policy = parseApprovalPolicy(policySource)
@@ -828,7 +828,7 @@ test('inactive commands, drafts, and non-comment events do not request review',
     { permissions: { writer: 'read' } },
     { draft: true },
   ]) {
-    const api = delegationApi({ comments: [command], ...scenario })
+    const api = delegationApi({ comments: [command], reviews: [review('turtle1999', 'CHANGES_REQUESTED')], ...scenario })
     await runWithHistory({
       event: { ...pullRequestEvent({ draft: scenario.draft }),
         ...(scenario.event ?? { issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }),
@@ -863,6 +863,93 @@ test('failed review requests publish an error status', async () => {
   }
 })
 
+test('delegation dismisses only the sender old decisions and stays active across dismissal events', async () => {
+  const reviews = [review('turtle1999', 'APPROVED', undefined, 10), review('turtle1999', 'CHANGES_REQUESTED', undefined, 11),
+    review('turtle1999', 'COMMENTED', undefined, 12), review('turtle1999', 'PENDING', null, 13),
+    review('writer', 'APPROVED', undefined, 20), review('other', 'CHANGES_REQUESTED', undefined, 21)]
+  const calls = []
+  const states = []
+  const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews })
+  const api = async (path, options) => {
+    if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
+    if (path.endsWith('/dismissals')) {
+      calls.push(options)
+      const id = Number(path.split('/').at(-2))
+      const dismissed = reviews.find(review => review.id === id)
+      assert.ok([10, 11].includes(id))
+      dismissed.state = 'DISMISSED'
+      return dismissed
+    }
+    if (path.endsWith('/requested_reviewers')) {
+      if (options?.method === 'POST') calls.push(options)
+      return { users: [] }
+    }
+    return read(path)
+  }
+  const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
+    policySource, api, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0,
+  })
+  assert.deepEqual(calls.map(call => call.method), ['PUT', 'PUT', 'POST'])
+  assert.equal(calls[0].body.event, 'DISMISS')
+  assert.equal(calls[0].body.message, 'This is by automated Angry Turtle Cyborg, not a human. @turtle1999 delegated approval to @writer via /delegate.')
+  assert.deepEqual(result.blockers, ['other'])
+  assert.equal(result.delegations.length, 1)
+  assert.deepEqual(result.delegations[0].reviewIds, [])
+  assert.deepEqual(states, ['pending', 'pending'])
+  reviews.find(review => review.id === 21).state = 'DISMISSED'
+  for (const state of ['APPROVED', 'DISMISSED', 'COMMENTED', 'APPROVED']) {
+    reviews.find(review => review.id === 20).state = state
+    const next = await evaluateDelegation({ api })
+    assert.equal(next.delegations.length, 1)
+    assert.equal(next.state, state === 'APPROVED' ? 'success' : 'pending')
+  }
+  reviews.push(review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z', 30))
+  const reclaimed = await evaluateDelegation({ api })
+  assert.deepEqual(reclaimed.delegations, [])
+  assert.equal(reclaimed.points, 1)
+})
+
+test('a new sender review arriving during dismissal is not dismissed or delegated', async () => {
+  const reviews = [review('turtle1999', 'CHANGES_REQUESTED')]
+  const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews })
+  const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
+    policySource, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0,
+    api: async (path, options) => {
+      if (path.includes('/statuses/')) return {}
+      if (path.endsWith('/dismissals')) {
+        assert.ok(path.endsWith('/reviews/10/dismissals'))
+        reviews[0].state = 'DISMISSED'
+        reviews.push(review('turtle1999', 'CHANGES_REQUESTED', '2026-09-16T00:00:00Z', 11))
+        return reviews[0]
+      }
+      assert.ok(!path.endsWith('/requested_reviewers'))
+      return read(path, options)
+    },
+  })
+  assert.deepEqual(result.delegations, [])
+  assert.deepEqual(result.blockers, ['turtle1999'])
+})
+
+test('a failed or unconfirmed dismissal prevents review requests and publishes error', async () => {
+  for (const rejected of [false, true]) {
+    const states = []
+    const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews: [review('turtle1999', 'CHANGES_REQUESTED')] })
+    await assert.rejects(runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
+      policySource, runUrl: 'https://github.example/run/1', write: () => {},
+      api: async (path, options) => {
+        if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
+        if (path.endsWith('/dismissals')) {
+          if (rejected) throw new Error('dismissal denied')
+          return { id: 10, state: 'CHANGES_REQUESTED' }
+        }
+        assert.ok(!path.endsWith('/requested_reviewers'))
+        return read(path)
+      },
+    }), rejected ? /dismissal denied/u : /dismissal was not confirmed/u)
+    assert.deepEqual(states, ['pending', 'error'])
+  }
+})
+
 test('reads all comment pages, logs the score owner, and fails closed on missing comment history', async () => {
   const statuses = []
   const output = []