diff --git a/.changeset/29233-flow-region-shape-publish.md b/.changeset/29233-flow-region-shape-publish.md new file mode 100644 index 00000000000..0fd128200b7 --- /dev/null +++ b/.changeset/29233-flow-region-shape-publish.md @@ -0,0 +1,20 @@ +--- +'@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 + +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, 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-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 bb4f8732a51..1c914279033 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-shape-invalid', + 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,58 @@ 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 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 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: { + 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-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', + }]); + }); + 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..2a997797914 --- /dev/null +++ b/packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#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 } 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; + +/** 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 === RULE) }; +} + +/** 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( + "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('validateFlowRegionShape'); + expect(issues).toEqual([{ + severity: 'error', + 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', () => { + 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]', message: refusedAt('one_branch', engineRefusal(flow)) }), + ]); + }); + + 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({ 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({ message: refusedAt('shapeless', sentence) })]); + }); + + 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') }, + }]); + const sentence = engineRefusal(flow)!; + expect(sentence).toMatch(/^try_catch 'guard' try → loop 'inner' body: /); + expect(regionIssues(flow).issues).toEqual([ + expect.objectContaining({ path: 'flows[0]', message: refusedAt('nested', sentence) }), + ]); + }); + + 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'))]); + 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', () => { + 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(); + 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)', () => { + const flow = flowOf('draft_loop', [loop('lp', closedBody('d'))]); + expect(evaluateRuntimeAuthoringGate({ type: 'flow', name: flow.name, state: 'draft', body: structuredClone(flow) }).error) + .toBeNull(); + }); +}); 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..7361a695b80 --- /dev/null +++ b/packages/qa/dogfood/test/flow-region-malformed-publish.dogfood.test.ts @@ -0,0 +1,181 @@ +// 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-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. + +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-shape-invalid'); + }; + + /** 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-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-shape-invalid'); + 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')); + 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'); + }, 30_000); + + 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"}'); + } + // 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); +});