Skip to content

Commit 2cb755e

Browse files
committed
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 <noreply@anthropic.com>
1 parent f99a058 commit 2cb755e

8 files changed

Lines changed: 86 additions & 179 deletions
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
'@objectstack/metadata-protocol': minor
2+
'@objectstack/lint': minor
33
---
44

55
**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)
88
Metadata-protocol: narrowed
99
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.
1010

11-
<!-- 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 whose region the automation engine already refused to register, with the engine's own sentence, so a published body that could never run has nothing a migration could rewrite it to: the author's fix is the one the refusal names. -->
11+
<!-- 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. -->
1212

13-
**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`).
13+
**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']`).
1414

1515
- **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.
16-
- **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.
17-
- **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.
18-
- **Unchanged.** A flow whose regions the engine accepts publishes and registers, as before. `os validate` and `os build` do not run this check yet.
16+
- **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.
17+
- **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.
18+
- **Unchanged.** A flow whose regions the engine accepts publishes and registers, as before.
1919

2020
**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).

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,11 +1981,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
19811981
input: 'parsed',
19821982
commands: ALL,
19831983
source: 'packages/lint/src/validate-flow-region-shape.ts',
1984-
surfaces: CLI_ONLY,
1985-
surfaceReason:
1986-
'Not yet on the runtime publish gate: whether a flow write whose region the engine refuses is refused at '
1987-
+ 'publish is the server lane\'s open question for the metadata write path, decided there; this entry '
1988-
+ 'brings the three authoring commands to the engine\'s registration verdict.',
1984+
// [#29233] Runtime publish gate (#4463): a flow whose region the engine
1985+
// refuses to register can only ever publish as a flow that never runs, so
1986+
// the draft read and every publish door refuse it here, through this one
1987+
// row, rather than through a gate-local copy of the same judgement.
1988+
surfaces: CLI_AND_RUNTIME,
1989+
runtimeTypes: ['flow'],
19891990
run: (stack) => validateFlowRegionShape(stack),
19901991
},
19911992
// The same predicate over a declarative connector's four header/env maps

‎packages/lint/src/runtime-gate.test.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,35 @@ import { validatePredicatePathRefs } from './validate-predicate-path-refs.js';
2121
import { validateWidgetBindings } from './validate-widget-bindings.js';
2222

2323
/** The issue's measured example: an approval flow whose expression approver is broken CEL. */
24+
// [#29233] Spelled as a body the flow schema accepts (a `type`, node labels,
25+
// an edge list, no top-level `trigger`), since the publish gate now runs the
26+
// region-shape rule, which refuses a flow the schema refuses. Each case below
27+
// still isolates the one thing it pins: the approver expression.
2428
const brokenApprovalFlow = {
2529
name: 'leave_approval',
2630
label: 'Leave approval',
27-
trigger: { type: 'record_change', object: 'leave_request', events: ['create'] },
31+
type: 'autolaunched',
2832
nodes: [
29-
{ id: 'start', type: 'start' },
33+
{ id: 'start', type: 'start', label: 'Start' },
3034
{
3135
id: 'approve',
3236
type: 'approval',
37+
label: 'Approve',
3338
config: { approvers: [{ type: 'expression', value: 'record.owner ==' }] },
3439
},
3540
],
41+
edges: [{ id: 'e1', source: 'start', target: 'approve' }],
3642
};
3743

3844
/** The same flow with an approver expression that parses and uses a legal root. */
3945
const cleanApprovalFlow = {
4046
...brokenApprovalFlow,
4147
nodes: [
42-
{ id: 'start', type: 'start' },
48+
{ id: 'start', type: 'start', label: 'Start' },
4349
{
4450
id: 'approve',
4551
type: 'approval',
52+
label: 'Approve',
4653
config: {
4754
approvers: [{ type: 'expression', value: 'current.owner' }],
4855
},

‎packages/lint/src/validate-flow-trigger-readiness.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,13 +1099,17 @@ describe('validateFlowTriggerReadiness', () => {
10991099
config: Record<string, unknown>,
11001100
overrides: Record<string, unknown> = {},
11011101
): Record<string, unknown> {
1102+
// [#29233] A label on the flow and its nodes: the runtime publish gate
1103+
// the cases below cross now runs the region-shape rule, which refuses a
1104+
// body the flow schema refuses.
11021105
return {
11031106
name: 'inbound_order',
1107+
label: 'Inbound order',
11041108
type: 'api',
11051109
status: 'active',
11061110
nodes: [
1107-
{ id: 'start', type: 'start', config },
1108-
{ id: 'end', type: 'end' },
1111+
{ id: 'start', type: 'start', label: 'Start', config },
1112+
{ id: 'end', type: 'end', label: 'End' },
11091113
],
11101114
edges: [{ id: 'e1', source: 'start', target: 'end' }],
11111115
...overrides,
@@ -1298,8 +1302,8 @@ describe('validateFlowTriggerReadiness', () => {
12981302
// it `flows[0].nodes[1].config.secret`, the rule's own finding path.
12991303
const startSecond = apiFlow({ hookId: 'intake' }, {
13001304
nodes: [
1301-
{ id: 'end', type: 'end' },
1302-
{ id: 'start', type: 'start', config: { hookId: 'intake' } },
1305+
{ id: 'end', type: 'end', label: 'End' },
1306+
{ id: 'start', type: 'start', label: 'Start', config: { hookId: 'intake' } },
13031307
],
13041308
});
13051309
const at = (paths: string[]) =>

‎packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ const CASES: Array<{ rule: string; type: string; item: { name: string } & Record
464464
// [#29233] A loop whose body closes on itself (body a → b → a, the
465465
// card's reproduction). It saved, read valid:true and PUBLISHED, and
466466
// the engine's region check then refused to register it.
467-
rule: 'flow-region-malformed',
467+
rule: 'flow-region-shape-invalid',
468468
type: 'flow',
469469
item: regionFlow('region_loop', {
470470
id: 'lp',
@@ -614,11 +614,13 @@ describe('a draft reads the verdict its publish gives (#26707)', () => {
614614
expect(published).toMatchObject({ success: true, publishedCount: 3, failedCount: 0 });
615615
});
616616

617-
it('[#29233] the region verdict is located at the container, at its depth, on the draft read', async () => {
617+
it('[#29233] the region verdict on the draft read is the registry rule\'s: the engine\'s sentence, at the item root', async () => {
618618
const { engine } = makeStubEngine();
619619
const protocol = new ObjectStackProtocolImplementation(engine);
620-
// A one-branch parallel block nested in a try region: the engine names
621-
// both levels, and the finding lands on the inner container.
620+
// A one-branch parallel block nested in a try region. The registry rule
621+
// judges the flow as one unit (as the engine does) and anchors the
622+
// flow itself, so the item-relative path is the item root; the
623+
// sentence names the region.
622624
const flow = regionFlow('nested_region', {
623625
id: 'guard', type: 'try_catch', label: 'Guard',
624626
config: {
@@ -633,10 +635,10 @@ describe('a draft reads the verdict its publish gives (#26707)', () => {
633635

634636
const diagnostics = await draftDiagnostics(protocol, 'flow', flow.name);
635637
expect(diagnostics.valid).toBe(false);
636-
expect((diagnostics.errors ?? []).filter((e) => e.code === 'flow-region-malformed')).toEqual([{
637-
path: 'nodes.1.config.try.nodes.0',
638-
message: "parallel 'fan': a parallel block needs at least 2 branches",
639-
code: 'flow-region-malformed',
638+
expect((diagnostics.errors ?? []).filter((e) => e.code === 'flow-region-shape-invalid')).toEqual([{
639+
path: '',
640+
message: "Flow 'nested_region' would be refused at registration: parallel 'fan': a parallel block needs at least 2 branches",
641+
code: 'flow-region-shape-invalid',
640642
}]);
641643
});
642644

‎packages/metadata-protocol/src/runtime-authoring-gate.flow-region.test.ts‎

Lines changed: 37 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* [#29233] The gate-local `flow-region-malformed` rule, on the pure gate: the
5-
* gate refuses the ADR-0031 regions the engine's registration refuses (a loop
6-
* body, a parallel branch, a try or catch region, at any depth), with the
7-
* sentence the engine throws, located on the container it names. The judge is
8-
* `validateControlFlow`, the function `canonicalizeFlow` calls; every expected
9-
* message below is that function's own answer for the case, never a
10-
* restatement. The draft-read and publish doors over a real protocol are pinned
11-
* in `protocol.draft-publish-verdict.test.ts`.
4+
* [#29233] The registry rule `flow-region-shape-invalid`
5+
* (`validateFlowRegionShape`, `@objectstack/lint`) on the pure runtime gate:
6+
* the gate refuses the ADR-0031 regions the engine's registration refuses (a
7+
* loop body, a parallel branch, a try or catch region, at any depth), with the
8+
* sentence the engine throws. The judge is `validateControlFlow`, the function
9+
* `canonicalizeFlow` calls; every expected sentence below is that function's
10+
* own answer for the case, never a restatement. The draft-read and publish
11+
* doors over a real protocol are pinned in
12+
* `protocol.draft-publish-verdict.test.ts`.
1213
*/
1314

1415
import { describe, expect, it } from 'vitest';
1516
import { FlowSchema, validateControlFlow } from '@objectstack/spec/automation';
16-
import { evaluateRuntimeAuthoringGate, FLOW_REGION_MALFORMED } from './runtime-authoring-gate.js';
17+
import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js';
18+
19+
/** The registry rule's id (`FLOW_REGION_SHAPE_INVALID` in `@objectstack/lint`). */
20+
const RULE = 'flow-region-shape-invalid';
1721

1822
type Rec = Record<string, unknown>;
1923

@@ -57,11 +61,14 @@ function engineRefusal(flow: unknown): string | null {
5761
function regionIssues(flow: { name: string }, state: 'active' | 'draft' = 'active') {
5862
const verdict = evaluateRuntimeAuthoringGate({ type: 'flow', name: flow.name, state, body: structuredClone(flow) });
5963
const err = verdict.error as (Error & { code: string; status: number; issues: any[]; rulesRun: string[] }) | null;
60-
return { err, issues: (err?.issues ?? []).filter((i) => i.rule === FLOW_REGION_MALFORMED) };
64+
return { err, issues: (err?.issues ?? []).filter((i) => i.rule === RULE) };
6165
}
6266

63-
describe('[#29233] the publish gate refuses the regions the engine refuses', () => {
64-
it('the card: a loop body a → b → a is refused on the loop, in the engine\'s sentence', () => {
67+
/** The registry rule's message: the engine's refusal behind the flow's name. */
68+
const refusedAt = (name: string, sentence: string | null) => `Flow '${name}' would be refused at registration: ${sentence}`;
69+
70+
describe('[#29233] the publish gate refuses the regions the engine refuses, through the registry rule', () => {
71+
it('the card: a loop body a → b → a is refused, in the engine\'s sentence', () => {
6572
const flow = flowOf('rgnprobe_loop', [loop('lp', closedBody('k'))]);
6673
const sentence = engineRefusal(flow);
6774
expect(sentence).toBe(
@@ -71,63 +78,57 @@ describe('[#29233] the publish gate refuses the regions the engine refuses', ()
7178
);
7279
const { err, issues } = regionIssues(flow);
7380
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
74-
expect(err!.rulesRun).toContain(FLOW_REGION_MALFORMED);
81+
expect(err!.rulesRun).toContain('validateFlowRegionShape');
7582
expect(issues).toEqual([{
7683
severity: 'error',
77-
rule: FLOW_REGION_MALFORMED,
78-
where: 'flow "rgnprobe_loop" · node "Loop lp"',
79-
path: 'flows[0].nodes[1]',
80-
message: sentence,
84+
rule: RULE,
85+
where: "flow 'rgnprobe_loop'",
86+
path: 'flows[0]',
87+
message: refusedAt('rgnprobe_loop', sentence),
8188
hint: expect.any(String),
8289
}]);
8390
});
8491

85-
it('a parallel block with one branch is refused on the block', () => {
92+
it('a parallel block with one branch is refused', () => {
8693
const flow = flowOf('one_branch', [{ id: 'fan', type: 'parallel', label: 'Fan', config: { branches: [oneStep('only')] } }]);
8794
expect(regionIssues(flow).issues).toEqual([
88-
expect.objectContaining({ path: 'flows[0].nodes[1]', message: engineRefusal(flow) }),
95+
expect.objectContaining({ path: 'flows[0]', message: refusedAt('one_branch', engineRefusal(flow)) }),
8996
]);
9097
});
9198

92-
it('a try region that closes on itself is refused on the try_catch', () => {
99+
it('a try region that closes on itself is refused', () => {
93100
const flow = flowOf('closed_try', [{
94101
id: 'guard', type: 'try_catch', label: 'Guard', config: { try: closedBody('t'), catch: oneStep('c') },
95102
}]);
96103
const sentence = engineRefusal(flow);
97104
expect(sentence).toMatch(/^try_catch 'guard' try: /);
98-
expect(regionIssues(flow).issues).toEqual([
99-
expect.objectContaining({ path: 'flows[0].nodes[1]', message: sentence }),
100-
]);
105+
expect(regionIssues(flow).issues).toEqual([expect.objectContaining({ message: refusedAt('closed_try', sentence) })]);
101106
});
102107

103108
it('a region its schema refuses is refused with the engine\'s "invalid region" sentence', () => {
104109
const flow = flowOf('shapeless', [loop('lp', { nodes: 'not a list' })]);
105110
const sentence = engineRefusal(flow);
106111
expect(sentence).toMatch(/^loop 'lp' body: invalid region — /);
107-
expect(regionIssues(flow).issues).toEqual([
108-
expect.objectContaining({ path: 'flows[0].nodes[1]', message: sentence }),
109-
]);
112+
expect(regionIssues(flow).issues).toEqual([expect.objectContaining({ message: refusedAt('shapeless', sentence) })]);
110113
});
111114

112-
it('a malformed region nested in another names both levels and lands on the inner container', () => {
115+
it('a malformed region nested in another is refused, its sentence naming both levels', () => {
113116
const flow = flowOf('nested', [{
114117
id: 'guard', type: 'try_catch', label: 'Guard',
115118
config: { try: { nodes: [loop('inner', closedBody('n'))], edges: [] }, catch: oneStep('c') },
116119
}]);
117120
const sentence = engineRefusal(flow)!;
118121
expect(sentence).toMatch(/^try_catch 'guard' try → loop 'inner' body: /);
119122
expect(regionIssues(flow).issues).toEqual([
120-
expect.objectContaining({
121-
where: 'flow "nested" · node "Loop inner"',
122-
path: 'flows[0].nodes[1].config.try.nodes[0]',
123-
message: sentence,
124-
}),
123+
expect.objectContaining({ path: 'flows[0]', message: refusedAt('nested', sentence) }),
125124
]);
126125
});
127126

128-
it('two malformed top-level containers are reported one finding each', () => {
127+
it('two malformed containers give one finding, the engine\'s first refusal', () => {
129128
const flow = flowOf('two_bad', [loop('first', closedBody('f')), loop('second', closedBody('s'))]);
130-
expect(regionIssues(flow).issues.map((i) => i.path)).toEqual(['flows[0].nodes[1]', 'flows[0].nodes[2]']);
129+
const sentence = engineRefusal(flow)!;
130+
expect(sentence).toMatch(/^loop 'first' body: /);
131+
expect(regionIssues(flow).issues).toEqual([expect.objectContaining({ message: refusedAt('two_bad', sentence) })]);
131132
});
132133

133134
it('controls: a well-formed loop, parallel and try_catch region, nested too, raise nothing', () => {
@@ -140,18 +141,13 @@ describe('[#29233] the publish gate refuses the regions the engine refuses', ()
140141
},
141142
]);
142143
expect(engineRefusal(flow)).toBeNull();
143-
expect(regionIssues(flow).issues).toEqual([]);
144+
const verdict = evaluateRuntimeAuthoringGate({ type: 'flow', name: flow.name, state: 'active', body: structuredClone(flow) });
145+
expect(((verdict.error as any)?.issues ?? []).filter((i: any) => i.rule === RULE)).toEqual([]);
144146
});
145147

146148
it('a draft is not gated (D1)', () => {
147149
const flow = flowOf('draft_loop', [loop('lp', closedBody('d'))]);
148150
expect(evaluateRuntimeAuthoringGate({ type: 'flow', name: flow.name, state: 'draft', body: structuredClone(flow) }).error)
149151
.toBeNull();
150152
});
151-
152-
it('a body the flow schema refuses is the schema\'s to report: no region finding', () => {
153-
const flow = { ...flowOf('no_label', [loop('lp', closedBody('x'))]), label: undefined };
154-
expect(FlowSchema.safeParse(flow).success).toBe(false);
155-
expect(regionIssues(flow).issues).toEqual([]);
156-
});
157153
});

0 commit comments

Comments
 (0)