Skip to content

Commit 5f7d554

Browse files
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
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/metadata-core': minor
4+
'@objectstack/service-automation': patch
5+
---
6+
7+
**BREAKING** fix(metadata)!: a flow whose loop is not closed by a `type: 'back'` edge is refused at publish, and its draft read says so, instead of publishing and then never registering
8+
9+
Clause-②: no (narrowing)
10+
Metadata-protocol: narrowed
11+
Skills-docs: none — `skills/objectstack-automation/references/state-machines-and-approvals.md` teaches that the resubmit edge MUST be typed `'back'` and that `registerFlow` rejects an unmarked cycle; both stay true, and the publish door now refuses the same flow earlier, in the same words.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped. The publish doors now refuse a flow body the automation engine already refused to register, with the engine's own sentence, so a published body that could never run has nothing a migration could rewrite it to: the author's fix is the one the refusal names. -->
14+
15+
**BREAKING** accept-set narrowing at the runtime authoring gate (`@objectstack/metadata-protocol`), which the metadata draft read and every publish door run (`PUT /api/v1/meta/:type/:name` live, `POST …/publish`, `POST /api/v1/packages/:id/publish-drafts`).
16+
17+
- **Before.** A flow whose top-level graph had a cycle with no edge typed `back` (ADR-0044) saved as a draft, its draft read answered `_diagnostics: { valid: true }`, and it published (`outcome: 'published'`). The automation engine then refused to register it ("Flow contains a cycle: push → check → revise → push. …"), so the flow never ran, and only the by-name read's `registration` and a boot-log warning said why.
18+
- **Now.** The draft still saves (drafts are never gated). Its read answers `valid: false` with an error coded `flow-undeclared-cycle` on the cycle-closing edge (`edges.N`), and every publish door refuses it `422 INVALID_METADATA` with that finding; a package publish refuses the batch (`outcome: 'refused'`). The message is the engine's registration refusal, word for word.
19+
- **One judge.** The cycle walk moved from `@objectstack/service-automation` to `@objectstack/metadata-core` as `findUndeclaredFlowCycle` (with `undeclaredFlowCycleMessage`). The engine's registration and the publish gate both call it, so they refuse exactly the same graphs; the engine's behaviour and message are unchanged. `@objectstack/metadata-core/testing` exports `FLOW_CYCLE_CORPUS`, the case table both faces are tested against.
20+
- **Unchanged.** A cycle with any of its edges typed `back` publishes and registers, as before. Cycles inside a `loop` / `parallel` / `try_catch` region are judged where they were. `os validate` and `os build` do not run this check yet.
21+
22+
**The remedy.** None for a flow that registers today: it publishes exactly as before. A flow this now refuses was already refused by the engine at registration and never ran; the refusal names the closing edge, and ADR-0044's `type: 'back'` on that edge is how an intended loop is declared. A stored flow published before this change is re-registered exactly as before (still refused by the engine).
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29025] The shared corpus of the flow-cycle judge (`findUndeclaredFlowCycle`, `./flow-cycle.ts`).
5+
*
6+
* Each case is a complete flow body that `FlowSchema` accepts, with the cycle
7+
* walk the judge must report (`null` = acyclic once `back` edges are set
8+
* aside). Three suites read it: the judge's own, the automation engine's
9+
* registration, and the runtime authoring gate's. Each asserts its face refuses
10+
* exactly the cases whose `cycle` is non-null, with the judge's message, so the
11+
* engine and the gate cannot drift apart on which graphs they refuse.
12+
*
13+
* Nodes are `start`, `assignment` and `end` only, and no edge carries a
14+
* condition, so no other registration or authoring check has anything to say
15+
* about a case: the cycle is the only verdict in play.
16+
*/
17+
18+
export interface FlowCycleCorpusCase {
19+
/** What the case pins, in a few words. */
20+
readonly title: string;
21+
/** A complete flow body; its `name` is unique in the corpus. */
22+
readonly flow: {
23+
readonly name: string;
24+
readonly label: string;
25+
readonly type: 'autolaunched';
26+
readonly nodes: ReadonlyArray<{ readonly id: string; readonly type: string; readonly label: string }>;
27+
readonly edges: ReadonlyArray<{ readonly id: string; readonly source: string; readonly target: string; readonly type?: string }>;
28+
};
29+
/** The cycle walk the judge reports, or `null` when the flow must register and publish. */
30+
readonly cycle: readonly string[] | null;
31+
/** The index in `edges` of the cycle-closing edge, when `cycle` is set. */
32+
readonly closingEdgeIndex?: number;
33+
}
34+
35+
const node = (id: string, type = 'assignment') => ({ id, type, label: id });
36+
37+
/** The card's shape: start → push → check → revise → push, the loop edge untyped. */
38+
const reworkNodes = [node('start', 'start'), node('push'), node('check'), node('revise'), node('end', 'end')];
39+
const reworkEdges = (loopType?: string) => [
40+
{ id: 'e1', source: 'start', target: 'push' },
41+
{ id: 'e2', source: 'push', target: 'check' },
42+
{ id: 'e3', source: 'check', target: 'end' },
43+
{ id: 'e4', source: 'check', target: 'revise' },
44+
{ id: 'e5', source: 'revise', target: 'push', ...(loopType ? { type: loopType } : {}) },
45+
];
46+
47+
export const FLOW_CYCLE_CORPUS: readonly FlowCycleCorpusCase[] = [
48+
{
49+
title: 'a rework loop whose closing edge is untyped is refused',
50+
flow: { name: 'cyc_rework_untyped', label: 'Rework', type: 'autolaunched', nodes: reworkNodes, edges: reworkEdges() },
51+
cycle: ['push', 'check', 'revise', 'push'],
52+
closingEdgeIndex: 4,
53+
},
54+
{
55+
title: 'the same loop with its closing edge typed back registers',
56+
flow: { name: 'cyc_rework_back', label: 'Rework', type: 'autolaunched', nodes: reworkNodes, edges: reworkEdges('back') },
57+
cycle: null,
58+
},
59+
{
60+
title: 'a closing edge typed fault still closes a cycle',
61+
flow: { name: 'cyc_rework_fault', label: 'Rework', type: 'autolaunched', nodes: reworkNodes, edges: reworkEdges('fault') },
62+
cycle: ['push', 'check', 'revise', 'push'],
63+
closingEdgeIndex: 4,
64+
},
65+
{
66+
title: 'a self-loop is refused',
67+
flow: {
68+
name: 'cyc_self', label: 'Self', type: 'autolaunched',
69+
nodes: [node('start', 'start'), node('again'), node('end', 'end')],
70+
edges: [
71+
{ id: 'e1', source: 'start', target: 'again' },
72+
{ id: 'e2', source: 'again', target: 'again' },
73+
{ id: 'e3', source: 'again', target: 'end' },
74+
],
75+
},
76+
cycle: ['again', 'again'],
77+
closingEdgeIndex: 1,
78+
},
79+
{
80+
title: 'a self-loop typed back registers',
81+
flow: {
82+
name: 'cyc_self_back', label: 'Self', type: 'autolaunched',
83+
nodes: [node('start', 'start'), node('again'), node('end', 'end')],
84+
edges: [
85+
{ id: 'e1', source: 'start', target: 'again' },
86+
{ id: 'e2', source: 'again', target: 'again', type: 'back' },
87+
{ id: 'e3', source: 'again', target: 'end' },
88+
],
89+
},
90+
cycle: null,
91+
},
92+
{
93+
title: 'any edge of the loop typed back breaks it — the graph minus back edges is what must be acyclic',
94+
flow: {
95+
name: 'cyc_mid_back', label: 'Mid', type: 'autolaunched', nodes: reworkNodes,
96+
edges: reworkEdges().map((e) => (e.id === 'e2' ? { ...e, type: 'back' } : e)),
97+
},
98+
cycle: null,
99+
},
100+
{
101+
title: 'two loops, one declared, one not: the undeclared one is refused',
102+
flow: {
103+
name: 'cyc_two_loops', label: 'Two', type: 'autolaunched',
104+
nodes: [node('start', 'start'), node('a'), node('b'), node('c'), node('end', 'end')],
105+
edges: [
106+
{ id: 'e1', source: 'start', target: 'a' },
107+
{ id: 'e2', source: 'a', target: 'b' },
108+
{ id: 'e3', source: 'b', target: 'a', type: 'back' },
109+
{ id: 'e4', source: 'b', target: 'c' },
110+
{ id: 'e5', source: 'c', target: 'b' },
111+
{ id: 'e6', source: 'c', target: 'end' },
112+
],
113+
},
114+
cycle: ['b', 'c', 'b'],
115+
closingEdgeIndex: 4,
116+
},
117+
{
118+
title: 'a diamond (two paths meeting again) is no cycle',
119+
flow: {
120+
name: 'cyc_diamond', label: 'Diamond', type: 'autolaunched',
121+
nodes: [node('start', 'start'), node('left'), node('right'), node('join'), node('end', 'end')],
122+
edges: [
123+
{ id: 'e1', source: 'start', target: 'left' },
124+
{ id: 'e2', source: 'start', target: 'right' },
125+
{ id: 'e3', source: 'left', target: 'join' },
126+
{ id: 'e4', source: 'right', target: 'join' },
127+
{ id: 'e5', source: 'join', target: 'end' },
128+
],
129+
},
130+
cycle: null,
131+
},
132+
];
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29025] The flow-cycle judge on its own corpus. The engine's registration
5+
* (`@objectstack/service-automation`) and the runtime authoring gate
6+
* (`@objectstack/metadata-protocol`) are driven from the same corpus in their
7+
* own suites; this file pins what the judge itself answers.
8+
*/
9+
10+
import { describe, expect, it } from 'vitest';
11+
import { findUndeclaredFlowCycle, undeclaredFlowCycleMessage } from './flow-cycle.js';
12+
import { FLOW_CYCLE_CORPUS } from './flow-cycle-corpus.js';
13+
14+
describe('[#29025] findUndeclaredFlowCycle', () => {
15+
for (const c of FLOW_CYCLE_CORPUS) {
16+
it(c.title, () => {
17+
const found = findUndeclaredFlowCycle(c.flow);
18+
if (c.cycle === null) {
19+
expect(found).toBeNull();
20+
return;
21+
}
22+
expect(found).toEqual({
23+
path: c.cycle,
24+
closingEdgeIndex: c.closingEdgeIndex,
25+
message: undeclaredFlowCycleMessage(c.cycle),
26+
});
27+
});
28+
}
29+
30+
it('the corpus holds both verdicts, so no face can pass it by refusing everything or nothing', () => {
31+
const refused = FLOW_CYCLE_CORPUS.filter((c) => c.cycle !== null).length;
32+
expect(refused).toBeGreaterThanOrEqual(3);
33+
expect(FLOW_CYCLE_CORPUS.length - refused).toBeGreaterThanOrEqual(3);
34+
expect(new Set(FLOW_CYCLE_CORPUS.map((c) => c.flow.name)).size).toBe(FLOW_CYCLE_CORPUS.length);
35+
});
36+
37+
it('the message names the walk and the back-edge remedy', () => {
38+
const message = undeclaredFlowCycleMessage(['a', 'b', 'a']);
39+
expect(message.startsWith('Flow contains a cycle: a → b → a.')).toBe(true);
40+
expect(message).toContain("type: 'back'");
41+
});
42+
43+
it('an edge whose endpoint names no node takes no part', () => {
44+
expect(findUndeclaredFlowCycle({
45+
nodes: [{ id: 'a' }],
46+
edges: [{ source: 'a', target: 'ghost' }, { source: 'ghost', target: 'a' }],
47+
})).toBeNull();
48+
});
49+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The ONE judge of an undeclared cycle in a flow's top-level graph (ADR-0044).
5+
*
6+
* A flow is a DAG once its declared back edges (`type: 'back'`) are set aside:
7+
* an author opts a rework loop in edge by edge, and an unmarked cycle is
8+
* refused. The automation engine refuses to REGISTER such a flow
9+
* (`AutomationEngine.detectCycles`, `@objectstack/service-automation`). Until
10+
* this module the judgement lived only there, so the metadata draft read said
11+
* `valid: true` and the publish doors published a flow the engine then refused,
12+
* and it never ran.
13+
*
14+
* Every door now asks this function: the engine at registration, and the
15+
* runtime authoring gate (`@objectstack/metadata-protocol`) that the draft
16+
* verdict and every publish door run. Both depend on this package, which
17+
* depends on `@objectstack/spec` alone, so neither face pulls the other's code
18+
* in. Both answer with {@link FlowUndeclaredCycle.message}, so the author reads
19+
* the same sentence wherever the flow is refused.
20+
*
21+
* Scope: the flow's own `nodes` / `edges`. Cycles inside an ADR-0031 region
22+
* (a `loop` body, a `parallel` branch, a `try_catch` region) are judged by the
23+
* spec's `validateControlFlow`, not here.
24+
*/
25+
26+
/** The structural slice of a flow node this judge reads. */
27+
export interface FlowCycleNode {
28+
readonly id: string;
29+
}
30+
31+
/** The structural slice of a flow edge this judge reads. */
32+
export interface FlowCycleEdge {
33+
readonly source: string;
34+
readonly target: string;
35+
/** `'back'` sets the edge aside (ADR-0044); absent reads as the schema's `'default'`. */
36+
readonly type?: string;
37+
}
38+
39+
/** What the judge finds: the first undeclared cycle, located. */
40+
export interface FlowUndeclaredCycle {
41+
/**
42+
* The cycle as a node-id walk that starts and ends on the same node —
43+
* `['push', 'check', 'revise', 'push']`.
44+
*/
45+
readonly path: readonly string[];
46+
/**
47+
* The index in `edges` of the edge that closes the cycle (the walk's last
48+
* step), so a door can locate the refusal on the edge an author would
49+
* mark `back`.
50+
*/
51+
readonly closingEdgeIndex: number;
52+
/** The refusal, the same sentence at every door. */
53+
readonly message: string;
54+
}
55+
56+
/** The refusal sentence for a cycle walk; the engine's registration error since ADR-0044. */
57+
export function undeclaredFlowCycleMessage(path: readonly string[]): string {
58+
return (
59+
`Flow contains a cycle: ${path.join(' → ')}. Only DAG flows are allowed — ` +
60+
`to author an intentional rework loop, mark the cycle-closing edge with type: 'back' (ADR-0044).`
61+
);
62+
}
63+
64+
/**
65+
* Find the first cycle in a flow's top-level graph once its `back` edges are
66+
* set aside, or `null` when there is none.
67+
*
68+
* Depth-first with white/gray/black colouring, nodes visited in declaration
69+
* order and edges followed in declaration order, so the cycle reported is the
70+
* one the engine has always named. An edge whose source or target names no
71+
* node takes no part (a dangling endpoint is another check's refusal).
72+
*/
73+
export function findUndeclaredFlowCycle(flow: {
74+
readonly nodes: readonly FlowCycleNode[];
75+
readonly edges: readonly FlowCycleEdge[];
76+
}): FlowUndeclaredCycle | null {
77+
const WHITE = 0, GRAY = 1, BLACK = 2;
78+
const color = new Map<string, number>();
79+
const parent = new Map<string, string>();
80+
81+
const adj = new Map<string, string[]>();
82+
for (const node of flow.nodes) {
83+
color.set(node.id, WHITE);
84+
adj.set(node.id, []);
85+
}
86+
for (const edge of flow.edges) {
87+
if (edge.type === 'back') continue; // ADR-0044 declared back-edge
88+
const targets = adj.get(edge.source);
89+
if (targets) targets.push(edge.target);
90+
}
91+
92+
const dfs = (nodeId: string): string[] | null => {
93+
color.set(nodeId, GRAY);
94+
for (const neighbor of adj.get(nodeId) ?? []) {
95+
if (color.get(neighbor) === GRAY) {
96+
// Back edge found — reconstruct the cycle.
97+
const cycle = [neighbor, nodeId];
98+
let cur = nodeId;
99+
while (cur !== neighbor) {
100+
cur = parent.get(cur)!;
101+
if (cur) cycle.push(cur);
102+
else break;
103+
}
104+
return cycle.reverse();
105+
}
106+
if (color.get(neighbor) === WHITE) {
107+
parent.set(neighbor, nodeId);
108+
const result = dfs(neighbor);
109+
if (result) return result;
110+
}
111+
}
112+
color.set(nodeId, BLACK);
113+
return null;
114+
};
115+
116+
for (const node of flow.nodes) {
117+
if (color.get(node.id) !== WHITE) continue;
118+
const path = dfs(node.id);
119+
if (!path) continue;
120+
const from = path[path.length - 2];
121+
const to = path[path.length - 1];
122+
const closingEdgeIndex = flow.edges.findIndex(
123+
(edge) => edge.type !== 'back' && edge.source === from && edge.target === to,
124+
);
125+
return { path, closingEdgeIndex, message: undeclaredFlowCycleMessage(path) };
126+
}
127+
return null;
128+
}

‎packages/metadata-core/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,9 @@ export * from './resolved-picklist-options.js';
150150
// and the registry fold's boot log, so the three cannot disagree about which
151151
// patch names a field or option its target lacks.
152152
export * from './extension-field-patch-targets.js';
153+
154+
// [#29025] The judge of an undeclared cycle in a flow's top-level graph
155+
// (ADR-0044) — one judge for the engine's registration and the runtime
156+
// authoring gate the draft verdict and the publish doors run, so a flow the
157+
// engine refuses to register no longer reads valid and publishes.
158+
export * from './flow-cycle.js';

‎packages/metadata-core/src/testing.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,8 @@ export * from './contract-suite.js';
1111
// exit that forgets the projection fails by name. Test-only: it belongs beside
1212
// the contract suite, not on the runtime entry.
1313
export * from './object-schema-fls-contract.js';
14+
// [#29025] The shared corpus both faces of the flow-cycle judge are driven
15+
// from — the engine's registration (`@objectstack/service-automation`) and the
16+
// runtime authoring gate (`@objectstack/metadata-protocol`) — so the two
17+
// refuse exactly the same graphs. Test-only, beside the FLS case table.
18+
export * from './flow-cycle-corpus.js';

0 commit comments

Comments
 (0)