feat(automation)!: an undeclared resumeAuthority is fail-CLOSED — the generic resume route becomes an opt-in (#5561 step two) - #6638
Merged
Conversation
…he generic resume route becomes an opt-in (#5561 step two) `AutomationEngine.resolveResumeAuthority` resolved an absent `resumeAuthority` to `'any'`, inherited from the Zod `.default('any')` that step one (PR #5725) removed. It now resolves to `'service'`: a pausing node type that never states who may continue its pauses is refused on `POST /automation/:name/runs/:runId/resume` with PERMISSION_DENIED / 403 until its descriptor says so. The generic door is an opt-in a descriptor declares with `resumeAuthority: 'any'`, not a default every pausing node inherits. This is the first of ADR-0044's two "directions recorded but deliberately not built here", and the maintainer's ruling split it in two: 2026-08-06 approved step one immediately and deferred this half to a breaking window; 2026-08-07 ruled that every already-decided protocol change lands in the v17 window, which is open now. PR #5725 left the shrink-to-one-expression form in writing — one expression — and that is what this changes. WHY THE GUESS GOES THIS WAY. The two mistakes are asymmetric. Guessing `'any'` continues a run past a decision nothing recorded, silently — that is #3823 exactly (ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the cost was an unaudited resubmit plus a destroyed remote run). Guessing `'service'` refuses a resume and hands the author back the one-line declaration that fixes it. Only one of the two is discoverable by the person who made it. WHAT CHANGED BEYOND THE ONE EXPRESSION, AND WHY EACH IS REQUIRED * The registry walk splits into `resolveDeclaredResumeAuthority` (the declared value, or `undefined`) and `resolveResumeAuthority` (that `??` the new `RESUME_AUTHORITY_WHEN_UNDECLARED` constant). One alias hop, one place the fail-closed default is written down. * The refusal message branches. Keeping the single old wording would tell an author who declared NOTHING that their node "is resumable only through its owning service (resumeAuthority: 'service')" — the declared-vs-actual gap this whole issue exists to close, restated as a log line. The undeclared branch names the omission and prints the fix; the declared-`'service'` branch is byte-identical to before, so `http-dispatcher.test.ts` and `approval-revise.test.ts` keep asserting on it unchanged. * The registration warning is rewritten. Step one's line promised that declaring `'any'` "changes no behaviour" — the exact sentence step two falsifies, and one that would steer an author away from the only field that restores their resume route. * Contract docs follow the behaviour: the spec field's TSDoc and `.describe()`, `automation-service.ts` (`forbidden`, `RESUME_AUTHORITY_SERVICE`, `resume()`), `runtime`'s route ledger and domain comment, `content/docs/automation/flows.mdx`, and the CI gate's header + finding text. ADR-0087. Registered as the step-17 semantic entry `action-descriptor-resume-authority-default-flip`. The precedent is protocol 12's `rest-requireauth-default-flip` — a secure-default flip with no metadata shape to rewrite, where "did this deployment mean it?" is a trust judgment no transform can make. The surface is a descriptor field set in plugin CODE, so there is no D2 conversion and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` / `storage-service-list-retired` / `actor-user-roles-to-positions` already carry. It differs from those in one way the entry states rather than leaves to be inferred: nothing is REMOVED, the field has been optional since step one, so tsc reports nothing at all — the generated upgrade guide is the only channel that reaches a third-party plugin author BEFORE a user meets a run that will not continue. ADR-0044 gains a dated landing section that supersedes its own "defaults to 'any'" paragraph. Leaving that in place would repeat the failure this issue was filed about: an ADR pointing at a deferred item nothing tracks. IN-TREE BEHAVIOUR IS UNCHANGED, AND THE FIXTURES ARE THE EVIDENCE. All six shipped pausing types declare their authority (`screen`/`wait`/`subflow`/`map` = `'any'` from step one, `approval`/`approval_revise` = `'service'`), and a scan of `suspend: true` across shipped sources finds exactly those six. Test fixtures were NOT unchanged: nine files' pausing fixtures were riding the inherited default and now declare `resumeAuthority: 'any'` themselves — the same one-line edit a plugin author makes, which is what makes them realistic rather than patched. Judged individually rather than re-spelled in bulk: * declaration added — engine, run-history, run-summary, suspended-run-store, suspension-release, engine-residual-log-cause, builtin/map-node, builtin/subflow-node (none of them is about the resume gate); * replaced outright — `resume-authority-gate.test.ts`'s "a node type with no published descriptor stays ungated" pinned exactly the semantics being deleted, so its verdict inverts to a refusal; * untouched — `suspended-screen-durability.test.ts`'s `pause_node`, which is only ever asserted to surface no screen and is never resumed. New coverage: an end-to-end trio for a `supportsPause: true` descriptor with no `resumeAuthority` — its generic resume is refused with a message naming the missing field (and NOT the owning-service wording), the same flow resumes once `'any'` is declared, and the in-process service marker still continues it while undeclared, so fail-closed does not mean bricked. Refs #3801, #3823, #3853, #5703; ADR-0044 amendment (2026-07-28) + landing section (2026-08-08), ADR-0019 #3801 addendum, ADR-0087 D3.
…regen the descriptor reference Two round-1 findings from the #5561 step-two flip, both real: * `engine-residual-log-cause.test.ts` site 11 parks a run on a `screen` node in an engine that never registered the screen executor, so that engine has no `screen` descriptor at all — which now resolves fail-closed and refuses the resume before `refuseInvalidScreenInput` is ever reached. The site is about a log seam, not about the gate, so it gets the production shape: `registerScreenNodes`, whose descriptor declares `resumeAuthority: 'any'`. Worth stating plainly rather than patching around: an undeclared type and an UNREGISTERED one resolve the same way on purpose. A pause whose executor never loaded is exactly the case where continuing it is a guess. * `content/docs/references/automation/node-executor.mdx` is generated from the field's `.describe()`, which the flip rewrote — regenerated by `check:generated --fix` (1 of 10 stale, now 10/10 clean).
…ume-authority-fail-closed
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 118 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…rade-guide, api-surface) Recovery commit: the dev agent was killed by a container restart between the merge commit and the tail of the regen chain; this completes the four-step on the merged tree. check:generated 10/10.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5561
resumeAuthority缺省语义翻 fail-closed —— #5561 的第二步,也是 ADR-00442026-07-28 修正案里「记录但刻意不在此建造」的第一项的收官。
裁决链(免新裁决)
修正案 C + A)立刻落地;第二步(默认翻 fail-closed)待下一个允许 breaking descriptor
change 的版本窗口。第一步已随 PR feat(spec,service-automation,devx): 未声明 resumeAuthority 的 pausing 节点类型不再静默 fail-open (#5561) #5725 合并(告警 + 静态门 +
.optional()+四个 builtin 显式
'any')。major 窗口,本单 hold 的重启条件满足。
Fail-closed descriptor default;ADR-0019 的 automation: the generic run-resume route needs an authorization gate keyed on the suspended node #3801 addendum。
前提复核(origin/main,step 1 产物逐项在位)
resumeAuthority为.optional()packages/spec/src/automation/node-executor.zod.ts:379?? 'any'service-automation/src/engine.ts:3037(resolveResumeAuthority)'any'screen-nodes.ts:55/wait-node.ts:199/subflow-node.ts:60/map-node.ts:59engine.tswarnIfResumeAuthorityUndeclared+scripts/check-resume-authority-declared.mjsresume-authority-gate.test.ts/resume-authority-declaration.test.tsgrep确认全仓只有一处同语义??,收缩形态与 PR #5725 正文预留的完全一致。改了什么
1. 一行语义翻转。
resolveResumeAuthority的回退由'any'改为'service'。为了让拒绝消息能区分「作者声明了
'service'」和「作者什么都没声明」,把注册表游走拆成resolveDeclaredResumeAuthority(返回声明值或undefined),resolveResumeAuthority则是它
?? RESUME_AUTHORITY_WHEN_UNDECLARED—— 别名跳转只有一份实现,fail-closed 默认值只有一个常量,三处文案(注册告警、拒绝消息、解析器)说的是同一件事。
2. 拒绝消息分岔 —— 这不是润色,是正确性。 翻转前那条消息只有一种措辞:
「resumable only through its owning service (
resumeAuthority: 'service')」。原样保留的话,一个从未声明的节点会被告知它声明了
'service'—— 正是本单要消灭的「声明 ≠ 事实」,换成日志形态复述一遍。现在未声明走独立措辞,点名缺省字段并给出那一行处方;已声明
'service'的措辞逐字未动(http-dispatcher.test.ts/approval-revise.test.ts对它的断言因此不受影响)。
3. 注册告警反转。 第一步的告警写着「Declaring
'any'explicitly silences this andchanges no behaviour」—— 这句正是第二步让它变成假话的。留着它会把作者推离那一个能恢复
resume 路由的字段。改为点名后果(REFUSES)与「声明
'any'才是 RESTORES 通用路由的动作」。4. 契约文档同步。 spec 的
resumeAuthorityTSDoc/describe(缺省语义反转 +'any'是 opt-in)、
automation-service.ts的forbidden/RESUME_AUTHORITY_SERVICE/resume()契约、runtime 的route-ledger记述与 domain 注释、content/docs/automation/flows.mdx(「the default」表述删除,补一条 Callout)、
check-resume-authority-declared.mjs的门头与 finding 文案。
5. ADR-0044 落地记述。 新增
### Fail-closed descriptor default — landed (2026-08-08, #5561),明确 supersede 上方「defaults to
'any'」那句 —— 本单立单的理由正是「ADR 指向一个不存在的跟踪项」,不回填就是同一个失败模式再来一次。第二条 deferred direction(per-suspension owner
claim)明确保持未建。
ADR-0087 迁移面
按既有条目形状找先例后,登记为 step 17 semantic 条目
action-descriptor-resume-authority-default-flip。先例是 protocol 12 的rest-requireauth-default-flip—— 同为 secure-default flip,无 metadata 形状可改写,「这个 posture 是不是当事人想要的」是 transform 做不了的信任判断;而「surface 是插件代码
里的描述符字段、非 stack metadata,故无 D2 conversion、刻意无 schema tombstone」这一处置,
data-driver-find-stream-retired/storage-service-list-retired/actor-user-roles-to-positions已经承载过。与那些条目不同的一点写进了条目本身:本次什么都没删,字段自第一步起就是 optional,
所以 tsc 一声不吭 —— 第三方插件作者没有编译错误可以发现它,生成的升级指南是唯一能在
「用户撞上一个续不动的 run」之前抵达的通道。这正是需要 ledger 条目的理由,而不是相反。
spec-changes.json与docs/protocol-upgrade-guide.md已 regen;changeset 文件里带着check:adr-0087-registration要求的 disposition marker(HTML 注释形式,内容为adr-0087: registered action-descriptor-resume-authority-default-flip)。此处刻意不贴原文:GitHub 的正文消毒器会把 HTML 注释整条吞掉(本 PR 初版正文就被吞了一次),而门读的是磁盘上的
changeset 文件,不是这段 PR 正文。门实测绿:
registered action-descriptor-resume-authority-default-flip (new here)。注意 changeset 正文含完整 FROM → TO 迁移处方,所以
no-migration-prescription那条豁免对它是关闭的 —— 真登记一个 ledger 条目是唯一的路,不是可选项。
仓内零行为变化,以及夹具为什么要动
在册六个暂停类型全部已显式声明(
screen/wait/subflow/map='any',approval/approval_revise='service'),rg "suspend: true"扫发货源码也只有这六个。所以生产语义零变化,翻转只影响「未来的 / 第三方的未声明执行器」。
但测试夹具不是零变化:9 个文件里的暂停夹具当年就是靠那个继承来的默认值在跑,现在必须
自己声明
resumeAuthority: 'any'。这与第一步给四个 builtin 补声明是同一个动作,也正是插件作者要做的那一行 —— 夹具因此变得更真实,而不是被打了补丁。夹具三类处置:
engine.test.ts/run-history/run-summary/suspended-run-store/suspension-release/engine-residual-log-cause/builtin/map-node/builtin/subflow-node):主题不是 resume 闸门,补上它依赖的 posture。resume-authority-gate.test.ts的a node type with no published descriptor stays ungated):它钉的正是被删掉的那条语义,断言方向反转为「拒绝」。open_pause由「靠默认值」改为显式'any';新增a pausing type that never declares resumeAuthority (#5561)三例 —— 生 resume 被拒(且消息点名缺省字段、不出现 owning-service 措辞)、加上
'any'后同一条流放行、未声明期间 service marker 仍能续跑(fail-closed 不等于砖化)。
suspended-screen-durability.test.ts的pause_node未动:它只验证getSuspendedScreen返回 null,从不 resume —— 逐个判,不整批重写。逆向验证(方向先判后跑)
预判:把
?? 'service'撤回'any',新增的三条端到端里应有 1 条转红(未声明执行器的生 resume 被放行,
success: true撞上PERMISSION_DENIED断言),另两条(显式
'any'放行 / service marker 放行)保持绿 —— 它们在两种默认值下都成立,这正说明单靠它们证明不了翻转,红的那一条才是。
bare_pause与解析器'service'断言同向转红。实测(只改常量
'service'→'any',只跑两个 resume-authority 套件):3 条红,与预判逐条对上:
refuses a node type that published no descriptor at all (#5561)(gate)、refuses the generic resume, naming the omission and the one-line fix(gate),以及 declaration 套件里的那 1 条 —— 输出被
tail截掉了它的正文,但只跑了两个文件、gate 文件贡献了上面两条,所以第三条必然是
resolves an undeclared pausing type to 'service'。恢复常量后:预判里「保持绿」的两条(显式
'any'放行 / service marker 放行)确实两种默认值下都绿 ——这正是它们单独证明不了翻转、而红的那 3 条才是证据的原因,故一并写明而不是只报总数。
验证读数
service-automation testspec testplugin-approvals testspec typecheck/plugin-approvals typecheck/runtime typecheckspec check:generatedcheck:docs,即.describe()驱动的node-executor.mdx),--fix后复跑 10/10 up to datecheck:spec-changes/check:upgrade-guidecheck:resume-authority-declaredcheck-adr-0087-registrationregistered action-descriptor-resume-authority-default-flip (new here)check:startup-registry-verdict/check:error-code-casing/check:route-envelope/check:engine-double-contractcheck-nul-bytes/check:docs-audit-scope/check-empty-changeset/check-changeset-no-majorpnpm lint首轮两处红,均已定位为环境而非改动,round 2 重建 dist 后转绿并记录在此以免误读:
plugin-approvals5 个文件 collection 失败(Failed to resolve entry for package "@objectstack/service-automation")与runtime typecheck的TS2307 Cannot find module '@objectstack/service-datasource'—— 都是本 worktree 只build 了
service-automation^...(依赖)而没 build 这两个包本身,即 AGENTS.md §9 陈旧产物陷阱的镜像;重建后 446/446 与 typecheck 全绿。
首轮另有 1 条真红,已修并单独成一次提交:
engine-residual-log-cause.test.ts的site 11 在一个从未注册 screen 执行器的引擎上 resume 一个
screen暂停 —— 该引擎里screen根本没有描述符,翻转后按 fail-closed 解析,resume 在到达refuseInvalidScreenInput之前就被拒了。该 site 的主题是日志缝而不是闸门,所以补成生产形态(
registerScreenNodes,其描述符声明'any')。这条值得点名:「未声明」与「未注册」被有意判成同一件事 —— 一个执行器从未加载成功的暂停,正是「继续它」纯属猜测的那种情形。