Repository navigation
feat(approvals): deliver atomic policy-gated batch decisions - #418
Conversation
c8b100e to
7a792fc
Compare
|
Added the persistence-failure regression: a synthetic second-member write fault now verifies every record rolls back to pending/not_started and no recovery envelope dispatches. Targeted approval-center suite: 26 passed, 1 intentional engine integration skip. |
waterbro-8
left a comment
There was a problem hiding this comment.
代码方向对,#410 那轮真正的合入阻断已经补上了:base 是当前 main(ac916af,ahead 1 / behind 0)、write/exec/network/过期/version/threshold>1/run-scope 有独立用例、部分 put 失败会回滚 before-image 且不发 pendingApprovals。客户端仍然不能投 pendingApprovals。但现在还不能批。
Blocker:#403 AC 测试被减掉了,不是「已有覆盖 + 补边界」
#410 里已经有、而且上次当成现有覆盖的两项,这次重做没带过来:
- 同源 tool 批量成功之后的 replay:同一 requestId 必须 200,且不能再发一次 pendingApprovals。Issue 原文就是 Idempotency/retry cannot duplicate a verdict。
- 无 batch 策略 / 不同来源 → 全员 rejected、零 envelope。现在服务端 sourceSignature 在 CI 里根本跑不到。请拆成两条,第二条必须是 策略已分类的 tool + 两个 source。
rejectBatch 只断言全员 rejected、没有信封,不能替代上面两条。
非产品验收。CI 11/11 是绿的,CODEOWNERS BLOCKED 是评审门,不是失败。我不会合。
PeterGuy326
left a comment
There was a problem hiding this comment.
Review (roleweave #418, reopens #403 against main)
Re-review of head 7a792fc. Base verified: origin/main is an ancestor of this branch, single commit, and the #410/feat/402 lineage is gone — this is a real main delivery.
What I reproduced locally (clean worktree, @roleweave/* linked to this branch's sources): all 9 #403 cases pass; approvals-center.test.js 27 tests / 26 pass / 1 skip (the skip is the APPROVAL_ENGINE_MODULE integration gate). CI green on Node 24 macOS + Ubuntu. Your body says 25 passed — that's an under-count, not an over-claim.
Claims that hold, checked against code rather than the description:
- Same-source is enforced on both ends:
sourceSignature()over the(kind, positionId, conversationId, turnId, runId, engine)tuple server-side, and cross-run checkboxes greyed out inApprovalQueue. tool-only is structural, not merely a default:risk === "medium"plus the policy schema pinningbatch.actionKindstolength === 1 && [0] === "tool", in bothvalidRuleand the store'svalid().write/exec/networkcannot be configured in.pendingApprovalsreally is internal-only:POST /turnsvalidates against a strictallowedKeyswhitelist that omits the field (apps/server/src/routes/turns.ts:102), so a public client cannot forge a plural verdict. I went looking specifically for this.- Rollback test is the real thing — it fails the second granted write and asserts the first member is restored to
pending/not_started/ no decision, plus zero engine calls. - Concurrency:
decideBatchholds theserial()mutex fromsynchronizethroughactiveKeys.add, so theexecution.turnId-set-but-not-yet-active window cannot be observed by a racingsynchronize. This is easy to get wrong and you got it right. synchronize()re-reads viastore.list()on every call, so the mid-loopthrow conflict("Approval policy did not settle the batch member")does not leak partial in-memory state.
Requested before merge
1. Rollback does not cover the audit chain — apps/server/src/approvals/service.ts:445-472.
decideBatch appends audit for all members first, then enters the record save loop; the catch restores records only. So when the second member's write fails, on disk you are left with two type: "decision", decision: "granted" audit events (chained into the SHA-256 previousHash ledger) while both records are still pending. audit() (service.ts:257) reads the audit file directly and never reconciles against record state, so GET /approvals/:id/audit exports grant evidence for approvals that were never granted — on a feature whose whole framing is a tamper-evident compliance trail.
The rollback test asserts record state and engine silence, but never touches audit, so nothing pins this.
Recommend one of:
- emit a compensating event per member carrying the same
batchIdwhen the record loop fails, so the chain self-describes the rollback; or - state it in Known limitations and assert current audit contents in the rollback test.
Related: the (approvalId, requestId) dedupe in appendAudit means re-submitting the same members under a new batch requestId re-appends a second granted per member. Deterministic batchMemberRequestId protects retries of the same batch id, which is the case you documented — this is the case after a rollback.
2. Rollback failure is swallowed — service.ts:465-471.
.catch(() => undefined) on the restore loop means a failed rollback is invisible; the caller sees only the original storage error while disk may sit half-granted. Given "全员回滚" is a headline guarantee here, at minimum publish approvals.changed and surface that the rollback itself failed.
3. threshold > 1 is only rejected in the fresh case — description and test overstate the gate.
The check is a.progress.pending !== 1, and policyProgress returns pending = threshold - granted (apps/server/src/approvals/policy.ts:85). So a threshold: 2 record with one prior grant has pending === 1 and is batchable by the second approver. #403 rejects batches that need more than one approval principal uses a fresh threshold: 2 (pending === 2), which is why it passes.
I think "this batch vote must settle each member" is the right invariant — but then the PR body's "threshold>1 ... independently covered" and docs/approvals-p0.md's "升级中的多人门槛不允许加入批量" are inaccurate. Either fix the wording, or tighten to threshold === 1 and add a case that pins whichever semantics you intend.
Non-blocking
- Queue projection is looser than server acceptance.
view()derivesbatchfromstatus/kind/risk/policy.batchonly; acceptance additionally requires noruninscope.allowed,maxItems >= n,progress.pending === 1, actor rights, same source, and a matching source turn. So the UI draws a checkbox on items the server will always reject — atoolwhose engine offeredrunscope is the reachable case. Consider extracting one eligibility predicate used by both.- Minor, same area:
selectBatchItemcomputesMath.min(item.batchMaxItems, selected.batchMaxItems)against a single representative rather than all selected. Unreachable today because same-source implies same position implies uniformmaxItems, but it is a trap if the source constraint ever loosens.
- Minor, same area:
- Unadvertised trust-boundary change bundled in.
approvalDecision's IPC whitelist gainsscope, including"run", and the desktop test that assertedscope: "run"is rejected was rewritten to assertscope: "workspace". Two consequences worth a sentence in the body: (a) this implies that onmainevery desktop approve/deny was400, becausedecide()unconditionally sendsscopeand the old whitelist lacked the key — a real bug, fixed as a side effect; (b) run-scope granting is now reachable from the renderer. Server-sidevalidRunScopeBindingstill fails closed on a forged binding, so this is not a vulnerability, but please add a case asserting run-scope round-trips and that an invalidrunBindingis still refused server-side. - Risk vocabulary.
ApprovalRiskLevelis"medium" | "high"— there is nolow. The gate ismedium, but the rejection copy ("not eligible for a low-risk batch"), the recovery turn input ("已批准的低风险动作"), the test names, and thedocs/approvals-p0.mdheading all say low-risk. The doc body is accurate ("服务端还要求请求上下文风险为medium"), so a reader can conclude a saferlowclass exists and is batchable, when it does not exist and would be rejected. Worth aligning. - Persistence failure returns a bare 500 (the test asserts 500), while the client copy
apr.submitFailedsays "结果尚未确认,请刷新后重试" — but the server rolled back cleanly. A typed code (e.g.approval_batch_persist_failed) would make the retry semantics match what actually happened. - Cross-repo ordering not stated. The recovery turn emits
pendingApprovals, which only digital-employee #337's engine schema accepts. If #418 lands first, every batch settles the records and then fails atbeforeRun, leavingindeterminate— please note the dependency and any engine-version floor in the body.
Not product 验收, and no merge judgment from this review. Findings 1-3 are the ones I would like answered.
waterbro-8
left a comment
There was a problem hiding this comment.
head ab45c05 相对 7a792fc 补上了胡奕舟 1–3:append-only decision_reverted、restore 失败不再 .catch(() => undefined)、threshold 用 progress.pending !== 1 收最后一票。回滚用例也核了链。这些点过了。还是不能批。
Blocker:#403 AC 测试仍缺,代码有、CI 跑不到
- 成功批量之后的 replay:同一
requestId必须 200,且不能再发一次pendingApprovals。decideBatch开头有prior分支,但approvals-center.test.ts没有成功路径的 replay 断言。Issue 原文就是 Idempotency/retry cannot duplicate a verdict。 - 已分类 tool + 两个 source:全员 rejected、零 envelope。
sourceSignature仍然只有实现、没有用例。请拆成两条,第二条必须是策略已分类的 tool + 两个 source。rejectBatch的 write/exec/network/过期/version/threshold/run-scope 不能替代这两条。
非产品验收。CI 这轮还在跑,CODEOWNERS BLOCKED 是评审门。我不会合。
b76b6b8 to
45c77b3
Compare
waterbro-8
left a comment
There was a problem hiding this comment.
head 45c77b3(rebase 到 main 610bea12 / #419)补上了上一轮两条 blocker,CI 全绿。
- 同源 restricted-tool 成功路径:同一
requestIdreplay 为 200,且pendingApprovals仍只有一次。 - 已分类 tool +
repo-owner/community-operator两个 source:全员 rejected、零 envelope。sourceSignature这条 CI 能跑到。 - 无 batch 策略的 tool 也有独立拒绝用例。胡奕舟 1–3 的
decision_reverted/ restore 失败可见 / 最后一票仍在。
代码 APPROVE,不是产品验收。CODEOWNERS BLOCKED 是评审门。我不会合。
PeterGuy326
left a comment
There was a problem hiding this comment.
Re-review — changes requested on 45c77b3
The previously reported record-write rollback path is fixed: the second-member put failure restores every before-image, appends per-member decision_reverted events with the same batchId, surfaces typed approval_storage_failed, and a retry leaves one unreverted grant. The successful same-requestId replay, unclassified tool, and classified different-source cases are now independently covered. I also verified the current head and green CI, and locally ran npm run build plus the approval-center suite (28 pass / 1 intentional skip).
Blocker: partial audit-append failure still leaves a false unreverted grant
decideBatch() appends each member decision sequentially at apps/server/src/approvals/service.ts:459-462, but the compensating decision_reverted logic is only inside the later record-save catch at lines 463-500. If audit append 1 succeeds and audit append 2 fails, control exits through the outer catch before any record is written and before any reversal is appended. The records remain pending, but /approvals/:id/audit exposes an unreverted decision: granted for member 1.
I reproduced this against the exact head by injecting a failure on the second appendAudit(... type: "decision"): the API returned 500 and records stayed pending, but the first member had one unreverted grant; the assertion expecting zero failed (1 !== 0).
Please make the pre-record audit phase compensating too: track successfully appended member decisions and append deterministic decision_reverted events for them if any later audit append fails. If that compensation itself fails, return the typed unconfirmed rollback/storage failure and publish refresh events, matching the record-restore failure contract. Add a regression that fails the second audit append and verifies no unreverted decision remains.
Test gap
The code now surfaces record restore failure and publishes approvals.changed, but the focused suite does not inject a restore failure to pin that branch. Please add the regression claimed in the handoff: force a restore put to fail, assert typed approval_storage_failed with the unconfirmed-rollback message, and assert refresh events are emitted.
Not product acceptance. I am not merging.
45c77b3 to
559f2fc
Compare
PeterGuy326
left a comment
There was a problem hiding this comment.
Re-review — APPROVE on 559f2fc
复核了上一轮 blocker 及当前完整分支:
- 第二次 decision audit append 失败时,现在只对已成功写入 ledger 的成员追加同
batchId、绑定原 member requestId 的decision_reverted;记录保持 pending,且不会派发 recovery envelope。 - record restore 自身失败不再被吞掉:返回 typed
approval_storage_failed,并发布approvals.changed让客户端重新读取磁盘真相。 - 新增两条故障注入回归覆盖上述路径;同 requestId replay、无 batch 策略、已分类但不同 source run 等既有边界仍在。
本地独立 worktree 验证:npm run build 通过;approvals-center.test.js 30 pass / 1 个预期 engine-integration skip。GitHub 失败的 macOS job 我已按原 head 重跑,现 Node 24 macOS/Ubuntu、smoke、installers、layout、CodeQL 全绿。
技术评审通过;不是产品验收,也不代替合并授权。
Reopens the #403 delivery against
mainafter #410 merged only into the already-squashedfeat/402branch.Implements the engine-facing batch decision path and the outstanding review work:
toolbatches. Coverage pins same-request replay, no batch classification, differing source runs,write,exec,network, expired, version-conflicted, run-scope, and batches still needing more than one vote; a final vote may batch-settle a multi-party member that already has prior grants;pendingApprovalsrecovery envelope;approval_storage_failedand publishesapprovals.changed.Validation:
npm run build;node --test --test-timeout=120000 apps/server/dist/test/approvals-center.test.js(30 passed, 1 intentional engine-integration skip).npm run test:rendererremains blocked before the approval tests by the branch baseline's unresolved@fullstack-ai-infra/uiimport; server and compilation validation above passed.