Skip to content

Commit bd4211f

Browse files
committed
Merge origin/main (批 10 #4973) into claude/issue-4001-automation-batch11
Second serial sync. One conflict, the same `automation/` section as last time, and it arrived exactly as 批 10 predicted in the paragraph it left there. Resolution — both waves' rows kept, header and subtotal recomputed from the SURVIVING rows rather than resolved in favour of a side: surviving rows execution 13 · etl 10 · flow 1 · bpmn-interop 5 · node-executor 4 header 44 (HEAD) / 42 (main) -> 33 strip of 75 subtotal 18 (HEAD) / 16 (main) -> 7 authorable of 33 Both incoming numbers were right against their own branch and wrong against the merge: mine counted 批 10's deletions as still present, main's counted mine. That is the third instance in one day of the shape 批 10 wrote down — the header CONFLICTS and gets attention, while the subtotal line conflicts with nothing and merges clean while being wrong on both sides. The paragraph is rewritten as a per-wave table plus the rule stated mechanically, so the next batch does not have to rediscover it a fourth time. `check:strictness-ledger` arbitrates, and passes. Milestone worth noting in the ledger prose: with 批 9 + 批 10 + 批 11 merged, `automation/`'s authorable strip is **etl.zod.ts alone** (7 sites). The other 26 are wire — including `flow.zod.ts`'s last site `FlowVersionHistorySchema`, which is why that file still has a row while having 0 authorable left and must not be read as unfinished work. os-regen four-step ran in full. The driver deferred `authorable-surface.json` and `references/integration/connector.mdx` this time (recorded in os-regen-pending) rather than text-merging them. checkout origin/main for every `merge=os-regen` path -> install + rebuild spec -> `check:generated --fix` (regenerated `content/docs/references/**` only) -> assert BOTH waves survive: 批 10's control-flow/state-machine reference pages and its 21 TryCatch/Loop/StateNode/Transition authorable-surface entries coexist with this branch's `automation/Webhook:_packageId` / `:protection` and its two pages. The only deletions in the merged artifacts belong to main's own `connector.rateLimitConfig` retirement (#4911/#4947), not to either batch. Verification on the merged tree: check:generated 8/8 · all 8 pure audits PASS check:strictness-ledger: 42 open file(s) / 289 strip site(s) — counts match @objectstack/spec 297 files / 7474 tests · tsc --noEmit clean Part of #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
2 parents 35a111e + 5278e11 commit bd4211f

43 files changed

Lines changed: 3118 additions & 452 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): an approval decision can no longer strand a flow run silently when no automation engine is attached (#4420)
6+
7+
#4420's fix closed every path by which a decision could be recorded while its
8+
flow stayed parked — except one, and it is the one where none of the new guards
9+
could run. Every guard it added (`assertRunResumable`'s pre-flight, the
10+
`RESUME_TARGET_LOST` refusal, the `RESUME_FAILED` throw) hangs off the
11+
automation engine. In a process where **no engine is attached**, all of them
12+
were skipped by the same `typeof this.automation?.resume === 'function'`
13+
condition that wrapped the resume itself — so the decision was written, the
14+
mirrored status field advanced, and the call answered HTTP 200 with
15+
`resumed: false` and **nothing logged at all**. That is #4420's reported
16+
symptom exactly, reproduced in the one composition its fix could not see.
17+
18+
The composition is reachable the same way the original bug was: a flow parks at
19+
an `approval` node in a process that has the automation service, and the
20+
decision arrives in one that does not (the plugin failed to init, or the host
21+
was recomposed between releases). The request row still carries a
22+
`flow_run_id` — which is the row's own declaration that a run is parked on this
23+
decision.
24+
25+
**What changes.** The decision still stands. Rolling it back is not on the
26+
table (a human really decided, and the row is durable by then), and refusing
27+
every such call would break the standalone approvals compositions the
28+
pre-flight deliberately protects — so `finalized` and `resumed` are unchanged
29+
for every existing caller. What changes is that the gap is no longer silent:
30+
31+
- it is logged at **`error`**, per the durability rule in `AGENTS.md`
32+
persisted state and runtime state disagree while nothing looks broken from
33+
the outside, which is the class that rule exists for;
34+
- the response carries **`resumeError`**, so `resumed: false` arrives with its
35+
reason and the stranded run's id instead of leaving the caller to guess
36+
whether a resume was even attempted.
37+
38+
It reuses the already-registered `RESUME_FAILED` code and the existing resume
39+
message shape rather than introducing a new vocabulary — the fact being
40+
reported (an outcome recorded whose run did not advance) is the same one.
41+
42+
Applied at all five sites that resume a recorded outcome: `decide`, the
43+
revision-limit auto-rejection, `sendBack`, `resubmit`, and both branches of
44+
`recall` (whose revise-window path needs `cancelRun` rather than `resume`).
45+
46+
A request that names **no** run is unaffected and stays quiet — there is
47+
nothing parked on it, and reporting one there would be the mirror-image
48+
failure that trains operators to skim `error`.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: remove `connector.rateLimitConfig` and the whole outbound rate-limit shape — the engine never existed (#4911, ADR-0049)
6+
7+
`ConnectorSchema.rateLimitConfig` let an author declare an outbound throttle for
8+
their connector — `strategy`, `maxRequests`, `windowSeconds`, `burstCapacity`,
9+
`respectUpstreamLimits`, `rateLimitHeaders` — and nothing anywhere applied it.
10+
This is not the ordinary declared-but-unread case; it is a step worse:
11+
**there is no outbound rate-limiting engine to wire it to.** The only token
12+
bucket the platform owns is `packages/runtime/src/security/rate-limit.ts`, and it
13+
is INBOUND — the dispatcher calls `consume(key)` on a request fingerprint and
14+
answers 429. No connector provider (`connector-rest`, `connector-openapi`,
15+
`connector-mcp`, `connector-slack`) reads the key, and no seam exists that could.
16+
17+
So a well-formed, schema-validated block told the author they had capped their
18+
call rate against a third party's quota, and capped nothing — the false-compliance
19+
class ADR-0049 exists for. With no implementation and no committed roadmap,
20+
`experimental` would be a promise nobody made; **absent** is the honest
21+
disposition. The vocabulary comes back *with* the engine, in one change
22+
(implementation-first — the #4834 / PR #4878 ruling for the plugin-runtime family).
23+
24+
FROM → TO:
25+
26+
| Removed | Replacement |
27+
| :--- | :--- |
28+
| `connector.rateLimitConfig` (key) | **none** — delete it; throttle at the connector provider or upstream gateway |
29+
| `ConnectorRateLimitConfigSchema` / `ConnectorRateLimitConfig` | **none** — importing either is TS2305 in v17 |
30+
| `RateLimitStrategySchema` / `RateLimitStrategy` | **none** — the enum had no other consumer |
31+
32+
**Do NOT substitute `shared`'s `RateLimitConfig`.** That is the INBOUND limiter
33+
(`enabled` / `windowMs` / `maxRequests`) and caps the calls others make to *us*
34+
the opposite direction. #4684 split the two names for exactly this confusion; the
35+
conversion deliberately does not rewrite one into the other, because that would
36+
silently change behaviour rather than losing a no-op.
37+
38+
The retirement kit:
39+
40+
- **Tombstone.** `ConnectorSchema` is not `.strict()`, so a plain delete would be
41+
a silent strip (ADR-0104). `retiredKey()` makes the removal audible in the two
42+
channels an upgrading author hits — `tsc` (the key types `never`) and the parse
43+
(the prescription itself). It reaches `stack.connectors[]` and
44+
`DeclarativeConnectorEntry`, which is `ConnectorSchema.superRefine(…)`.
45+
- **ADR-0087 D2 conversion + D3 chain step** (`connector-rate-limit-config-removed`,
46+
`retiredFromLoadPath`): `os migrate meta --from 16` deletes the key from author
47+
sources and stored rows replay clean. A lossless delete — the block never had an
48+
effect to lose.
49+
- **The shape goes with the key.** `ConnectorRateLimitConfigSchema` and the
50+
`RateLimitStrategySchema` enum it embedded had no other consumer, and an
51+
exported schema with no consumer reads as a capability to whoever finds it
52+
(#3950).
53+
- **#4684's rename is absorbed.** `integration/RateLimitConfig`
54+
`integration/ConnectorRateLimitConfig` and this retirement landed in the same
55+
unreleased major; composed they are a plain delete, so the `RENAMED_DEFS` entry
56+
is removed rather than pointing at a def this build no longer emits.
57+
- Baselines updated deliberately: `json-schema.manifest.json` (−2 defs),
58+
`authorable-surface.json` (−6 def lines; `Connector` /
59+
`DeclarativeConnectorEntry` gain `… [RETIRED]`), `api-surface.json` (−4
60+
exports). `api-surface-signatures.json` is unchanged by construction — it hashes
61+
each `defineX` parameter as TypeScript *prints* it, a reference
62+
(`z.input<typeof ConnectorSchema >`), so key-level narrowing never reaches it.
63+
64+
No runtime behaviour changes — that impossibility is the reason for the removal.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): 健康检查的超时守卫在 race 落定时被清除,周期性检查不再堆积孤儿定时器 (#4875)
6+
7+
`PluginHealthMonitor.performHealthCheck()` 里那条 race 的守卫由 `timeout()` armed 之后就被
8+
扔掉:插件的 `checkMethod` 赢下 race 之后,那根 `setTimeout` 既没 `clearTimeout` 也没
9+
`unref()`,带着 ref 一直挂满整个 `config.timeout`。这与 #4813 修掉的两处(内核 init/start
10+
守卫,PR #4874)是同一种漏法。
11+
12+
差别在于**健康检查是周期性的**:内核那两处是启动时一次性的固定份额(4 个插件 = 8 根),这里
13+
则是**每个插件每一轮各留一根**,`interval` 越密、`timeout` 越长,堆得越高 —— 一个
14+
`interval: 30s` / `timeout: 5s` 的插件在任意时刻都挂着若干根本该在毫秒级就回收的定时器。
15+
今天这条还没发作,只是因为 `startMonitoring()` 目前没有被内核启动流程调用;一旦健康监控被接进
16+
宿主,它就是 #4813 的放大版。
17+
18+
修法与 #4874 同形:`timeout()` 换成私有 helper `raceCheckTimeout()`,`try { await
19+
Promise.race(...) } finally { clearTimeout(guard) }`。
20+
21+
**为什么是 `clearTimeout` 而不是 `unref()`** `unref()` 让定时器不再钉住事件循环的同时,
22+
也让它不再是一个守卫 —— 若检查永不 settle 且没有别的东西撑着事件循环,Node 会在定时器触发
23+
之前退出,超时被静默吞掉。守卫必须在 race 未决期间保持 ref'd、在落定那一刻被回收,这正是
24+
`finally { clearTimeout(guard) }` 表达的语义。回归测试因此是三条:守卫赢不了时不留 ref'd
25+
定时器、连跑多轮不累积(fake timers 下计数,能识破 `unref()` 式的假修复)、以及检查真的挂住时
26+
超时照常上报。
27+
28+
超时时长(`config.timeout`)一个都没动 —— 问题从来不在时长,而在没人回收。
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): 维度合并键不再把「未分配」并进「空白」,并改为长度前缀消歧 (#4821)
6+
7+
`mergeByDimensions` 是每一份多查询 dataset 结果的装配缝:主查询与每个带 `filter`
8+
的 measure 的补充子查询在这里对齐,`compareTo` 窗口自 #4870 起也按 measure 扇出后
9+
经由同一个缝合并回来。这里一次键碰撞不会报错 —— 一个分组静默吸走另一个分组的数字,
10+
网格仍然保持看起来合理的行数和列数。
11+
12+
**#4821 报告的机制与实际的缺陷不完全一致,先把这一点说清楚。** 原键是
13+
`String(row[d] ?? '')` 以一个**直接写进源码的裸 U+0001 字节**相连。裸控制字符渲染
14+
为空,所以 issue 正文读到的是 `join('')`,其头号复现(`['ab','c']``['a','bc']`
15+
同键为 `"abc"`)其实并不成立 —— 分隔符一直在,只是看不见。真正咬人的是另外两条:
16+
17+
- `?? ''`**真正为 null** 的维度与**空字符串**维度键成同一个值。于是「未分配」被
18+
并进「空白」:一行吞掉另一行的 measure,另一行的列则整个缺失 —— 而 #4708 的空组
19+
填充随后会给它填上一个理直气壮的 `0`。一个真实计数为 3 的分组因此显示为 0。
20+
- 单字符分隔符只在「没有任何维度****包含该字符」时才无歧义。维度值是用户数据
21+
(文本字段、导入记录),所以那是一个假设而非保证,且一旦不成立同样静默。
22+
23+
**改法:长度前缀 + 显式空值哨兵。** 每段编码为 `<长度>:<值>`,`2:ab1:c`
24+
`1:a2:bc` 对任意输入都不同,不再保留任何字符、也不再有看不见的字节留给下一个读者
25+
误读(本 issue 正是这样被误读出来的)。null/undefined 单独走一个哨兵段,与消歧这件
26+
事解耦。
27+
28+
**逐段的 `String()` 强制被刻意保留**,这与一文件之隔的 `cross-object-rebucket.ts`
29+
的 JSON 键不是同一笔交易:后者重新分桶的是**同一个查询**的行,一列只有一种类型,
30+
JSON 在那里免费且能换来真实的区分(空桶 `null` vs 字面量字符串 `"null"`)。本函数
31+
做的是相反的事 —— 跨**不同查询**对齐行,而驱动确实会对同一个分组返回不同的 JS 类型
32+
(本文件 `compareValues` 的注释即记着 "numeric strings, which is how some drivers
33+
return SUM results")。改用 `JSON.stringify` 会把 `1``"1"` 渲染成两个键,让今天
34+
能正确合并的行不再合并 —— 用一个新的静默缺陷换掉旧的,不算修好。该行为已有回归钉
35+
测试锁住。
36+
37+
仅影响内部合并键,响应中的任何值都不改变。
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): a fresh database no longer boots "drifted", and the drift
6+
detector never points `--allow-destructive` at an index the framework created
7+
(#4884)
8+
9+
Booting `examples/app-showcase` on a brand-new empty SQLite file printed two
10+
`[schema-drift]` warnings before the server was even ready, both about the
11+
ADR-0048 overlay indexes the same boot had just created. Both were false, and
12+
one of them was dangerous:
13+
14+
> `[schema-drift] sys_metadata: index 'idx_sys_metadata_overlay_draft' UNIQUE
15+
> (type, name, organization_id) carries ObjectStack's generated naming but
16+
> matches no declared index (orphaned) — "os migrate apply --allow-destructive"
17+
> to drop it.`
18+
19+
`idx_sys_metadata_overlay_draft` is the unique index enforcing **draft-overlay
20+
uniqueness**. An operator following our own boot advice would have dropped a
21+
live data-integrity guarantee to fix a problem that did not exist — and, worse,
22+
learned to treat `--allow-destructive` as routine boot hygiene, which is exactly
23+
what makes the *next*, real drift warning dangerous.
24+
25+
Three fixes, in the driver's detector only (no metadata declaration changed —
26+
`sys-metadata.object.ts` documents its four-column `indexes[]` entry as *the
27+
fallback shape for drivers without the runtime migration*, and that contract
28+
still holds for the drivers that rely on it):
29+
30+
- **The index key is now read as written.** Introspection took the key from each
31+
dialect's per-column catalogue view (`PRAGMA index_info`, `pg_attribute`,
32+
`STATISTICS.COLUMN_NAME`), which describes an expression key as a NULL column
33+
and nothing else. The canonical
34+
`(type, name, organization_id, COALESCE(package_id,''))` overlay index
35+
therefore arrived as three columns and was reported as a mismatch against its
36+
own four-column declaration. SQLite and Postgres now parse the index
37+
definition (`sqlite_master.sql` / `pg_get_indexdef`), MySQL reads
38+
`STATISTICS.EXPRESSION` where the server has it, and `COALESCE(col, <literal>)`
39+
is recognised as keying on `col` — which is what ADR-0048 uses it for: a plain
40+
UNIQUE index treats NULLs as distinct, so package-less globals would not be
41+
unique among themselves.
42+
- **Partial predicates are captured.** A `WHERE`-restricted index is something
43+
`syncDeclaredIndexes` can neither create nor rebuild, so the detector no
44+
longer claims authorship of one, no longer calls it orphaned, and never
45+
proposes a remedy it could not undo.
46+
- **The driver keeps a ledger of the index DDL it executed.** An index this
47+
process created through raw `execute()` — how `metadata-protocol`'s
48+
`ensureOverlayIndex` issues its migration — is the framework's to manage. This
49+
also covers the plain-index fallback the same migration takes on dialects that
50+
reject partial indexes.
51+
52+
Genuine drift is unaffected: an orphaned generated index, a redefined declared
53+
index and the #3696 legacy-unique replacement are all still detected, still
54+
categorised exactly as before, and still remediable through `os migrate`.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
'@objectstack/spec': major
3+
---
4+
5+
**BREAKING**`automation/control-flow` and `automation/state-machine` reject unknown keys (#4001 批 10, ADR-0078)
6+
7+
Eleven authoring shapes that silently discarded undeclared keys now refuse them with a
8+
named surface, the offending key echoed back, and a rename or prescription. Metadata that
9+
used to parse "successfully" while losing the key you wrote now returns 422.
10+
11+
**`automation/control-flow.zod.ts`**`FlowRegionSchema`, `LoopConfigSchema`,
12+
`ParallelBranchSchema`, `ParallelConfigSchema`, `TryCatchConfigSchema`.
13+
14+
**`automation/state-machine.zod.ts`**`ActionRefSchema` (object branch),
15+
`GuardRefSchema` (object branch), `TransitionSchema`, `StateNodeSchema`, its `meta` block,
16+
and `StateMachineSchema`.
17+
18+
## What was actually being lost
19+
20+
A `state_machine` on an agent's `lifecycle` with `onn` where `on` was meant parsed clean
21+
and came back with **no transitions at all** — the declaration whose entire purpose is to
22+
deny undeclared transitions, silently emptied and reported valid. A `loop` config with
23+
`maxIteration` (singular) came back uncapped. A `parallel` branch with `label` instead of
24+
`name` came back unnamed.
25+
26+
## Migration — FROM → TO
27+
28+
Renames the rejection now suggests for you:
29+
30+
| you wrote | write instead | on |
31+
|---|---|---|
32+
| `guard` | `cond` | a state transition (XState v5 renamed it the other way; this protocol kept `cond`) |
33+
| `action` | `actions` | a state transition |
34+
| `itemVariable` | `iteratorVariable` | a `loop` config |
35+
| `maxIteration` | `maxIterations` | a `loop` config |
36+
| `label` | `name` | a `parallel` branch |
37+
| `onn` / `entery` / typos | `on` / `entry` | a state node |
38+
39+
Keys with no replacement, and what to do instead:
40+
41+
- **`finally` on `try_catch`** — there is no `finally` region. The node's ordinary
42+
out-edges run whichever way the protected region went; put the always-run steps in the
43+
nodes **after** the container.
44+
- **`join` / `joinGateway` on `parallel`** — the join is implicit; the block continues once
45+
when every branch completes. `join_gateway` is a BPMN interop node type, never a
46+
`parallel` config key.
47+
- **`flowName` on `loop`** — that key belongs to the `map` node, which runs a subflow per
48+
item. A `loop` runs an inline region: move the steps into `config.body`, or change the
49+
node `type` to `map`.
50+
- **`name` / `label` on a region** — a `loop` body, a `try` region and a `catch` region are
51+
not named; only a `parallel` branch carries a `name`.
52+
- **`transitions` on a state node** — a state node declares transitions as `on`, keyed by
53+
event type. `transitions` is the key on the object-level `state_machine` **validation
54+
rule** (`validations[].transitions`), a different declaration.
55+
- **`context` on a state machine** — this protocol declares only the context SHAPE, as
56+
`contextSchema`. There is no key for seeding initial values, so the two are not a rename
57+
of each other.
58+
59+
## Two notes for upgraders
60+
61+
`ActionRef` / `GuardRef` are unions, so a rejected key on their object branch surfaces as
62+
zod's `invalid_union` (`"Invalid input"`) with the real prescription nested one level down
63+
in `issue.errors[]` rather than in the top-level message. The prescription is present in
64+
`ZodError.message` and in REST error bodies; single-line formatters drop it.
65+
66+
`StateNodeSchema.meta` is **closed**, not a passthrough bag. XState treats `meta` as open,
67+
but the hand-written `StateNodeConfig` type here declares exactly `label` / `description` /
68+
`color` / `aiInstructions`, nothing in the platform reads any other key, and the previous
69+
behaviour was not openness but strip — an authored `meta` arrived as `{}`.
70+
71+
All three example apps (`app-showcase`, `app-crm`, `app-todo`) validate unchanged, so no
72+
ADR-0087 conversion accompanies this change.

content/docs/references/automation/control-flow.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,44 @@ interop node types (`parallel_gateway` / `join_gateway` / `boundary_event`),
7373

7474
which remain author-invisible interchange representations.
7575

76+
## Unknown keys are rejected (#4001 / ADR-0078)
77+
78+
Every shape below is `strictObject`. Before that they were plain `z.object`,
79+
80+
so zod's default `.strip` applied and a key this file does not declare was
81+
82+
**discarded in silence** — the container still parsed, still registered, and
83+
84+
still ran, with the author's configuration simply absent. On these five
85+
86+
shapes that silence is unusually expensive, because each one carries
87+
88+
*control* rather than data: a swallowed `maxIterations` is an uncapped loop,
89+
90+
a swallowed branch key is a branch that runs without what it was given.
91+
92+
### How this relates to `validateControlFlow`
93+
94+
`validateControlFlow` is a **sibling guard, not a key gate** — it answers
95+
96+
"is this region single-entry / single-exit / acyclic", which no amount of
97+
98+
key strictness can answer. The two do not overlap and cannot fight: the
99+
100+
schema rejects undeclared KEYS, the analysis rejects malformed STRUCTURE.
101+
102+
They do now meet at one seam, deliberately — `validateControlFlow`
103+
104+
`safeParse`s each region slot before analyzing it, so from #4001 that parse
105+
106+
is also where a region's undeclared key surfaces, reported as
107+
108+
`<where>: invalid region — <the strictObject message>`. Nothing was
109+
110+
duplicated and nothing was removed; the structural prose this guard exists
111+
112+
for is untouched, and it simply stopped silently repairing its own input.
113+
76114
<Callout type="info">
77115
**Source:** `packages/spec/src/automation/control-flow.zod.ts`
78116
</Callout>

0 commit comments

Comments
 (0)