Skip to content

Commit fd34226

Browse files
authored
Merge branch 'main' into claude/issue-15632-error-envelope-both-families
2 parents 50d799c + 3f98c2a commit fd34226

24 files changed

Lines changed: 1512 additions & 370 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
---
5+
6+
fix(automation): a `wait` node must say what resumes it — the config block is required at the contract, and the executor stops defaulting to a duration-less timer (#17928)
7+
8+
**BREAKING** — a `type: 'wait'` flow node with no `waitEventConfig` block, and a
9+
`type: 'boundary_event'` node with no `boundaryConfig` block, no longer parse.
10+
Under `eventType: 'timer'`, `timerDuration` is now required and may not be blank
11+
— and that half sits on the `waitEventConfig` BLOCK, not on the node type, so it
12+
bites on ANY node carrying the block: a `start` node spelled
13+
`waitEventConfig: { eventType: 'timer' }` parsed before and is refused now. It is
14+
still a narrowing in every direction (no shape starts parsing that did not), and
15+
the block is inert on a node type no executor reads it from, so the practical
16+
reach is `wait`.
17+
18+
`eventType` has been required *inside* each block since protocol 17, so
19+
`waitEventConfig: {}` was already a loud parse error. The block itself was
20+
optional — so "omit the key" and "omit the block" were two documents with two
21+
verdicts, and the accepted one was the silent one. It is also the state a
22+
freshly created node is in, which is what made it reachable from a designer's
23+
default screen rather than only by hand-authoring.
24+
25+
What that document did, measured through a real `engine.execute()` run rather
26+
than read off the source:
27+
28+
```
29+
FROM { id: 'pause', type: 'wait', label: 'Wait' } // parses clean
30+
-> { success: true, suspend: true } // run status: paused
31+
scheduled jobs: [] <- with a job service ANSWERING
32+
variables: no `pause.waitUntil` <- cold boot cannot re-arm
33+
log lines: 0 at any level <- warn, error, info, debug
34+
35+
TO FlowNodeSchema.safeParse(...)
36+
-> { success: false,
37+
issues: [{ code: 'custom', path: ['waitEventConfig'],
38+
message: 'a `wait` node requires a `waitEventConfig` block saying
39+
what resumes it … `waitEventConfig: { eventType: 'timer',
40+
timerDuration: 'PT1H' }` … or `{ eventType: 'signal',
41+
signalName: 'order_paid' }` …' }] }
42+
```
43+
44+
The control — the same node with `{ eventType: 'timer', timerDuration: 'PT1H' }`
45+
— armed the one-shot job and persisted the deadline, so the zeros above are a
46+
reading of this path and not of a dead harness.
47+
48+
**The executor follows the contract.** `wait-node.ts` carried
49+
`(node.waitEventConfig ?? {})` and `String(wec.eventType ?? 'timer')` under a
50+
comment declaring the second one deliberate — "a wait node without one is a
51+
VALID TIMER WAIT". Both fallbacks are retired. A node that still reaches
52+
`execute` without the block (a stored pre-migration document on a path that
53+
skipped the parse) is now a **guard refusal**`errorClass: 'guard'`, so a
54+
`fault` edge cannot route a metadata defect into a handler that reports success
55+
— and it **logs**, naming the node and the remedy, because the defect being
56+
closed was silence. It never suspends with `success: true` again. Two smaller
57+
corrections ride along in the same return: the timer branch stops answering
58+
`output` as a present key holding `undefined` (it is absent when no deadline was
59+
computed), and the reversed comment is deleted rather than left describing a
60+
behaviour that is gone.
61+
62+
**`screen.mode` now declares the default the executor applies; `http.method`
63+
still declares none.** Both were read by running the executors with the key
64+
absent, not by reading the Zod:
65+
66+
| key | absent ⇒ the runtime applies | declared |
67+
| --- | --- | --- |
68+
| `ScreenConfig.mode` | `'create'` (object-form branch; the flat `fields` branch never reads it) | `.default('create')` |
69+
| `HttpConfig.method` | `GET` inline, **`POST`** when `durable: true` | ⛔ none — two values, no single default |
70+
71+
Declaring `.default('GET')` on `method` would materialise `GET` at parse time,
72+
the durable arm's own `?? 'POST'` would never fire again, and every stored
73+
durable callout that omits the method would silently change verb. That is the
74+
defect this card exists to end, pointed the other way.
75+
76+
**Migration.** A stored `wait` node with no block has no lossless conversion —
77+
the missing value is an intent no artifact records, and the old runtime's pick
78+
(`'timer'` with no duration) was not a wait at all — so this is an ADR-0087 D3
79+
semantic entry rather than a D2 conversion: `os migrate meta --from 17` names
80+
each node to edit. Declare the resume condition and re-publish the flow. ⚠️
81+
Behaviour the fix deliberately changes: a run that used to park forever now
82+
waits the duration you declare or the signal you name.
83+
84+
**`boundary_event` gets the contract half only.** The runtime registers no
85+
executor for that node type at all — a flow reaching one fails with
86+
`NO_EXECUTOR` before any config is read, identically whether the block is
87+
present or absent — so there is no silent executor branch behind it. The
88+
refusal fixes the authoring surface; `try_catch` (ADR-0031) remains the native
89+
construct for error handling.
90+
91+
<!-- adr-0087: registered wait-node-event-config-required -->

.claude/skills/checklist-test/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ expected-fail 探针 —— 永不在真实缺陷上把条款打成绿。
120120
**issue 是纯文本 —— 永不放图。** 截图只为让你和子代理**现场**得出判定;它们是判断辅
121121
助,随运行环境一起丢弃。持久报告需要的是**复现规则,不是图片**
122122

123-
`issue_write`(github MCP)立单:
123+
经 REST 代理 `POST .../issues` 立单(`Content-Type: application/json`;⛔ 永不 MCP `issue_write`,锁 1 已拒):
124124

125125
- **标题** —— `QA run · <selector> (<已判定>/<总数>) · <sha8> · <date> · <counts>`:恰好
126126
一种形状、严格解析,`(<已判定>/<总数>)` **强制**;语法与退役写法见

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
375375
- 三类内再过时前提检查,三选一:晋级 / 关闭 not planned / hold;判级只在此轮,不在立单时。
376376
- ⛔ 写入前对每张卡现读当前状态;任何列表快照读数一律作废。
377377
- ⛔ 带 `pm:dispatched` / assignee / 任何 open 或 merged PR 引用的卡,一律不动。
378-
- `issue_write` 会替换标签集并清空未传字段:写入时必须回传 `assignees`
378+
- 标签/assignee 写恒经 `scripts/pm/label-write.mjs`(四步、回读);⛔ 永不 MCP `issue_write`(锁 1 已拒)
379379
- 车道席可附证据/前提重验,⛔ 不定级不改标;skills 车道 finding 由该席自分诊,全仓轮跳过。
380380
- 域分批与 sweep 打包晋级五条照用;每批约定与积压告警见 `references/dispatch-runbook.md`
381381
- `pm:retriage` 每 fire 先答异议评论所求,答后同笔摘标;须维护者答的进收件箱,标照摘。
@@ -612,7 +612,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
612612
- 速读五段固定:改了什么/为什么改/风险与代价(含回滚)/席位意见/你要做的(一个动作)。
613613
- 草稿归 dev:受管面 PR 正文带 `## 维护者速读(草稿)`,中文、业务角度,席位意见留空。
614614
- 终稿 = 席位对照自己读的 diff 校正草稿、填席位意见后贴评论;维护者只读评论。
615-
- PR 上的标签 = 待维护者审阅,不入六态;与请审同为等人合清单,随合并或撤回判决离开
615+
- PR 上的标签 = 待维护者审阅,不入六态;与请审同为等人批清单,随获批或撤回判决离开
616616
- 批准判定单源 = 队列守卫常量 `GOVERNED_APPROVERS`:授权账户 APPROVED 即算,⛔ 不卡 `commit_id`
617617
- 批准后再推亦不过期;席位落地 = 过落地前检、清标、ready、auto-merge,踢出/变基同法。
618618
- ③ 在 draft PR 上向两个授权批准账户 `os-zhuang``hotlong` 都 request review,主动推。
@@ -623,7 +623,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
623623
- 已入队才读到本条 ⇒ 转 draft 与 disable 都做;出队以阳性探针答,ref 缺席只旁证。
624624
- skills 车道自有 PR:纯代码面如 `scripts/pm/` 由本席按达档自审(清单不减)后落地。
625625
- 受管面两层:事实层仅本技能 `references/`,其余为规则层(含发布 `skills/**` 与 SKILL.md)。
626-
- 规则层四件套等人合;事实层 PR(受管路径全在该目录)经席内达档复核后 ready → 入队。
626+
- 规则层四件套等人批;事实层 PR(受管路径全在该目录)经席内达档复核后 ready → 入队。
627627
- 路径面干净的才转 ready → 入队;队列是唯一被认可的落地路径,⛔ 永不队列外合并。
628628
- 入队资格 = PR 上每一个 check 全绿,⛔ 不是 required 子集;required 集是队列强制的地板。
629629
- 非必查红是真缺陷或坏门,归 PM 入队前处置;第三种按设计而红,三条全立才可带红入队:

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,8 @@ localStorage / auth gotchas.
270270
spent once per PR: the OWNING seat then lands it, later pushes included, re-queuing after an ejection or a rebase on
271271
its own pre-landing check; this gate does not re-review it. Hand-authored governed content needs that approval; a PR
272272
whose only governed paths are register rows the queue leg regenerates byte-exact clears with zero approvals — an
273-
uncertified recompute, drift or a hand-authored sibling keeps it governed. Unapproved, the bypass direct merge
274-
(人工直合) is the only landing. **Landing is tiered**: a PR whose governed paths all lie under
273+
uncertified recompute, drift or a hand-authored sibling keeps it governed. Unapproved, no seat lands it: the
274+
ending is that approval, then the owning seat. **Landing is tiered**: a PR whose governed paths all lie under
275275
`.claude/skills/pm-dispatch/references/` lands through the queue after the skills seat's contract-tier review; every
276276
other governed path is the rules layer and waits for the maintainer's word, which the director seat requests as ONE
277277
batch of at most five rows — the approval stays the maintainer's click. ⛔ **No agent seat submits an approving
@@ -281,7 +281,7 @@ localStorage / auth gotchas.
281281
**Already armed or queued when you read this?** Convert it back to **draft** AND disable auto-merge — draft is
282282
what removes queue membership, disabling alone drops only the arming — then confirm from the remote that it is in
283283
neither the queue nor `origin/main`. **Draft is no barrier by itself — the barrier is this directive**, and a
284-
human merge IS the review record, ⛔ not a relaxation. Behind it: the queue guard refuses an unpinned governed
284+
spent approval IS the review record, ⛔ not a relaxation. Behind it: the queue guard refuses an unpinned governed
285285
diff; CODEOWNERS routes review requests for `docs/adr/` only, so nothing summons the maintainer on the other four;
286286
the post-merge audit (`scripts/pm/check-governed-merges.mjs`) lists every governed-surface merge with its approver
287287
and merger — a merger the maintainer does not recognise, or any agent approval, is a seat violation, filed and

content/docs/api/error-catalog.mdx

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: Error Code Catalog
33
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
44
---
55

6-
ObjectStack uses a structured error system with **9 error categories** and **51 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
6+
ObjectStack uses a structured error system with **9 error categories** and **52 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
77

88
This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
99
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
@@ -74,6 +74,61 @@ entry's cross-reference sentence so the in-process one stays findable.
7474
}
7575
```
7676

77+
### `VALIDATION_FAILED`
78+
**Cause:** Record-level validation refused the write. This is the code a
79+
*producer* names — `ValidationError` from the record and rule validators, and
80+
any error of the same shape (`code: 'VALIDATION_FAILED'` / `name:
81+
'ValidationError'`) — as opposed to `VALIDATION_ERROR` above, which is *derived
82+
from the status* when the producer named no code at all. It covers required,
83+
type, format, length, range and picklist violations, a dangling `lookup` /
84+
`master_detail` reference (`fields[].code === 'reference_not_found'` — see the
85+
callout below), a malformed `expectedVersion` / `If-Match` token, and a batch
86+
row that names no record id.
87+
**Fix:** Branch on this code and read `fields[]`. Each entry carries `field`
88+
(the API name, so a form can focus the right input), `code` from the field-level
89+
catalog ([ADR-0114](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0114-field-level-error-code-catalog.md)),
90+
a `message` rendered in the caller's locale, and — where they apply — `label`,
91+
`constraint`, `value` and `options`. Correct the named values and resend; no
92+
retry of the same payload can succeed.
93+
**Retry:** `no_retry`
94+
95+
<Callout type="warn">
96+
**Where the per-field list rides is decided by the door, not by the code — and
97+
it can be empty.** On the `/data` routes the envelope is flat and `fields` is a
98+
top-level sibling of `code`, always present, `[]` when the producer named none:
99+
100+
```json
101+
{
102+
"error": "Email is not a valid email address",
103+
"code": "VALIDATION_FAILED",
104+
"fields": [
105+
{ "field": "email", "code": "invalid_format", "label": "Email",
106+
"message": "Email is not a valid email address" }
107+
]
108+
}
109+
```
110+
111+
The same throw served through the runtime dispatcher answers the nested
112+
envelope, and the list rides in `details` instead:
113+
114+
```json
115+
{
116+
"success": false,
117+
"error": {
118+
"code": "VALIDATION_FAILED",
119+
"message": "Email is not a valid email address",
120+
"httpStatus": 400,
121+
"details": { "fields": [{ "field": "email", "code": "invalid_format" }] }
122+
}
123+
}
124+
```
125+
126+
Both are `400`. A refusal a route *builds* itself rather than throwing — share
127+
link creation without `object` / `recordId`, for instance — carries the code and
128+
the sentence and **no `fields` at all**. So read the sentence out of `error` /
129+
`message`, and treat `fields` as a refinement that may be absent or empty.
130+
</Callout>
131+
77132
### `INVALID_FIELD`
78133
**Cause:** A field name in the request does not exist on the target object. On a
79134
list read this also covers an unreserved query parameter — `GET /data/:object`
@@ -810,7 +865,7 @@ async function handleApiCall() {
810865

811866
| Status | Category | Common Codes |
812867
|:---:|:---|:---|
813-
| 400 | `validation` | `VALIDATION_ERROR`, `INVALID_FIELD`, `MISSING_REQUIRED_FIELD`, `INVALID_QUERY` |
868+
| 400 | `validation` | `VALIDATION_FAILED`, `VALIDATION_ERROR`, `INVALID_FIELD`, `MISSING_REQUIRED_FIELD`, `INVALID_QUERY` |
814869
| 401 | `authentication` | `UNAUTHENTICATED`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` |
815870
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` |
816871
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |

0 commit comments

Comments
 (0)