feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node - #18688
Conversation
…tured region Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…d an end node An ADR-0031 region body runs synchronously inside the enclosing run, so it can neither park that run on a durable pause nor terminate it. The engine already refused both at run time, silently and after the executor had written its progress state into the enclosing scope. FlowSchema now refuses the shapes at parse, naming the node and the region. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 63832c83c862ea414b73672c6005d41ed26e6857 && git checkout 63832c83c862ea414b73672c6005d41ed26e6857
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2767af8e8354511f9c82ce402b147ad12c512819 6de9d662f6df5e38be9303647845704a357e6b50 && git checkout -B drift-repro 2767af8e8354511f9c82ce402b147ad12c512819 && git merge --no-ff 6de9d662f6df5e38be9303647845704a357e6b50
node scripts/docs-audit/affected-docs.mjs --json 2767af8e8354511f9c82ce402b147ad12c512819
|
…arse refusal `registerFlow` parses through `FlowSchema` (`canonicalizeStoredFlow`), so the region-nested refusing `end` this case registered can no longer be registered at all — the refusal it asserted at the region boundary is now met one door earlier, at load. The fixture is unchanged and the case still fails the day the shape becomes declarable again; what it no longer covers (`runRegion`'s `isRefusalSignal` arm, now reachable only past `MAX_REGION_DEPTH`) is stated in the docblock rather than left to be discovered. The changeset's scope line said `packages/services` is untouched and the run-time refusal stays exactly as it was. Measured: registration and the ADR-0087 stored-row rehydration seam both parse, so a stored row carrying a refused shape stops loading, and the `end` arm's run-time refusal has no other caller. Corrected in place. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
… types Ruling batch #153 item 1, letter D: inside `loop` / `parallel` branch / `try_catch` bodies at any depth the refused population is `screen`, `wait`, `approval`, `approval_revise` and `end`. `map` and `subflow` are not refused by type — they pause exactly when the child flow `config.flowName` names pauses, a record this parse does not hold, so a type-keyed refusal would also refuse `loop { map(synchronous child) }`, a shape that runs correctly. `FLOW_PAUSE_CAPABLE_NODE_TYPES` (unreleased, added on this branch) becomes `FLOW_UNCONDITIONAL_PAUSE_NODE_TYPES` so the exported name states the population the rule keys on rather than a capability list two of whose members it does not judge. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Seat decision on the conflict the rename surfaced: ruling D orders a `minor` for `@objectstack/spec`, and `check:api-surface` grades a removed export breaking, so the rename and the ruling cannot both stand. The ruling asks for a change to the refused POPULATION, not to the export's name. `FLOW_PAUSE_CAPABLE_NODE_TYPES` keeps its identifier and its place in `api-surface/automation.json`; only its contents narrow to the four types that pause unconditionally. The docblock now leads with "read the contents, not the name" and states why the name is kept, so the mismatch is declared rather than discovered. `src/migrations/registry.ts` is regenerated from the edited ADR-0087 entry. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
The declaration-text gate (check:api-surface-declarations) and its shards landed on main after this branch point, so the gate could not be run here at all. Merging brings it in; the shard regeneration is judged separately. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
`check:api-surface-declarations` landed on main after this branch point; the
merge brings it in. Its delta on this branch is 0 removed / 1 added /
2 reshaped, and all three are non-narrowing:
+ FLOW_PAUSE_CAPABLE_NODE_TYPES — introduced by this PR; it is on neither
main's api-surface nor main's declaration shard, so "added" is accurate.
~ ApprovalDecision, ApprovalNodeConfigSchema — property ORDER inside their
`z.ZodEnum<{...}>` type literals, same members, same literal values.
Object type members are order-insensitive in TypeScript, so old and new
are mutually assignable; proved with a two-direction assignability probe
plus a `@ts-expect-error` negative control, tsc exit 0.
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
Contract review — PR #18688Served-tier: CONTRACT_REVIEW_TIER Reviewed against ruling D (comment ① Derived judgments — the accept set and the public surface, each named and judged
Claims 1–8 from the brief — verified with my own runs
⭐ The seat's correction — verified independently: the seat is right.
② Clause ② and semver
③ Boundary flags — each with why it does not block
NOT MEASURED
Scratch (all of it under PASS — Generated by Claude Code |
✅ 达档合约复核 PASS —— 落地 head
|
| 消融 | 读数 |
|---|---|
把 'map' 加进常量 |
恰好 4 条红(map/subflow 的排除钉),其余一条不动;还原后 blob 与 HEAD 相同,porcelain 0 |
把规则本身停掉(=== 0 → >= 0) |
18 红 / 11 绿 —— 红的是全部拒绝断言,绿的恰是过度拒绝护栏与边界钉 |
⇒ 规则可证伪,且不过度拒绝。基线与还原都是 29 passed (29)。
⭐ 它还复现了本席点名的假绿对照:从 service-automation 目录 require.resolve('@objectstack/spec/automation') 落在 dist/ 上,产物读出 ["screen","wait","approval","approval_revise"],而未动的兄弟常量 FLOW_STRUCTURAL_NODE_TYPES 仍读 ["start","end"](证明这个读数不是恒定值),mtime 是本次构建。⇒ 这条绿不是陈旧 dist 给的假绿。
⛔ 本席被逮到两条,两条都实测复验、当场认下
① 本席用了一个过期的仪器,而且这正是本席今天警告过别人两次的坑。
本席多次报 --pair 18688 exit 0,还据此在正文里写「不 actionable」。实测:
| 脚本 | blob | 含 C8 |
|---|---|---|
| 共享检出(本席一直在跑的那个) | ccd5ad7c9a00 |
0 |
origin/main 与本 PR head |
3a270ef2eb5f |
18 |
亮对照 C1 两边 50 / 51 ⇒ 读法没问题,差的是脚本。C8 规则 01:41Z 随 #18859 落在 main 上,本席的共享检出停在自己的开发分支上,从来没拿到过。
⇒ 本席今天所有 --pair 读数都是无 C8 的脚本取的。 已用 main 的脚本全部重取:#18688 = exit 4、#18638 = 0、#18890 = 0。⇒ 真有问题的只有本卡,但纪律问题是系统性的。
C8 说的是:本席在本卡上持有两条存活的 Claim:,而协议禁止第二条 Claim:。已按 C8 点名的修法修:Release:(5729634742)+ 唯一一条新 Claim:(5729639847)⇒ --pair 18688 现在 exit 0,claim.selected 1、claim.rejected 2。
② 「packages/services 零路径」只对本轮成立,对 PR 累计 diff 不成立 —— 而且这是一条对裁决字面的偏离。
裁决 D 第 1 条写「packages/services untouched;那 5 条测试与 #15616 的套件照旧」,而 #15788 那条 region-end 用例正是那 5 条之一。⇒ 裁决那句「B 什么都不破坏」对这一条不成立:第 1 条自己要求 end 在解析期被拒,而 registerFlow 走解析 ⇒ 旧的运行期断言按构造不可达。
处理:夹具逐字未动,用例数 12 → 12,断言加强(区域路径、报文、且断言什么都没注册)。⛔ 没删、没跳过、没隔离,⛔ 引擎源码未动。复核测全了并判不挡 —— 但它是偏离,写在明处,⛔ 不埋。
分级
Clause-②: yes(无臂),@objectstack/spec minor。收窄确实是 breaking,但发布窗口期版本号不是承载体 —— 闸门自己的表头这么写,而两个强制承载体(changeset 的 BREAKING 横幅、ADR-0087 处置)都在,两道闸都绿。
运行期兄弟卡 #18881 确实存在且开着 ⇒ Part of 不会把本卡晾在半空。
Generated by Claude Code
…ed on the origin/main merge The merge of origin/main (b146102) routed four paths to the os-regen driver, which defers rather than text-merging: the three api-surface-declarations shards automation.txt, data.txt and ui.txt, and the generated reference page content/docs/references/ui/component.mdx. On every one of them the driver kept this branch's side and silently dropped main's, so step 2 of the sanctioned sequence restored main's side into the worktree and this commit re-derives all four from the merged tree. What each side contributed, now both present: ui.txt / component.mdx main's element:text.variant widening to the published nine (#19019), plus this branch's evaluated-slot narrowing data.txt main's transport no-transactions declaration (#18890) and the $orderby dual declaration (#19018), plus this branch's `source: string` narrowing on ConditionalValidationSchema and CrossFieldValidationSchema automation.txt main's structured-region pause/end refusal (#18688), plus this branch's narrowing The two MIXED, deliberately unrouted paths were hand-resolved by git's ordinary three-way merge and verified rather than eyeballed: registry.ts's hand-written remainder (generated regions stripped with the merge script's own awk) is byte-identical across base, both sides and the merge, and its line count is exactly additive (17142 + 121 + 74 = 17337), with both sides' migration entries present by id. component.zod.ts is additive too (3750 + 4 + 45 = 3799) and its single .superRefine() is untouched. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…fuses stops its parent, in `subflow` and in `map` alike (objectstack-ai#18706) Part of objectstack-ai#18110 Part of objectstack-ai#18555 ⛔ Deliberately `Part of`, not `Fixes`, for both: the ruling on objectstack-ai#18110 (director batch objectstack-ai#145 item 4, letter **A**, maintainer 「同意,其他也同意」) states that the lane seat closes both cards at landing, after verifying the two arms independently. ## The defect A child flow that ends on an `end` node declaring `outcome: 'refused'` rolled up to its parent as an **ordinary success**. `subflow-node.ts` branched only on `child.status === 'paused'` and `!child.success`. A refused child is neither — `finishRefusedRun` answers `{ success: true, status: 'refused' }`, because *a refusal is a successful evaluation that says no* — so it fell straight through the ordinary success exit. The parent walked the node's out-edges, recorded `completed`, and fired its **own** `successMessage` over the child's refusal. `map-node.ts` had the line-for-line identical branch set and the identical missing arm (objectstack-ai#18555): a refusing row's output went into `state.results` and every row after it was processed anyway. This is fail-open in the direction nobody notices. A refusing `end` is most often a gate — an approval, an eligibility check, a precondition — and a gate whose "no" lets the run through finishes green. ## The change — one channel, two call sites | | | |---|---| | **New on `NodeExecutionResult`** | `refuse?: boolean` and `refusalMessage?: string`, beside the `suspend?: boolean` that already exists for the pause half of the same unwinding protocol. | | **`executeNode`** | throws `new FlowRefusalSignal(node.id, result.refusalMessage)` **at the position the suspend signal is thrown** — after the success step is pushed, after the `childSteps` fold, after output write-back. | | **`subflow-node.ts` / `map-node.ts`** | both gain the `refused` arm that sets it. | | **Region-boundary diagnostic** | generalised to name whichever node carried the refusal. **Text only.** | The throw **position** is the design, not a convenience: it is what keeps the child's `selected` / `acted` / `unmeasuredEffect` rollup (objectstack-ai#4354) in the run log and therefore in the run summary. A refusing child really can have written rows before it said no, and an unwind that began any earlier would drop exactly those counts. This is the property option B was rejected for losing, and it is pinned in both test files. ⛔ A refusal is still **not** a failure: it does not consume retry budget, is not routable by a `fault` edge, and is not counted in `nodes[].failures`. ### What this change deliberately does not do - **`packages/spec` untouched**, no new status value (`refused` has been a published member of `TERMINAL_RUN_STATUSES` since objectstack-ai#15788), no authorable edge semantics. - **Region semantics untouched.** objectstack-ai#18112's option B is not implemented and no container is taught to rethrow. Only the diagnostic's wording changed, per objectstack-ai#18112's ruling, which assigns the runtime text to this PR and the authoring-time half to objectstack-ai#15646 / PR objectstack-ai#18688. - **Option B** (an unexported internal seam) and **option C** (defer) are not taken. ## `Clause-②: yes (widening)` `NodeExecutionResult` is barrel-exported from this package's single entry point (`src/index.ts:9`), so two new optional members are a widening of the published executor contract. Changeset: `@objectstack/service-automation` **minor**. Contract review is owed on the review, per the ruling. Additive for third parties: an executor that never sets `refuse` behaves exactly as before. ## Premise re-verification (every position re-taken, ⛔ none inherited) `engine.ts` moved the same day the ruling was written (`99fcb4a`, 2026-09-17T11:57:43Z), so every line number handed to me was stale. Re-taken with `git show origin/main:PATH` at `1bc22b3`: | reading | ruling / prior report said | re-taken | verdict | |---|---|---|---| | `NodeExecutionResult` | `:332` | `:332` | unchanged | | `TERMINAL_RUN_STATUSES` | `:1365` | `:1365` | unchanged — `refused` already published | | the single `new FlowRefusalSignal` | `:9319` | **`:9352`** | moved, shape intact, still the only site, still `node.type === 'end'` | | the suspend throw | `:9611` | **`:9648`** | moved; still after the success step, the `childSteps` fold and output write-back — the ruling's prescribed position **exists as described** | | the region boundary | `:9956` | **`:9991`** | moved | | `NodeExecutionResult` barrel-exported | `src/index.ts:9` | confirmed | clause ② `yes` holds | | `map-node.ts` branch set | `:191` / `:207`, `refused` 0 hits | confirmed, control `child` = 31 hits | identical hole | `FlowRefusalSignal` is still not exported and this change does not export it — callers still see `status: 'refused'`. ## Verification - **Both arms pinned separately**, so deleting either one fails a test by itself: - `src/builtin/subflow-refused-rollup.test.ts` — 6 tests - `src/builtin/map-refused-rollup.test.ts` — 7 tests - **Ablation, both legs**, each proven on disk (HEAD blob hash vs mutated blob hash, anchor counts before and after) and each restored from `HEAD` with `git diff HEAD` empty: - ablating **only** the `subflow` arm ⇒ 5 failed in `subflow-refused-rollup.test.ts`, `map-refused-rollup.test.ts` **entirely green**; - ablating **only** the `map` arm ⇒ 6 failed in `map-refused-rollup.test.ts`, `subflow-refused-rollup.test.ts` **entirely green**. - The ablation also corrected the pins: both `objectstack-ai#4354 rollup` legs were originally green under ablation, because the totals are equally true of the unfixed engine, which rolled the same metrics up and then carried on. They now assert `status === 'refused'` in the same test. - **Controls**, mandatory and green on both sides: a **non**-refused child still completes the parent, fires the parent's `successMessage`, walks its out-edges, and rolls up identical totals — on both the `subflow` and the `map` path. - `pnpm --filter @objectstack/service-automation test` — **138 files / 1652 tests passed**, re-run after merging `origin/main`. - `pnpm --filter @objectstack/service-automation typecheck` — exit 0, test layer included (0 files / 0 errors in the debt ledger). - **Gate roster derived from merge-base, not from a predicted path list**: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths). 60 families derived, re-derived after the merge with **zero delta**. Reconciled with `--ran`: **60 accounted for, 58 run green, 2 NOT MEASURED**. - `pnpm check:dual-build-cjs-loads` and `pnpm check:type-check-debt` both exited **3 — `PREREQUISITE NOT MET`**, each printing "this is NOT a pass: nothing was measured". Both require a whole-repo build; that is CI's run, not this PR's. ⛔ Not recorded as a failed measurement. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 because its positive control is pinned to a commit outside this shallow checkout; after `git fetch --deepen` it exits 0 with 48 cases. - `pnpm lint` (`eslint . --no-inline-config`) — **full repo, exit 0**. ⛔ No narrowing claimed; the whole scan ran. ## Acceptance notes — found in passing, ⛔ not fixed here 1. **A child that PAUSES and then refuses on resume still rolls up as a success, and worse, may strand its parent.** This PR closes the synchronous path only. On the delegation path `resumeInternal` reads `childRes.status === 'paused'`, then failure, then treats everything else as "child completed" — a refused child (`success: true`) falls through there exactly as it used to here. And on the up-bubble path the refusal arm returns `finishRefusedRun` from the `catch` **before** `bubbleToParent` is reached on the completion path, so a child that refuses after a pause never wakes its parent at all. Read from source, ⛔ not driven. This is a separate mechanism (resume / bubble machinery), outside the ruling's prescription, and it is reported for the seat to file rather than ridden in. Dedupe words: `paused child refused bubbleToParent` · `resumeInternal childRes status refused` · `subflow delegation refused child` · `map re-entry mapItemDone refused` · `parent stranded refused child`. 2. **Noted, not filed** — the definition of the `refused` run status ("the flow reached an `end` node declaring `outcome: 'refused'`") now under-describes its producers. It is written in `packages/services/service-automation/src/sys-automation-run.object.ts:178` and, identically, in `packages/spec` (`src/automation/execution.zod.ts`, `src/contracts/automation-service.ts`), with a pin test and a generated reference page downstream. Correcting it needs a `packages/spec` edit, which this card's ruling puts out of bounds (clause 5: that routes to the `domain:spec` seat). Successor: the `domain:spec` seat already working this boundary on objectstack-ai#15646 / PR objectstack-ai#18688. 3. Three hard-wired "an `end` node" attributions inside the files this PR already edits were generalised in place, because this change is what makes them false: the `FlowRefusalSignal` docblock, `finishRefusedRun`'s header, and its durability `error` log line. Comments and one log string; no gate reads them. --- _Generated by [Claude Code](https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…asure — narrow `DashboardWidgetSchema.values` for the metric/kpi/gauge/solid-gauge/bullet family (objectui#8894 ruling D) (objectstack-ai#18720) Fixes objectstack-ai#17779 Clause-②: yes (narrowing) Executes maintainer ruling **D** on objectui#8894 (decision batch objectstack-ai#119 item 4, 2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 — judge the protocol wrong: a metric-family widget takes exactly one measure. The direction was not re-opened here. ## What changed `DashboardWidgetSchema.values` was `z.array(z.string()).min(1)` with **no upper bound on any widget type**, so a `metric` tile could declare three measures; the dataset query selected and computed all three and the tile rendered `values[0]`. The other two were queried and dropped on the floor (objectui#7293 defect 1). objectui#8887's sub-caption made the tile honest about dropping them; it did not make the document legal. - `checkDashboardWidgetMetricMeasureArity` — a new exported object-level check, chained onto the same door by identifier, refusing more than one measure on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` and on a widget that declares no `type` (it defaults to `metric`, and the message says so rather than claiming the author wrote it). One `custom` issue at `values`, naming the widget's `id`, the count, and the authored `type`, and prescribing one measure per tile — "make N tiles for N measures" — plus the visuals that DO render several numbers. - **Exactly one is a conjunction**: the field's own `.min(1)` still owns the empty array (`too_small`, unchanged, and the new check deliberately adds no second issue there); the new check owns the upper bound. - `.changeset/17779-...` — `minor`, **BREAKING** banner, ADR-0087 disposition `registered dashboard-widget-metric-family-multi-measure-refused`. - `packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts` — one new entry file, plus the `gen:migration-registry` lap. No other file in that directory was touched and nothing was hand-edited inside the generated regions of `registry.ts`. - The `values` doc string now states the arity rule it enforces, so the generated reference page stops saying only "at least one". ## The three questions the dispatch asked, answered by measurement ### 1. `superRefine`, not a per-type union arm — because a union destroys every other diagnostic on this door Eight widget bodies through `z.union([metricArm, otherArm])` versus one more `.superRefine` on the strict object, measured on this tree: | body | union arms | the spelling shipped | |---|---|---| | `bogusProp` on a widget | `(root) invalid_union: Invalid input` | the strict-object refusal, naming the key + the history sentence | | `categoryField` / `valueField` | `(root) invalid_union: Invalid input` | the `WIDGET_GUIDANCE_SETS` ADR-0021 prescription | | `titel` | `(root) invalid_union: Invalid input` | "Did you mean `titel` → `title`?" | | `type: 'ziggurat'` | `(root) invalid_union: Invalid input` | `invalid_value` at `type`, listing all twenty | | `metric` + 3 measures | `too_big` at `values` | the curated `custom` refusal at `values` | Four of eight bodies lose their whole diagnostic to one bare `Invalid input`. That is not a new observation on this file: the `compareTo` docblock already records it for the same reason (objectstack-ai#5014 — "a union collapses into one bare `Invalid input` on the wire … A plain strict object's errors reach the author"), and `view-union-diagnostics.test.ts` is the entire apparatus objectui needed **because** `ViewMetadataSchema` is a union. A second union here would commission that apparatus again to buy a refusal the object-level form gives for free. Second datum, measured: zod 4.4.3 throws `Cannot overwrite keys on object schemas containing refinements. Use .safeExtend() instead` on a plain `.extend()` that redeclares a key, so the arms cannot even be built from the existing door without `.safeExtend()` or a duplicated declaration. ### 2. `major` does collide with `check-changeset-no-major` — so the changeset is `minor` The guard is **armed**: there is no `.changeset/pre.json`, so the RC exemption does not apply, and the only other route is the `allow-major` PR label whose own error text says "a whole-stack major release is genuinely intended" — false for this PR. Its header states the convention: every publishable package is in the Changesets `fixed` group, so one `major` promotes all ~70 packages; during the launch window a breaking change ships `minor` and **breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the bump level**. `pr-automation.yml`'s "WHICH LEVEL" prose says the same in the place the author reads it. So the card's "major changeset" is satisfied as `minor` + `**BREAKING**` + `registered ...`, and `check-adr-0087-registration --base origin/main` reads the changeset back as `[BREAKING+bang+clause-②-narrowing] registered dashboard-widget-metric-family-multi-measure-refused`. ### 3. The migration entry's acceptance criteria, re-derived from what the code refuses Not a restatement of the card. Two things the card's wording implies that the machinery does **not** do, both measured and both written into the entry: - **The TODO cannot name your dropped measures.** `applyMetaMigrations` maps `step.semantic` straight onto the result (`chain.ts`) with no per-document interpolation and no filtering by whether the stack even carries the shape, and `SemanticMigration` has only static string fields. `os migrate meta` therefore prints the entry's prose, not a list. The **refusal** is what names them, per widget, on the re-parse — so the entry tells the author to drive the fix off `os build`, not off the migrate output. - **Splitting into N tiles is not attempted**, as the card says — and the entry states why in the registry's own terms: N tiles need N ids and N boxes on a 12-column grid, which is a layout fact about a dashboard the registry has never seen. The rest of `acceptanceCriteria` is the measured accept/refuse matrix: which door refuses (publish, not objectui's `.shape`-mirror editor), the empty-array carve-out, the aborting `invalid_value` on an unknown `type`, the un-reachable "does this measure exist in the dataset", and the fact that `.omit()` / `.pick()` / `.partial()` already threw before this change. ## Controls **LIT** — a legal single-measure metric tile parses **identically before and after**, and the non-metric families are untouched. Sixteen bodies through `DashboardWidgetSchema.safeParse`, before and after the change: | body | before | after | |---|---|---| | `metric` + 1 measure | ACCEPT, `values: ["amount_sum"]` | ACCEPT, `values: ["amount_sum"]` | | `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` + 2–3 measures | ACCEPT (all five) | REFUSE `values:custom` (all five) | | no `type` + 3 measures | ACCEPT, `type: "metric"` | REFUSE `values:custom` | | `bar` / `line` / `table` / `pivot` / `funnel` + 3 measures | ACCEPT | ACCEPT (unchanged) | | `metric` + `values: []` | REFUSE `values:too_small` | REFUSE `values:too_small` (one issue, not two) | | `type: 'ziggurat'` + 3 measures | REFUSE `type:invalid_value` | REFUSE `type:invalid_value` (alone) | | `metric` + 3 measures + `bogusProp` | REFUSE `unrecognized_keys` | REFUSE `unrecognized_keys` | The whole taxonomy is covered by a pin that asserts the metric family plus the fifteen others **is** `ChartTypeSchema.options`, so a new chart type cannot land uncovered by either list. **DARK** — things that must read **0**, with paths and counts: - `.min(1)` **array** keys in `packages/spec/src/ui/dashboard.zod.ts` other than `values`: **0**. The file has exactly two `.min(1)` code sites at the branch point — `values` (line 706) and `dashboard.columns` (line 1151, `z.number().int().min(1).max(24)`, a number bound, not an array). The latter is byte-identical after the change; every other new `.min(1)` occurrence in the file is inside a docblock. - `ReportSchema.values` (`packages/spec/src/ui/report.zod.ts`, lines 237 and 314) is a separate declaration, `optional()`, with no `.min(1)` and no arity check, and its `type` enum (`tabular` / `summary` / `matrix` / `joined`) contains **0** metric-family members. Untouched, and not the same defect. - Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` / `.md` / `.yaml` **at the branch point** `72dd95fa5a`: **187** brace-local literals carrying a `values: [...]`, **39** of them on a metric-family `type` (both lit controls), and **0** of those carrying more than one measure. Nothing in the monorepo moves. On this branch the same scan reads 205 / 49 / **7**, and all seven are the fixtures this PR added. - `check:authorable-surface` is green with no regeneration: **0** authorable keys move. `check:api-surface` reports `0 breaking (removed/narrowed), 1 added` — the new exported check. ## Verification Red before green, with the mutation proved on disk and the restore hash-verified: ``` HEAD blob : 30c6d78 worktree blob : 30c6d78 (at HEAD before the mutation) anchor occurrences BEFORE: 1 AFTER: 0 injected line: 1 mutated blob : 90548649227c3971f16b7dc85b02e1bab8155f96 (differs -> the edit really landed) RED vitest exit=1 17 failed | 205 passed (222) restored blob : 30c6d78 git diff HEAD on the path: empty GREEN vitest exit=0 222 passed (222) ``` The mutation removed only the `.superRefine(checkDashboardWidgetMetricMeasureArity)` attachment, leaving the function declared — so the 17 reds are the door's behaviour, not a compile failure. The script carried a `trap ... EXIT INT TERM` restore against an absolute `git rev-parse --show-toplevel` path, restored with `git checkout HEAD -- path` (never a bare `git checkout --`), and proved the restore by blob hash **and** an empty `git diff HEAD`. - `pnpm --filter @objectstack/spec test` — **486 files / 13933 tests passed**, exit 0. - `pnpm --filter @objectstack/spec typecheck` — exit 0 (`check:scripts-typecheck` and `check:test-typecheck` included; the test-layer ledger held at 54 files / 259 errors / 144 pinned signatures, shrink-only). - `pnpm --filter @objectstack/spec check:generated` — **all 15 generated artifacts up to date**, exit 0, after regenerating exactly the three it proved stale (`api-surface/`, `export-origins/`, `content/docs/references/**`). - Changeset gates: `check-adr-0087-registration --base origin/main` exit 0 (+ `--self-test`, 384 assertions), `check-changeset-no-major --base origin/main` exit 0, `check-empty-changeset --base origin/main` exit 0. - `pnpm check:nul-bytes` exit 0 (8812 text files, no raw control bytes), plus `check:widget-option-census`, `check:liveness`, `check:exported-any`, `check:dual-source-exports`, `check:entry-nameability`, `check:empty-state`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:merge-driver`, `check:pm-widening-tells`, `check:spec-docblock-symbol-anchors`, `check:dts-closure`, `check:published-files`, `check:spec-parsed-alias`, `check:page-declaration-shape`, `check:corpus-claim-drift`, `check:skill-examples`, `check:docs-transcript-drift`, `check:doc-formula-expressions`, `check:variant-docs`, `check:llms-txt`, `check:yaml-examples`, `check:objectui-pin-citations`, and the ten doc gates the regenerated `.mdx` newly derives — every one exit 0. - **Repo-wide lint, not a narrowing**: `node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format json` at `ea17ab8491`, 81s — **6822 files linted, 0 errors, 0 warnings**, exit 0. ## Migration-entry adjacency — checked, not assumed `packages/spec/src/migrations/entries/` is one file per entry and the entries README records the measured objectstack-ai#8344 table: two in-flight registrations merge clean **unless** their ids are adjacent in sort order or both are the first entry of a new major. Enumerated the `18.*` semantic directory and every open PR's file list on 2026-09-17: - In-flight ADDED semantic registrations: `ui-list-view-groupbyfield-padded-refused` (objectstack-ai#18695), `structured-region-body-pause-and-end-refused` (objectstack-ai#18688), `evaluated-expression-slots-source-required` (objectstack-ai#18638), `manifest-id-reverse-domain-required` (objectstack-ai#18319). (objectstack-ai#18420 modifies an existing entry, which is not an insertion.) - This entry's immediate neighbours in the sorted set are `dashboard-header-modal-target-page-only` and `dashboard-widget-stage-order-non-funnel-refused` — **both already landed on `main`**, neither in flight — and it is not the first entry of major 18. Neither ejection row applies. The seat's expectation about objectstack-ai#18695 held, and was verified rather than assumed. The two projections the README names came back **byte-identical, and that is correct rather than a skipped step**: `build-spec-changes.ts` and `build-upgrade-guide.ts` both loop `for (major = MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++)`, and `PROTOCOL_MAJOR` is **17** while this entry registers under **18**. Both were regenerated anyway and `check:spec-changes` / `check:upgrade-guide` are green. ## Acceptance notes Noted, not filed — neither is a reproducible defect, a contract violation, or a metadata-authoring trap: - **zod 4.4.3 refuses `.extend()` that overwrites a key on a refined object** ("Use `.safeExtend()` instead"), measured here while probing the union spelling. It is a trap for the next author who mirrors or re-arms this door — recorded in the new check's docblock and in the migration entry, which is where that author looks. Successor: whoever lands objectui#8894's half, which must re-attach this export onto a `.shape` mirror. - **An ADR-0087 semantic entry cannot name per-document values.** `applyMetaMigrations` emits `step.semantic` unconditionally and `SemanticMigration` carries only static strings, so a card instruction of the form "emit a structured TODO naming X" is unsatisfiable as literally written — the refusal message is the only per-document channel. Recorded in this entry's `acceptanceCriteria`. Successor: the next card that writes that instruction. ## Downstream, not in this PR Card item 3 (objectui's contract twins gain the refusal pin; the runtime warning becomes the door refusal) is the objectui half and objectui#8894 is `pm:blocked` on this card. Nothing in `../objectui` was touched. Until that package imports and chains `checkDashboardWidgetMetricMeasureArity`, its `.shape`-mirror editor keeps accepting three measures on a `metric` and the author meets this refusal at publish — stated in the check's docblock and in the migration entry rather than left implied. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --- > ⏱️ **席位代改正文(dev 只写一次,⛔ 不 PATCH 正文;事后要改的由本席代写)。** 两处: > > - **`applyMigrationChain` → `applyMetaMigrations`(2 处)** —— 前者在树上**不存在**; > 真函数是 `packages/spec/src/migrations/chain.ts:68`,CLI 调它,根 api-surface 导出它。 > 同一处错名也写进了 ADR-0087 语义条目、并经 `gen:migration-registry` 复制进 > `registry.ts:6765` —— 那段文本会被 `os migrate meta` 在协议 18 打印出来, > 所以读者照着 grep 会一无所获。已随 `3b15ca1254` 修正(条目 + 重生成,⛔ 未手改 registry.ts)。 > ⭐ 这一条由**达档隔离契约复核**判出(记录见下方 PASS/FAIL 评论),⛔ 不是本席自己看出来的。 > - **`Clause-②: no (narrowing)` → `yes (widening)`** —— 该行**只定路由**,⛔ 非终审: > 章程原文「只定是否必过席内契约复核的保守方向」,机械地板「新导出符号…恒 `yes`」。 > 本 diff 在 `api-surface/ui.json` 上**净增一个导出符号** > (`checkDashboardWidgetMetricMeasureArity`,+1 / 移除 0,本席对着 merge-base `72dd95fa5a` 实测), > ⇒ 地板落在 `yes`。认领侧早已是 `yes (widening)`,正文与 changeset 两个载体**落后于它**; > changeset 已随 `881db1280d` 对齐,并在行内写明两条轴(接受集**收窄**、公开面**扩大**), > 免得 CHANGELOG 读成「本改动放宽了行为」。 > >⚠️ **破坏性未受影响**:`check-adr-0087-registration` 仍读作 breaking, > 经 `**BREAKING**` 横幅与摘要里的 `!`;它失去的 `clause-②-narrowing` 信号从来不是唯一载体 > (实测 `[BREAKING+bang]`,exit 0)。 > > ⏱️ **再正一次(席位):`yes (widening)` → `yes (narrowing)`。** 上一版本席以为「收窄行为 + 扩大公开面」在这套两态词表里没有正确拼法,于是取了 `widening` 并写了一段话解释「它不是那个意思」。**那个前提是错的**:`readClause2Line` 认 `yes (narrowing)`,而`check-adr-0087-registration` 的自测逐字命名了这个形状 ——「the `narrowing` arm beside a `yes` value — a diff that **widens AND narrows**」。⇒ 值仍是 `yes`(机械地板:新导出符号),但**臂**改回 `narrowing`,`clause-②-narrowing` 信号随之回到 ADR-0087 门禁(实测 `[BREAKING+bang+clause-②-narrowing]`,exit 0)。⭐ 这一条由第二次达档复核在 ③ 里作为**边界旗标**提出,⛔ 不是 FAIL;本席自己验过词表才动手。⚠️ 顺带一提 `no (widening)` 读作 **malformed** —— 臂不是自由的:`no` 只配 `narrowing`,`yes` 两者皆可。 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…e the 27 signature hashes (objectstack-ai#18971) Fixes objectstack-ai#16045 Clause-②: yes (widening) Ruled at `5560224701` (director batch objectstack-ai#60, 2026-09-06, maintainer verbatim 「同意」), re-affirmed by triage at `5724532096`: option A, a readable declaration-text snapshot, ⛔ not a hash. The card body's three mutually exclusive routes predate that ruling and were not re-litigated here. `@objectstack/spec` pinned its public surface on one axis. `api-surface/` records each export as `name (kind)`, and a signature change, a renamed interface field and a dropped union member move **none** of those rows. The only shape pin was `api-surface-signatures.json`: 27 rows, and reference-level even there. This adds `api-surface-declarations/`, the declaration text the packed build actually emits for every export of every published entry point, and retires the 27 hashes it subsumes. ## The counts, re-derived on this head before the first generation The ruling asks for this by name; the card's own numbers were self-declared unverified and 12 days old. | Number | Card | This head (`b33898f5d`) | Unit, and what would make it something else | |---|---|---|---| | entry points | 17 | **17** | type entry points in the `exports` map — those whose `require.types` ends in `.d.ts`. Adding or removing one such subpath. | | `exports` map entries | (not stated) | **19** | every key in the map. The extra two are `./openapi.json` and `./package.json` — asset subpaths with no declaration at all, filtered out by the same `.d.ts` test `build-api-surface.ts` has always applied. ⇒ premise 1 resolved: **17 is right and the map did not grow**; 19 counts two things that were never entry points. | | pinned rows | 5309 | **5336** | `name (kind)` rows summed over the 17 `api-surface/` shards. +27 since the card. Ratio unmoved: 27/5336 = 0.51%, so the headline 99.5% stands. | | distinct exported names | (not stated) | **5200** | (entry, name) pairs. The gap to 5336 is dual-declared names, which are two rows by design. | | signature hashes | 27 | **27** | top-level keys of `api-surface-signatures.json`. Bright control: the first value really is a `sha256:` string, so this counts signature entries and not empty objects. | Premise 3 also holds: all 17 packed `.d.ts` files exist and resolve through the map (3,215,437 bytes for the root entry down to 13,081 for `./integration`). No entry point lacks a packed declaration, so the gap the dispatch reserved for itself did not open. ## What the artefact costs — premise 4, which nobody had costed | | | |---|---| | shards | 17, one per entry point | | declaration blocks | 5336 | | bytes | **12,661,943 (12.08 MiB)** | | lines | **237,706** | | gzipped | **1,071,825 (1.02 MiB)** — against this package's ~17.57 MiB compressed `dist`, so about **+5.8%** of tarball | | largest shard | `system.txt`, 3,592,701 bytes / 73,283 lines | | median declaration | **81 bytes** | | skew | the 20 largest declarations hold **~65%** of all bytes; four exceed 20,000 lines each (`EnvironmentArtifactSchema` 21,868, `ObjectStackDefinitionSchema` and `ObjectStackSchema` 21,851, `ChangeSetSchema` 20,395) | Stated plainly, as the dispatch asks, and ⛔ not as a veto: the packed `.d.ts` is a tsup dts rollup, so a Zod schema's declaration is its **fully expanded** structural type. That expansion is exactly what makes an inner field rename visible — and it is also why a single schema can produce a 21,000-line diff. The ruling's stated reason for choosing text over a hash is that the contract-review seat reads the diff; that reasoning holds per declaration and is worth a second look at the top twenty. One reading, for whoever wants it: 31% of declarations hold 97.7% of the bytes, so nothing cheap is available by trimming the tail. ## Both instruments, measured on one tree at one commit The card's thesis is that the old pin cannot fail on a shape change. Not argued — ablated, with the mutation proven on disk by blob hash and the mutation proven to have reached `dist/` before any verdict was read. **A. the source-level control — a renamed interface field, the card's own class.** `JobRunOutcome.reason?` renamed to `degradationReason?` in `packages/spec/src/contracts/job-service.ts` (blob `363443e2` to `d4b1520c`), spec rebuilt, `ablation-dist-preflight` exit 0 confirming the marker reached the built artefact: ``` check:api-surface exit=0 "public API surface unchanged" [BLIND] check:api-surface-declarations exit=1 "~ JobRunOutcome (interface)" [SEES IT] ``` Restore leg: blob back to `363443e2`, rebuilt, `ablation-dist-preflight --absent` exit 0 (marker gone from all 214 built files), `git diff HEAD` clean, gate back to exit 0. **B. the gate can fail on its own artefact.** One field renamed inside `qa.txt` by hand (blob `3f5efb04` to `5b86fec2`, injected occurrences 1, deleted text 0): exit **1**, attributed to `TestSuiteSchema (const)`, failure text naming the regenerate command. Restored to the HEAD blob, `git diff HEAD` empty: exit **0**. ## The retirement, and the coverage proof the ruling demands All **27** signature names resolve to a declaration block in `api-surface-declarations/root.txt`, **0 missing** — enumerated from `defineAction` through `defineWebhook`, each as `(function)`. One honest qualification, because the subsumption is not uniform. For those 27 factory declarations the text is `declare function defineAction(config: z.input of ActionSchema): ActionParsed;` — a type **reference**, exactly as blind to an inner-key narrowing as `typeToString` was. What is gained is not sharper text on the 27; it is the **5309 other declarations**, including `ActionSchema` itself, whose own expanded block is where such a narrowing shows up. So the retirement is a strict superset of pinned declarations, not an equal trade. Nothing published read the retired file — it was never in this package's `files[]`. ## Where it lands, and why there - Generator: `packages/spec/scripts/build-api-surface-declarations.ts`, beside the eight sibling artefact generators, reading the same input through the same `collectEntries` logic. The ruling says "one generator script under `scripts/`"; this reads that as the directory the whole family lives in, because the artefact reads the **built dist** and only the lane that builds spec can run its gate. - Artefact: `packages/spec/api-surface-declarations/ENTRY.txt`, a sibling **directory** of `api-surface/`. Not inside it: `listShardNames` throws on any file in that directory that is not a `NAME.json` shard, so `api-surface/` is closed by construction. No existing `api-surface/*.json` is regenerated by this PR (`check:api-surface` green throughout), which keeps it clear of PR objectstack-ai#18688 and PR objectstack-ai#18319. - Gate: `check:api-surface-declarations`, a step in lint.yml's `Type Check · consumer gates` lane after the two build steps, with `check:api-surface` and the other dist-reading gates. **No new required context** — a step in an existing lane. Registered in the `check:generated` ledger, in `REGEN_ARTIFACTS`, and in `.gitattributes` as `merge=os-regen`. - Sharded per entry point from day one, for the reason its neighbour is: the merge queue rebuilds server-side where no custom driver runs, so two PRs sharing one generated file evict the second. Pit 1 from `5715457322` is answered by the layout rather than by an assumption — and `check:merge-driver`, which reconciles `.gitattributes` against `REGEN_ARTIFACTS` in both directions, is green over the swap. - Published, with the reason the gate demands. `check:published-files` refuses a `files[]` entry that carries none; the registered line says what a consumer does with it — read two published tarballs and see *which declared shape* moved between releases, the question `api-surface` cannot answer. If 1.02 MiB of tarball is judged too much, one line of `files[]` removes it without touching anything else. Three registries had to learn about the new gate, each because it discovered the gate on its own rather than because a list named it: - `check:published-files` — demanded the reason above. - `scripts/pm/dispatch-gates.mjs` — its live manifest edge gave the new gate a population before anything listed it, which is the eighth member of a class whose seventh was recorded the same way. Declared as `CLASS_EIGHTH`, with a case asserting the edge really reaches it. - `scripts/pm/check-widening-tells.mjs` — `PUBLISHED_SURFACES` is derived from `REGEN_ARTIFACTS`, so retiring the signatures row dropped it off that surface and reddened two self-test cases. Both are retargeted to state the retirement as a counterfactual (the surface follows the table, not a literal); ⛔ the new artefact is **not** added to that surface, because the ruling assigns "is a snapshot diff a Clause-② signal" to the skills seat by name and out of this card's scope. Both directions are now pinned, so the boundary is declared rather than forgotten. 483 cases pass, up from 481. ## Verification - **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from the merge base, 120 commands, every exit code redirected to a file and read back. **All 120 green.** Four returned exit **3** PREREQUISITE NOT MET on first pass (`check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`); each names a build, each was built and re-run green, and none is recorded as a finding. Reconciled with `--ran`. - **Tests**: `@objectstack/spec` local project **488 files / 14,182 tests passed**; the tooling suites that name the edited scripts, both projects, **10 files / 220 tests passed** (`sharded-artifacts`, `check-generated-ledger`, `dist-freshness`, `dist-freshness-adoption`, `api-surface-dual-kind-rows.pin`, `build-schemas-check-mode`, `def-key-collisions`, `root-index`, `export-list`, `docs-import-surface`). `pnpm --filter @objectstack/spec typecheck` green. - **eslint, the union rather than a narrowing**: `eslint . --no-inline-config --format json` at `b33898f5d` examined **6856 files**, **0 errors, 0 warnings**, exit 0. The population is eslint's own config resolution and the count is read from its JSON output; type-aware linting is not enabled in `eslint.config.mjs` (no `parserOptions.project`, no typed rules), so this diff cannot move an untouched file's verdict either way. - **Control bytes**: `check:nul-bytes` green over 8906 files, plus a direct scan of all 31 changed paths for the wider control-byte class — no matches. - `scripts/check-single-claim-paths.mjs` in the diffstat is **not mine**: it arrived with the one-commit `origin/main` merge (`16cb493d5`) this PR carries. ## Acceptance notes - `.claude/skills/spec-property-retirement/SKILL.md` line 124 lists `api-surface-signatures` as an instance of a retirement shape, and that row goes stale with this landing. ⛔ Left untouched on purpose: `.claude/**` is a governed surface, so editing it would make this whole PR maintainer-landed for a one-word prose nit. Noted, not filed. - `packages/spec/scripts/build-schemas.ts` line 830 carries the same stale mention. Left untouched because PR objectstack-ai#18952 holds that file; noted, not filed, with the later lander as the natural carrier. - Three files in this diff are held by open PRs and were edited anyway because the retirement forces it, not by choice: `scripts/pm/check-widening-tells.mjs` (PR objectstack-ai#18948), `scripts/pm/dispatch-gates.mjs` (PR objectstack-ai#18903) and `.github/workflows/lint.yml` (PRs objectstack-ai#18946, objectstack-ai#18889, objectstack-ai#18414). All are hand-written files where a text conflict is visible rather than silent, and all three of my hunks are small and far from theirs. Whoever lands second resolves. - The top-20 skew above is a reading, not a finding: no gate is wrong and nothing is unenforced. It is recorded here because the ruling's own justification for text over hash is per-declaration readability, and at 21,000 lines a declaration that argument thins out. ## 维护者速读(草稿) **改了什么。** `@objectstack/spec` 从今天起为它的**每一个**公开导出留一份"形状快照" —— 不是哈希,而是打包后 `.d.ts` 里那段声明原文,按入口点分成 17 个文件签入仓库,并配一道 CI 闸门:重新生成后对不上就红,失败信息里直接给出重新生成的命令。同时退休了旧的 27 条签名哈希文件。 **为什么改。** 原来的 pin 只记"某个名字还在不在",5336 行里只有 27 行能看出"形状变没变"。也就是说:把一个接口字段改名、砍掉一个联合成员、改一个函数签名 —— 这些都是会让客户升级后编译失败的破坏性改动 —— 全部一路绿灯。本次 PR 里有实测:改了 `JobRunOutcome` 的一个字段名之后,旧闸门 `check:api-surface` 退出码 **0**(看不见),新闸门退出码 **1**(点名了那个 interface)。路线是 2026-09-06 决策批次 objectstack-ai#60 里您逐字「同意」的那一条。 **风险与代价(含回滚)。** 代价是体积:12.08 MiB 文本、23.7 万行,压缩后 1.02 MiB,相当于 npm 包增长约 5.8%。更值得注意的是分布极不均匀 —— 最大的 4 个 schema 各自超过 2 万行声明文本,一旦它们变动,复核席位面对的是一份 2 万行的 diff;而裁决选"文本不选哈希"的理由恰恰是"diff 可读"。这一点我按实测如实报告,未自行改动路线。回滚成本很低:从 `files[]` 去掉一行即可停止随包发布;整道闸门回滚就是撤销本 PR,不留任何数据迁移。 **席位意见。** **你要做的。** 只有一件事需要您判断:12 MiB / 23.7 万行这个量级,以及最大 4 个 schema 的 diff 可读性,是否仍符合当初选 A 方案时的预期。若认为需要收窄,那是裁决层面的一次增补,不是本 PR 的返工。其余部分已按裁决落地并自证。 --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ion fails the run with a named refusal (objectstack-ai#19140) Fixes objectstack-ai#18881 Fixes objectstack-ai#15646 Clause-②: no — this is a refusal being added, pulling runtime behaviour back to objectstack-ai#3267's declared 禁. The flow accept set does not widen, and no export leaves this package: `FlowRegionSuspensionRefusalError` lives in a new internal module that `src/index.ts` does not re-export, exactly as `guard-refusal.ts` and `partial-steps.ts` do. ## The defect, measured before anything was written A node contained in an ADR-0031 structured region body — a `loop` body, a `parallel` branch, a `try_catch` try or catch region, **at any depth** — that durably suspends now FAILS the run with a named refusal carrying the region node, the suspending node and the sub-flow. `runRegion` already converted such a suspension, but into a plain `Error`, which is indistinguishable from a node that simply failed. The card's own reproduction, `loop { try_catch { map(pausing child) } }`, on `origin/main` at `c70581bc` with only the new test file added: ``` × the card's reproduction: `loop { try_catch { map(pausing) } }` fails, and the catch handler never sees it AssertionError: expected true to be false // result.success × the error NAMES the region node, the suspending node and the sub-flow AssertionError: the given combination of arguments (undefined and string) is invalid × counts the refusal ONCE: `summary.failed = 1`, on the region node AssertionError: expected +0 to be 1 × `loop { map(pausing) }` — the region node is the `loop` Expected: "sweep" Received: "durable pause inside a structured region (node 'per_cell') is not supported" Tests 6 failed | 2 passed (8) ``` Read that fourth line: even where the run DID fail, the sentence named neither the region nor the sub-flow. The `try_catch` case is worse — the enclosing container read the plain `Error` as "the try region failed", ran its catch handler, and the run finished `completed` with `summary.failed = 0`. The `map`'s progress state (`nodeId.$mapState`) is written into the ENCLOSING scope, so the residue a contained refusal leaves is read back as progress by the next entry to the same node: iteration 2 saw `started === collection.length`, ran nothing, and reported success again. ## What changed - **`src/region-suspension-refusal.ts`** (new, internal): `FlowRegionSuspensionRefusalError` carries `regionNodeId`, `regionKind`, `suspendedNodeId` and `subFlowName` as FIELDS as well as in its message, so a reader never parses the sentence. Branded as a objectstack-ai#3863 guard refusal, so a `fault` edge on the enclosing container cannot route it either — the one-edge switch that would otherwise re-open the same silence. The predicate is duck-typed on a registered symbol, matching `isGuardRefusal` / `isSuspendSignal`, because this package ships ESM and CJS from one source and a cross-realm `instanceof` answering `false` would mean a container swallowing the refusal again. - **`runRegion`** raises it in place of the plain `Error`, resolving the sub-flow from the suspending node's own `config.flowName`. A refusal raised at an INNER boundary is re-thrown untouched, so the region named is the one the author nested the pause in and not the frame the unwind passes through. - **`try_catch` re-throws it** from both the try-attempt arm and the catch-region arm, and spends NO retry attempt on it: re-entering the region would re-enter the pausing node, and the metadata is what is wrong. **`parallel` re-throws it** rather than folding it into its returned — and therefore `fault`-routable — branch failure. `loop` already re-threw unchanged. The attempt's steps ride out on objectstack-ai#13803's channel, so rows the region really did write stay in the run log and in the objectstack-ai#4354 totals. - **One refusal is one failure.** The region node's own frame records the `EXECUTION_ERROR` step and publishes `{$error}`, exactly as any thrown node failure does; every enclosing container records nothing, so `summary.failed` counts the fault and not the nesting depth. ⛔ **No parse-time rule is added here.** objectstack-ai#18688 landed that half. ⛔ **No new `error.code`**: the closed `ERROR_CODE_LEDGER` (ADR-0112) lives in `packages/spec`, outside this card's declared file surface; the refusal is named by its type and its fields. ## The closing keyword on objectstack-ai#15646, and the round trip it took to get there ⭐ This section previously explained why the body said `Part of objectstack-ai#15646`. It now says `Fixes`, and the reasoning is kept rather than deleted because the round trip is the record. **Ruling D's execution clause names the mechanism**, verbatim: 「The spec half lands with `Part of objectstack-ai#15646`; the runtime card's PR closes this card with `Fixes objectstack-ai#15646` once both are on `main`.」 Both halves exist: `78436637` (PR objectstack-ai#18688) is an ancestor of `origin/main` and of this branch's base, and the parse-time refusal does **not** reach the card's reproduction — `map` and `subflow` are deliberately not judged by type there — so the runtime arm is live and every fixture in the new suite registers and runs. **What blocked the literal mechanism was a shipped gate.** `check:closing-target-claim` refuses a PR closing a card whose thread carries no live `Claim:` naming that PR's head branch. objectstack-ai#15646 carried three claims, all naming `claude/issue-15646-region-pause-end-refusal`, and its newest protocol event was a `Release:` (`5730095126`) — no live claim, none naming this branch. ⛔ A dispatched executor may not post a claim of its own to clear that, so the delivering dev shipped `Part of` and reported the conclusion as falsified. **That was the correct call at its authority level.** **The gate's own remedy 1 is a PM act**, and the PM seat performed it: the `domain:spec` seat had already released this card's runtime half to `domain:services` in writing (「由 services 车道重新认领」), so the services seat claimed it on this branch (`issuecomment-5737225992`) and assigned itself. The claim is simply true — this card's remaining half really is in flight here. **Re-measured after the claim**, with the gate's own prescribed invocation: `check:closing-target-claim` **exits 0**, reporting 「PR objectstack-ai#19140 closes objectstack-ai#18881, objectstack-ai#15646, and each carries a `Claim:` whose `Branch:` line names `claude/issue-18881-region-durable-suspension-refusal`」. ⇒ the ruling is executed **literally**, via the gate's prescribed route. ⛔ Not a re-adjudication of the ruling, and ⛔ not an evasion of the gate — the gate exists to stop a second seat duplicating work on an unclaimed card, and a truthful claim serves that purpose. ## Tests All figures below were taken at `bad6404b0`, the final commit on this branch. **The new suite** — `packages/services/service-automation/src/region-durable-suspension-refusal.test.ts`, 8 tests, every refusal case paired with a synchronous control: ``` pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 \ src/region-durable-suspension-refusal.test.ts Test Files 1 passed (1) Tests 8 passed (8) ``` **The whole package**, which is what CI runs and the only scope that can see this class of breakage: ``` pnpm --filter @objectstack/service-automation test Test Files 139 passed (139) Tests 1660 passed (1660) pnpm --filter @objectstack/service-automation typecheck check:test-typecheck: OK — 0 file(s) / 0 error(s) / 0 pinned signature(s) ``` ⭐ **1652 + 8 = 1660.** PR objectstack-ai#18688's body measured this package at **138 files / 1652 tests, all passing** on the tree this branch is cut from. This branch adds exactly one file and eight tests and lands at 139 / 1660, so nothing was lost, re-homed, skipped or quarantined. objectstack-ai#15616's five tests, objectstack-ai#15788's runtime `end` test and objectstack-ai#16314's rollup suite are **untouched in the diff** and green in that run. **Reverse validation — two ablations, each mutated on disk through `scripts/ablation-replace.mjs` (anchor must hit, blob hash must change) and restored against `HEAD`.** The package's own tests import their subject through relative specifiers (`./engine.js`), so the mutation is live from source with no `dist` on the path — which each ablation demonstrates by going red. 1. **The `try_catch` re-throw arm**, the one that decides whether this card's defect exists: ``` ablation-replace: anchor "if (isRegionSuspensionRefusal(err)) {" x1 -> x0 ablation-replace: blob c4ed5b3 -> c3b44a8af269 × the card's reproduction: `loop { try_catch { map(pausing) } }` fails … × the error NAMES the region node, the suspending node and the sub-flow × counts the refusal ONCE: `summary.failed = 1`, on the region node Tests 3 failed | 5 passed (8) ablation-replace: ok restored: blob == HEAD (c4ed5b3) and `git diff HEAD` is empty ``` The three `loop` / `parallel` cases and both controls stay GREEN under that mutation, which is the second reading it buys: the `try_catch` arm is precisely what closes the contained case, and the other two region kinds are closed by a different arm. 2. **The one-refusal-one-failure rule**, because `summary.failed = 1` is the assertion most at risk of being vacuously true: ``` ablation-replace: anchor "isRegionSuspensionRefusal(execErr) && execErr.regionNodeId !== node.id;" x1 -> x0 × counts the refusal ONCE: `summary.failed = 1`, on the region node AssertionError: expected 2 to be 1 Tests 1 failed | 7 passed (8) ablation-replace: ok restored: blob == HEAD (53fee28) and `git diff HEAD` is empty ``` `2` is exactly the nesting-depth reading — the `try_catch` and the `loop` each recording the same event — that the suppression prevents. **Gates.** Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths passed, reconciled with `--ran`: ``` Run reconciliation — 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3) ``` Two of the 59 first answered `PREREQUISITE NOT MET` (exit 3, ⛔ not a finding) because they read built output the whole tree has to supply — `check:dual-build-cjs-loads` and `check:type-check-debt`. Both were re-run after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 tasks) and both exit 0. `check:plugin-teardown-shape --self-test` first answered exit 3 on a pinned fixture commit this shallow checkout could not reach; after `git fetch --unshallow` it passes its 48 cases. **Lint — a declared narrowing, and a measured one.** `pnpm lint` is `eslint . --no-inline-config`: a whole-repo scan whose broadest block is `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`, read from `eslint.config.mjs` itself. Run instead over this diff's five lintable paths, counted from the linter's own `--format json` output: **5 files, 0 errors, 0 warnings**. The narrowing excludes nothing, and that is a property of the config rather than an assumption: `eslint.config.mjs` **never enables type-aware linting for ANY file** — no `parserOptions.project`, no typed `@typescript-eslint` rules — stated verbatim at `eslint.config.mjs:326` with its own positive-control measurement, so every rule is per-file and syntactic and no edit here can move the verdict on a file it does not contain.⚠️ `dispatch-gates` reports its derivation as taken from a tree behind `origin/main`, with `scripts/check-release-spec-changes.mjs` and `scripts/ts-parse.mjs` changed across that range. Neither declares a `packages/services` population — `check:spec-changes` is spec-release-scoped and `ts-parse.mjs` is a parser library that declares no family — so the derived list is unchanged at 59. CI derives it again on the merge base. ## Acceptance notes Noted while reading, ⛔ not filed and ⛔ not fixed here — none is a reproducible defect, a contract violation or a metadata-authoring trap: - `runRegion`'s `isRefusalSignal` arm (objectstack-ai#15788) still converts a region-contained REFUSAL into a plain `Error`, so an enclosing `try_catch` can still contain that one. It is not reachable today: objectstack-ai#18688 refuses an `end` inside a region body at parse, and the other producers are a `subflow` / `map` whose CHILD run refused — a different card's surface (objectstack-ai#18112's option B is explicitly not implemented). Carrier if it ever becomes reachable: objectstack-ai#18112. ⛔ Deliberately left alone; touching it would move accept/reject behaviour outside this card's ruling. - The resumed and up-bubble legs of a region-contained pause are objectstack-ai#18714's and are held serial behind this card, so nothing here touches `resumeInternal` or `creditChildRun`. --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
Part of #15646
Clause-②: yes
The flow accept set shrinks for five node types inside region bodies — shapes the runtime never honoured. Ruling D clause 4 states it verbatim.
domain:servicescard. ⇒ this PR lands withPart of, ⛔ notFixes; #15646 stays open until the runtime half lands.Everything below the horizontal rule was written when the card was ruled C, and it argues for route A ("Recommendation: A, as implemented"). ⛔ That is no longer what this PR does. It is kept unedited as the record of how the decision was reached — ⛔ deleting it would erase the evidence the later ruling was made on.
What this PR does NOW, per ruling D (batch #153 item 1, comment
5724940095, maintainer 「其他同意」):Inside
loop/parallelbranch /try_catch(try and catch) bodies at any depth,FlowSchema.superRefinerefuses five node types:screen,wait,approval,approval_revise, andend.⛔
mapandsubfloware NOT refused by type. They pause exactly when the child flow theirconfig.flowNamenames pauses — a different metadata record, not in hand at parse. Refusing them by type would also refuseloop { map(synchronous child) }, which runs correctly today.packages/speckeeps its published identifierFLOW_PAUSE_CAPABLE_NODE_TYPES; only its contents narrow.packages/servicesis untouched by this round — measured, zero paths. The 5-tests-in-3-files cost the section below describes does not occur: the whole package runs 138 files / 1652 tests, all passing, with a false-green control proving the test read the rebuilt artifact and not a staledist.packages/services/service-automation/src/end-node-refused-outcome.test.ts(+57/−35) is in the diff, from the earlier round's commit87973cab8d1. Ruling D clause 1 says 「packages/servicesuntouched; the 5 tests and service-automation: amapnode inside aloopbody runs its collection ONCE — iterations 2..n do nothing, reportsuccess, and the run completes green #15616's suite stand」 — and service-automation: honouroutcome: 'refused'on the flowendnode — a terminalrefusedrun status (distinct fromfailed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788's region-endcase was one of those 5. ⇒ the ruling's premise 「B breaks nothing」 was false for that one case: clause 1 itself ordersendrefused at parse, andregisterFlowparses, so the old run-time assertion is unreachable by construction. What was done: the fixture is byte-identical, case count 12 → 12, and the assertion is strengthened (region path, message text, and that nothing registered) so it fails again the day the shape becomes declarable. ⛔ No test deleted, skipped or quarantined; ⛔ no engine source moved; service-automation: amapnode inside aloopbody runs its collection ONCE — iterations 2..n do nothing, reportsuccess, and the run completes green #15616's suite and the other three files are untouched and green. The at-tier review measured all of this and ruled it non-blocking — but it is a deviation and it is stated here rather than buried.CI on
6de9d662f6df: 32 success, 3 skipped, 0 failure, 0 pending.⛔ Two corrections the
domain:specseat owes on its own recordFLOW_PAUSE_CAPABLE_NODE_TYPESremoves a published export and therefore forces a major. Measured since: main'spackages/spec/api-surface/automation.jsongreps 0 for that name (lit controlsFLOW_BUILTIN_NODE_TYPESandFLOW_STRUCTURAL_NODE_TYPES= 1 each; dark control = 0), and the branch greps 1. ⇒ the constant is introduced by this PR and is on no consumer's import path; the gate's 「1 breaking (removed)」 was computed against the branch's own earlier snapshot. The decision stands and costs nothing — a second name would be cost without benefit — but ⛔ the record must not carry 「a removed published export」 as a fact about consumers. The round measured this and told the seat; the seat re-measured and confirms it.--pair 18688re-measured 「exit 0」 at this head. That reading came from a STALE INSTRUMENT. The shared checkout'scheck-clause2-carriers.mjsis blobccd5ad7c9a00and contains 0 occurrences of rule C8;origin/main's and this head's is blob3a270ef2eb5fand contains 18 (lit controlC1: 50 vs 51, so the reader works). C8 landed onmainat 01:41Z via fix(pm): a SECONDClaim:by one seat is NAMED, not ranked as a supersession #18859 and the shared checkout never had it. ⇒ every--pairreading this seat took today was taken with a script that cannot see C8. Re-taken withorigin/main's script: feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node #18688 exit 4 on C8 — this seat held two liveClaim:comments on service-automation: a PAUSINGmapinside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returnssuccesshaving run nothing #15646 (5722016855,5728277407), which the protocol forbids. Repaired as C8 prescribes:Release:(5729634742) then ONE freshClaim:(5729639847).--pair 18688now exits 0 —claim.selected1,claim.rejected2. The at-tier review caught this; the seat re-measured and confirms it.Route C, as ruled. Director seat, summon #24, batch #145 item 5 — #15646 (comment) (maintainer 「同意,其他也同意」), with the batch #146 scope addition — #15646 (comment) (maintainer 「146 同意」), which attached #3267's 禁 ruling and absorbed #18112 into this card. One PR, one changeset, two refusals in one rule family.
🛑 Read this first — this PR is NOT ready to land, and the reason is a measured decision, not a bug
packages/specis green end to end. 5 tests in 3packages/services/service-automationfiles now fail, and every one of them fails for the same reason: the fixture can no longer be REGISTERED, becauseAutomationEngine.registerFlowparses throughFlowSchema.parse(engine.ts:3941) and this rule refuses the shape.domain:specseat after a classification round — the table below replaces one that named 5 tests in 3 files. That earlier count was taken by running three named files; CI runspnpm --filter @objectstack/service-automation test, the whole package, and a named-file subset cannot see this class of breakage.os-dev.md:56reserves this body to the PR-open write, so the round named the wording and the seat writes it.src/builtin/contained-failure-rollup.test.tsloop { subflow }—git merge-base --is-ancestorexit 0), so this is a measurement gap, ⛔ not driftsrc/builtin/map-in-loop-iteration-state.test.tsloop { body: [ map, probe ] }over a non-pausing child: 5 iterations x 2 items ⇒ 10 child runs,failed = 0either way, a fresh result set per iterationsrc/builtin/contained-failure-visibility.test.tssubflowchild; the region shape is the vehicle, not the subjectsrc/end-node-refused-outcome.test.tsMeasured with the package suite: at
e10b395cee, 12 failed / 1627 passed (1639) across 4 files. After the fix below, at87973cab8d1: 11 failed / 1628 passed.⭐ One of the twelve was never blocked on the open question, and it is repaired here. #15788's region-
endcase sits in both candidate populations — this body defines route B as the unconditionally pausing types plusend— so no answer to the question below moves it. It is re-homed to the registration refusal: the fixture is unchanged byte for byte, and the case now asserts the ZodError's located path, its message and prescription, and that nothing registered. ⛔ Not a deletion — it fails again the day the shape becomes declarable.The 11 are mutually exclusive with route A, and that is measured rather than argued. Ablating⚠️ Method note that is load-bearing:
FLOW_PAUSE_CAPABLE_NODE_TYPESto route B's definition turns all 11 green with nothing else moving; route A on the same four files is 11 red.service-automationresolves@objectstack/specthroughdist, so the ablation was rebuilt and verified present in 18 built artifacts before anything was read — an unrebuilt ablation would have gone green and proved nothing. Restored afterwards, verified absent from all 216 artifacts, whole-tree porcelain empty.⭐ The 11 are NOT one cost. 3 of them (#15616) are free: under route A the shape becomes undeclarable, so the defect is unreachable and the regression suite converts to a refusal pin — mechanically, the same conversion performed above for #15788; that file's second describe (a TOP-LEVEL pausing map) is untouched and green, so the durable-pause half keeps its coverage. The other 8 (#16314, #14456) are a genuine re-home onto a top-level delegating node, and
loop { subflow }over five rows with one failing is the shape #15617's ruling named, so any re-home must record that the measurement no longer runs on it.⛔ Not repaired here. The dispatch fences⚠️ and note precisely what that fence claims: it is true of the diff, which touches no
packages/services("the engine's runtime refusal stays exactly as it is") —packages/servicesfile. Read as a claim about effect it is false, because the parse refusal changes what those suites can register. The changeset carries the same correction, and two of these three are other cards' regression suites: deleting or re-homing #15616's and #15788's coverage is a decision, not a fixture edit. Two of them are also evidence about the rule itself, which is the open question below.The open question: does the narrowing take a shape that WORKS with it?
The ruling's population is "a node that can durably pause (
map/subflowwith a pausing child, approval-class nodes)". Measured:mapandsubflowpause exactly when the child flow they NAME pauses — a different metadata record — so "with a pausing child" is not decidable at parse. Only two spellings are:loop { map(synchronous child) }— a shape that runs correctly today and was deliberately fixed 12 days ago by service-automation: amapnode inside aloopbody runs its collection ONCE — iterations 2..n do nothing, reportsuccess, and the run completes green #15616 / PR fix(service-automation): scope amapnode's progress state to one execution of its collection #15648, whose regression suite is 3 of the 5 failures above.screen/wait/approval/approval_revise) plusend. Refuses nothing that works today, and the 3mapfailures disappear. Cost: this card's own reproduction —loop { try_catch { map(pausing child) } }— stays declarable and stays silently green, so the card is not closed.There is no third reading available to a parse. Recommendation: A, as implemented — #3267 is ruled 禁 ("structured regions do not support durable pause"), and a shape whose legality lives in a record the author is not editing, revocable by editing that record, is not a contract. Under A the five tests are re-homed (a top-level
map, a top-levelend) or retired with a statement, in this PR or a follow-up, once the seat says the coverage may move.Step Zero — the ruling's precondition, answered before any code was written
Answer: YES for the nesting and for the node vocabulary this rule judges, with two boundaries that are declared rather than discovered. What was measured, on this branch's base
7f7b8557df:collectFlowGraphs(packages/spec/src/automation/control-flow.zod.ts) yields the top-level graph plus every region body, depth first, with ascopelabel and apaththat anchors a Zod issue where the author wrote the node.FlowSchema'ssuperRefinealready walks exactly that and refuses on it — the Decision: do a flow's top-levelnodes[]and its region bodies (loop/try_catch/parallel) share ONE node-id space, or two? — uniqueness is now enforced inside each, never across #16134 one-node-id-space rule. The PM seat's clue held: there is no refusing layer for this shape, but the walk and the refusal machinery are both live and in the same file.defineActionDescriptorliterals, not by recall:supportsPause: trueappears onscreen/wait/subflow/map(packages/services/service-automation/src/builtin/) andapproval/approval_revise(packages/plugins/plugin-approvals/src/) — six, the same six the ADR-0044resumeAuthoritydefault-flip migration entry names in its own prose. They are published here asFLOW_PAUSE_CAPABLE_NODE_TYPES.endis fully static —FLOW_STRUCTURAL_NODE_TYPES, a node type the engine handles with no executor at all.What is NOT decidable, and what this rule does about it. Whether a given node will pause is not decidable at parse, in two different ways, and both are stated in the docblock, in the changeset and in the ADR-0087 entry:
map/subflowpause exactly when the child flow they NAME pauses (map.config.flowName, an opaque reference to another metadata record). So the rule judges the node TYPE, not the run. That is wider than the runs that actually broke — a region-nestedmapover a synchronous child parsed green before and is refused now — and it is deliberate: the old shape's legality lived in a record the author is not editing and could be revoked by editing that record. "Legal until somebody adds awaitto the child flow" is not a contract.FlowNodeSchema.typeis a validatedstring), and a parse has no registry. Pinned as a boundary test so it moves deliberately.MAX_REGION_DEPTH(32). The walk stops there.nodes[]and its region bodies (loop/try_catch/parallel) share ONE node-id space, or two? — uniqueness is now enforced inside each, never across #16134's duplicate-id rule, there is no second spec refusal behind the ceiling for this rule —analyzeRegionsays nothing about pausing nodes — so past depth 32 the engine's run-time refusal is the only one. Measured and pinned at nesting 32 (refused) / 33 (not judged), and stated in the changeset rather than left for an author to find.What changed
FlowSchema.superRefinegains one walk overcollectFlowGraphs, skipping the flow's own graph, that raises acustomissue anchored at[...regionPath, 'nodes', i, 'type']for:loop 'sweep' body → try_catch 'guard' try), why a region body cannot host it, and the fix;endnode in a region body, whatever itsoutcome— anendthere was a no-op, and a refusing one was converted into a region error at the same boundary (service-automation: honouroutcome: 'refused'on the flowendnode — a terminalrefusedrun status (distinct fromfailed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788). The ruled prescription is the message: a region body cannot end the run; put theendon the top-level graph.FLOW_PAUSE_CAPABLE_NODE_TYPESis the new export (api-surface/export-originsregenerated). The two approval entries are the declared constantsAPPROVAL_NODE_TYPE/APPROVAL_REVISE_NODE_TYPE, so a rename cannot desynchronise them.⛔
packages/servicesis untouched — this is authoring-time enforcement only. ⛔ No engine rollback seam (route A, no card filed, per the ruling). ⛔ No runtime detection inmap(route B, refused). ⛔ #15617'sfailedfold is not addressed.Tests
New file
packages/spec/src/automation/flow-region-pause-and-end.test.ts— every case fails without the rule:loopbody,try_catchtry and catch,parallelbranch. A rule coveringlooponly is route B wearing C's clothes; thetry_catchcatch arm and theparallelbranch arm are the two route B could never see, and each has its own case.loop { try_catch { map } }, refused with the chained region path.endstill parse on the top-level graph; every non-pausing type still parses inside a region; a node merely namedendorwaitin a region still parses (the rule judgestype, notid).defineFlowandformatZodErrorrenderings.Two existing cases pinned the behaviour this rule replaces and were replaced rather than re-spelled, each saying so in its own comment:
end-node-outcome.test.ts's region-nestedend(its subject — anend-in-region whose config is judged one door later — no longer exists) andflow.test.ts's BPMNwaitEventConfigregion case (now asserts the earlier refusal and keeps the region-contract half it actually exists to measure). TherequireTypeScopedConfigdocblock that asserted a nested block-lesswaitparses green was corrected in the same edit.Verification
Measured on
e10b395cee. Heavy runs go throughscripts/pm/os-verify-lock.sh; every exit code below is read from the wrapper's ownVERDICT command-exitline or captured into a variable before any pipe — never$?after one.pnpm --filter @objectstack/spec buildcommand-exit 0pnpm --filter @objectstack/spec test(whole package)command-exit 0— 486 files, 13895 tests, 0 failedpnpm --filter @objectstack/spec typecheck(tsc --noEmit+check:scripts-typecheck+check:test-typecheck)command-exit 0pnpm --filter @objectstack/spec check:generatedcommand-exit 0— all 15 artifacts up to datepnpm lint(whole repo,eslint . --no-inline-config)exit 0— run in full, so nothing here is a narrowingdispatch-gates.mjs --ranreconciliationexit 0— 85 derived, 81 run, 4 NOT-MEASURED, 0 UNRUN@objectstack/service-automation— the 3 files whose fixtures feed this ruleexit 1— 5 failed / 24 passed, see the section at the topReverse verification (one-shot, restored). The rule's own early-exit was mutated (
graph.path.length === 0→>= 0), and the mutation was proved on disk before anything was read from the run — anchor grep 1 → 0, marker grep 0 → 1, blob044bbbba→56a1c350:flow-region-pause-and-end.test.ts: 20 failed / 7 passed. The 20 are exactly the refusal assertions; the 7 that survive are the over-reach guards and the two boundary pins, which must stay green with the rule absent. That split is itself the reading: a rule that also broke the negative cases would be refusing too much.git checkout HEAD --the file, blob back to044bbbba,git diff HEADclean,git statusempty, same file 27/27 passed.No
distpreflight applies: the test imports./flow.zodby relative source path, so the subject never resolves throughpackages/spec/dist. A restoretrapwas armed for the whole window.The four NOT-MEASURED gates are
check:doc-formula-expressions,check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt— each exited 3, PREREQUISITE NOT MET, printing in its own words that nothing was measured. All four read built output across packages this diff does not touch and need a repo-wide build; CI'sBuild CoreandLint & Repo Gatesare where they run. ⛔ Not green, not red — unrun.Not measured, stated: CI convergence on this PR (the report is filed at the end of local verification); the branch has not been merged forward since
7f7b8557df, somain's newer commits are tested by CI and the queue rather than here.Review-gate reading, not an action.
scripts/pm/check-clause2-carriers.mjs --pair 18688exits 4 on two rows, both belonging to the claiming seat and ⛔ neither touched here: C1 — card #15646 carriesneeds:contract-reviewwhile this PR does not (the gate is a dual carrier); C2 — no comment on the card's thread is a machine-legible claim comment (none has a first line beginningClaim:carrying theClause-②:line), so the declaration limb has nothing to read. The declaration itself is at the top of this body and in the changeset.Acceptance notes
Observations from this card's reading, recorded here and not filed — none is a reproducible defect, a contract violation, or an authoring trap:
map/subflowwith a pausing child", describes the defect population rather than a decidable rule population, and its "a shape the runtime never honoured" is exact forend,screen,waitand the approval pair but not for amapover a synchronous child, which runs today. The PR takes the capability reading — the only one that makes C the complete fix the ruling's own reasoning requires — and the changeset states the cost in the author's own terms. Noted so a reviewer reads the widening deliberately rather than discovering it.FlowRunSummary's two paragraphs disagree for a subflow parent —failedis declared a node fold, while the summary is declared to answer "what did this run cause" and roll a child's totals up #15617 is open; it is closed / completed. Nothing here depends on it.engine.ts:9937in the ruling readsengine.ts:9970on this tree — line numbers are clues, and this one was re-read rather than trusted.🤖 Generated with Claude Code
https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Generated by Claude Code