Bläddra i källkod

fix: authenticate and reconcile approval delegations

Turtle 2 dagar sedan
förälder
incheckning
3e9b6ad94e

+ 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;
 # 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:
 # 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
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md
-2026-09-15-pr-approval-delegation.md: c55c8b79414e1a7c4920791fea911212d7a8a235
-2026-09-15-pr-approval-delegation.zh.md: 113ee93b246e4c71dca2bf4e9f788ce29ec98084
+2026-09-15-pr-approval-delegation.md: 4dcf0e8a8d23d66da11381586e376fdf39d23635
+2026-09-15-pr-approval-delegation.zh.md: bf21724d672aaf534bfe94540b76878177eeb57e

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

@@ -12,7 +12,7 @@ A reviewer may trust another reviewer to decide a particular PR while retaining
 
 
 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 [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 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).
+The latest surviving eligible command in comment creation order determines the recipient. Edited commands require the latest editor to match the author’s immutable account ID; other writers cannot transfer or cancel that author’s points by editing a comment. 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 filters conversation comment changes for `/delegate`, resolves the live open PR head, and reads comments and editor identity as API data. Every evaluation reconciles active eligible commands, dismissing prior decision reviews and requesting recipients unless already approved or requested. This covers replaced queued events and draft-to-ready transitions; 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 failures fail evaluation; request failures are logged separately and retried on later evaluations. Closed or merged PRs are skipped before status writes and dependency setup. It preserves the separate [review-workflow validation](2026-09-10-approval-review-workflow-identity.md).
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 
@@ -30,4 +30,4 @@ The latest surviving command in comment creation order determines the recipient.
 
 
 ## Consequences
 ## 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, 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.
+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 and editor 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.

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

@@ -12,7 +12,7 @@ Status: implemented
 
 
 [批准策略](../../../../.github/review-ownership/README.md#delegating-points) 接受 PR 普通评论中的 `/delegate @username`。每位符合资格的委托人,其分数跟随指定受托人的有效批准,按委托人的权重计入一次。委托仅适用于当前 PR,且双方都必须具有写权限,不能是 PR 作者。收到的分数不能继续转交。命令会通过 GitHub 撤销委托人原有的批准和变更请求。其他有效阻塞评审仍遵循[阻塞评审策略](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) 保持不变。
+按评论创建顺序,最新、仍存在且符合资格的命令决定受托人。编辑过的命令要求最近编辑者与作者的不可变账户 ID 一致;其他有写权限的人不能通过编辑评论转交或取消作者的分数。委托给自己或随后提交评审会恢复使用委托人本人的评审决定。仅评论的评审也会收回分数;尚未提交的评审不会。时间戳相同时以评审为准,随后撤销评审也不会重新启用委托。评审之后的新命令可以再次委托。编辑和删除会根据当前评论重算,包括恢复较早的命令。可信发布器按 `/delegate` 筛选普通评论变更,解析实时且未关闭的 PR 头提交,并将评论及编辑者身份作为 API 数据读取。每次评估都会处理所有有效且符合资格的命令,撤销旧的决定性评审,并请求尚未批准、尚未收到请求的受托人进行评审。这覆盖了排队事件被替换以及草稿转为 ready 的情况;其他 requested reviewers 保持不变。撤销后刷新分数;撤销保留旧提交时间戳,不会取消委托。移除受托人的批准只会移除计入的分数,委托仍然保留,供其下次批准使用。草稿 PR 不撤销或请求评审。撤销失败会使评估失败;请求失败单独记录日志,并在后续评估中重试。已关闭或合并的 PR 会在写入状态和安装依赖之前跳过。独立的[评审工作流验证](2026-09-10-approval-review-workflow-identity.zh.md) 保持不变。
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 
@@ -30,4 +30,4 @@ Status: implemented
 
 
 ## Consequences
 ## Consequences
 
 
-委托保留委托人的[生产代码归属权重](2026-09-11-production-blame-approval-weight.zh.md)和独立的作者信用规则。每次评估都需要完整评论历史和参与者的当前权限;历史缺失会使评估失败。编辑较早命令不会改变其优先级,删除较新命令可能重新启用较早命令。策略测试覆盖分数守恒、评审触发的收回、旧评审撤销、权限过滤、评审请求、阻塞评审、分页和失败状态发布;工作流测试约束可信执行及每个 PR 共享的并发分组。过期评审和最新推送要求仍由 GitHub 原生规则负责。
+委托保留委托人的[生产代码归属权重](2026-09-11-production-blame-approval-weight.zh.md)和独立的作者信用规则。每次评估都需要完整评论与编辑者历史和参与者的当前权限;历史缺失会使评估失败。编辑较早命令不会改变其优先级,删除较新命令可能重新启用较早命令。策略测试覆盖分数守恒、评审触发的收回、旧评审撤销、权限过滤、评审请求、阻塞评审、分页和失败状态发布;工作流测试约束可信执行及每个 PR 共享的并发分组。过期评审和最新推送要求仍由 GitHub 原生规则负责。

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

@@ -28,15 +28,15 @@ Production source means supported code files under `src/` in `packages/`, `apps/
 
 
 Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
 Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
 
 
-The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. It also runs when a PR conversation comment is created, edited, or deleted. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed event.
+The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before status writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though approval statuses target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed event.
 
 
 <a id="delegating-points"></a>
 <a id="delegating-points"></a>
 
 
 ## Delegating points
 ## 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 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.
+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. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. 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 review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests.
 
 
-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.
+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 eligible, author-authorized 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. Submitting an inline diff comment with “Add single comment” also creates a comment-only review and reclaims the points. 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. An edited command is accepted only when GitHub identifies the original author as its latest editor; edits by other accounts cannot delegate or cancel that author’s points.
 
 
 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.
 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.
 
 
@@ -44,7 +44,7 @@ Each sender contributes at most once, using their own fixed weight or production
 
 
 ## Security
 ## 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 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.
+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. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision.
 
 
 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.
 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.
 
 
@@ -52,7 +52,7 @@ Approval policy changes take effect only after they merge into the default branc
 
 
 ## Verification
 ## Verification
 
 
-Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
+Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
 
 
 <a id="dev-note"></a>
 <a id="dev-note"></a>
 
 

+ 94 - 42
.github/review-ownership/check-approval.mjs

@@ -1,6 +1,6 @@
 #!/usr/bin/env node
 #!/usr/bin/env node
 
 
-import { readFileSync } from 'node:fs'
+import { appendFileSync, readFileSync } from 'node:fs'
 import process from 'node:process'
 import process from 'node:process'
 import { pathToFileURL } from 'node:url'
 import { pathToFileURL } from 'node:url'
 
 
@@ -132,23 +132,63 @@ async function listRecords(api, path, subject) {
   }
   }
 }
 }
 
 
-// The API orders comments by creation ID; edits do not reorder commands.
-function effectiveDelegations(comments, reviews) {
-  const delegations = new Map()
+async function delegationCommands(api, comments) {
+  const candidates = []
   for (const comment of comments) {
   for (const comment of comments) {
     if (!isRecord(comment)) throw new Error('pull-request comment is not an object')
     if (!isRecord(comment)) throw new Error('pull-request comment is not an object')
     if (comment.user === null) continue
     if (comment.user === null) continue
     if (typeof comment.body !== 'string') throw new Error('pull-request comment has no body')
     if (typeof comment.body !== 'string') throw new Error('pull-request comment has no body')
     const match = /^\/delegate @([^\s]+)$/u.exec(comment.body.trim())
     const match = /^\/delegate @([^\s]+)$/u.exec(comment.body.trim())
     if (!match || !LOGIN.test(match[1])) continue
     if (!match || !LOGIN.test(match[1])) continue
-    const login = validateLogin(comment.user?.login, 'delegation author').toLowerCase()
-    const delegate = match[1].toLowerCase()
-    if (login === delegate) delegations.delete(login)
-    else {
-      if (!Number.isSafeInteger(comment.id) || comment.id <= 0) throw new Error('delegation comment has no valid ID')
-      delegations.set(login, { delegate, commentId: comment.id, createdAt: timestamp(comment.created_at, 'delegation comment') })
+    const login = validateLogin(comment.user?.login, 'delegation author')
+    if (!Number.isSafeInteger(comment.id) || comment.id <= 0) throw new Error('delegation comment has no valid ID')
+    if (!comment.node_id || !comment.user.node_id) throw new Error('delegation comment has no account or node ID')
+    candidates.push({ comment, login, delegate: match[1], createdAt: timestamp(comment.created_at, 'delegation comment') })
+  }
+  const commands = []
+  for (let offset = 0; offset < candidates.length; offset += PAGE_SIZE) {
+    const batch = candidates.slice(offset, offset + PAGE_SIZE)
+    const response = await api('/graphql', {
+      method: 'POST',
+      body: {
+        query: `query($ids: [ID!]!) {
+          nodes(ids: $ids) { ... on IssueComment {
+            id body createdAt lastEditedAt
+            author { ... on Node { id } }
+            editor { ... on Node { id } }
+          } }
+        }`,
+        variables: { ids: batch.map(({ comment }) => comment.node_id) },
+      },
+    })
+    const nodes = response?.data?.nodes
+    if (response?.errors?.length || !Array.isArray(nodes) || nodes.length !== batch.length) {
+      throw new Error('delegation comment editor history is incomplete')
+    }
+    for (const [index, command] of batch.entries()) {
+      const node = nodes[index]
+      const { comment } = command
+      if (node?.id !== comment.node_id || node.body !== comment.body || node.createdAt !== comment.created_at
+        || node.author?.id !== comment.user.node_id || node.lastEditedAt === undefined) {
+        throw new Error('delegation comment changed or editor history is incomplete')
+      }
+      // Other writers can edit a comment without changing its original author.
+      if (node.lastEditedAt !== null && node.editor?.id !== node.author.id) continue
+      commands.push(command)
     }
     }
   }
   }
+  return commands
+}
+
+// Reviews must first pass effectiveReviewDecisions; commands retain creation order after edits.
+function effectiveDelegations(commands, reviews, writers) {
+  const delegations = new Map()
+  for (const { login, delegate, comment, createdAt } of commands) {
+    const key = login.toLowerCase()
+    if (!writers.has(key) || !writers.has(delegate.toLowerCase())) continue
+    if (key === delegate.toLowerCase()) delegations.delete(key)
+    else delegations.set(key, { delegate: delegate.toLowerCase(), commentId: comment.id, createdAt })
+  }
   for (const review of reviews) {
   for (const review of reviews) {
     if (review.user === null || review.state.toUpperCase() === 'PENDING') continue
     if (review.user === null || review.state.toUpperCase() === 'PENDING') continue
     const login = review.user.login.toLowerCase()
     const login = review.user.login.toLowerCase()
@@ -169,7 +209,7 @@ function timestamp(value, subject) {
 /**
 /**
  * Evaluate approval points from current reviews and repository permissions.
  * 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.
  * @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, 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.
+ * @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[], approved: boolean}>, 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 }) {
 export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) {
   const pull = pullRequestFromEvent(event)
   const pull = pullRequestFromEvent(event)
@@ -182,11 +222,11 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
   const decisions = effectiveReviewDecisions(reviews)
   const decisions = effectiveReviewDecisions(reviews)
     .filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
     .filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
   const comments = await listRecords(api, `/repos/${pull.repository}/issues/${pull.number}/comments`, 'pull-request comments')
   const comments = await listRecords(api, `/repos/${pull.repository}/issues/${pull.number}/comments`, 'pull-request comments')
-  const delegations = effectiveDelegations(comments, reviews)
+  const commands = await delegationCommands(api, comments)
   const participants = new Map(decisions.map(({ login }) => [login.toLowerCase(), login]))
   const participants = new Map(decisions.map(({ login }) => [login.toLowerCase(), login]))
-  for (const [login, { delegate }] of delegations) {
-    participants.set(login, participants.get(login) ?? login)
-    participants.set(delegate, participants.get(delegate) ?? delegate)
+  for (const { login, delegate } of commands) {
+    participants.set(login.toLowerCase(), participants.get(login.toLowerCase()) ?? login)
+    participants.set(delegate.toLowerCase(), participants.get(delegate.toLowerCase()) ?? delegate)
   }
   }
   participants.delete(pull.author.toLowerCase())
   participants.delete(pull.author.toLowerCase())
   const writers = new Set()
   const writers = new Set()
@@ -196,6 +236,7 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
     if (WRITABLE_PERMISSIONS.has(permission)) writers.add(key)
     if (WRITABLE_PERMISSIONS.has(permission)) writers.add(key)
     else ignoredReviewers.push(login)
     else ignoredReviewers.push(login)
   }
   }
+  const delegations = effectiveDelegations(commands, reviews, writers)
   const approvals = []
   const approvals = []
   const activeDelegations = []
   const activeDelegations = []
   const blockers = decisions.filter(({ login, state }) => writers.has(login.toLowerCase()) && state === 'CHANGES_REQUESTED')
   const blockers = decisions.filter(({ login, state }) => writers.has(login.toLowerCase()) && state === 'CHANGES_REQUESTED')
@@ -206,7 +247,7 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
     const target = delegation?.delegate
     const target = delegation?.delegate
     const delegatedTo = writers.has(target) ? target : undefined
     const delegatedTo = writers.has(target) ? target : undefined
     if (delegatedTo) activeDelegations.push({
     if (delegatedTo) activeDelegations.push({
-      login: participants.get(key), delegatedTo: participants.get(delegatedTo), commentId: delegation.commentId,
+      login: participants.get(key), delegatedTo: participants.get(delegatedTo), commentId: delegation.commentId, approved: approved.has(delegatedTo),
       reviewIds: reviews.filter(review => review.user?.login.toLowerCase() === key
       reviewIds: reviews.filter(review => review.user?.login.toLowerCase() === key
         && ['APPROVED', 'CHANGES_REQUESTED'].includes(review.state.toUpperCase()))
         && ['APPROVED', 'CHANGES_REQUESTED'].includes(review.state.toUpperCase()))
         .map(review => positiveInteger(review.id, 'delegated review ID')),
         .map(review => positiveInteger(review.id, 'delegated review ID')),
@@ -274,8 +315,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
   let result
   let result
   try {
   try {
     result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
     result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
-    const delegation = activeCommentDelegation(event, result)
-    if (delegation?.reviewIds.length) {
+    for (const delegation of result.delegations) {
       for (const reviewId of delegation.reviewIds) {
       for (const reviewId of delegation.reviewIds) {
         const dismissed = await api(`/repos/${pull.repository}/pulls/${pull.number}/reviews/${reviewId}/dismissals`, {
         const dismissed = await api(`/repos/${pull.repository}/pulls/${pull.number}/reviews/${reviewId}/dismissals`, {
           method: 'PUT',
           method: 'PUT',
@@ -286,9 +326,10 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
         }
         }
         write(`Dismissed @${delegation.login}'s review ${reviewId} for delegation to @${delegation.delegatedTo}.`)
         write(`Dismissed @${delegation.login}'s review ${reviewId} for delegation to @${delegation.delegatedTo}.`)
       }
       }
+    }
+    if (result.delegations.some(({ reviewIds }) => reviewIds.length)) {
       result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
       result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
     }
     }
-    await requestDelegatedReview(event, result, api, write)
   } catch (error) {
   } catch (error) {
     await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
     await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
     throw error
     throw error
@@ -303,6 +344,11 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
   writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
   writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
   await publishStatus(api, pull, result.state, result.description, runUrl)
   await publishStatus(api, pull, result.state, result.description, runUrl)
   write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
   write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
+  try {
+    await requestDelegatedReviews(result, api, write)
+  } catch (error) {
+    write(`Review request failed; approval score is unchanged and the next evaluation will retry: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`)
+  }
   return result
   return result
 }
 }
 
 
@@ -320,7 +366,7 @@ export async function publishApprovalPhase({ event, api, runUrl, phase }) {
 /**
 /**
  * Resolve the reviewed pull request from a completed run of the review-event workflow file.
  * Resolve the reviewed pull request from a completed run of the review-event workflow file.
  * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Trusted workflow inputs.
  * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Trusted workflow inputs.
- * @returns {Promise<Record<string, unknown> | null>} Event with a current pull request, or null after the pull-request head changes.
+ * @returns {Promise<Record<string, unknown> | null>} Event with a current pull request, or null after the PR closes or its head changes.
  */
  */
 export async function approvalEventFromWorkflowRun({ event, api }) {
 export async function approvalEventFromWorkflowRun({ event, api }) {
   const repository = repositoryFromEvent(event)
   const repository = repositoryFromEvent(event)
@@ -341,7 +387,8 @@ export async function approvalEventFromWorkflowRun({ event, api }) {
   }
   }
   const pull = await api(`/repos/${repository}/pulls/${pullNumber}`)
   const pull = await api(`/repos/${repository}/pulls/${pullNumber}`)
   if (!isRecord(pull) || !isRecord(pull.head)) throw new Error(`pull request #${pullNumber} response is invalid`)
   if (!isRecord(pull) || !isRecord(pull.head)) throw new Error(`pull request #${pullNumber} response is invalid`)
-  if (pull.head.sha !== expectedHeadSha) return null
+  if (!['open', 'closed'].includes(pull.state)) throw new Error('pull request has an invalid state')
+  if (pull.state === 'closed' || pull.head.sha !== expectedHeadSha) return null
   return { ...event, pull_request: pull }
   return { ...event, pull_request: pull }
 }
 }
 
 
@@ -366,21 +413,29 @@ export async function approvalEventFromComment({ event, api }) {
   return resolved
   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)
+/**
+ * Refresh a pull-request event before any status write; closed PRs and stale heads are skipped.
+ * @param {{event: unknown, api: (path: string) => Promise<unknown>}} options Pull-request event and API caller.
+ * @returns {Promise<Record<string, unknown> | null>} Live event, or null for a closed PR or superseded head.
+ */
+export async function approvalEventFromPullRequest({ event, api }) {
+  const expected = pullRequestFromEvent(event)
+  const resolved = await approvalEventFromComment({ event: { ...event, issue: { number: expected.number, pull_request: {} } }, api })
+  if (resolved === null || resolved.pull_request.head.sha !== expected.headSha) return null
+  return { ...event, pull_request: resolved.pull_request }
 }
 }
 
 
-async function requestDelegatedReview(event, result, api, write) {
-  const delegation = activeCommentDelegation(event, result)
-  if (!delegation) return
+async function requestDelegatedReviews(result, api, write) {
+  const recipients = new Map(result.delegations.filter(({ approved }) => !approved)
+    .map(({ delegatedTo }) => [delegatedTo.toLowerCase(), delegatedTo]))
+  if (!recipients.size) return
   const path = `/repos/${result.pull.repository}/pulls/${result.pull.number}/requested_reviewers`
   const path = `/repos/${result.pull.repository}/pulls/${result.pull.number}/requested_reviewers`
   const requested = await api(path)
   const requested = await api(path)
   if (!isRecord(requested) || !Array.isArray(requested.users)) throw new Error('requested reviewers response has no users array')
   if (!isRecord(requested) || !Array.isArray(requested.users)) throw new Error('requested reviewers response has no users array')
-  const logins = requested.users.map(user => validateLogin(user?.login, 'requested reviewer').toLowerCase())
-  if (logins.includes(delegation.delegatedTo.toLowerCase())) return
-  await api(path, { method: 'POST', body: { reviewers: [delegation.delegatedTo] } })
-  write(`Requested review from @${delegation.delegatedTo} for @${delegation.login}'s delegation.`)
+  for (const user of requested.users) recipients.delete(validateLogin(user?.login, 'requested reviewer').toLowerCase())
+  if (!recipients.size) return
+  await api(path, { method: 'POST', body: { reviewers: [...recipients.values()] } })
+  write(`Requested delegated reviews from ${[...recipients.values()].map(login => `@${login}`).join(', ')}.`)
 }
 }
 
 
 function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail, authorCredit = null, delegations = []) {
 function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail, authorCredit = null, delegations = []) {
@@ -504,22 +559,19 @@ async function main() {
   })
   })
   if (isRecord(event) && isRecord(event.issue)) {
   if (isRecord(event) && isRecord(event.issue)) {
     event = await approvalEventFromComment({ event, api })
     event = await approvalEventFromComment({ event, api })
-    if (event === null) return
+  } else if (isRecord(event) && isRecord(event.workflow_run)) {
+    event = await approvalEventFromWorkflowRun({ event, api })
+  } else {
+    event = await approvalEventFromPullRequest({ event, api })
   }
   }
-  if (isRecord(event) && isRecord(event.workflow_run)) {
-    const resolved = await approvalEventFromWorkflowRun({
-      event,
-      api,
-    })
-    if (resolved === null) {
-      process.stdout.write('Skipped a review event for a superseded pull-request head.\n')
-      return
-    }
-    event = resolved
+  if (event === null) {
+    process.stdout.write('Skipped an issue, closed pull request, or superseded pull-request head.\n')
+    return
   }
   }
   const phase = process.argv[2]
   const phase = process.argv[2]
   if (phase) {
   if (phase) {
     await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
     await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
+    if (phase === 'pending' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
     return
     return
   }
   }
   await runApprovalCheck({
   await runApprovalCheck({

+ 145 - 22
.github/review-ownership/check-approval.test.mjs

@@ -5,6 +5,7 @@ import test from 'node:test'
 import {
 import {
   approvalEventFromWorkflowRun,
   approvalEventFromWorkflowRun,
   approvalEventFromComment,
   approvalEventFromComment,
+  approvalEventFromPullRequest,
   createGitHubApi,
   createGitHubApi,
   effectiveReviewDecisions,
   effectiveReviewDecisions,
   evaluateApproval as evaluateWithHistory,
   evaluateApproval as evaluateWithHistory,
@@ -24,6 +25,7 @@ const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
   repository: { full_name: 'deepseek-harness/deepseek-harness' },
   repository: { full_name: 'deepseek-harness/deepseek-harness' },
   pull_request: {
   pull_request: {
     number: 42,
     number: 42,
+    state: 'open',
     draft,
     draft,
     user: { login: author, node_id: 'author-id' },
     user: { login: author, node_id: 'author-id' },
     head: { sha: HEAD_SHA },
     head: { sha: HEAD_SHA },
@@ -623,10 +625,17 @@ test('bot authors receive the same history credit', async () => {
   assert.equal(result.state, 'success')
   assert.equal(result.state, 'success')
 })
 })
 
 
-const comment = (login, body, created_at = '2026-09-15T00:00:00Z', id = 1) => ({ user: { login }, body, created_at, id })
+const comment = (login, body, created_at = '2026-09-15T00:00:00Z', id = 1) => ({
+  user: { login, node_id: `account:${login.toLowerCase()}` }, body, created_at, id, node_id: `comment:${login}:${id}:${body}:${created_at}`,
+})
 
 
 function delegationApi({ comments = [], reviews = [], permissions = {} } = {}) {
 function delegationApi({ comments = [], reviews = [], permissions = {} } = {}) {
-  return async path => {
+  return async (path, options) => {
+    if (path === '/graphql') return { data: { nodes: options.body.variables.ids.map(id => {
+      const entry = comments.find(comment => comment.node_id === id)
+      return { id, body: entry.body, createdAt: entry.created_at, author: { id: entry.user.node_id }, lastEditedAt: null, editor: null,
+        ...entry.editorMetadata }
+    }) } }
     if (path.includes('/comments?')) return comments
     if (path.includes('/comments?')) return comments
     if (path.includes('/reviews?')) return reviews
     if (path.includes('/reviews?')) return reviews
     const match = /\/collaborators\/([^/]+)\/permission$/u.exec(path)
     const match = /\/collaborators\/([^/]+)\/permission$/u.exec(path)
@@ -659,7 +668,7 @@ test('delegates the sender fixed points once after the recipient approves, retai
         ...(senderState === 'CHANGES_REQUESTED' ? ['turtle1999'] : []),
         ...(senderState === 'CHANGES_REQUESTED' ? ['turtle1999'] : []),
         ...(recipientState === 'CHANGES_REQUESTED' ? ['writer'] : []),
         ...(recipientState === 'CHANGES_REQUESTED' ? ['writer'] : []),
       ])
       ])
-      if (points) assert.deepEqual(result.approvals[0], { login: 'turtle1999', points: 2, delegatedTo: 'writer' })
+      if (points) assert.deepEqual(result.approvals[0], { login: senderState ? 'turtle1999' : 'Turtle1999', points: 2, delegatedTo: 'writer' })
     }
     }
   }
   }
 })
 })
@@ -806,23 +815,20 @@ test('an active delegate command requests review once and preserves other reques
             return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] }
             return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] }
           }
           }
           if (path.includes('/statuses/')) return {}
           if (path.includes('/statuses/')) return {}
-          return api(path)
+          return api(path, options)
         },
         },
       })
       })
       assert.equal(result.points, 0)
       assert.equal(result.points, 0)
       assert.deepEqual(requests, alreadyRequested ? [] : [{ reviewers: ['writer'] }])
       assert.deepEqual(requests, alreadyRequested ? [] : [{ reviewers: ['writer'] }])
-      assert.equal(output.includes("Requested review from @writer for @turtle1999's delegation."), !alreadyRequested)
+      assert.equal(output.includes("Requested delegated reviews from @writer."), !alreadyRequested)
     }
     }
   }
   }
 })
 })
 
 
-test('inactive commands, drafts, and non-comment events do not request review', async () => {
+test('inactive commands and drafts do not request review', async () => {
   const command = comment('turtle1999', '/delegate @writer')
   const command = comment('turtle1999', '/delegate @writer')
   for (const scenario of [
   for (const scenario of [
-    { event: {} },
-    { event: { issue: { number: 42, pull_request: {} }, action: 'deleted', comment: { id: 1 } } },
     { comments: [comment('turtle1999', '/delegate @turtle1999')] },
     { comments: [comment('turtle1999', '/delegate @turtle1999')] },
-    { comments: [command, comment('turtle1999', '/delegate @other', '2026-09-16T00:00:00Z', 2)] },
     { reviews: [review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z')] },
     { reviews: [review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z')] },
     { permissions: { turtle1999: 'read' } },
     { permissions: { turtle1999: 'read' } },
     { permissions: { writer: 'read' } },
     { permissions: { writer: 'read' } },
@@ -843,23 +849,26 @@ test('inactive commands, drafts, and non-comment events do not request review',
   }
   }
 })
 })
 
 
-test('failed review requests publish an error status', async () => {
+test('failed review requests preserve the computed score and report the retry', async () => {
   for (const failedRead of [false, true]) {
   for (const failedRead of [false, true]) {
     const statuses = []
     const statuses = []
-    const api = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')] })
-    await assert.rejects(runWithHistory({
+    const api = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews: [review('imccyu', 'APPROVED')] })
+    const output = []
+    await runWithHistory({
       event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
       event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
-      policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {},
+      policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), write: line => output.push(line),
       api: async (path, options) => {
       api: async (path, options) => {
         if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
         if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
         if (path.endsWith('/requested_reviewers')) {
         if (path.endsWith('/requested_reviewers')) {
           if (options?.method === 'POST') throw new Error('request rejected')
           if (options?.method === 'POST') throw new Error('request rejected')
           return failedRead ? {} : { users: [] }
           return failedRead ? {} : { users: [] }
         }
         }
-        return api(path)
+        return api(path, options)
       },
       },
-    }), failedRead ? /no users array/u : /request rejected/u)
-    assert.deepEqual(statuses, ['pending', 'error'])
+    })
+    assert.match(output.at(-1), failedRead ? /no users array/u : /request rejected/u)
+    assert.match(output.at(-1), /score is unchanged.*will retry/u)
+    assert.deepEqual(statuses, ['pending', 'success'])
   }
   }
 })
 })
 
 
@@ -884,12 +893,12 @@ test('delegation dismisses only the sender old decisions and stays active across
       if (options?.method === 'POST') calls.push(options)
       if (options?.method === 'POST') calls.push(options)
       return { users: [] }
       return { users: [] }
     }
     }
-    return read(path)
+    return read(path, options)
   }
   }
   const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
   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,
     policySource, api, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0,
   })
   })
-  assert.deepEqual(calls.map(call => call.method), ['PUT', 'PUT', 'POST'])
+  assert.deepEqual(calls.map(call => call.method), ['PUT', 'PUT'])
   assert.equal(calls[0].body.event, 'DISMISS')
   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.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.deepEqual(result.blockers, ['other'])
@@ -943,7 +952,7 @@ test('a failed or unconfirmed dismissal prevents review requests and publishes e
           return { id: 10, state: 'CHANGES_REQUESTED' }
           return { id: 10, state: 'CHANGES_REQUESTED' }
         }
         }
         assert.ok(!path.endsWith('/requested_reviewers'))
         assert.ok(!path.endsWith('/requested_reviewers'))
-        return read(path)
+        return read(path, options)
       },
       },
     }), rejected ? /dismissal denied/u : /dismissal was not confirmed/u)
     }), rejected ? /dismissal denied/u : /dismissal was not confirmed/u)
     assert.deepEqual(states, ['pending', 'error'])
     assert.deepEqual(states, ['pending', 'error'])
@@ -953,21 +962,22 @@ test('a failed or unconfirmed dismissal prevents review requests and publishes e
 test('reads all comment pages, logs the score owner, and fails closed on missing comment history', async () => {
 test('reads all comment pages, logs the score owner, and fails closed on missing comment history', async () => {
   const statuses = []
   const statuses = []
   const output = []
   const output = []
-  const api = delegationApi({ reviews: [review('writer', 'APPROVED')] })
+  const pageCommand = comment('turtle1999', '/delegate @writer')
+  const api = delegationApi({ comments: [pageCommand], reviews: [review('writer', 'APPROVED')] })
   const run = comments => runWithHistory({
   const run = comments => runWithHistory({
     event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
     event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
     getMergedCount: async () => 0, write: line => output.push(line),
     getMergedCount: async () => 0, write: line => output.push(line),
     api: async (path, options) => {
     api: async (path, options) => {
       if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
       if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
       if (path.includes('/comments?')) return comments(path)
       if (path.includes('/comments?')) return comments(path)
-      return api(path)
+      return api(path, options)
     },
     },
   })
   })
   const pages = []
   const pages = []
   await run(path => {
   await run(path => {
     pages.push(path)
     pages.push(path)
     return path.endsWith('page=1') ? Array.from({ length: 100 }, () => comment('writer', 'text'))
     return path.endsWith('page=1') ? Array.from({ length: 100 }, () => comment('writer', 'text'))
-      : [comment('turtle1999', '/delegate @writer')]
+      : [pageCommand]
   })
   })
   assert.equal(pages.length, 2)
   assert.equal(pages.length, 2)
   assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)'))
   assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)'))
@@ -1006,3 +1016,116 @@ test('comment events resolve the live PR head and skip ordinary issues and close
     await assert.rejects(approvalEventFromComment({ event, api: async () => response }))
     await assert.rejects(approvalEventFromComment({ event, api: async () => response }))
   }
   }
 })
 })
+
+test('only the author can authorize a command edit, on every event reconstruction', async () => {
+  for (const editor of [{ id: 'account:attacker' }, null, { id: 'account:turtle1999' }]) {
+    const command = { ...comment('turtle1999', '/delegate @attacker'), editorMetadata: {
+      lastEditedAt: '2026-09-15T01:00:00Z', editor,
+    } }
+    const result = await evaluateDelegation({ api: delegationApi({ comments: [command], reviews: [review('attacker', 'APPROVED')] }) })
+    assert.equal(result.points, editor?.id === 'account:turtle1999' ? 3 : 1)
+    assert.equal(result.delegations.length, editor?.id === 'account:turtle1999' ? 1 : 0)
+  }
+})
+
+test('ineligible or third-party edited commands do not replace an earlier eligible delegation', async () => {
+  for (const later of [comment('turtle1999', '/delegate @reader'), comment('turtle1999', '/delegate @author'),
+    { ...comment('turtle1999', '/delegate @turtle1999'), editorMetadata: { lastEditedAt: '2026-09-15T01:00:00Z', editor: { id: 'account:attacker' } } }]) {
+    const result = await evaluateDelegation({ api: delegationApi({
+      comments: [comment('turtle1999', '/delegate @writer'), later],
+      reviews: [review('writer', 'APPROVED')], permissions: { reader: 'read' },
+    }) })
+    assert.equal(result.points, 3)
+    assert.equal(result.delegations[0].delegatedTo, 'writer')
+  }
+})
+
+test('missing, partial, changed, or failed editor metadata prevents scoring', async () => {
+  const command = comment('turtle1999', '/delegate @writer')
+  const read = delegationApi({ comments: [command], reviews: [review('writer', 'APPROVED')] })
+  for (const response of [null, {}, { data: { nodes: [] } }, { data: { nodes: [null] } }, { errors: [{ message: 'forbidden' }] }]) {
+    await assert.rejects(evaluateDelegation({ api: (path, options) => path === '/graphql' ? response : read(path, options) }), /editor history/u)
+  }
+  for (const patch of [{ body: '/delegate @attacker' }, { author: { id: 'account:attacker' } }, { lastEditedAt: undefined }, { id: 'other-comment' }]) {
+    await assert.rejects(evaluateDelegation({ api: delegationApi({ comments: [{ ...command, editorMetadata: patch }] }) }), /editor history/u)
+  }
+  await assert.rejects(evaluateDelegation({ api: (path, options) => {
+    if (path === '/graphql') throw new Error('editor API unavailable')
+    return read(path, options)
+  } }), /editor API unavailable/u)
+})
+
+test('editor verification batches complete command history', async () => {
+  const comments = Array.from({ length: 101 }, (_, index) => comment('turtle1999', '/delegate @writer', undefined, index + 1))
+  const read = delegationApi({ comments })
+  const batches = []
+  const result = await evaluateDelegation({ api: (path, options) => {
+    if (path.includes('/comments?')) return path.endsWith('page=1') ? comments.slice(0, 100) : comments.slice(100)
+    if (path === '/graphql') batches.push(options.body.variables.ids.length)
+    return read(path, options)
+  } })
+  assert.deepEqual(batches, [100, 1])
+  assert.equal(result.delegations[0].commentId, 101)
+})
+
+test('later events reconcile all active delegations after a draft or replaced comment event', async () => {
+  for (const action of ['ready_for_review', 'synchronize', 'submitted', 'deleted']) {
+    const reviews = [review('Turtle1999', 'CHANGES_REQUESTED', undefined, 10), review('second', 'APPROVED', undefined, 11)]
+    const read = delegationApi({ comments: [comment('Turtle1999', '/delegate @Writer'), comment('second', '/delegate @WRITER')], reviews })
+    const effects = []
+    const requested = []
+    const api = async (path, options) => {
+      if (path.includes('/statuses/')) return {}
+      if (path.endsWith('/dismissals')) {
+        const old = reviews.find(review => path.endsWith(`/reviews/${review.id}/dismissals`))
+        effects.push(old.id)
+        old.state = 'DISMISSED'
+        return old
+      }
+      if (path.endsWith('/requested_reviewers')) {
+        if (options?.method === 'POST') { effects.push(options.body); requested.push({ login: 'writer' }) }
+        return { users: requested }
+      }
+      return read(path, options)
+    }
+    const run = draft => runWithHistory({ event: { ...pullRequestEvent({ draft }), action }, policySource, api,
+      runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {},
+    })
+    await run(true)
+    assert.deepEqual(effects, [])
+    const result = await run(false)
+    assert.deepEqual(effects, [10, 11, { reviewers: ['Writer'] }])
+    assert.equal(result.delegations[0].login, 'Turtle1999')
+    await run(false)
+    assert.equal(effects.length, 3)
+  }
+})
+
+test('already approved delegates need no new request', async () => {
+  const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')],
+    reviews: [review('writer', 'APPROVED')],
+  })
+  const result = await runWithHistory({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
+    api: (path, options) => {
+      if (path.includes('/statuses/')) return {}
+      assert.ok(!path.endsWith('/requested_reviewers'))
+      return read(path, options)
+    },
+  })
+  assert.equal(result.state, 'success')
+})
+
+test('pull-request events use live state and skip closed, merged, and superseded PRs', async () => {
+  const event = pullRequestEvent()
+  for (const patch of [{ state: 'closed' }, { state: 'closed', merged: true, merge_commit_sha: HEAD_SHA },
+    { head: { sha: 'abcdef1234567890abcdef1234567890abcdef12' } }]) {
+    assert.equal(await approvalEventFromPullRequest({ event, api: async () => ({ ...event.pull_request, ...patch }) }), null)
+  }
+  const ready = await approvalEventFromPullRequest({ event: pullRequestEvent({ draft: true }), api: async () => event.pull_request })
+  assert.equal(ready.pull_request.draft, false)
+  assert.equal(await approvalEventFromWorkflowRun({ event: {
+    repository: event.repository,
+    workflow_run: { path: '.github/workflows/weighted-approval-review-event.yml', event: 'pull_request_review',
+      conclusion: 'success', head_sha: HEAD_SHA, display_title: 'weighted-approval-review-event:42', pull_requests: [] },
+  }, api: async () => ({ ...event.pull_request, state: 'closed', merged: true }) }), null)
+})

+ 1 - 0
.github/workflows/weighted-approval-review-event.yml

@@ -9,6 +9,7 @@ permissions: {}
 
 
 jobs:
 jobs:
   record-review-event:
   record-review-event:
+    if: github.event.pull_request.state == 'open'
     name: record weighted approval review event
     name: record weighted approval review event
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     timeout-minutes: 2
     timeout-minutes: 2

+ 10 - 3
.github/workflows/weighted-approval.yml

@@ -20,7 +20,11 @@ concurrency:
 
 
 jobs:
 jobs:
   publish-status:
   publish-status:
-    if: (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && (github.event_name != 'issue_comment' || github.event.issue.pull_request)
+    if: >-
+      (github.event_name != 'pull_request_target' || github.event.pull_request.state == 'open') &&
+      (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') &&
+      (github.event_name != 'issue_comment' || (github.event.issue.pull_request && github.event.issue.state == 'open' &&
+        (contains(github.event.comment.body, '/delegate') || contains(github.event.changes.body.from, '/delegate'))))
     name: weighted approval publisher
     name: weighted approval publisher
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     timeout-minutes: 5
     timeout-minutes: 5
@@ -39,20 +43,23 @@ jobs:
           GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
           GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
         run: node .github/review-ownership/check-approval.mjs pending
         run: node .github/review-ownership/check-approval.mjs pending
       # SECURITY: dependency setup runs in the status-writing job.
       # SECURITY: dependency setup runs in the status-writing job.
-      - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+      - if: steps.revoke.outputs.active == 'true'
+        uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
         with:
         with:
           python-version: '3.10'
           python-version: '3.10'
           cache: pip
           cache: pip
           cache-dependency-path: .github/review-ownership/requirements.txt
           cache-dependency-path: .github/review-ownership/requirements.txt
       - name: Install production lexer
       - name: Install production lexer
+        if: steps.revoke.outputs.active == 'true'
         run: python3 -m pip install -r .github/review-ownership/requirements.txt
         run: python3 -m pip install -r .github/review-ownership/requirements.txt
       - name: Publish weighted approval status
       - name: Publish weighted approval status
+        if: steps.revoke.outputs.active == 'true'
         env:
         env:
           GITHUB_TOKEN: ${{ github.token }}
           GITHUB_TOKEN: ${{ github.token }}
           GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
           GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
         run: node .github/review-ownership/check-approval.mjs
         run: node .github/review-ownership/check-approval.mjs
       - name: Publish approval setup failure
       - name: Publish approval setup failure
-        if: failure() && steps.revoke.outcome == 'success'
+        if: failure() && steps.revoke.outputs.active == 'true'
         env:
         env:
           GITHUB_TOKEN: ${{ github.token }}
           GITHUB_TOKEN: ${{ github.token }}
           GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
           GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

+ 9 - 2
scripts/ci-workflow.spec.ts

@@ -935,7 +935,10 @@ describe('Weighted approval workflow', () => {
       'cancel-in-progress': false,
       'cancel-in-progress': false,
     })
     })
     expect(job).toMatchObject({
     expect(job).toMatchObject({
-      if: "(github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && (github.event_name != 'issue_comment' || github.event.issue.pull_request)",
+      if: "(github.event_name != 'pull_request_target' || github.event.pull_request.state == 'open') && "
+        + "(github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && "
+        + "(github.event_name != 'issue_comment' || (github.event.issue.pull_request && github.event.issue.state == 'open' &&\n"
+        + "  (contains(github.event.comment.body, '/delegate') || contains(github.event.changes.body.from, '/delegate'))))",
       name: 'weighted approval publisher',
       name: 'weighted approval publisher',
       'runs-on': 'ubuntu-latest',
       'runs-on': 'ubuntu-latest',
       'timeout-minutes': 5,
       'timeout-minutes': 5,
@@ -948,13 +951,14 @@ describe('Weighted approval workflow', () => {
       },
       },
     })
     })
     const setupIndex = steps.findIndex(step => typeof step.uses === 'string' && step.uses.startsWith('actions/setup-python@'))
     const setupIndex = steps.findIndex(step => typeof step.uses === 'string' && step.uses.startsWith('actions/setup-python@'))
+    expect(steps[setupIndex]?.if).toBe("steps.revoke.outputs.active == 'true'")
     expect(steps[setupIndex]?.uses).toBe('actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1')
     expect(steps[setupIndex]?.uses).toBe('actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1')
     const revokeIndex = steps.findIndex(step => step.id === 'revoke')
     const revokeIndex = steps.findIndex(step => step.id === 'revoke')
     expect(revokeIndex).toBeGreaterThan(steps.indexOf(checkout!))
     expect(revokeIndex).toBeGreaterThan(steps.indexOf(checkout!))
     expect(revokeIndex).toBeLessThan(setupIndex)
     expect(revokeIndex).toBeLessThan(setupIndex)
     expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs pending')
     expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs pending')
     expect(steps.at(-1)).toMatchObject({
     expect(steps.at(-1)).toMatchObject({
-      if: "failure() && steps.revoke.outcome == 'success'",
+      if: "failure() && steps.revoke.outputs.active == 'true'",
       run: 'node .github/review-ownership/check-approval.mjs error',
       run: 'node .github/review-ownership/check-approval.mjs error',
     })
     })
     const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'python-sdk')
     const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'python-sdk')
@@ -963,9 +967,11 @@ describe('Weighted approval workflow', () => {
       run: "uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'",
       run: "uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'",
     })
     })
     expect(steps.find(step => step.name === 'Install production lexer')).toMatchObject({
     expect(steps.find(step => step.name === 'Install production lexer')).toMatchObject({
+      if: "steps.revoke.outputs.active == 'true'",
       run: 'python3 -m pip install -r .github/review-ownership/requirements.txt',
       run: 'python3 -m pip install -r .github/review-ownership/requirements.txt',
     })
     })
     expect(publish).toMatchObject({
     expect(publish).toMatchObject({
+      if: "steps.revoke.outputs.active == 'true'",
       env: {
       env: {
         GITHUB_TOKEN: '${{ github.token }}',
         GITHUB_TOKEN: '${{ github.token }}',
         GITHUB_RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}',
         GITHUB_RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}',
@@ -973,6 +979,7 @@ describe('Weighted approval workflow', () => {
       run: 'node .github/review-ownership/check-approval.mjs',
       run: 'node .github/review-ownership/check-approval.mjs',
     })
     })
     expect(recordJob).toMatchObject({
     expect(recordJob).toMatchObject({
+      if: "github.event.pull_request.state == 'open'",
       name: 'record weighted approval review event',
       name: 'record weighted approval review event',
       'runs-on': 'ubuntu-latest',
       'runs-on': 'ubuntu-latest',
       'timeout-minutes': 2,
       'timeout-minutes': 2,