Skip to content

Commit e4c61a7

Browse files
authored
fix(automation,lint,spec): validate the expression slots a node's configSchema declares (#4027) (#4040)
A node type's designer `configSchema` and the config keys its validators traverse were two unreconciled lists. The engine's `registerFlow` pass and the author-time `objectstack validate` pass each hardcoded `config.condition` / `edge.condition` and assumed every other node string was a `{var}` template, so a declared expression property outside that set was validated by nobody. That is how #3528 shipped. `screen.fields[].visibleWhen` had been on the `screen` descriptor since #3304 — typed `xExpression: 'expression'` (bare CEL) and offered to authors in Studio — and neither validator traversed it. An app authored the predicate in the other dialect and it passed tsc, `objectstack validate` and registration in silence; because `required` IS enforced, a conditional field rendered unconditionally and blocked Submit on an input the user never saw. - `FLOW_NODE_EXPRESSION_PATHS` (spec) declares the expression-bearing node config paths, each recording its dialect, with a pure resolver for `fields[]` repeaters. - Both validators read it, reporting located, quoted errors. - A reconciliation ratchet derives the expression properties from the live descriptors and fails both ways: a new `xExpression` property with no ledger entry, or a stale entry no descriptor declares. Walks every builtin. Only bare-CEL slots are checked. `loop`/`map` collections are recorded as `flow-template` and skipped — braces are CORRECT in that dialect and no validator implements it, so checking them would reject every valid flow. `ActionDescriptor.configSchema` no longer claims `registerFlow()` validates `config` against it; it never did.
1 parent 7df7c64 commit e4c61a7

11 files changed

Lines changed: 787 additions & 17 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/lint': minor
5+
---
6+
7+
Validate the expression slots a flow node's `configSchema` declares (#4027).
8+
9+
A node type's designer `configSchema` and the keys its validators traverse were
10+
two unreconciled lists. Both the engine's `registerFlow` pass and the author-time
11+
`objectstack validate` pass hardcoded `config.condition` / `edge.condition` and
12+
assumed every other node string was a `{var}` template — so a declared expression
13+
property outside that hardcoded set was validated by nobody.
14+
15+
That is how #3528 shipped. `screen.fields[].visibleWhen` has been on the `screen`
16+
descriptor since #3304, typed `xExpression: 'expression'` (bare CEL) and offered
17+
to authors in Studio, but no validator traversed it. An app authored the
18+
predicate in the *other* dialect — `'{createOpportunity} == true'` — and it passed
19+
`tsc`, `objectstack validate` and registration in silence. Because `required` *is*
20+
enforced, a field the author had made conditional rendered unconditionally and
21+
blocked Submit on an input the user was never shown: the run paused forever and no
22+
resume was ever issued.
23+
24+
Now:
25+
26+
- **`FLOW_NODE_EXPRESSION_PATHS`** (`@objectstack/spec`) is the declared ledger of
27+
expression-bearing node config paths, each recording the dialect it takes.
28+
- **Both validators read it.** A malformed `visibleWhen` is a located, quoted
29+
error at `registerFlow` *and* at `objectstack validate` — `node 'screen_1'
30+
(screen) screen field visibleWhen at config.fields[1].visibleWhen`.
31+
- **A reconciliation ratchet** derives the expression properties from the live
32+
descriptors and fails CI in both directions: a new `xExpression` property with
33+
no ledger entry, or a stale entry no descriptor declares. It walks every
34+
registered builtin, not just `screen`.
35+
36+
Dialects are recorded rather than assumed because there are three, and two of them
37+
disagree about braces: bare CEL (`{…}` is the #1491 brace-trap), single-brace
38+
`{var}` flow interpolation (`{…}` is correct), and the ADR-0032 §3 double-brace
39+
text template. Only bare-CEL slots are checked — `loop.collection` and
40+
`map.collection` are recorded as `flow-template` and deliberately left alone,
41+
since no validator implements their dialect and checking them under either of the
42+
other two would reject every currently-valid flow.
43+
44+
`ActionDescriptor.configSchema`'s TSDoc no longer claims `registerFlow()`
45+
validates `config` against it. It never did: `FlowNodeSchema.config` is
46+
`z.record(z.unknown())`, so types, `required`, `enum` and unknown keys are still
47+
unenforced. The doc now states exactly what is checked and what is designer-facing
48+
only, so nothing relies on a guard that does not exist.

content/docs/references/automation/node-executor.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018)
7070
| **icon** | `string` | optional | Icon id resolved by the designer |
7171
| **category** | `Enum<'logic' \| 'data' \| 'io' \| 'human' \| 'control' \| 'custom'>` || Palette category |
7272
| **paradigms** | `Enum<'flow' \| 'approval'>[]` || Authoring surfaces that may offer this action |
73-
| **configSchema** | `any` | optional | JSON Schema for the node config (drives form + parse validation) |
73+
| **configSchema** | `any` | optional | JSON Schema for the node config (drives the designer form; only declared expression slots are validated) |
7474
| **supportsPause** | `boolean` || Supports async pause/resume |
7575
| **supportsCancellation** | `boolean` || Supports cancellation |
7676
| **supportsRetry** | `boolean` || Supports retry on failure |

packages/lint/src/validate-expressions.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,4 +521,92 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
521521

522522
// The ADR-0062 D7 `field.columnName`-on-external-objects lint was removed with
523523
// `field.columnName` itself (#2377): external column mapping is `external.columnMap`.
524+
525+
/**
526+
* Descriptor-declared expression slots (#4027). Before this, the flow walk
527+
* hardcoded `config.condition` and assumed every other node string was a
528+
* template, so `screen.fields[].visibleWhen` — declared bare CEL since #3304 —
529+
* was validated by nobody and #3528 shipped the wrong dialect through
530+
* `objectstack validate` in silence.
531+
*/
532+
describe('descriptor-declared expression slots (#4027)', () => {
533+
const screenFlow = (fields: unknown[]) => ({
534+
objects,
535+
flows: [{
536+
name: 'lead_conversion',
537+
nodes: [
538+
{ id: 'start', type: 'start', config: { objectName: 'crm_lead' } },
539+
{ id: 'screen_1', type: 'screen', config: { fields } },
540+
],
541+
edges: [],
542+
}],
543+
});
544+
545+
it('flags a `{var}` template dialect in a screen field visibleWhen', () => {
546+
// The exact predicate HotCRM shipped (#3528).
547+
const issues = validateStackExpressions(screenFlow([
548+
{ name: 'createOpportunity', type: 'boolean', required: true },
549+
{ name: 'opportunityName', type: 'text', required: true, visibleWhen: '{createOpportunity} == true' },
550+
]));
551+
const found = issues.filter(i => i.where.includes('visibleWhen'));
552+
expect(found).toHaveLength(1);
553+
expect(found[0].severity).toBe('error');
554+
expect(found[0].where).toContain("flow 'lead_conversion'");
555+
expect(found[0].where).toContain("node 'screen_1'");
556+
// Indexed into the repeater, so the author knows WHICH field.
557+
expect(found[0].where).toContain('config.fields[1].visibleWhen');
558+
expect(found[0].source).toBe('{createOpportunity} == true');
559+
});
560+
561+
it('passes the corrected bare-CEL predicate', () => {
562+
const issues = validateStackExpressions(screenFlow([
563+
{ name: 'createOpportunity', type: 'boolean', required: true, defaultValue: false },
564+
{ name: 'opportunityName', type: 'text', required: true, visibleWhen: 'createOpportunity == true' },
565+
]));
566+
expect(issues.filter(i => i.where.includes('visibleWhen'))).toHaveLength(0);
567+
});
568+
569+
it('does not report the screen field names as unknown object fields', () => {
570+
// A screen's `visibleWhen` binds the screen's OWN collected values, not the
571+
// trigger record's fields — so no schema hint is passed. Were one passed,
572+
// `createOpportunity` would be flagged as an unknown `crm_lead` field and
573+
// every correct predicate would carry a spurious finding.
574+
const issues = validateStackExpressions(screenFlow([
575+
{ name: 'createOpportunity', type: 'boolean' },
576+
{ name: 'opportunityName', visibleWhen: 'createOpportunity == true' },
577+
]));
578+
expect(issues).toHaveLength(0);
579+
});
580+
581+
it('leaves a correct single-brace loop collection alone', () => {
582+
// `loop.collection` is the single-brace `{var}` flow-interpolation dialect,
583+
// where braces are CORRECT. It is recorded in the ledger as `flow-template`
584+
// and deliberately not validated — checking it as a CEL predicate (or as an
585+
// ADR-0032 §3 double-brace template) would fail every valid flow.
586+
const issues = validateStackExpressions({
587+
objects,
588+
flows: [{
589+
name: 'loop_flow',
590+
nodes: [
591+
{ id: 'start', type: 'start', config: { objectName: 'crm_lead' } },
592+
{ id: 'loop_1', type: 'loop', config: { collection: '{tasks}', iteratorVariable: 'task' } },
593+
],
594+
edges: [],
595+
}],
596+
});
597+
expect(issues).toHaveLength(0);
598+
});
599+
600+
it('tolerates a screen with no fields, a non-array fields, and no config', () => {
601+
expect(validateStackExpressions(screenFlow([]))).toHaveLength(0);
602+
expect(validateStackExpressions({
603+
objects,
604+
flows: [{
605+
name: 'f',
606+
nodes: [{ id: 's', type: 'screen', config: { fields: 'nope' } }, { id: 's2', type: 'screen' }],
607+
edges: [],
608+
}],
609+
})).toHaveLength(0);
610+
});
611+
});
524612
});

packages/lint/src/validate-expressions.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
* from `@objectstack/formula`, so the verdict matches `registerFlow` and the
1010
* agent `validate_expression` tool exactly.
1111
*
12-
* Scope (v1): flow predicates (start/decision `config.condition` + edge
13-
* `condition`), object validation-rule / formula predicates, and UI action
14-
* `visible` / `disabled` predicates. Each error is located (flow/object/action
15-
* + node/edge/field) with a corrective message.
12+
* Scope: flow predicates (start/decision `config.condition` + edge `condition`),
13+
* every **descriptor-declared** expression slot named by
14+
* `FLOW_NODE_EXPRESSION_PATHS` (#4027 — e.g. a screen field's `visibleWhen`),
15+
* object validation-rule / formula predicates, and UI action `visible` /
16+
* `disabled` predicates. Each error is located (flow/object/action +
17+
* node/edge/field) with a corrective message.
1618
*/
1719

1820
import { validateExpression } from '@objectstack/formula';
21+
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
1922

2023
export interface ExprIssue {
2124
where: string;
@@ -111,6 +114,18 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
111114
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
112115
};
113116

117+
/**
118+
* A declared bare-CEL slot (#4027). No object schema is passed: these slots
119+
* bind the *screen's own* collected values, not the trigger record's fields, so
120+
* a field-existence pass would report every field name as unknown.
121+
*/
122+
const checkDeclaredPredicate = (where: string, raw: unknown): void => {
123+
if (raw == null) return;
124+
const res = validateExpression('predicate', raw as string | { dialect?: string; source?: string });
125+
for (const e of res.errors) issues.push({ where, message: e.message, source: e.source, severity: 'error' });
126+
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
127+
};
128+
114129
// ── Flows ──────────────────────────────────────────────────────────
115130
for (const flow of asArray(stack.flows)) {
116131
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
@@ -124,6 +139,26 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
124139
for (const node of nodes) {
125140
const cfg = (node.config ?? {}) as AnyRec;
126141
check(`flow '${flowName}' · node '${node.id}' (${node.type}) condition`, cfg.condition, objectName);
142+
143+
// Descriptor-declared expression slots (#4027). Before this, the traversal
144+
// hardcoded `condition` and assumed every other node string was a `{var}`
145+
// template — so `screen.fields[].visibleWhen`, declared bare CEL since
146+
// #3304, was validated by nobody and #3528 shipped a template-dialect
147+
// predicate through compile, validate and run time in silence.
148+
// Only `predicate` slots are checkable: `flow-template` slots take the
149+
// single-brace `{var}` dialect `interpolate()` implements, which no
150+
// validator covers (the `template` role enforces ADR-0032 §3's
151+
// double-brace text template and would reject every correct
152+
// `loop.collection`). The ledger records them regardless, so the
153+
// reconciliation ratchet still sees the marker.
154+
const nodeType = typeof node.type === 'string' ? node.type : '';
155+
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
156+
if (found.entry.role !== 'predicate') continue;
157+
checkDeclaredPredicate(
158+
`flow '${flowName}' · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
159+
found.value,
160+
);
161+
}
127162
// #1870 — a `script` node must declare a callable target (`actionType` or
128163
// `function`). A node with neither is a silent no-op that otherwise passes
129164
// build. (Function *existence* isn't checkable here — functions are code,

0 commit comments

Comments
 (0)