From a37c2885eb34bfccb659d333c0cf5a392a8cdf54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:38:06 +0000 Subject: [PATCH 1/4] test(metadata-protocol): pin the publish refusal of a malformed flow region (red on main) A flow whose loop / parallel / try_catch region the engine refuses to register reads valid as a draft and publishes. These pins assert the gate refuses it with the engine's sentence, on the draft read and at every publish door, with well-formed region controls. Claude-Session: https://claude.ai/code/session_01ADHkmSfgX3fNF6rAXudbtz Co-authored-by: Claude --- .../protocol.draft-publish-verdict.test.ts | 99 ++++++++++ ...runtime-authoring-gate.flow-region.test.ts | 157 ++++++++++++++++ ...w-region-malformed-publish.dogfood.test.ts | 177 ++++++++++++++++++ 3 files changed, 433 insertions(+) create mode 100644 packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts create mode 100644 packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts diff --git a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts index bb4f8732a51..b3c652897b9 100644 --- a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts +++ b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts @@ -460,8 +460,57 @@ const CASES: Array<{ rule: string; type: string; item: { name: string } & Record ], }, }, + { + // [#29233] A loop whose body closes on itself (body a → b → a, the + // card's reproduction). It saved, read valid:true and PUBLISHED, and + // the engine's region check then refused to register it. + rule: 'flow-region-malformed', + type: 'flow', + item: regionFlow('region_loop', { + id: 'lp', + type: 'loop', + label: 'Loop', + config: { + collection: [1, 2], + iteratorVariable: 'row', + body: { + nodes: [ + { id: 'step_a', type: 'assignment', label: 'A' }, + { id: 'step_b', type: 'assignment', label: 'B' }, + ], + edges: [ + { id: 'b1', source: 'step_a', target: 'step_b' }, + { id: 'b2', source: 'step_b', target: 'step_a' }, + ], + }, + }, + }), + }, ]; +/** [#29233] start → CONTAINER → end, the container's region carrying the case. */ +function regionFlow(name: string, container: Record) { + return { + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + container, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: container.id as string }, + { id: 'e2', source: container.id as string, target: 'end' }, + ], + }; +} + +/** [#29233] One straight body of a single step, the well-formed region shape. */ +function oneStep(id: string) { + return { nodes: [{ id, type: 'assignment', label: id }], edges: [] }; +} + /** Stage one env-wide, package-bound draft (Studio's autosave shape). */ async function stageDraft(protocol: ObjectStackProtocolImplementation, type: string, item: { name: string }) { return (protocol as any).saveMetaItem({ type, name: item.name, item, packageId: PKG, mode: 'draft' }); @@ -541,6 +590,56 @@ describe('a draft reads the verdict its publish gives (#26707)', () => { expect(published).toMatchObject({ success: true, failedCount: 0 }); }); + it('[#29233] a well-formed loop, parallel and try_catch region each still read valid:true and publish', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const flows = [ + regionFlow('ok_loop', { + id: 'lp', type: 'loop', label: 'Loop', + config: { collection: [1, 2], iteratorVariable: 'row', body: oneStep('loop_step') }, + }), + regionFlow('ok_parallel', { + id: 'fan', type: 'parallel', label: 'Fan', + config: { branches: [oneStep('left_step'), oneStep('right_step')] }, + }), + regionFlow('ok_try', { + id: 'guard', type: 'try_catch', label: 'Guard', + config: { try: oneStep('try_step'), catch: oneStep('catch_step') }, + }), + ]; + for (const flow of flows) await stageDraft(protocol, 'flow', flow); + + for (const flow of flows) expect(await draftDiagnostics(protocol, 'flow', flow.name)).toEqual({ valid: true }); + const published: any = await protocol.publishPackageDrafts({ packageId: PKG }); + expect(published).toMatchObject({ success: true, publishedCount: 3, failedCount: 0 }); + }); + + it('[#29233] the region verdict is located at the container, at its depth, on the draft read', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + // A one-branch parallel block nested in a try region: the engine names + // both levels, and the finding lands on the inner container. + const flow = regionFlow('nested_region', { + id: 'guard', type: 'try_catch', label: 'Guard', + config: { + try: { + nodes: [{ id: 'fan', type: 'parallel', label: 'Fan', config: { branches: [oneStep('only_step')] } }], + edges: [], + }, + catch: oneStep('catch_step'), + }, + }); + await stageDraft(protocol, 'flow', flow); + + const diagnostics = await draftDiagnostics(protocol, 'flow', flow.name); + expect(diagnostics.valid).toBe(false); + expect((diagnostics.errors ?? []).filter((e) => e.code === 'flow-region-malformed')).toEqual([{ + path: 'nodes.1.config.try.nodes.0', + message: "parallel 'fan': a parallel block needs at least 2 branches", + code: 'flow-region-malformed', + }]); + }); + it('[#29025] the same loop with its closing edge typed back still reads valid:true and publishes', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts new file mode 100644 index 00000000000..944ec10ecd9 --- /dev/null +++ b/packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29233] The gate-local `flow-region-malformed` rule, on the pure gate: the + * gate refuses the ADR-0031 regions the engine's registration refuses (a loop + * body, a parallel branch, a try or catch region, at any depth), with the + * sentence the engine throws, located on the container it names. The judge is + * `validateControlFlow`, the function `canonicalizeFlow` calls; every expected + * message below is that function's own answer for the case, never a + * restatement. The draft-read and publish doors over a real protocol are pinned + * in `protocol.draft-publish-verdict.test.ts`. + */ + +import { describe, expect, it } from 'vitest'; +import { FlowSchema, validateControlFlow } from '@objectstack/spec/automation'; +import { evaluateRuntimeAuthoringGate, FLOW_REGION_MALFORMED } from './runtime-authoring-gate.js'; + +type Rec = Record; + +/** start → the given nodes (unwired) → end; region cases need no top-level edges. */ +const flowOf = (name: string, nodes: Rec[]) => ({ + name, + label: name, + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, ...nodes, { id: 'end', type: 'end', label: 'End' }], + edges: [] as Rec[], +}); + +const oneStep = (id: string) => ({ nodes: [{ id, type: 'assignment', label: id }], edges: [] }); + +/** A two-step body whose edges close on themselves: no entry, no exit, a cycle. */ +const closedBody = (prefix: string) => ({ + nodes: [ + { id: `${prefix}_a`, type: 'assignment', label: 'A' }, + { id: `${prefix}_b`, type: 'assignment', label: 'B' }, + ], + edges: [ + { id: `${prefix}_1`, source: `${prefix}_a`, target: `${prefix}_b` }, + { id: `${prefix}_2`, source: `${prefix}_b`, target: `${prefix}_a` }, + ], +}); + +const loop = (id: string, body: unknown) => ({ + id, type: 'loop', label: `Loop ${id}`, config: { collection: [1, 2], iteratorVariable: 'row', body }, +}); + +/** What the engine's registration throws for this flow, or null. */ +function engineRefusal(flow: unknown): string | null { + try { + validateControlFlow(FlowSchema.parse(structuredClone(flow))); + return null; + } catch (e) { + return (e as Error).message; + } +} + +function regionIssues(flow: { name: string }, state: 'active' | 'draft' = 'active') { + const verdict = evaluateRuntimeAuthoringGate({ type: 'flow', name: flow.name, state, body: structuredClone(flow) }); + const err = verdict.error as (Error & { code: string; status: number; issues: any[]; rulesRun: string[] }) | null; + return { err, issues: (err?.issues ?? []).filter((i) => i.rule === FLOW_REGION_MALFORMED) }; +} + +describe('[#29233] the publish gate refuses the regions the engine refuses', () => { + it('the card: a loop body a → b → a is refused on the loop, in the engine\'s sentence', () => { + const flow = flowOf('rgnprobe_loop', [loop('lp', closedBody('k'))]); + const sentence = engineRefusal(flow); + expect(sentence).toBe( + "loop 'lp' body: region has no entry node (every node has an incoming edge — cyclic?); " + + 'region has no exit node (every node has an outgoing edge — cyclic?); ' + + 'region contains a cycle (region bodies must be acyclic)', + ); + const { err, issues } = regionIssues(flow); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(err!.rulesRun).toContain(FLOW_REGION_MALFORMED); + expect(issues).toEqual([{ + severity: 'error', + rule: FLOW_REGION_MALFORMED, + where: 'flow "rgnprobe_loop" · node "Loop lp"', + path: 'flows[0].nodes[1]', + message: sentence, + hint: expect.any(String), + }]); + }); + + it('a parallel block with one branch is refused on the block', () => { + const flow = flowOf('one_branch', [{ id: 'fan', type: 'parallel', label: 'Fan', config: { branches: [oneStep('only')] } }]); + expect(regionIssues(flow).issues).toEqual([ + expect.objectContaining({ path: 'flows[0].nodes[1]', message: engineRefusal(flow) }), + ]); + }); + + it('a try region that closes on itself is refused on the try_catch', () => { + const flow = flowOf('closed_try', [{ + id: 'guard', type: 'try_catch', label: 'Guard', config: { try: closedBody('t'), catch: oneStep('c') }, + }]); + const sentence = engineRefusal(flow); + expect(sentence).toMatch(/^try_catch 'guard' try: /); + expect(regionIssues(flow).issues).toEqual([ + expect.objectContaining({ path: 'flows[0].nodes[1]', message: sentence }), + ]); + }); + + it('a region its schema refuses is refused with the engine\'s "invalid region" sentence', () => { + const flow = flowOf('shapeless', [loop('lp', { nodes: 'not a list' })]); + const sentence = engineRefusal(flow); + expect(sentence).toMatch(/^loop 'lp' body: invalid region — /); + expect(regionIssues(flow).issues).toEqual([ + expect.objectContaining({ path: 'flows[0].nodes[1]', message: sentence }), + ]); + }); + + it('a malformed region nested in another names both levels and lands on the inner container', () => { + const flow = flowOf('nested', [{ + id: 'guard', type: 'try_catch', label: 'Guard', + config: { try: { nodes: [loop('inner', closedBody('n'))], edges: [] }, catch: oneStep('c') }, + }]); + const sentence = engineRefusal(flow)!; + expect(sentence).toMatch(/^try_catch 'guard' try → loop 'inner' body: /); + expect(regionIssues(flow).issues).toEqual([ + expect.objectContaining({ + where: 'flow "nested" · node "Loop inner"', + path: 'flows[0].nodes[1].config.try.nodes[0]', + message: sentence, + }), + ]); + }); + + it('two malformed top-level containers are reported one finding each', () => { + const flow = flowOf('two_bad', [loop('first', closedBody('f')), loop('second', closedBody('s'))]); + expect(regionIssues(flow).issues.map((i) => i.path)).toEqual(['flows[0].nodes[1]', 'flows[0].nodes[2]']); + }); + + it('controls: a well-formed loop, parallel and try_catch region, nested too, raise nothing', () => { + const flow = flowOf('all_good', [ + loop('lp', oneStep('l1')), + { id: 'fan', type: 'parallel', label: 'Fan', config: { branches: [oneStep('p1'), oneStep('p2')] } }, + { + id: 'guard', type: 'try_catch', label: 'Guard', + config: { try: { nodes: [loop('inner', oneStep('i1'))], edges: [] }, catch: oneStep('c1') }, + }, + ]); + expect(engineRefusal(flow)).toBeNull(); + expect(regionIssues(flow).issues).toEqual([]); + }); + + it('a draft is not gated (D1)', () => { + const flow = flowOf('draft_loop', [loop('lp', closedBody('d'))]); + expect(evaluateRuntimeAuthoringGate({ type: 'flow', name: flow.name, state: 'draft', body: structuredClone(flow) }).error) + .toBeNull(); + }); + + it('a body the flow schema refuses is the schema\'s to report: no region finding', () => { + const flow = { ...flowOf('no_label', [loop('lp', closedBody('x'))]), label: undefined }; + expect(FlowSchema.safeParse(flow).success).toBe(false); + expect(regionIssues(flow).issues).toEqual([]); + }); +}); diff --git a/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts b/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts new file mode 100644 index 00000000000..a446abd427b --- /dev/null +++ b/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#29233] A flow whose loop / parallel / try_catch region is malformed is +// refused at every publish door, and its draft read says so, over the real +// showcase composition and through the REST doors the card measured. +// +// ## What was broken +// +// `PUT /api/v1/meta/flow/:name?mode=draft&package=PKG` with a loop whose body +// is a → b → a saved, the draft read answered `_diagnostics: { valid: true }`, +// and `publish-drafts` answered `outcome: 'published'`. The automation engine +// then refused to register the flow ("loop 'lp' body: region has no entry +// node …"), so it never ran, and only the by-name read's `registration` and +// the boot log said why. +// +// ## What these cases pin +// +// The draft still saves (drafts are never gated). Its read answers +// `valid: false` with the `flow-region-malformed` finding on the loop node, in +// the engine's own sentence; `publish-drafts`, the per-item publish and a live +// save each refuse it before any write. Control: a well-formed loop, parallel +// and try_catch region each read valid, publish, and the engine registers them. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const PKG_BAD = 'app.rgnprobe29233'; +const PKG_OK = 'app.rgnprobeok29233'; + +/** The engine's registration refusal for the card's loop. */ +const REGION_SENTENCE = + "loop 'lp' body: region has no entry node (every node has an incoming edge — cyclic?); " + + 'region has no exit node (every node has an outgoing edge — cyclic?); ' + + 'region contains a cycle (region bodies must be acyclic)'; + +const step = (id: string) => ({ id, type: 'assignment', label: id }); +const oneStep = (id: string) => ({ nodes: [step(id)], edges: [] }); + +/** start → CONTAINER → end. */ +const regionFlow = (name: string, container: { id: string } & Record) => ({ + name, + label: 'Region probe', + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, container, { id: 'end', type: 'end', label: 'End' }], + edges: [ + { id: 'e1', source: 'start', target: container.id }, + { id: 'e2', source: container.id, target: 'end' }, + ], +}); + +/** The card's flow: a loop whose body a → b → a closes on itself. */ +const closedLoopFlow = (name: string, prefix: string) => regionFlow(name, { + id: 'lp', + type: 'loop', + label: 'Loop', + config: { + collection: [1, 2], + iteratorVariable: 'row', + body: { + nodes: [step(`${prefix}_a`), step(`${prefix}_b`)], + edges: [ + { id: `${prefix}_1`, source: `${prefix}_a`, target: `${prefix}_b` }, + { id: `${prefix}_2`, source: `${prefix}_b`, target: `${prefix}_a` }, + ], + }, + }, +}); + +describe('dogfood: a flow with a malformed region is refused at publish, and its draft says so (#29233)', () => { + let stack: VerifyStack; + let token: string; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack.apiAs(token, method, path, body); + const json: any = await res.json().catch(() => ({})); + return { status: res.status, json }; + }; + + const createPackage = async (id: string, namespace: string) => { + const created = await call('POST', '/packages', { + manifest: { id, name: `Region probe ${namespace}`, version: '0.1.0', type: 'app', namespace }, + }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + }; + + const expectRegionRefusal = (res: { status: number; json: any }) => { + expect(res.status, JSON.stringify(res.json)).toBe(422); + const text = JSON.stringify(res.json); + expect(text).toContain('INVALID_METADATA'); + expect(text).toContain('flow-region-malformed'); + }; + + /** No active row was written: the by-name read serves nothing under that name. */ + const expectNoActiveRow = async (name: string) => { + const served = await call('GET', `/meta/flow/${name}`); + expect(served.status, JSON.stringify(served.json)).toBe(404); + }; + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + await createPackage(PKG_BAD, 'rgnprobe'); + await createPackage(PKG_OK, 'rgnprobeok'); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('the card: the draft saves, reads valid:false on the loop, and publish-drafts refuses it before any write', async () => { + const saved = await call('PUT', `/meta/flow/rgnprobe_loop?mode=draft&package=${PKG_BAD}`, closedLoopFlow('rgnprobe_loop', 'k')); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + + const read = await call('GET', `/meta/flow/rgnprobe_loop?state=draft&package=${PKG_BAD}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + const diagnostics = JSON.stringify(read.json); + expect(diagnostics).toContain('"valid":false'); + expect(diagnostics).toContain('"code":"flow-region-malformed"'); + expect(diagnostics).toContain('"path":"nodes.1"'); + expect(diagnostics).toContain(JSON.stringify(REGION_SENTENCE)); + + const published = await call('POST', `/packages/${PKG_BAD}/publish-drafts`, {}); + expect(published.status, JSON.stringify(published.json)).toBe(200); + expect(published.json?.data).toMatchObject({ outcome: 'refused', publishedCount: 0 }); + expect(JSON.stringify(published.json)).toContain('flow-region-malformed'); + await expectNoActiveRow('rgnprobe_loop'); + }); + + it('the per-item publish and a live save refuse it too, before any write', async () => { + const drafted = await call('PUT', '/meta/flow/rgnprobe_item?mode=draft', closedLoopFlow('rgnprobe_item', 'i')); + expect(drafted.status, JSON.stringify(drafted.json)).toBe(200); + expectRegionRefusal(await call('POST', '/meta/flow/rgnprobe_item/publish', {})); + await expectNoActiveRow('rgnprobe_item'); + + expectRegionRefusal(await call('PUT', '/meta/flow/rgnprobe_live', closedLoopFlow('rgnprobe_live', 'v'))); + await expectNoActiveRow('rgnprobe_live'); + }); + + it('control: a well-formed loop, parallel and try_catch region each read valid, publish, and register', async () => { + const flows = [ + regionFlow('rgnprobeok_loop', { + id: 'lp', type: 'loop', label: 'Loop', + config: { collection: [1, 2], iteratorVariable: 'row', body: oneStep('ok_loop_step') }, + }), + regionFlow('rgnprobeok_parallel', { + id: 'fan', type: 'parallel', label: 'Fan', + config: { branches: [oneStep('ok_left_step'), oneStep('ok_right_step')] }, + }), + regionFlow('rgnprobeok_try', { + id: 'guard', type: 'try_catch', label: 'Guard', + config: { try: oneStep('ok_try_step'), catch: oneStep('ok_catch_step') }, + }), + ]; + for (const flow of flows) { + const saved = await call('PUT', `/meta/flow/${flow.name}?mode=draft&package=${PKG_OK}`, flow); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + const read = await call('GET', `/meta/flow/${flow.name}?state=draft&package=${PKG_OK}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + expect(JSON.stringify(read.json)).toContain('"_diagnostics":{"valid":true}'); + } + + const published = await call('POST', `/packages/${PKG_OK}/publish-drafts`, {}); + expect(published.status, JSON.stringify(published.json)).toBe(200); + expect(published.json?.data).toMatchObject({ outcome: 'published', publishedCount: 3 }); + + for (const flow of flows) { + let served = ''; + for (let i = 0; i < 50; i += 1) { + served = JSON.stringify((await call('GET', `/meta/flow/${flow.name}`)).json); + if (served.includes('"registration":{"state":"registered"}')) break; + await new Promise((r) => setTimeout(r, 100)); + } + expect(served, flow.name).toContain('"registration":{"state":"registered"}'); + } + }); +}); From 467bf1996a7db76e8cf2c8d7d7ee102e821a43a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:41:01 +0000 Subject: [PATCH 2/4] fix(metadata-protocol)!: refuse a malformed flow region at publish with the engine's sentence The runtime authoring gate now asks validateControlFlow, the judge the engine's registration calls, on the body parsed through FlowSchema as the engine parses it. A flow whose loop / parallel / try_catch region the engine refuses reads valid:false as a draft and is refused at every publish door, located on the container node at fault. Claude-Session: https://claude.ai/code/session_01ADHkmSfgX3fNF6rAXudbtz Co-authored-by: Claude --- .../29233-flow-region-malformed-publish.md | 20 ++++ .../src/runtime-authoring-gate.ts | 108 +++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 .changeset/29233-flow-region-malformed-publish.md diff --git a/.changeset/29233-flow-region-malformed-publish.md b/.changeset/29233-flow-region-malformed-publish.md new file mode 100644 index 00000000000..79a0f4ad8b0 --- /dev/null +++ b/.changeset/29233-flow-region-malformed-publish.md @@ -0,0 +1,20 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +**BREAKING** fix(metadata)!: a flow whose loop, parallel or try_catch region is malformed is refused at publish, and its draft read says so, instead of publishing and then never registering + +Clause-②: no (narrowing) +Metadata-protocol: narrowed +Skills-docs: none — no published skill teaches that a malformed region publishes; the automation skill's region rules (one entry, one exit, acyclic) stay true, and the publish door now refuses a region that breaks them earlier, in the engine's words. + + + +**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`). + +- **Before.** A flow whose ADR-0031 region was malformed (a `loop` body, a `parallel` branch or a `try_catch` try or catch region, at any depth, with no single entry or exit node, a cycle inside it, a shape its region schema refuses, or a `parallel` block with fewer than two branches) saved as a draft, its draft read answered `_diagnostics: { valid: true }`, and it published (`outcome: 'published'`). The automation engine then refused to register it ("loop 'lp' body: region has no entry node …; region contains a cycle (region bodies must be acyclic)"), so the flow never ran, and only the by-name read's `registration` and a boot-log warning said why. +- **Now.** The draft still saves (drafts are never gated). Its read answers `valid: false` with an error coded `flow-region-malformed` on the container node whose region is at fault (`nodes.N`, or the nested path such as `nodes.1.config.try.nodes.0`), 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. +- **One judge.** The gate calls `validateControlFlow` (`@objectstack/spec/automation`), the function the engine's registration calls, on the body parsed through `FlowSchema` as the engine parses it. A body the flow schema refuses is reported by the schema, as before. +- **Unchanged.** A flow whose regions the engine accepts publishes and registers, as before. `os validate` and `os build` do not run this check yet. + +**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 region and what is wrong with it. Give the region one entry node and one exit node with no cycle inside it, and give a `parallel` block at least two branches. A stored flow published before this change is re-registered exactly as before (still refused by the engine). diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index ebbd3219079..5588e797742 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -61,7 +61,17 @@ import { // here rather than borrowed from `@objectstack/lint` because the runtime gate // may only reach that package through its kernel-safe `/runtime` entry (the // wiring guard's third invariant), and `walkFlowNodes` is not on it. -import { FLOW_REGION_SLOTS_BY_TYPE } from '@objectstack/spec/automation'; +// +// [#29233] Beside it, the engine's region judge and the parse it reads: +// `canonicalizeFlow` (service-automation) hands `validateControlFlow` the body +// through `FlowSchema`, and so does the gate-local rule below, so the publish +// and the registration refuse the same regions. +import { + FLOW_REGION_SLOTS_BY_TYPE, + FlowSchema, + validateControlFlow, + type FlowNodeParsed, +} from '@objectstack/spec/automation'; // [#20312] The ADR-0080 compiler itself — the function behind the CLI-only // `validateJsxPages` rule, imported rather than re-implemented, so the save // door and `os validate` judge a page's source with one compiler. Imported from @@ -603,6 +613,96 @@ export function findFlowCycleGaps(args: { type: string; name: string; body: unkn }]; } +// ───────────────────────────────────────────────────────────────────────────── +// #29233 — a flow whose loop / parallel / try_catch region is malformed. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The rule id of the finding below: the automation engine refuses to register + * a flow whose ADR-0031 region (a `loop` body, a `parallel` branch, a + * `try_catch` try or catch region, at any depth) is malformed: no single entry + * or exit node, a cycle inside the region, a region its own schema refuses, or + * a `parallel` block with fewer than two branches. + * + * ## The defect this closes + * + * The engine's refusal ran at registration only (`canonicalizeFlow`). A flow + * whose loop body closed on itself read `valid: true` as a draft, published + * through the per-item and the package doors, and was then refused + * registration, so the flow never ran and only the by-name read's + * `registration` and the boot log said why. + * + * ## Why it is gate-local + * + * The sibling of {@link FLOW_UNDECLARED_CYCLE}, for the reason given there. The + * judge is not restated: {@link validateControlFlow} (`@objectstack/spec`), the + * function the engine's registration calls, on the input the engine hands it + * (the body through `FlowSchema`), so the gate refuses exactly the regions the + * engine refuses, in the engine's words. + */ +export const FLOW_REGION_MALFORMED = 'flow-region-malformed'; + +/** The engine's refusal of these nodes' regions, or `null` when it accepts them. */ +function controlFlowRefusal(nodes: FlowNodeParsed[]): string | null { + try { + validateControlFlow({ nodes }); + return null; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } +} + +/** + * Judge one about-to-be-published `flow` body for a malformed region: one + * `error` finding per top-level node whose regions the engine refuses, located + * on the innermost container the refusal names, with the engine's sentence. + * + * PURE. A body `FlowSchema` refuses is the schema's to report, so it takes no + * part here. The engine throws on the first malformed region of the whole + * flow; asking the same judge one top-level node at a time reports each + * top-level container it would refuse, each in the sentence the engine throws + * for it. Locating never judges: a nested container becomes the anchor only + * when the judge, asked about it alone, answers the tail of the same sentence + * (the engine prefixes a nested region's sentence with the path down to it). + */ +export function findFlowRegionGaps(args: { type: string; name: string; body: unknown }): RuntimeAuthoringIssue[] { + if (args.type !== 'flow' || !isRec(args.body)) return []; + const parsed = FlowSchema.safeParse(args.body); + if (!parsed.success) return []; + const flow = parsed.data; + const flowName = flow.name || args.name; + // Every node at every depth over the parsed body, pre-order. Parsing keeps + // every list's order, so each path indexes the body as the author wrote it. + const walked = walkNodes(flow as unknown as AnyRec, 'flows[0]'); + const issues: RuntimeAuthoringIssue[] = []; + flow.nodes.forEach((top, index) => { + const message = controlFlowRefusal([top]); + if (message === null) return; + const topPath = `flows[0].nodes[${index}]`; + let anchor = walked.find((w) => w.path === topPath)!; + // The containers that answer the same sentence form one chain down to + // the region at fault, and pre-order meets them outermost first. + for (const w of walked) { + if (!w.path.startsWith(`${topPath}.`)) continue; + const inner = controlFlowRefusal([w.node as unknown as FlowNodeParsed]); + if (inner !== null && (message === inner || message.endsWith(` → ${inner}`))) anchor = w; + } + issues.push({ + severity: 'error', + rule: FLOW_REGION_MALFORMED, + where: `flow "${flowName}" · node "${nodeLabel(anchor.node, anchor.index)}"`, + path: anchor.path, + message, + hint: + `Give the region one entry node, one exit node and no cycle inside it; a parallel block ` + + `needs at least two branches. The automation engine registers a flow only when every loop, ` + + `parallel and try_catch region is well formed (ADR-0031), so this flow would publish and ` + + `never run.`, + }); + }); + return issues; +} + // ───────────────────────────────────────────────────────────────────────────── // #26188 — a HIERARCHY scope on a runtime with no hierarchy resolver. // ───────────────────────────────────────────────────────────────────────────── @@ -1376,8 +1476,12 @@ export function evaluateRuntimeAuthoringGate(args: { // [#29025] The engine's registration refusal of an undeclared cycle, asked // of the same judge so the publish and the registration cannot disagree. const flowCycleGaps = findFlowCycleGaps({ type: args.type, name: args.name, body: args.body }); + // [#29233] The engine's registration refusal of a malformed region, asked + // of the same judge, on the same parse, for the same reason. + const flowRegionGaps = findFlowRegionGaps({ type: args.type, name: args.name, body: args.body }); const localIssues = [ ...flowCycleGaps, + ...flowRegionGaps, ...scheduleOrgGaps, ...packagedRuleCopyGaps, ...hierarchyScopeGaps, @@ -1440,7 +1544,7 @@ export function evaluateRuntimeAuthoringGate(args: { // here would reintroduce exactly the ambiguity it was added to remove. const rulesRun = [ ...result.rulesRun, - ...(args.type === 'flow' ? [FLOW_UNDECLARED_CYCLE, PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING] : []), + ...(args.type === 'flow' ? [FLOW_UNDECLARED_CYCLE, FLOW_REGION_MALFORMED, PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING] : []), ...(args.type === 'view' ? [PUBLIC_FORM_INTAKE_UNAVAILABLE] : []), ...(args.type === 'permission' && args.hierarchyResolverMounted !== undefined ? [HIERARCHY_SCOPE_CAPABILITY_REQUIRED] From 813305213fe74b9a078ab03c3fc8b5390c5af79d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:12:34 +0000 Subject: [PATCH 3/4] test(dogfood): give the region-publish pins a measured per-test budget The control (three saves, three reads, a package publish, three registration polls) timed out at the default 5 s inside the full battery on a shared box; it passes alone. Claude-Session: https://claude.ai/code/session_01ADHkmSfgX3fNF6rAXudbtz Co-authored-by: Claude --- .../test/flow-region-malformed-publish.dogfood.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts b/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts index a446abd427b..ab73696bace 100644 --- a/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts @@ -125,7 +125,7 @@ describe('dogfood: a flow with a malformed region is refused at publish, and its expect(published.json?.data).toMatchObject({ outcome: 'refused', publishedCount: 0 }); expect(JSON.stringify(published.json)).toContain('flow-region-malformed'); await expectNoActiveRow('rgnprobe_loop'); - }); + }, 30_000); it('the per-item publish and a live save refuse it too, before any write', async () => { const drafted = await call('PUT', '/meta/flow/rgnprobe_item?mode=draft', closedLoopFlow('rgnprobe_item', 'i')); @@ -135,7 +135,7 @@ describe('dogfood: a flow with a malformed region is refused at publish, and its expectRegionRefusal(await call('PUT', '/meta/flow/rgnprobe_live', closedLoopFlow('rgnprobe_live', 'v'))); await expectNoActiveRow('rgnprobe_live'); - }); + }, 30_000); it('control: a well-formed loop, parallel and try_catch region each read valid, publish, and register', async () => { const flows = [ @@ -173,5 +173,8 @@ describe('dogfood: a flow with a malformed region is refused at publish, and its } expect(served, flow.name).toContain('"registration":{"state":"registered"}'); } - }); + // Three saves, three reads, a package publish and up to three registration + // polls of 5 s each: the default 5 s per test was measured too tight inside + // the full battery on a shared box. + }, 60_000); }); From 2cb755efdb4bfd792eb46b6ca88f155a55fb63ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:02:47 +0000 Subject: [PATCH 4/4] fix(lint)!: refuse a malformed flow region at publish through the registry rule Seat ruling: one judge, one name. The gate-local flow-region-malformed rule is dropped and validateFlowRegionShape (flow-region-shape-invalid) now runs on the runtime publish gate for flow writes (CLI_AND_RUNTIME, runtimeTypes flow). The door pins and the dogfood file are re-pointed to the registry rule id; two lint test files get schema-valid flow fixtures, since the rule refuses a flow the schema refuses. Claude-Session: https://claude.ai/code/session_01ADHkmSfgX3fNF6rAXudbtz Co-authored-by: Claude --- ....md => 29233-flow-region-shape-publish.md} | 12 +- packages/lint/src/authoring-rules.ts | 11 +- packages/lint/src/runtime-gate.test.ts | 13 ++- .../validate-flow-trigger-readiness.test.ts | 12 +- .../protocol.draft-publish-verdict.test.ts | 18 +-- ...runtime-authoring-gate.flow-region.test.ts | 78 ++++++------- .../src/runtime-authoring-gate.ts | 108 +----------------- ...w-region-malformed-publish.dogfood.test.ts | 13 ++- 8 files changed, 86 insertions(+), 179 deletions(-) rename .changeset/{29233-flow-region-malformed-publish.md => 29233-flow-region-shape-publish.md} (55%) diff --git a/.changeset/29233-flow-region-malformed-publish.md b/.changeset/29233-flow-region-shape-publish.md similarity index 55% rename from .changeset/29233-flow-region-malformed-publish.md rename to .changeset/29233-flow-region-shape-publish.md index 79a0f4ad8b0..0fd128200b7 100644 --- a/.changeset/29233-flow-region-malformed-publish.md +++ b/.changeset/29233-flow-region-shape-publish.md @@ -1,5 +1,5 @@ --- -'@objectstack/metadata-protocol': minor +'@objectstack/lint': minor --- **BREAKING** fix(metadata)!: a flow whose loop, parallel or try_catch region is malformed is refused at publish, and its draft read says so, instead of publishing and then never registering @@ -8,13 +8,13 @@ Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — no published skill teaches that a malformed region publishes; the automation skill's region rules (one entry, one exit, acyclic) stay true, and the publish door now refuses a region that breaks them earlier, in the engine's words. - + -**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`). +**BREAKING** accept-set narrowing at the runtime authoring gate, 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`). The `@objectstack/lint` authoring rule `validateFlowRegionShape` (`flow-region-shape-invalid`), which `os validate`, `os build` and `os lint` already run, now also runs on that gate for `flow` writes (`surfaces: CLI_AND_RUNTIME`, `runtimeTypes: ['flow']`). - **Before.** A flow whose ADR-0031 region was malformed (a `loop` body, a `parallel` branch or a `try_catch` try or catch region, at any depth, with no single entry or exit node, a cycle inside it, a shape its region schema refuses, or a `parallel` block with fewer than two branches) saved as a draft, its draft read answered `_diagnostics: { valid: true }`, and it published (`outcome: 'published'`). The automation engine then refused to register it ("loop 'lp' body: region has no entry node …; region contains a cycle (region bodies must be acyclic)"), so the flow never ran, and only the by-name read's `registration` and a boot-log warning said why. -- **Now.** The draft still saves (drafts are never gated). Its read answers `valid: false` with an error coded `flow-region-malformed` on the container node whose region is at fault (`nodes.N`, or the nested path such as `nodes.1.config.try.nodes.0`), 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. -- **One judge.** The gate calls `validateControlFlow` (`@objectstack/spec/automation`), the function the engine's registration calls, on the body parsed through `FlowSchema` as the engine parses it. A body the flow schema refuses is reported by the schema, as before. -- **Unchanged.** A flow whose regions the engine accepts publishes and registers, as before. `os validate` and `os build` do not run this check yet. +- **Now.** The draft still saves (drafts are never gated). Its read answers `valid: false` with an error coded `flow-region-shape-invalid`, and every publish door refuses it `422 INVALID_METADATA` with that finding; a package publish refuses the batch (`outcome: 'refused'`). The message is "Flow 'NAME' would be refused at registration: " followed by the engine's registration refusal, word for word. The finding names the flow (one per flow, the engine's first refusal); the sentence names the region. +- **One judge, one name.** The rule calls `FlowSchema`, then `validateControlFlow` (`@objectstack/spec/automation`), the two calls the engine's registration makes. A flow body the flow schema refuses is reported by this rule too ("could not be judged for its region shape"), as on the CLI. +- **Unchanged.** A flow whose regions the engine accepts publishes and registers, as before. **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 region and what is wrong with it. Give the region one entry node and one exit node with no cycle inside it, and give a `parallel` block at least two branches. A stored flow published before this change is re-registered exactly as before (still refused by the engine). diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 06a65c9ae40..488c91a8d30 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1981,11 +1981,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'parsed', commands: ALL, source: 'packages/lint/src/validate-flow-region-shape.ts', - surfaces: CLI_ONLY, - surfaceReason: - 'Not yet on the runtime publish gate: whether a flow write whose region the engine refuses is refused at ' - + 'publish is the server lane\'s open question for the metadata write path, decided there; this entry ' - + 'brings the three authoring commands to the engine\'s registration verdict.', + // [#29233] Runtime publish gate (#4463): a flow whose region the engine + // refuses to register can only ever publish as a flow that never runs, so + // the draft read and every publish door refuse it here, through this one + // row, rather than through a gate-local copy of the same judgement. + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['flow'], run: (stack) => validateFlowRegionShape(stack), }, // The same predicate over a declarative connector's four header/env maps diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index 4da52465f46..4abb0603aa6 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -21,28 +21,35 @@ import { validatePredicatePathRefs } from './validate-predicate-path-refs.js'; import { validateWidgetBindings } from './validate-widget-bindings.js'; /** The issue's measured example: an approval flow whose expression approver is broken CEL. */ +// [#29233] Spelled as a body the flow schema accepts (a `type`, node labels, +// an edge list, no top-level `trigger`), since the publish gate now runs the +// region-shape rule, which refuses a flow the schema refuses. Each case below +// still isolates the one thing it pins: the approver expression. const brokenApprovalFlow = { name: 'leave_approval', label: 'Leave approval', - trigger: { type: 'record_change', object: 'leave_request', events: ['create'] }, + type: 'autolaunched', nodes: [ - { id: 'start', type: 'start' }, + { id: 'start', type: 'start', label: 'Start' }, { id: 'approve', type: 'approval', + label: 'Approve', config: { approvers: [{ type: 'expression', value: 'record.owner ==' }] }, }, ], + edges: [{ id: 'e1', source: 'start', target: 'approve' }], }; /** The same flow with an approver expression that parses and uses a legal root. */ const cleanApprovalFlow = { ...brokenApprovalFlow, nodes: [ - { id: 'start', type: 'start' }, + { id: 'start', type: 'start', label: 'Start' }, { id: 'approve', type: 'approval', + label: 'Approve', config: { approvers: [{ type: 'expression', value: 'current.owner' }], }, diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 48c2e8c76bb..509d7f93c46 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -1099,13 +1099,17 @@ describe('validateFlowTriggerReadiness', () => { config: Record, overrides: Record = {}, ): Record { + // [#29233] A label on the flow and its nodes: the runtime publish gate + // the cases below cross now runs the region-shape rule, which refuses a + // body the flow schema refuses. return { name: 'inbound_order', + label: 'Inbound order', type: 'api', status: 'active', nodes: [ - { id: 'start', type: 'start', config }, - { id: 'end', type: 'end' }, + { id: 'start', type: 'start', label: 'Start', config }, + { id: 'end', type: 'end', label: 'End' }, ], edges: [{ id: 'e1', source: 'start', target: 'end' }], ...overrides, @@ -1298,8 +1302,8 @@ describe('validateFlowTriggerReadiness', () => { // it `flows[0].nodes[1].config.secret`, the rule's own finding path. const startSecond = apiFlow({ hookId: 'intake' }, { nodes: [ - { id: 'end', type: 'end' }, - { id: 'start', type: 'start', config: { hookId: 'intake' } }, + { id: 'end', type: 'end', label: 'End' }, + { id: 'start', type: 'start', label: 'Start', config: { hookId: 'intake' } }, ], }); const at = (paths: string[]) => diff --git a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts index b3c652897b9..1c914279033 100644 --- a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts +++ b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts @@ -464,7 +464,7 @@ const CASES: Array<{ rule: string; type: string; item: { name: string } & Record // [#29233] A loop whose body closes on itself (body a → b → a, the // card's reproduction). It saved, read valid:true and PUBLISHED, and // the engine's region check then refused to register it. - rule: 'flow-region-malformed', + rule: 'flow-region-shape-invalid', type: 'flow', item: regionFlow('region_loop', { id: 'lp', @@ -614,11 +614,13 @@ describe('a draft reads the verdict its publish gives (#26707)', () => { expect(published).toMatchObject({ success: true, publishedCount: 3, failedCount: 0 }); }); - it('[#29233] the region verdict is located at the container, at its depth, on the draft read', async () => { + it('[#29233] the region verdict on the draft read is the registry rule\'s: the engine\'s sentence, at the item root', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - // A one-branch parallel block nested in a try region: the engine names - // both levels, and the finding lands on the inner container. + // A one-branch parallel block nested in a try region. The registry rule + // judges the flow as one unit (as the engine does) and anchors the + // flow itself, so the item-relative path is the item root; the + // sentence names the region. const flow = regionFlow('nested_region', { id: 'guard', type: 'try_catch', label: 'Guard', config: { @@ -633,10 +635,10 @@ describe('a draft reads the verdict its publish gives (#26707)', () => { const diagnostics = await draftDiagnostics(protocol, 'flow', flow.name); expect(diagnostics.valid).toBe(false); - expect((diagnostics.errors ?? []).filter((e) => e.code === 'flow-region-malformed')).toEqual([{ - path: 'nodes.1.config.try.nodes.0', - message: "parallel 'fan': a parallel block needs at least 2 branches", - code: 'flow-region-malformed', + expect((diagnostics.errors ?? []).filter((e) => e.code === 'flow-region-shape-invalid')).toEqual([{ + path: '', + message: "Flow 'nested_region' would be refused at registration: parallel 'fan': a parallel block needs at least 2 branches", + code: 'flow-region-shape-invalid', }]); }); diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts b/packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts index 944ec10ecd9..2a997797914 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts @@ -1,19 +1,23 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#29233] The gate-local `flow-region-malformed` rule, on the pure gate: the - * gate refuses the ADR-0031 regions the engine's registration refuses (a loop - * body, a parallel branch, a try or catch region, at any depth), with the - * sentence the engine throws, located on the container it names. The judge is - * `validateControlFlow`, the function `canonicalizeFlow` calls; every expected - * message below is that function's own answer for the case, never a - * restatement. The draft-read and publish doors over a real protocol are pinned - * in `protocol.draft-publish-verdict.test.ts`. + * [#29233] The registry rule `flow-region-shape-invalid` + * (`validateFlowRegionShape`, `@objectstack/lint`) on the pure runtime gate: + * the gate refuses the ADR-0031 regions the engine's registration refuses (a + * loop body, a parallel branch, a try or catch region, at any depth), with the + * sentence the engine throws. The judge is `validateControlFlow`, the function + * `canonicalizeFlow` calls; every expected sentence below is that function's + * own answer for the case, never a restatement. The draft-read and publish + * doors over a real protocol are pinned in + * `protocol.draft-publish-verdict.test.ts`. */ import { describe, expect, it } from 'vitest'; import { FlowSchema, validateControlFlow } from '@objectstack/spec/automation'; -import { evaluateRuntimeAuthoringGate, FLOW_REGION_MALFORMED } from './runtime-authoring-gate.js'; +import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js'; + +/** The registry rule's id (`FLOW_REGION_SHAPE_INVALID` in `@objectstack/lint`). */ +const RULE = 'flow-region-shape-invalid'; type Rec = Record; @@ -57,11 +61,14 @@ function engineRefusal(flow: unknown): string | null { function regionIssues(flow: { name: string }, state: 'active' | 'draft' = 'active') { const verdict = evaluateRuntimeAuthoringGate({ type: 'flow', name: flow.name, state, body: structuredClone(flow) }); const err = verdict.error as (Error & { code: string; status: number; issues: any[]; rulesRun: string[] }) | null; - return { err, issues: (err?.issues ?? []).filter((i) => i.rule === FLOW_REGION_MALFORMED) }; + return { err, issues: (err?.issues ?? []).filter((i) => i.rule === RULE) }; } -describe('[#29233] the publish gate refuses the regions the engine refuses', () => { - it('the card: a loop body a → b → a is refused on the loop, in the engine\'s sentence', () => { +/** The registry rule's message: the engine's refusal behind the flow's name. */ +const refusedAt = (name: string, sentence: string | null) => `Flow '${name}' would be refused at registration: ${sentence}`; + +describe('[#29233] the publish gate refuses the regions the engine refuses, through the registry rule', () => { + it('the card: a loop body a → b → a is refused, in the engine\'s sentence', () => { const flow = flowOf('rgnprobe_loop', [loop('lp', closedBody('k'))]); const sentence = engineRefusal(flow); expect(sentence).toBe( @@ -71,45 +78,41 @@ describe('[#29233] the publish gate refuses the regions the engine refuses', () ); const { err, issues } = regionIssues(flow); expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); - expect(err!.rulesRun).toContain(FLOW_REGION_MALFORMED); + expect(err!.rulesRun).toContain('validateFlowRegionShape'); expect(issues).toEqual([{ severity: 'error', - rule: FLOW_REGION_MALFORMED, - where: 'flow "rgnprobe_loop" · node "Loop lp"', - path: 'flows[0].nodes[1]', - message: sentence, + rule: RULE, + where: "flow 'rgnprobe_loop'", + path: 'flows[0]', + message: refusedAt('rgnprobe_loop', sentence), hint: expect.any(String), }]); }); - it('a parallel block with one branch is refused on the block', () => { + it('a parallel block with one branch is refused', () => { const flow = flowOf('one_branch', [{ id: 'fan', type: 'parallel', label: 'Fan', config: { branches: [oneStep('only')] } }]); expect(regionIssues(flow).issues).toEqual([ - expect.objectContaining({ path: 'flows[0].nodes[1]', message: engineRefusal(flow) }), + expect.objectContaining({ path: 'flows[0]', message: refusedAt('one_branch', engineRefusal(flow)) }), ]); }); - it('a try region that closes on itself is refused on the try_catch', () => { + it('a try region that closes on itself is refused', () => { const flow = flowOf('closed_try', [{ id: 'guard', type: 'try_catch', label: 'Guard', config: { try: closedBody('t'), catch: oneStep('c') }, }]); const sentence = engineRefusal(flow); expect(sentence).toMatch(/^try_catch 'guard' try: /); - expect(regionIssues(flow).issues).toEqual([ - expect.objectContaining({ path: 'flows[0].nodes[1]', message: sentence }), - ]); + expect(regionIssues(flow).issues).toEqual([expect.objectContaining({ message: refusedAt('closed_try', sentence) })]); }); it('a region its schema refuses is refused with the engine\'s "invalid region" sentence', () => { const flow = flowOf('shapeless', [loop('lp', { nodes: 'not a list' })]); const sentence = engineRefusal(flow); expect(sentence).toMatch(/^loop 'lp' body: invalid region — /); - expect(regionIssues(flow).issues).toEqual([ - expect.objectContaining({ path: 'flows[0].nodes[1]', message: sentence }), - ]); + expect(regionIssues(flow).issues).toEqual([expect.objectContaining({ message: refusedAt('shapeless', sentence) })]); }); - it('a malformed region nested in another names both levels and lands on the inner container', () => { + it('a malformed region nested in another is refused, its sentence naming both levels', () => { const flow = flowOf('nested', [{ id: 'guard', type: 'try_catch', label: 'Guard', config: { try: { nodes: [loop('inner', closedBody('n'))], edges: [] }, catch: oneStep('c') }, @@ -117,17 +120,15 @@ describe('[#29233] the publish gate refuses the regions the engine refuses', () const sentence = engineRefusal(flow)!; expect(sentence).toMatch(/^try_catch 'guard' try → loop 'inner' body: /); expect(regionIssues(flow).issues).toEqual([ - expect.objectContaining({ - where: 'flow "nested" · node "Loop inner"', - path: 'flows[0].nodes[1].config.try.nodes[0]', - message: sentence, - }), + expect.objectContaining({ path: 'flows[0]', message: refusedAt('nested', sentence) }), ]); }); - it('two malformed top-level containers are reported one finding each', () => { + it('two malformed containers give one finding, the engine\'s first refusal', () => { const flow = flowOf('two_bad', [loop('first', closedBody('f')), loop('second', closedBody('s'))]); - expect(regionIssues(flow).issues.map((i) => i.path)).toEqual(['flows[0].nodes[1]', 'flows[0].nodes[2]']); + const sentence = engineRefusal(flow)!; + expect(sentence).toMatch(/^loop 'first' body: /); + expect(regionIssues(flow).issues).toEqual([expect.objectContaining({ message: refusedAt('two_bad', sentence) })]); }); it('controls: a well-formed loop, parallel and try_catch region, nested too, raise nothing', () => { @@ -140,7 +141,8 @@ describe('[#29233] the publish gate refuses the regions the engine refuses', () }, ]); expect(engineRefusal(flow)).toBeNull(); - expect(regionIssues(flow).issues).toEqual([]); + const verdict = evaluateRuntimeAuthoringGate({ type: 'flow', name: flow.name, state: 'active', body: structuredClone(flow) }); + expect(((verdict.error as any)?.issues ?? []).filter((i: any) => i.rule === RULE)).toEqual([]); }); it('a draft is not gated (D1)', () => { @@ -148,10 +150,4 @@ describe('[#29233] the publish gate refuses the regions the engine refuses', () expect(evaluateRuntimeAuthoringGate({ type: 'flow', name: flow.name, state: 'draft', body: structuredClone(flow) }).error) .toBeNull(); }); - - it('a body the flow schema refuses is the schema\'s to report: no region finding', () => { - const flow = { ...flowOf('no_label', [loop('lp', closedBody('x'))]), label: undefined }; - expect(FlowSchema.safeParse(flow).success).toBe(false); - expect(regionIssues(flow).issues).toEqual([]); - }); }); diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index 5588e797742..ebbd3219079 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -61,17 +61,7 @@ import { // here rather than borrowed from `@objectstack/lint` because the runtime gate // may only reach that package through its kernel-safe `/runtime` entry (the // wiring guard's third invariant), and `walkFlowNodes` is not on it. -// -// [#29233] Beside it, the engine's region judge and the parse it reads: -// `canonicalizeFlow` (service-automation) hands `validateControlFlow` the body -// through `FlowSchema`, and so does the gate-local rule below, so the publish -// and the registration refuse the same regions. -import { - FLOW_REGION_SLOTS_BY_TYPE, - FlowSchema, - validateControlFlow, - type FlowNodeParsed, -} from '@objectstack/spec/automation'; +import { FLOW_REGION_SLOTS_BY_TYPE } from '@objectstack/spec/automation'; // [#20312] The ADR-0080 compiler itself — the function behind the CLI-only // `validateJsxPages` rule, imported rather than re-implemented, so the save // door and `os validate` judge a page's source with one compiler. Imported from @@ -613,96 +603,6 @@ export function findFlowCycleGaps(args: { type: string; name: string; body: unkn }]; } -// ───────────────────────────────────────────────────────────────────────────── -// #29233 — a flow whose loop / parallel / try_catch region is malformed. -// ───────────────────────────────────────────────────────────────────────────── - -/** - * The rule id of the finding below: the automation engine refuses to register - * a flow whose ADR-0031 region (a `loop` body, a `parallel` branch, a - * `try_catch` try or catch region, at any depth) is malformed: no single entry - * or exit node, a cycle inside the region, a region its own schema refuses, or - * a `parallel` block with fewer than two branches. - * - * ## The defect this closes - * - * The engine's refusal ran at registration only (`canonicalizeFlow`). A flow - * whose loop body closed on itself read `valid: true` as a draft, published - * through the per-item and the package doors, and was then refused - * registration, so the flow never ran and only the by-name read's - * `registration` and the boot log said why. - * - * ## Why it is gate-local - * - * The sibling of {@link FLOW_UNDECLARED_CYCLE}, for the reason given there. The - * judge is not restated: {@link validateControlFlow} (`@objectstack/spec`), the - * function the engine's registration calls, on the input the engine hands it - * (the body through `FlowSchema`), so the gate refuses exactly the regions the - * engine refuses, in the engine's words. - */ -export const FLOW_REGION_MALFORMED = 'flow-region-malformed'; - -/** The engine's refusal of these nodes' regions, or `null` when it accepts them. */ -function controlFlowRefusal(nodes: FlowNodeParsed[]): string | null { - try { - validateControlFlow({ nodes }); - return null; - } catch (e) { - return e instanceof Error ? e.message : String(e); - } -} - -/** - * Judge one about-to-be-published `flow` body for a malformed region: one - * `error` finding per top-level node whose regions the engine refuses, located - * on the innermost container the refusal names, with the engine's sentence. - * - * PURE. A body `FlowSchema` refuses is the schema's to report, so it takes no - * part here. The engine throws on the first malformed region of the whole - * flow; asking the same judge one top-level node at a time reports each - * top-level container it would refuse, each in the sentence the engine throws - * for it. Locating never judges: a nested container becomes the anchor only - * when the judge, asked about it alone, answers the tail of the same sentence - * (the engine prefixes a nested region's sentence with the path down to it). - */ -export function findFlowRegionGaps(args: { type: string; name: string; body: unknown }): RuntimeAuthoringIssue[] { - if (args.type !== 'flow' || !isRec(args.body)) return []; - const parsed = FlowSchema.safeParse(args.body); - if (!parsed.success) return []; - const flow = parsed.data; - const flowName = flow.name || args.name; - // Every node at every depth over the parsed body, pre-order. Parsing keeps - // every list's order, so each path indexes the body as the author wrote it. - const walked = walkNodes(flow as unknown as AnyRec, 'flows[0]'); - const issues: RuntimeAuthoringIssue[] = []; - flow.nodes.forEach((top, index) => { - const message = controlFlowRefusal([top]); - if (message === null) return; - const topPath = `flows[0].nodes[${index}]`; - let anchor = walked.find((w) => w.path === topPath)!; - // The containers that answer the same sentence form one chain down to - // the region at fault, and pre-order meets them outermost first. - for (const w of walked) { - if (!w.path.startsWith(`${topPath}.`)) continue; - const inner = controlFlowRefusal([w.node as unknown as FlowNodeParsed]); - if (inner !== null && (message === inner || message.endsWith(` → ${inner}`))) anchor = w; - } - issues.push({ - severity: 'error', - rule: FLOW_REGION_MALFORMED, - where: `flow "${flowName}" · node "${nodeLabel(anchor.node, anchor.index)}"`, - path: anchor.path, - message, - hint: - `Give the region one entry node, one exit node and no cycle inside it; a parallel block ` - + `needs at least two branches. The automation engine registers a flow only when every loop, ` - + `parallel and try_catch region is well formed (ADR-0031), so this flow would publish and ` - + `never run.`, - }); - }); - return issues; -} - // ───────────────────────────────────────────────────────────────────────────── // #26188 — a HIERARCHY scope on a runtime with no hierarchy resolver. // ───────────────────────────────────────────────────────────────────────────── @@ -1476,12 +1376,8 @@ export function evaluateRuntimeAuthoringGate(args: { // [#29025] The engine's registration refusal of an undeclared cycle, asked // of the same judge so the publish and the registration cannot disagree. const flowCycleGaps = findFlowCycleGaps({ type: args.type, name: args.name, body: args.body }); - // [#29233] The engine's registration refusal of a malformed region, asked - // of the same judge, on the same parse, for the same reason. - const flowRegionGaps = findFlowRegionGaps({ type: args.type, name: args.name, body: args.body }); const localIssues = [ ...flowCycleGaps, - ...flowRegionGaps, ...scheduleOrgGaps, ...packagedRuleCopyGaps, ...hierarchyScopeGaps, @@ -1544,7 +1440,7 @@ export function evaluateRuntimeAuthoringGate(args: { // here would reintroduce exactly the ambiguity it was added to remove. const rulesRun = [ ...result.rulesRun, - ...(args.type === 'flow' ? [FLOW_UNDECLARED_CYCLE, FLOW_REGION_MALFORMED, PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING] : []), + ...(args.type === 'flow' ? [FLOW_UNDECLARED_CYCLE, PLATFORM_SCHEDULE_CREATE_RECORD_ORG_MISSING] : []), ...(args.type === 'view' ? [PUBLIC_FORM_INTAKE_UNAVAILABLE] : []), ...(args.type === 'permission' && args.hierarchyResolverMounted !== undefined ? [HIERARCHY_SCOPE_CAPABILITY_REQUIRED] diff --git a/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts b/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts index ab73696bace..7361a695b80 100644 --- a/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts @@ -16,7 +16,7 @@ // ## What these cases pin // // The draft still saves (drafts are never gated). Its read answers -// `valid: false` with the `flow-region-malformed` finding on the loop node, in +// `valid: false` with the `flow-region-shape-invalid` finding on the flow, in // the engine's own sentence; `publish-drafts`, the per-item publish and a live // save each refuse it before any write. Control: a well-formed loop, parallel // and try_catch region each read valid, publish, and the engine registers them. @@ -88,7 +88,7 @@ describe('dogfood: a flow with a malformed region is refused at publish, and its expect(res.status, JSON.stringify(res.json)).toBe(422); const text = JSON.stringify(res.json); expect(text).toContain('INVALID_METADATA'); - expect(text).toContain('flow-region-malformed'); + expect(text).toContain('flow-region-shape-invalid'); }; /** No active row was written: the by-name read serves nothing under that name. */ @@ -116,14 +116,15 @@ describe('dogfood: a flow with a malformed region is refused at publish, and its expect(read.status, JSON.stringify(read.json)).toBe(200); const diagnostics = JSON.stringify(read.json); expect(diagnostics).toContain('"valid":false'); - expect(diagnostics).toContain('"code":"flow-region-malformed"'); - expect(diagnostics).toContain('"path":"nodes.1"'); - expect(diagnostics).toContain(JSON.stringify(REGION_SENTENCE)); + expect(diagnostics).toContain('"code":"flow-region-shape-invalid"'); + // The registry rule anchors the flow itself: the item root, in item-relative spelling. + expect(diagnostics).toContain('"path":""'); + expect(diagnostics).toContain(JSON.stringify(`Flow 'rgnprobe_loop' would be refused at registration: ${REGION_SENTENCE}`)); const published = await call('POST', `/packages/${PKG_BAD}/publish-drafts`, {}); expect(published.status, JSON.stringify(published.json)).toBe(200); expect(published.json?.data).toMatchObject({ outcome: 'refused', publishedCount: 0 }); - expect(JSON.stringify(published.json)).toContain('flow-region-malformed'); + expect(JSON.stringify(published.json)).toContain('flow-region-shape-invalid'); await expectNoActiveRow('rgnprobe_loop'); }, 30_000);