Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .changeset/resume-authority-fail-closed-by-omission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
'@objectstack/service-automation': major
'@objectstack/spec': major
'@objectstack/runtime': patch
---

feat(automation)!: 未声明 `resumeAuthority` 的暂停节点改为 fail-closed —— 通用 resume 路由从「默认开门」变成「显式 `'any'` 才开门」(#5561 第二步)

<!-- adr-0087: registered action-descriptor-resume-authority-default-flip -->

**BREAKING**(仅影响注册了暂停型节点、且描述符未声明 `resumeAuthority` 的执行器 ——
本仓内为零)。`AutomationEngine.resolveResumeAuthority` 对缺省值的解析由 `'any'` 翻成
`'service'`:一个从未声明「谁可以续跑它产生的暂停」的节点类型,其暂停在通用路由
`POST /automation/:name/runs/:runId/resume` 上被拒绝(`PERMISSION_DENIED` / 403),
直到它的描述符把话说出来。通用 resume 门从此是描述符**主动 opt-in** 的一扇门,不是每个
暂停节点**继承**来的默认。

这是 ADR-0044 2026-07-28 修正案里「记录但刻意不在此建造」的第一项,分两步落地。
第一步(#5561 / PR #5725,非 breaking)把 `ActionDescriptorSchema.resumeAuthority`
的 Zod `.default('any')` 摘成 `.optional()`。那个默认值的问题不只是取值不对,而是它
**抹掉了事实**:`defineActionDescriptor` 在任何消费者看到对象之前就把 key 填上了,于是
「作者选了 `'any'`」和「作者从没考虑过」parse 出逐字节相同的描述符,遗漏根本无法被观测。
默认值摘掉之后「缺省」才重新可见,注册告警与 `check:resume-authority-declared` CI 门也
才写得出来。第二步就是本次改动:让缺省真正意味着 fail-closed。

### 为什么往「拒绝」这个方向猜

两种猜错的代价不对称,这就是全部理由。猜 `'any'`,会让一次 resume 走过一个**没有任何
记录的决策**,而且悄无声息 —— #3823 就是这么发生的:ADR-0044 把审批的 `revise` 边指向
了通用 `wait`,`wait` 本身声明 `'any'` 完全正确,而站在「服务持有」位置上的那个暂停
继承了一个没人选过的 fail-open 值;实测代价是一次未经审计的重新提交,外加一个被销毁的
远程 run。猜 `'service'`,则是返回一次拒绝,并把修好它的那一行原样交回作者手里。
两种错误里只有一种能被犯错的人自己发现。

### 迁移:`resumeAuthority` 未声明 → 显式声明(一行)

只有**注册暂停型节点的插件作者**需要动手,处方是在描述符上加一行:

```ts
// FROM —— 依赖旧默认值,暂停可被通用路由续跑
defineActionDescriptor({
type: 'my_pause', version: '1.0.0', name: 'My Pause',
supportsPause: true,
});

// TO —— 通用路由确实是这个暂停的正门时(screen 式收集输入、signal wait 式外部生产者)
defineActionDescriptor({
type: 'my_pause', version: '1.0.0', name: 'My Pause',
supportsPause: true, resumeAuthority: 'any',
});

// TO —— 续跑是「某个服务必须先授权并记录的决策」的尾巴时
defineActionDescriptor({
type: 'my_pause', version: '1.0.0', name: 'My Pause',
supportsPause: true, resumeAuthority: 'service',
});
```

两个值都被接受,**只有沉默改变了含义**。三条运行时通道会指着同一件事说话:注册时按类型
去重的一次告警、resume 被拒时那条点名缺省字段并给出处方的错误消息,以及本仓自有执行器的
`check:resume-authority-declared` CI 门。

⚠️ `supportsPause` 本身是一个没有任何执行路径强制的声明(#5703)—— run 会暂停是因为
`execute()` 返回了 `suspend: true`。所以一个「会暂停但把 `supportsPause` 留成 false」
的执行器,注册告警与 CI 门**都看不见它**,只有 resume 时的拒绝消息会带上同一份处方。
请按同一条规则手工核一遍这类执行器。

### 仓内零行为变化

在册的六个暂停类型全部已显式声明:`screen` / `wait` / `subflow` / `map` 声明 `'any'`
(第一步补齐),`approval` / `approval_revise` 声明 `'service'`。解析器测试与端到端测试
都把这份清单和它们的解析结果一起断言 —— 一个只靠「什么都没注册」而变绿的零点名,和真的
零点名是两回事。

`@objectstack/runtime` 只是注释与路由账本(`route-ledger`)的记述同步,无行为改动。
39 changes: 26 additions & 13 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -493,16 +493,26 @@ The resume route is generic, so **the node the run is parked on** decides
whether a raw resume is a legitimate continuation. Every action descriptor
carries a `resumeAuthority`:

- **`'any'`** (the default — `screen`, `wait`, your own pausing nodes): the
caller supplies the continuation and the route is the intended door.
- **`'service'`** (`approval`): continuing is a *side effect* of a decision
that some service must authorize and record first, so only that service may
drive it. `ApprovalService.decide` enforces the approver slate, writes the
`sys_approval_action` row, mirrors the status field — **then** resumes.

A resume of a `'service'` pause through the route answers **403** and changes
nothing: the request stays pending and the run stays parked, so the real
decision can still land. (Before this gate a raw resume walked the `approve`
- **`'any'`** (`screen`, `wait`): the caller supplies the continuation and the
route is the intended door.
- **`'service'`** (`approval`, `approval_revise`): continuing is a *side effect*
of a decision that some service must authorize and record first, so only that
service may drive it. `ApprovalService.decide` enforces the approver slate,
writes the `sys_approval_action` row, mirrors the status field — **then**
resumes.

<Callout type="warn">
**There is no default — an omission means `'service'`.** A pausing node type
whose descriptor never declares `resumeAuthority` is closed to the generic
route: its pauses answer **403** with a message naming the missing field. If
the route *is* the intended door for your node, declare
`resumeAuthority: 'any'` explicitly. This is an opt-in, not an inheritance —
a pause nobody claimed is one nobody authorized.
</Callout>

A resume of a `'service'` (or undeclared) pause through the route answers **403**
and changes nothing: the request stays pending and the run stays parked, so the
real decision can still land. (Before this gate a raw resume walked the `approve`
edge with no decision recorded, leaving the `sys_approval_request` row and the
run permanently disagreeing.)

Expand Down Expand Up @@ -570,9 +580,12 @@ action with no `params` is untouched:
- `signal.output`, which is the node-*output* namespace of the approval-style
resume envelope rather than the screen's collected-values channel.

Registering a pausing node of your own? Declare `resumeAuthority: 'service'` on
its descriptor when the decision to continue belongs to your service rather
than to whoever holds the run id.
Registering a pausing node of your own? **Declare `resumeAuthority` — the field
is not optional in practice.** Use `'any'` when whoever holds the run id is
meant to supply the continuation (a screen's inputs, a signal wait's external
producer), and `'service'` when the decision to continue belongs to your service
rather than to whoever holds the run id. Declaring neither leaves your pauses
refused on the generic route, and the engine warns about it at registration.

### Parallel approvals — one aggregating node, not two pauses

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/node-executor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018)
| **needsOutbox** | `boolean` | ✅ | Dispatch via service-messaging outbox (retry/idempotency/dead-letter) |
| **isAsync** | `boolean` | ✅ | Suspends the flow awaiting an external reply |
| **handlerContract** | `Enum<'none' \| 'pure'>` | ✅ | Effect contract for author-supplied code this action invokes: 'none' (invokes none) or 'pure' (must not write — it returns a value and the flow graph persists it) |
| **resumeAuthority** | `Enum<'any' \| 'service'>` | optional | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Deliberately has no default an omission is a distinct, reportable fact, and a pausing node type that omits it is warned about at registration (#5561) |
| **resumeAuthority** | `Enum<'any' \| 'service'>` | optional | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Carries no schema default so an omission stays observable — and an omission is fail-CLOSED at run time, equivalent to 'service': a pausing node whose pause is open to the generic route must declare 'any' explicitly (#5561) |
| **maturity** | `Enum<'ga' \| 'beta' \| 'reserved'>` | ✅ | Runtime maturity: ga (shipped), beta, or reserved (contract only — designers grey this out) |
| **source** | `Enum<'builtin' \| 'plugin'>` | ✅ | builtin = platform baseline; plugin = third-party contributed |
| **deprecated** | `boolean` | ✅ | Deprecated alias kept for back-compat |
Expand Down
46 changes: 46 additions & 0 deletions docs/adr/0044-approval-send-back-for-revision.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,49 @@ window. A graph that wanted `revise → notify → window` is refused rather tha
analysed for "every pause reachable on this branch is service-owned", which is
unbounded. Send-back already notifies the submitter itself, so the pattern has no
lost capability behind it.

### Fail-closed descriptor default — landed (2026-08-08, #5561)

The first of the two "directions recorded but deliberately not built here" is now
built, in two steps, and this section supersedes the paragraph above that says
`resumeAuthority` "defaults to `'any'`".

- **Step one (#5561, PR #5725, non-breaking).** `ActionDescriptorSchema.resumeAuthority`
dropped its Zod `.default('any')` and became `.optional()`. That default was not
merely a bad value, it was an *erasure*: `defineActionDescriptor` filled the key
before any consumer saw the object, so "the author chose `'any'`" and "the author
never considered it" parsed byte-identically and the omission could not be
detected at all. With the default gone, absent means absent — which is what made
a registration warning (`AutomationEngine.registerNodeExecutor`, once per node
type) and a CI gate (`check:resume-authority-declared`, AST over shipped
`defineActionDescriptor` literals) expressible. The four pausing built-ins
(`screen`, `wait`, `subflow`, `map`) declared `'any'` explicitly in the same
step, so the warning named nothing on a stock boot the day it shipped.
- **Step two (#5561, this change, breaking).** `AutomationEngine.resolveResumeAuthority`
resolves an absent value to `'service'` instead of `'any'`. A pausing node type
that never declares who may continue its pauses is now closed to the generic
resume route: `POST /automation/:name/runs/:runId/resume` answers **403** and
names the missing field. The generic door is an **opt-in** a descriptor states
with `resumeAuthority: 'any'`, not a default every pausing node inherits.

**Why the amendment's reasoning survives the split.** The direction was always
about which way to guess when nobody declared. Guessing `'any'` continues a run
past a decision nothing recorded, and says nothing — that is #3823 exactly, and
its demonstrated 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 mistakes is discoverable by the
person who made it, and for a platform whose node vocabulary is extended by
plugins and by AI-written metadata, discoverability at authoring time is the whole
argument.

**Migration.** One line, on the descriptor of any pausing executor that relied on
the old default: `resumeAuthority: 'any'`. Registered in the ADR-0087 chain as
`action-descriptor-resume-authority-default-flip` (step 17, semantic — it is a
posture change with no metadata shape to rewrite, the same category as protocol
12's `api.requireAuth` flip). In-tree the flip moves nothing: all six shipped
pausing types already declare their authority, which the resolver tests assert
alongside the inventory they depend on.

**The second deferred direction is untouched.** *Per-suspension owner claim* stays
unbuilt and unneeded for the same reason as before — the cases we have are
resolved by node type plus a fail-closed default, which is now what exists.
Loading
Loading