Skip to content

automation: the revise-window wait pause is service-owned but type-keyed gating can't see it #3823

Closed

Description

@os-zhuang

Found while implementing the resume gate for #3801 (PR #3822). Adjacent seam, same
trust boundary, different shape — filing rather than widening that PR.

Reasoned from code, not demonstrated — no repro was written, same caveat as
#3801.

Requirement

#3822 gates POST /api/v1/automation/:name/runs/:runId/resume on the node type
that produced the suspension: a descriptor may declare resumeAuthority: 'service',
and approval does, so an approval pause continues only through ApprovalService.

ADR-0044's revise window is the same trust boundary in a shape that key cannot
see. Send-back resumes the run down the revise edge onto an ordinary wait node
(signal flavor) placed by the flow author
(ADR-0044 §D3: "The revise edge targets an
ordinary wait node — the durable pause already shipped for timers/signals"). The run
parks there until ApprovalService.resubmit, which enforces submitter-only, refuses
a colliding pending request, and audits a resubmit action before resuming.

Nothing enforces that path. wait is resumeAuthority: 'any' — correctly, since a
signal-flavored wait is meant to be resumable by an external producer — so a raw
resume of a run parked in a revise window walks the declared back-edge into the
approval node, opening round N+1 with no sys_approval_action row and none of
resubmit's checks. Lower severity than the approval bypass #3801 closed (it forces a
resubmit rather than an approval), but it is the same class: a service-owned
continuation reachable around the service.

Why the #3822 mechanism doesn't extend to it

The gate keys on the node's registry type, and the type here is a generic wait
indistinguishable from every other author-placed wait in the system. Gating all wait
nodes would break the documented signal/webhook/manual resume contract.

Closing it needs a per-suspension owner claim rather than a per-type one — the
suspension itself recording that a service owns this particular pause. Sketch, for
discussion rather than a settled design: let a resume signal carry an owner claim that
is persisted onto the continuation, so the service that drove the run into the wait
also owns driving it out; the type-keyed declaration then becomes the default when no
claim is recorded.

Refs #3801, #3822. Context recorded in the 2026-07-28 addendum to
ADR-0019.

Activity

  1. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    ContributorAuthor

    Now demonstrated, and the severity in the description above is understated. Repro built on the existing approval-revise.test.ts harness (real engine + real ApprovalService), run against main @ 93f267f81.

    Confirmed: unauthorized resubmit, no audit row

    A raw resume(runId) on a run parked at the revise-window wait:

    code:            undefined          ← not refused; `wait` is resumeAuthority 'any'
    round-2 request: opened (new id)    ← the back-edge fired, the approval node re-entered
    audit trail:     ['submit', 'revise']   ← no 'resubmit' row, ever
    

    Two corrections to the description: it needs no branchLabel (an empty body works — the labelled back-edge is walked either way, verified both ways), and it bypasses resubmit's submitter-only check as well as the audit row.

    Not in the description: it can permanently kill the run

    ApprovalService.resubmit refuses with DUPLICATE_REQUEST when another request is already pending on the same record, and the code comment says exactly why:

    A colliding pending request on the same record … would make the approval node's re-entry fail AFTER the engine consumed the suspension — permanently killing the run. Refuse up front instead.

    That guard lives in the service, and the raw resume goes around it:

    service.resubmit(...)   → throws DUPLICATE_REQUEST, run still suspended (1)
    automation.resume(runId) → { success: false,
                                 error: "Node 'review' failed: … DUPLICATE_REQUEST: a pending
                                         approval already exists for fin_expense/x1" }
                               suspended runs: 0        ← consumed, run terminally dead
    

    The approval is then unresolvable: the round-1 request stays returned, the run that owned it is gone, and no resubmit can ever reach it. Opportunistic (it needs a colliding pending request to exist, which an ordinary user can often cause — a record-change trigger re-firing inside the revise window is the documented case), but it is remote run destruction, not just a missing audit row.

    This changes the recommended fix

    The description sketches a per-suspension owner claim. Having looked harder, the simpler option is the one ADR-0044 considered and rejected — "Approval node re-suspends itself in a 'revise mode' (no wait node)" — or an equivalent dedicated node type. Either makes the revise pause typed as service-owned, so the existing resumeAuthority gate covers it with no new machinery. ADR-0044 chose the generic wait for reuse; what we know now is that a generic node sitting in a service-owned position is exactly what a type-keyed gate cannot see.

    The owner-claim design is still the more general answer, but it needs care the sketch didn't account for: an approval whose approve branch leads to a screen must NOT inherit the claim, or the UI can no longer advance that screen. Blanket inheritance breaks screen flows downstream of an approval.

    Refs #3801, #3853, #3879.


    Generated by Claude Code

  2. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 3, 2026
  3. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 5, 2026
  4. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    分诊(services 车道 PM,session_01BWS4heBoAitLmzCLhcYdbK):挂 needs-user-decision,不入派发队列。

    背景

    #3822 已为 resume 端点建立按节点类型的门(resumeAuthority),approval 暂停已受保护。本单指出 ADR-0044 的 revise window 停在一个普通 wait 节点上,类型门天然看不见它;2026-07-28 的实证评论把严重度抬了一级:raw resume 不仅绕过 submitter-only 与审计行,在存在碰撞 pending request 时会在引擎消费掉 suspension 之后失败,永久杀死 run(审批不可再解)。缺陷真实、已实证,不是观察级。

    要拍板什么

    让 revise 暂停变成 service-owned 的机制,两案分叉在契约形状上:

    方案 A:专用 typed 暂停(approval 节点以 revise 模式自挂起,或专用节点类型)——现有 resumeAuthority 类型门直接覆盖,零新机制。但这正是 ADR-0044 §Alternatives 考虑过并拒绝的形状(当时理由:复用已有的 durable wait)。采纳 = 推翻一处已裁决的 ADR 备选,须维护者确认。

    方案 B:per-suspension owner claim(驱动 run 进 wait 的服务持有唤出权,持久化在 suspension 上)——更通用,类型门退化为无 claim 时的默认。但实证评论指出了继承陷阱:approval 的 approve 分支通向 screen 时不得继承 claim,否则 UI 无法推进 screen —— 规则隐性,机制面更宽。

    三轴分析

    • 实际业务需求:revise window 是 ADR-0044 已发布、审批流实际在用的能力;本单是把它的信任边界补完整,属核心能力正确性,不是能力扩张。已实证的 run destruction 是真实损害面。
    • 项目长远合理性:A 让「服务拥有的暂停」在类型系统里显式存在,与 fix(automation,approvals): gate the generic run-resume route on the suspended node (#3801) #3822 的既有门复用,契约面收敛;B 引入跨节点新机制,当下只有一个消费者,通用性是预支的。
    • 防 AI 写元数据犯错:A 是 declared = enforced 的直接形状 —— 节点类型即权限,flow 作者(人或 AI)无法误配;B 的 claim 继承例外规则恰是 AI 最易写错、且错了静默的那类隐性知识。

    建议

    方案 A,三轴同向;因涉及推翻 ADR-0044 的一处已拒绝备选,按升级门槛交维护者拍板,不代答。拍板后本单摘标签回队列,可直接派发。


    Generated by Claude Code

  5. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 5, 2026
  6. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    维护者裁决(2026-08-05,经 services 车道 PM 转达):批准方案 A —— revise 暂停改为 typed / service-owned(approval 节点以 revise 模式自挂起,或专用节点类型 —— 实施者按代码现状定形,两者等价于「类型门可见」),使 #3822 的既有 resumeAuthority 门直接覆盖,零新机制。ADR-0044 §Alternatives 中被拒绝的该备选由本裁决显式推翻 —— 实施 PR 须附 ADR-0044 的 amendment 段落记录这次改判及其依据(2026-07-28 实证评论:raw resume 可绕过 submitter-only/审计并在碰撞 pending request 下永久杀死 run)。

    摘 needs-user-decision,回 pm:queue。排程:service-automation 包串行链 —— #5529(在飞)落地后,本单优先于 #5048/#4792 派发(已实证的 run destruction > 日志格式/装配强制)。

    实施注意(from 实证评论,派发时会写进派发词):

    • owner-claim 方案已否 —— 尤其它的 screen 继承陷阱;
    • revise 模式的 typed 暂停不得影响 approve 分支通向 screen 的既有 UI 推进;
    • 复现 harness 已有(approval-revise.test.ts + 实证评论的三段实录),反向验证以它为基。

    Generated by Claude Code

  7. self-assigned this
    on Aug 5, 2026
  8. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 4 轮(services 车道)。维护者已批方案 A(见上方裁决评论);同包前序 #5529(PR #5549)已 MERGED,串行解锁。
    会话:session_01BWS4heBoAitLmzCLhcYdbK
    分支:claude/issue-3823-revise-pause-typed
    Worktree:objectstack-issue-3823
    域:domain:services
    文件面:packages/plugins/plugin-approvals/**(approval 节点 revise 模式自挂起 / 专用类型)、packages/services/service-automation/**(如需 resumeAuthority 声明面)、docs/adr/0044-*.md(amendment 段,记录推翻已拒绝备选的裁决)、两包测试、.changeset/*.md

    同包 #5048/#4792 继续排队,本单落地后依次。


    Generated by Claude Code

  9. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    文件面申报修正(同一认领,PM 补录 —— 供各车道全局在飞检查读取):PR #5560 的实际触碰面超出原申报,全量为:

    • packages/spec/src/automation/approval.zod.ts(新增 APPROVAL_REVISE_NODE_TYPE 常量 + 文档)+ api-surface 生成物再生成 —— 触 spec 契约面;spec 座位当前空缺,由本车道按跨域单一 PR 例外承载,spec 座位接管者请以本评论为准对账;
    • packages/plugins/plugin-approvals/**(新节点类型 + sendBack 拒绝)— 原申报内;
    • packages/lint/**(新 rule flow-approval-revise-target-not-service-owned,error 级)— devx 面;
    • examples/app-showcase(存量 revise flow 迁移)、skills/objectstack-automation/**、content/docs/automation/approvals.mdx、docs/adr/0044-*.md(amendment,原申报内);
    • packages/service-automation:零改动(裁决验收标准)。

    原申报漏列 spec/lint/examples 面是 PM 的申报不足,不是 dev 越权 —— 已批形状(typed 节点类型)本质上要求词表声明与编写期拒绝。入队前将按 A 项纪律核对 spec 生成物与在飞无交集。


    Generated by Claude Code

  10. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    复核:ACCEPT → PR #5560(CI 绿后转 ready 入合并队列)。

    已核项(对照维护者裁决逐条):

    • 形状 = 已批 A 案的「专用节点类型」变体:approval_revise 在 spec 声明、与 approval 节点同一注册点(不可能有半套部署)、resumeAuthority: 'service';service-automation 逐字节零改动 —— 正中「既有类型门直接覆盖、零新机制」的验收标准;
    • ADR-0044 amendment 义务完成:批准线、Implementation 段、D3/status 更新、D2 conversion 否决理由(拓扑条件语义重写 + 实测存量:cloud 零 revise flow、Studio 尚不能画、showcase 唯一一个已随 PR 迁移);
    • 约束 1(screen):真 screen executor 用例钉住 approve→screen 仍走通用 resume;约束 2(向后兼容):存量 flow 继续注册运行、审批可决,仅 send-back 被前置拒绝且报文带一个 token 的处方;已停在旧窗口的 run recorded-first 不追溯改型 —— 决定都写进了 ADR 与 PR;
    • 两道带处方的拒绝(sendBack 写入前 + lint error 级,复用已接线的 lintFlowPatterns 注册项,零新接线);
    • 反向验证隔离了「类型门」这一条论断(只放开 descriptor),3 红且 dev 如实说明另两案例为何不受反转影响;
    • 四包 10438 用例全绿;check:generated 仅 api-surface 因新导出 stale,已再生成提交;fixture 按消费半径清扫(5 个测试文件 + showcase);
    • A 项核对:PR 触 spec 生成物(api-surface)—— 当前无其它在飞 PR 触同族生成物(spec 座位空缺,近期 spec PR 均已落地),入队安全;落地后照例核对条目存活;
    • 文件面超出原申报部分已由 PM 补录修正案(上一条评论),spec 座位接管者以此对账;
    • 顺带发现 automation: resumeAuthority defaults to 'any', so every future pausing node ships fail-open — ADR-0044 says this is "tracked separately" and nothing tracks it #5561(resumeAuthority 默认 'any' 对未来暂停类型 fail-open)已按 finding 立单,下轮分诊。

    本单闭环意义:#3801(approval 绕过)→ #3822(类型门)→ 本单(revise 窗口纳入门内)—— 审批信任边界补完。落地后解锁同包 #5048 → #4792。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions