Repository navigation
Commit 5f7d554
fix(metadata)!: refuse a flow with an undeclared cycle at publish, as the engine refuses it at registration (#29226)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi92ir5G3Q6HWZpySnE18mErT)_
Fixes #29025
Clause-②: no (narrowing)
Metadata-protocol: narrowed
## 维护者速读
- 改了什么:流程里闭合回环的那条边没标 `type: 'back'` 时,草稿读取现在报 `valid: false`(错误码
`flow-undeclared-cycle`,定位到闭环边),单项发布和整包发布都拒收(422)。环检测从
service-automation 挪到 metadata-core 成为唯一判定,引擎注册和发布门都调它。
- 为什么改:以前这种流程草稿显示有效、发布成功,然后引擎拒绝注册,流程永远不跑,只有启动日志里能看到原因。
- 风险与回滚:只拒收引擎本来就不会注册的流程;任一环边标了 back 的回环照常发布和注册(对照用例已钉住)。引擎的报错文字不变。回滚即
revert 本 PR。
-
先看:`packages/metadata-protocol/src/runtime-authoring-gate.ts`(`findFlowCycleGaps`
与并入 `localIssues`
的一行)、`packages/metadata-core/src/flow-cycle.ts`(判定本体,原样搬自引擎)、`packages/services/service-automation/src/flow-registration-checks.ts`(`detectCycles`
改为调用共享判定)。
- 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run
src/runtime-authoring-gate.flow-cycle.test.ts
src/protocol.draft-publish-verdict.test.ts`,预期全部通过,含「未标 back 被拒」与「标 back
的同一回环照常发布」两条。
- 需要你做的:无,席位落地。
## What changes
- `packages/metadata-core/src/flow-cycle.ts` (new):
`findUndeclaredFlowCycle(flow)` and `undeclaredFlowCycleMessage(path)`.
The DFS is the engine's `detectCycles` body, moved unedited, plus the
index of the cycle-closing edge so a door can locate the finding.
Exported from the package root.
-
`packages/services/service-automation/src/flow-registration-checks.ts`:
`detectCycles` now throws `findUndeclaredFlowCycle(flow).message`. Same
walk, same sentence, same throw site; the engine's behaviour does not
change.
- `packages/metadata-protocol/src/runtime-authoring-gate.ts`: a
gate-local rule `flow-undeclared-cycle` (`FLOW_UNDECLARED_CYCLE`,
`findFlowCycleGaps`), folded into the gate's one verdict and disclosed
in `rulesRun` for every flow. Severity `error`, path `flows[0].edges[N]`
(the closing edge, which `_diagnostics` re-spells `edges.N`), message =
the engine's sentence. Because the draft verdict (#26707) reads the same
gate, the draft read now says `valid: false`, and the per-item publish,
the live save and `publish-drafts` refuse it.
- `packages/metadata-core/src/flow-cycle-corpus.ts` (new, exported from
`@objectstack/metadata-core/testing` only): `FLOW_CYCLE_CORPUS`, eight
complete flow bodies with the expected verdict (four refused, four that
must register), driven by the judge's, the engine's and the gate's
suites.
- `.changeset/29025-flow-undeclared-cycle-publish.md`:
`metadata-protocol` and `metadata-core` minor, `service-automation`
patch; BREAKING banner with before / now, `adr-0087: not-required
(no-migration-prescription)`.
Why gate-local and not an `AUTHORING_RULES` entry: the registry lives in
`@objectstack/lint`, which can reach only `spec`, `formula` and
`sdui-parser`. Putting the rule there would need a new `lint` →
`metadata-core` edge (or the judge in `packages/spec`, the spec lane's).
It would also fold #29051 (`os validate` / `os build`) into this PR,
which the seat holds separately. Both are in the report's open
questions.
## Tests
No existing assertion was edited.
New:
- `packages/metadata-core/src/flow-cycle.test.ts`: the judge on the
corpus (path, closing-edge index, message), the corpus holds both
verdicts, a dangling endpoint takes no part.
-
`packages/metadata-protocol/src/runtime-authoring-gate.flow-cycle.test.ts`:
the pure gate on the corpus. Each refused case is one located `error`
with the judge's message, and each accepted case has no cycle finding.
Drafts are not gated (D1).
-
`packages/services/service-automation/src/flow-cycle-registration.test.ts`:
`registerFlow` on the same corpus, refused exactly when the corpus names
a cycle, with the judge's message. These two files are the parity pin:
one corpus, both faces.
-
`packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts`:
the card's shape (push → check → revise → push) added as the last
`CASES` row (saves, reads `valid: false`, publish refuses with the same
words). New control: the same loop with its closing edge typed `back`
reads `valid: true` and publishes.
-
`packages/qa/dogfood/test/flow-undeclared-cycle-publish.dogfood.test.ts`:
over the showcase composition and REST. A draft `PUT` saves, `GET
?state=draft` carries `flow-undeclared-cycle` on `edges.4` with the
engine's sentence, and `publish-drafts` answers `outcome: 'refused'`.
Control: the `back`-typed loop reads valid, publishes, and the by-name
read shows `registration.state: registered`.
## Acceptance notes
- Sibling #29051 (`os validate` / `os build` pass a top-level cycle):
`findUndeclaredFlowCycle` serves it as is.
`packages/cli/src/utils/flow-region-shape.ts` already runs
`validateControlFlow` per flow, and `@objectstack/cli` already depends
on `@objectstack/metadata-core`. So the build face is one call in that
file plus its test. That is a different file surface from this PR, so it
is left to the seat's serial card.
- Same defect class, not fixed here: the gate never runs the spec's
`validateControlFlow`, so a flow whose `loop` body (or `parallel` /
`try_catch` region) is cyclic or malformed also passes the publish gate
while `registerFlow` refuses it. Measured on the pure gate at this head:
a loop body `a → b → a` gives `evaluateRuntimeAuthoringGate(...).error
=== null`, and `validateControlFlow` throws "loop 'lp' body: region has
no entry node … region contains a cycle". This is the gate-side twin of
the CLI change made for #28893.
## Verification
<details><summary>Red first on origin/main (fea2dbb), gate fix removed,
draft-verdict suite</summary>
`× flow — [flow-undeclared-cycle]: the draft saves, reads valid:false,
and publish refuses with the same words`. Received `{ code: undefined,
status: undefined }`: the publish was not refused.
</details>
<details><summary>Ablation 1: the gate's finding removed from
localIssues (src, scripts/ablation-replace.mjs, anchor 1 → 0, restored
blob == HEAD)</summary>
The gate suite and the draft-verdict suite go 5 failed / 39 passed: the
four refused corpus cases plus the card's draft-verdict case. The
controls stay green. Restored, `git diff HEAD` empty.
</details>
<details><summary>Ablation 2: the judge's back-edge exemption removed
(metadata-core rebuilt, ablation-dist-preflight marker present in dist,
then absent after restore and rebuild)</summary>
Gate + draft-verdict: 5 failed (the three back-typed controls, the
two-loops case, and the draft-verdict back control). Engine registration
corpus: 4 failed (the same four corpus cases). A first attempt used a
block-comment marker that esbuild strips: the preflight failed, so that
reading was discarded and the ablation was redone with a marker that
survives the build.
</details>
<details><summary>Package suites and typecheck (before the main
merge)</summary>
Under the verify lock, `typecheck` then `test` for the three packages,
VERDICT command-exit 0: metadata-core 23 files / 896 tests,
metadata-protocol 298 files (4 skipped) / 22074 passed (26 skipped),
service-automation 258 files / 3317 tests. `eslint --no-inline-config`
on every changed `.ts` file: exit 0.
</details>
<details><summary>Derived gates (dispatch-gates --commands, 66 families)
at 8a38432</summary>
All 66 derived families exit 0; `dispatch-gates --ran` with exit codes
recorded: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED
(a DERIVED zero)". Three needed a second run.
`check-adr-0087-registration` was red on the first changeset text, which
carried a FROM → TO label that the gate reads as a migration
prescription; the changeset now states the remedy, and the gate reads it
green with `not-required (no-migration-prescription)`.
`check-plugin-teardown-shape --self-test` exited 3 (its fixture commit
is outside the shallow clone) and is green after fetching that commit.
`check:dual-build-cjs-loads` exited 3 (seven packages outside the
dogfood closure had no dist) and is green after building them (115
entries / 70 packages). The changeset-reading gates were re-run after
the changeset commit, all exit 0.
</details>
<details><summary>Dogfood battery at the final head</summary>
Head 8a38432 (main merged at 694a118), closure built with `pnpm
--filter '@objectstack/dogfood^...' build`. `pnpm --filter
@objectstack/dogfood test` under the lock: VERDICT command-exit 0, Test
Files 388 passed / 3 skipped (391), Tests 3206 passed / 28 skipped
(3234), no failure. The new file was also run alone with the verbose
reporter: 2/2 passed (card 750 ms, control 1067 ms).
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (eslint on the changed
files; the repo-wide run is CI's).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: n/a.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmQvBMGvejv3APX96eu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6fe99f8 commit 5f7d554
12 files changed
Lines changed: 690 additions & 53 deletions
File tree
- .changeset
- packages
- metadata-core/src
- metadata-protocol/src
- qa/dogfood/test
- services/service-automation/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
150 | 150 | | |
151 | 151 | | |
152 | 152 | | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
0 commit comments