Skip to content
20 changes: 20 additions & 0 deletions .changeset/29233-flow-region-shape-publish.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped. The publish doors now refuse a flow body the automation engine already refuses to register, with the engine's own sentence, so a published body that could never run has nothing a migration could rewrite it to: the author's fix is the one the refusal names. -->

**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).
11 changes: 6 additions & 5 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions packages/lint/src/runtime-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
},
Expand Down
12 changes: 8 additions & 4 deletions packages/lint/src/validate-flow-trigger-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1099,13 +1099,17 @@ describe('validateFlowTriggerReadiness', () => {
config: Record<string, unknown>,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
// [#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,
Expand Down Expand Up @@ -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[]) =>
Expand Down
101 changes: 101 additions & 0 deletions packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
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' });
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading