From 5dcd9fc5a87a6ed94766406b0f9d954b296df527 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:36:31 +0000 Subject: [PATCH 1/5] test(cli): pin os validate/build to the flow-cycle corpus (red before the fix) Claude-Session: https://claude.ai/code/session_01ADHkmSGEsjUPQp4S968p2X Co-authored-by: Claude --- .../cli/src/utils/flow-region-shape.test.ts | 40 +++++++++++++++ .../test/validate-flow-region-shape.test.ts | 51 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/packages/cli/src/utils/flow-region-shape.test.ts b/packages/cli/src/utils/flow-region-shape.test.ts index 29b46230948..81508464092 100644 --- a/packages/cli/src/utils/flow-region-shape.test.ts +++ b/packages/cli/src/utils/flow-region-shape.test.ts @@ -17,6 +17,9 @@ import { describe, expect, it } from 'vitest'; +import { undeclaredFlowCycleMessage } from '@objectstack/metadata-core'; +import { FLOW_CYCLE_CORPUS } from '@objectstack/metadata-core/testing'; + import { findFlowRegionRefusals } from './flow-region-shape.js'; /** A body `FlowSchema` refuses: `nodes` must be an array. */ @@ -46,3 +49,40 @@ describe('[#29059] a flow the flow schema refuses is a row, never a silent pass' expect(located(rows)).toEqual([{ path: 'packages[1].manifest.flows[0]', code: 'VALIDATION_ERROR', flow: 'rgn_broken' }]); }); }); + +/** + * [#29051] An undeclared cycle in a flow's TOP-LEVEL graph (ADR-0044) is a row. + * + * The engine refuses such a flow at registration with + * `findUndeclaredFlowCycle` (`@objectstack/metadata-core`), and the walk asks + * the same judge. The cases are the judge's own corpus, the one the engine's + * registration and the runtime publish gate are driven from, so the CLI + * refuses exactly the graphs they refuse, in the engine's sentence. + */ +describe('[#29051] a top-level cycle not declared back is refused, as the engine refuses it', () => { + for (const c of FLOW_CYCLE_CORPUS) { + it(c.title, () => { + const rows = findFlowRegionRefusals({ flows: [c.flow] }); + if (c.cycle === null) { + expect(rows).toEqual([]); + return; + } + expect(located(rows)).toEqual([{ path: 'flows[0]', code: 'VALIDATION_ERROR', flow: c.flow.name }]); + expect(rows[0]!.message).toBe(`Flow '${c.flow.name}' would be refused at registration: ${undeclaredFlowCycleMessage(c.cycle)}`); + }); + } + + it('a flow with both a cycle and a malformed region is refused for the cycle first, in the engine\'s order', () => { + const rework = FLOW_CYCLE_CORPUS.find((c) => c.flow.name === 'cyc_rework_untyped')!; + const flow = { + ...rework.flow, + nodes: rework.flow.nodes.map((node) => + node.id === 'check' + ? { ...node, type: 'parallel', config: { branches: [{ nodes: [{ id: 'only', type: 'assignment', label: 'only' }], edges: [] }] } } + : node), + }; + const rows = findFlowRegionRefusals({ flows: [flow] }); + expect(rows).toHaveLength(1); + expect(rows[0]!.message).toContain(undeclaredFlowCycleMessage(rework.cycle!)); + }); +}); diff --git a/packages/cli/test/validate-flow-region-shape.test.ts b/packages/cli/test/validate-flow-region-shape.test.ts index d5f427f9c59..764a2bdf5b8 100644 --- a/packages/cli/test/validate-flow-region-shape.test.ts +++ b/packages/cli/test/validate-flow-region-shape.test.ts @@ -23,6 +23,8 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import Compile from '../src/commands/compile'; import Validate from '../src/commands/validate'; +import { undeclaredFlowCycleMessage } from '@objectstack/metadata-core'; +import { FLOW_CYCLE_CORPUS } from '@objectstack/metadata-core/testing'; import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); @@ -139,6 +141,10 @@ const CASES: Record; refusal?: string } stack: assembledWith(flow('rgn_ok', guard(fanOut(true))), flow('rgn_ops_lead_time', guard(fanOut(false)))), refusal: "try_catch 'guard' catch: region must be single-exit but has 3: x, y, z", }, + // [#29051] The flow-cycle judge's whole corpus in one stack, and the cases it + // must pass on their own. + cycles: { stack: stackWith(FLOW_CYCLE_CORPUS.map((c) => c.flow)) }, + cyclesDeclared: { stack: stackWith(FLOW_CYCLE_CORPUS.filter((c) => c.cycle === null).map((c) => c.flow)) }, }; let root = ''; @@ -211,3 +217,48 @@ describe('[#28893] validate and build refuse a flow region the engine refuses, w expect(built.code).toBe(0); }, 60_000); }); + +/** + * [#29051] A flow whose TOP-LEVEL graph closes a cycle with no `type: 'back'` + * edge (ADR-0044). The engine refuses it at registration with + * `findUndeclaredFlowCycle` (`@objectstack/metadata-core`); before this, + * `os validate` and `os build` passed it with exit 0 and the flow never ran. + * Driven from the judge's own corpus, so the doors refuse exactly the cases + * the engine and the publish gate refuse, each at its own flow, in the + * engine's sentence. + */ +describe('[#29051] validate and build refuse an undeclared top-level flow cycle, with the engine\'s sentence', () => { + const refused = FLOW_CYCLE_CORPUS + .map((c, i) => ({ c, i })) + .filter(({ c }) => c.cycle !== null) + .map(({ c, i }) => ({ path: `flows[${i}]`, code: 'VALIDATION_ERROR', flow: c.flow.name, cycle: undeclaredFlowCycleMessage(c.cycle!) })); + const rowsOf = (errors: Array>) => + errors.filter((r) => String(r.message).includes('Flow contains a cycle')); + const expectRefused = (errors: Array>) => { + const rows = rowsOf(errors); + expect(rows.map(({ path, code, flow }) => ({ path, code, flow })), JSON.stringify(errors, null, 2)) + .toEqual(refused.map(({ path, code, flow }) => ({ path, code, flow }))); + rows.forEach((row, k) => expect(String(row.message)).toContain(refused[k]!.cycle)); + }; + + it('validate exits 1 with one row per refused corpus flow; build refuses the same and writes nothing', async () => { + const validated = await validate('cycles', '--json'); + expect(validated.code, validated.stdout).toBe(1); + expectRefused(json(validated, 'validate cycles').errors as Array>); + + const built = await build('cycles', '--json'); + expect(built.code, built.stdout).toBe(1); + const payload = json(built, 'build cycles'); + expect(payload.success).toBe(false); + expectRefused(payload.errors as Array>); + expect(existsSync(fixtures.cycles.dist)).toBe(false); + }, 60_000); + + it('CONTROL: the corpus flows whose loops are declared back (and the acyclic ones) validate and build', async () => { + const validated = await validate('cyclesDeclared', '--json'); + expect(validated.code, validated.stdout).toBe(0); + const built = await build('cyclesDeclared', '--json'); + expect(json(built, 'build cyclesDeclared').success, built.stdout).toBe(true); + expect(built.code).toBe(0); + }, 60_000); +}); From 241763a13457bde10c741f321d7b7f525e5d653b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:46:28 +0000 Subject: [PATCH 2/5] fix(cli)!: os validate and os build refuse an undeclared top-level flow cycle, with the engine's sentence The CLI's flow walk asks findUndeclaredFlowCycle (@objectstack/metadata-core), the judge the engine's registration and the runtime publish gate call, before validateControlFlow, in the engine's order. Claude-Session: https://claude.ai/code/session_01ADHkmSGEsjUPQp4S968p2X Co-authored-by: Claude --- .changeset/29051-cli-flow-undeclared-cycle.md | 19 ++++++++++ packages/cli/src/commands/compile.ts | 8 ++-- packages/cli/src/commands/validate.ts | 8 ++-- packages/cli/src/utils/flow-region-shape.ts | 37 +++++++++++++------ 4 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 .changeset/29051-cli-flow-undeclared-cycle.md diff --git a/.changeset/29051-cli-flow-undeclared-cycle.md b/.changeset/29051-cli-flow-undeclared-cycle.md new file mode 100644 index 00000000000..26a066b7560 --- /dev/null +++ b/.changeset/29051-cli-flow-undeclared-cycle.md @@ -0,0 +1,19 @@ +--- +'@objectstack/cli': patch +--- + +**BREAKING** fix(cli)!: `os validate` and `os build` refuse a flow whose top-level graph closes a cycle with no `type: 'back'` edge, as the automation engine refuses it at registration + +Clause-②: no (narrowing) +Metadata-protocol: none +Skills-docs: none — `skills/objectstack-automation/references/state-machines-and-approvals.md` already teaches that a cycle-closing edge MUST be typed `'back'` and that an unmarked cycle is refused; that stays true, and the CLI now refuses the same flow before it ships. + + + +**BREAKING** accept-set narrowing at the CLI's author-time doors (`@objectstack/cli`). + +- **Before.** A flow whose top-level graph had a cycle with no edge typed `back` (ADR-0044), such as `start → push → check → revise → push`, passed `os validate` and `os build` with exit 0. The engine then refused to register it at boot ("Flow contains a cycle: …"), logged a warning, and ran without it. +- **Now.** Both commands refuse it, exit 1, and `os build` writes no artifact. The row is `VALIDATION_ERROR`, located at the flow (`flows[N]`, or `packages[K].manifest.flows[N]` in an assembled artifact), and its message is the engine's registration refusal, word for word. The judge is `findUndeclaredFlowCycle` from `@objectstack/metadata-core`, the one the engine's registration and the runtime publish gate call, so all three refuse exactly the same graphs. Like the engine, the cycle is judged before the ADR-0031 region shape, and each flow reports its first refusal. +- **Unchanged.** A cycle with any of its edges typed `back` validates and builds, as before. Cycles inside a `loop` / `parallel` / `try_catch` region are judged by the region check, as before. + +**The remedy.** None for a flow that registers today. A flow this now refuses never ran: if the loop is intended, set `type: 'back'` on the cycle-closing edge (ADR-0044); otherwise remove or redirect that edge. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 9a450afe6b2..f62aa622238 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -846,7 +846,9 @@ export default class Compile extends Command { // `validateControlFlow`). Before this the build emitted a flow the // engine then refused at boot, logging a warning and running without // it; now the door that ships refuses it and writes no artifact. Same - // two faces as the gates above. + // two faces as the gates above. [#29051] The same walk first asks + // the engine's top-level cycle judge (ADR-0044, + // `findUndeclaredFlowCycle`), in the engine's order. const regionRefusals = findFlowRegionRefusals(result.data as Record); if (regionRefusals.length > 0) { if (flags.json) { @@ -855,10 +857,10 @@ export default class Compile extends Command { } const n = regionRefusals.length; console.log(''); - printError(`The automation engine would refuse to register ${n} flow${n > 1 ? 's' : ''} for a malformed region (ADR-0031)`); + printError(`The automation engine would refuse to register ${n} flow${n > 1 ? 's' : ''} for an undeclared cycle (ADR-0044) or a malformed region (ADR-0031)`); printBulletList( regionRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`), - { noun: 'flow region refusal(s)', remedy: JSON_FULL_LIST_REMEDY }, + { noun: 'flow registration refusal(s)', remedy: JSON_FULL_LIST_REMEDY }, ); this.exit(1); } diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index aa7682a7b0e..c64b8e02a17 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -761,7 +761,9 @@ export default class Validate extends Command { // (`FlowSchema`, then the spec's `validateControlFlow`), so a flow // green here is one boot registers instead of logging // `[Automation] failed to register flow` and running without it; see - // `utils/flow-region-shape.ts`. + // `utils/flow-region-shape.ts`. [#29051] The same walk first asks + // the engine's top-level cycle judge (ADR-0044, + // `findUndeclaredFlowCycle`), in the engine's order. const regionRefusals = findFlowRegionRefusals(result.data as Record); if (regionRefusals.length > 0) { if (flags.json) { @@ -776,10 +778,10 @@ export default class Validate extends Command { } const n = regionRefusals.length; console.log(''); - printError(`The automation engine would refuse to register ${n} flow${n > 1 ? 's' : ''} for a malformed region (ADR-0031)`); + printError(`The automation engine would refuse to register ${n} flow${n > 1 ? 's' : ''} for an undeclared cycle (ADR-0044) or a malformed region (ADR-0031)`); printBulletList( regionRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`), - { noun: 'flow region refusal(s)', remedy: JSON_FULL_LIST_REMEDY }, + { noun: 'flow registration refusal(s)', remedy: JSON_FULL_LIST_REMEDY }, ); this.exit(1); } diff --git a/packages/cli/src/utils/flow-region-shape.ts b/packages/cli/src/utils/flow-region-shape.ts index e6ebf20c648..bae8447a826 100644 --- a/packages/cli/src/utils/flow-region-shape.ts +++ b/packages/cli/src/utils/flow-region-shape.ts @@ -29,11 +29,16 @@ * gate exists to close. The row says the flow could not be judged, with the * schema's issues, and the door refuses on it like any other row. * - * Not judged here: a cycle in the flow's TOP-LEVEL graph. The engine refuses it - * with `detectCycles`, which lives in `@objectstack/service-automation` and is - * not exported; region bodies ARE cycle-checked, by `validateControlFlow`. + * [#29051] Also judged: a cycle in the flow's TOP-LEVEL graph that no + * `type: 'back'` edge declares (ADR-0044). The engine asks + * `findUndeclaredFlowCycle` (`@objectstack/metadata-core`) at registration, + * BEFORE `validateControlFlow`, and so does this walk: the same judge, the same + * order, the engine's sentence. The runtime authoring gate asks it too, so a + * flow refused here is one the publish doors refuse. Region bodies are + * cycle-checked by `validateControlFlow`, not by this call. */ +import { findUndeclaredFlowCycle } from '@objectstack/metadata-core'; import { FlowSchema, validateControlFlow } from '@objectstack/spec/automation'; import { artifactPackages } from './artifact-packages.js'; @@ -53,7 +58,11 @@ export interface FlowRegionRefusalRow { message: string; } -/** Every flow of the parsed stack the engine would refuse to register for its region shape. */ +/** + * Every flow of the parsed stack the engine would refuse to register for an + * undeclared top-level cycle or for its region shape — one row per flow, the + * engine's first refusal. + */ export function findFlowRegionRefusals(parsed: Record): FlowRegionRefusalRow[] { const entries = artifactPackages(parsed); const subjects = entries.length > 0 @@ -78,16 +87,22 @@ export function findFlowRegionRefusals(parsed: Record): FlowReg }); return; } + const name = result.data.name; + const refuse = (reason: string) => rows.push({ + path: `${at}flows[${i}]`, + code: 'VALIDATION_ERROR', + flow: name, + message: `Flow '${name}' would be refused at registration: ${reason}`, + }); + const cycle = findUndeclaredFlowCycle(result.data); + if (cycle) { + refuse(cycle.message); + return; + } try { validateControlFlow(result.data); } catch (err) { - const name = result.data.name; - rows.push({ - path: `${at}flows[${i}]`, - code: 'VALIDATION_ERROR', - flow: name, - message: `Flow '${name}' would be refused at registration: ${err instanceof Error ? err.message : String(err)}`, - }); + refuse(err instanceof Error ? err.message : String(err)); } }); } From 55171669b16fd57d8913e0620c54ab5449e1ba38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:22:33 +0000 Subject: [PATCH 3/5] test(cli): alias @objectstack/metadata-core/testing to source for the flow-cycle corpus check:test-source-alias names the new subpath import; the corpus the CLI tests read is the checkout's, not a stale dist/testing.js. Claude-Session: https://claude.ai/code/session_01ADHkmSGEsjUPQp4S968p2X Co-authored-by: Claude --- packages/cli/vitest.config.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 7d5ad3dacb7..4a8fc76e42a 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -875,6 +875,19 @@ export default defineConfig({ find: /^create-objectstack\/created-summary$/, replacement: path.resolve(__dirname, '../create-objectstack/src/created-summary.ts'), }, + // [#29051] `FLOW_CYCLE_CORPUS`, the case table the flow-cycle judge, the + // engine's registration and the publish gate are driven from, pins + // `os validate` / `os build` to the same graphs + // (`src/utils/flow-region-shape.test.ts`, + // `test/validate-flow-region-shape.test.ts`). The subpath's `exports` + // entry is built from `src/testing.ts`; aliased so the corpus the tests + // read is the checkout's, not a stale `dist/testing.js`. The bare + // `@objectstack/metadata-core` specifier is NOT aliased here: it stays in + // this package's measured ledger entry, unchanged. + { + find: /^@objectstack\/metadata-core\/testing$/, + replacement: path.resolve(__dirname, '../metadata-core/src/testing.ts'), + }, // `test/serve-node-env-production-default.e2e.test.ts` (#11113) writes a // FIXTURE config file whose text is `import { AuthPlugin } from // '@objectstack/plugin-auth'` — real code, but code the fixture's own From 1f72080c7e90377aa4afca8b41375a243cea4311 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:04:27 +0000 Subject: [PATCH 4/5] test(cli): classify findFlowCycleRefusals as a shared validate/build gate The gate-parity ledger names every call site in compile.ts and validate.ts; the cycle walk runs in both and is not a registry rule because lint has no metadata-core edge. Claude-Session: https://claude.ai/code/session_01ADHkmSGEsjUPQp4S968p2X Co-authored-by: Claude --- packages/cli/test/validate-build-gate-parity.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 632d6647001..770ea80c9cc 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -213,6 +213,14 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [ // (#28727), so the row moved here — deleted there rather than reworded, for // the reason the `runPerPackageAuthoringRules` row above gives. 'findExtensionFieldSuffixRefusals', + // [#29051] An undeclared cycle in a flow's top-level graph (ADR-0044), + // judged by the automation engine's own registration judge + // (`findUndeclaredFlowCycle`, `@objectstack/metadata-core`), which the + // publish doors run too. Not a registry rule: `@objectstack/lint` does not + // depend on metadata-core, so the rule table cannot reach the judge without + // a new package edge, and a copy of the walk is what the ruling forbids. So + // `os lint` does not give this verdict; validate and build do. + 'findFlowCycleRefusals', ]; /** From 5b16eb349b8d570ea22c453f279e62ae6e94db7d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:47:28 +0000 Subject: [PATCH 5/5] docs(cli): the metadata-core/testing alias comment names the current test file and the bare specifier's own alias entry Comment-only. Claude-Session: https://claude.ai/code/session_01ADHkmSGEsjUPQp4S968p2X Co-authored-by: Claude --- packages/cli/vitest.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 4a8fc76e42a..bc6ffa7acc8 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -878,12 +878,12 @@ export default defineConfig({ // [#29051] `FLOW_CYCLE_CORPUS`, the case table the flow-cycle judge, the // engine's registration and the publish gate are driven from, pins // `os validate` / `os build` to the same graphs - // (`src/utils/flow-region-shape.test.ts`, + // (`src/utils/flow-undeclared-cycle.test.ts`, // `test/validate-flow-region-shape.test.ts`). The subpath's `exports` // entry is built from `src/testing.ts`; aliased so the corpus the tests // read is the checkout's, not a stale `dist/testing.js`. The bare - // `@objectstack/metadata-core` specifier is NOT aliased here: it stays in - // this package's measured ledger entry, unchanged. + // `@objectstack/metadata-core` specifier has its own anchored entry + // below (the #20158 block); this one covers only the `/testing` subpath. { find: /^@objectstack\/metadata-core\/testing$/, replacement: path.resolve(__dirname, '../metadata-core/src/testing.ts'),