Skip to content

Commit 7dc45eb

Browse files
fix(spec)!: refuse a flow node config its executor cannot run — a required key left out, or a decision branch list it cannot read — at all three doors (#20316) (#20416)
Fixes #20316 Clause-②: no (narrowing) A flow node config its executor cannot run is now refused where the flow is built, at all three build doors (`FlowSchema.parse`, `AutomationEngine.registerFlow`, `objectstack validate`), by ONE judge: `flowNodeConfigRefusals` in `@objectstack/spec/automation`. It covers the whole family; #20317 is folded into this card: - **a key a builtin's executor contract requires, left out** — the census below, `loop.collection` (#20317) and `map.collection` among them; - **a `decision` branch list its executor cannot read** — a branch with no `label` (the card's shape), a branch that is not an object, and `conditions` that is not an array. ## What was wrong, measured on `origin/main` `a88a1bb399` before the change Probe scripts drive door 1 (`FlowSchema.safeParse`, spec `dist`), door 2 (`AutomationEngine.registerFlow` with `installBuiltinNodes`, then `execute`), and door 3 (the BUILT CLI, `node packages/cli/bin/run.js validate --json` in a stack directory holding one `objectstack.config.ts`). The run leg swaps the two marker nodes `y` / `x` for a recording executor, so the route taken is observable. The decision has out-edges `d → y` labelled `yes` and `d → x` with `isDefault: true`. | lit control | door 1 | door 2 | door 3 | run | |:--|:--|:--|:--|:--| | (a) decision branch `{ expression: 'true' }`, no `label` | `success=true` | registered | `valid: true`, exit 0 | `success=true`, visited `["x","y"]`: EVERY out-edge | | (a) control: `{ label: 'yes', expression: 'true' }` | `success=true` | registered | `valid: true`, exit 0 | `success=true`, visited `["y"]` | | (b) `conditions: ['true']` | `success=true` | registered | `valid: true`, exit 0 | `success=false`, `condition evaluation error: A structural condition …` | | (c) `loop` with `iteratorVariable` and a one-node body, no `collection` | `success=true` | registered | `valid: true`, exit 0 | `success=false`, `loop 'l': config does not satisfy the loop contract — config.collection: Invalid input` | | (c) control: the same loop with `collection: [1, 2]` | `success=true` | registered | `valid: true`, exit 0 | `success=true`, the body ran twice | | (d) `map` with `flowName`, no `collection` | `success=true` | registered | `valid: true`, exit 0 | `success=false`, `map 'm': config does not satisfy the map contract — config.collection: Invalid input` | Further shapes measured on the run leg only: `conditions: {}` and `conditions: 5` fail with `conditions is not iterable`; `conditions: 'x'` fails with a condition evaluation error, because a string is iterated character by character; a `null` branch fails with `Cannot read properties of null`; `label: null`, `''`, `0`, `' '` and `42` all run green down both out-edges. The last two also log the engine's unclaimed-label warn. `conditions: null` routes like no `conditions` at all, which is legal and stays admitted. ## After, measured on the merged branch (spec `dist` built from this head; the CLI reads it at run time) | lit control | door 1 | door 2 | door 3 | |:--|:--|:--|:--| | (a) no `label` | `custom` at `nodes.1.config.conditions.0.label` | throws the same issue | `valid: false`, exit 1, `custom` at `flows.0.nodes.1.config.conditions.0.label` | | (a) control | `success=true` | registered, run visits `["y"]` | `valid: true`, exit 0 | | (b) `conditions: ['true']` | `custom` at `nodes.1.config.conditions.0` | throws the same issue | `valid: false`, exit 1, same path | | (c) loop, no `collection` | `custom` at `nodes.1.config.collection` | throws the same issue | `valid: false`, exit 1, same path | | (c) control | `success=true` | registered, body ran twice | `valid: true`, exit 0 | | (d) map, no `collection` | `custom` at `nodes.1.config.collection` | throws the same issue | `valid: false`, exit 1, same path | ## The fix: one judge, three doors - `packages/spec/src/automation/flow-node-config-refusals.ts` (new). It lives beside the walk file, not inside it, because it reads the executor contracts, and two of their modules import `flow-node-expression-paths.ts`. A static import there closed a cycle that read `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` mid-evaluation (measured: `z.toJSONSchema(undefined)` in 4 spec tests). - `getBuiltinNodeConfigContracts()` maps a node type to the very schema its executor hands `parseNodeConfig`, plus `loop`'s parse condition (a legacy flat-graph `loop` with no `body` is not parsed, so it stays exempt). It is built on first use, never at module load. - `flowNodeConfigRefusals(nodeType, config)` has two arms: - **Contract arm.** It parses `config ?? {}` against the contract, on the executor's own condition, and keeps an issue ONLY where the key it names is absent from what was authored. A present value of the wrong type, or an undeclared key, stays where it is judged today. It also skips issues inside an ADR-0031 region (walked as its own graph) and inside a ledger `value` slot (`fields.*`, `assignments.*`), where a malformed envelope is the value-envelope pass's finding. A plain requirement gets `node-config-key-missing` with a prescription naming the key and the node type. A requirement a rule of the contract states gets `node-config-key-required-by-rule`, carrying the contract's own message: a `notify` with no `template` needs a `title`, and a `lookup` screen field needs its `reference`. - **Decision arm.** `decision` is parsed by nothing at run time, so this arm states what its executor reads: `conditions` present and not `null` is an array (`decision-conditions-not-array`); every branch is an object (`decision-branch-not-object`); every branch's `label` is a non-blank string (`decision-branch-label-missing`, with `found`: `absent`, `null`, `blank`, or the kind). - `packages/spec/src/automation/flow-node-expression-paths.ts`: the five new codes join `FLOW_SLOT_REFUSAL_CODES` and `FlowSlotRefusalParams` in #20352's shape (a stable kebab-case `code` and typed `params` beside the message). `FlowNodeConfigRefusal` adds the `path` inside `config`. One walk fix: an ARRAY element is no longer read as an object missing its slot, so `conditions: [['true']]` gets one refusal (a branch that is not an object), not also a missing `expression`. - `packages/spec/src/automation/flow.zod.ts`: a `FlowSchema.superRefine` block calls the judge for every node, walked with `collectFlowGraphs`, so a node in a region body is anchored where the author wrote it. It is a presence rule, not a key-set closure. - `registerFlow`: no engine change. It parses first (`canonicalizeStoredFlow` → `FlowSchema.parse`), after the ADR-0087 conversions, so the parse's issue is what it throws. - `packages/lint/src/validate-expressions.ts`: `validateStackExpressions` calls the same judge, for a stack handed to it with no parse in front. One exception: a `script`'s absent `function` stays the existing callable check's finding. That check reads the pre-conversion spellings this pass may be handed (the `functionName` alias, the retired dispatch keys) and names each. **Route choices.** 1. **The judge parses the executor's contract and keeps absences. It is not a hand-written table of required keys.** Business need: the census is 18 keys over 13 node types, and the contract already states every one of them, rule-required keys included. Long-term design: a table beside the contracts would be a second statement of them, free to drift; the ratchet below reads the executors' own parse calls instead. Guarding AI authors: both forms refuse loudly, but this one uses the contract's own words where the contract has them. No scope growth: keeping absence only leaves every other contract finding exactly where it was. 2. **The label rule is "a non-blank string", wider than "the key is absent".** Every one of those values sends the matched branch down every out-edge (measured above), and the repo already has one notion of blank (`NON_BLANK_STRING`). Refusing only the absent key would leave `label: ''`, which misroutes identically. 3. **A non-array `conditions` is refused as well as a non-object branch.** It is the container of the same branch walk, and every non-null non-array value fails the run (measured above). ## Acceptance notes ### Family census: every key a contract-parsing builtin's executor requires Enumerated from the code: every `parseNodeConfig(…)` call under `service-automation/src/builtin/`, cross-checked against the expression-path ledger's required-key reconciliation. The channels require `loop.collection` and `map.collection`, which that ratchet had recorded as "no door refuses their absence". It now asserts the judge refuses both. Door status was measured by the probes above on `a88a1bb399` (before) and on this branch (after), each row beside its whole-config accept control, which is admitted at every door both before and after. | node kind | key left out | runtime reader | before: parse / register / validate | after: all three doors | |:--|:--|:--|:--|:--| | `get_record` | `objectName` | `crud-nodes.ts:264` | admit / admit / admit | refused at `config.objectName` | | `create_record` | `objectName` | `crud-nodes.ts:335` | admit / admit / admit | refused | | `update_record` | `objectName` | `crud-nodes.ts:484` | admit / admit / admit | refused | | `delete_record` | `objectName` | `crud-nodes.ts:578` | admit / admit / admit | refused | | `notify` | `recipients` | `notify-node.ts:250` | admit / admit / admit | refused | | `notify` | `title` (no `template`; rule) | `notify-node.ts:250` | admit / admit / admit | refused, in the contract's words | | `http` | `url` | `http-nodes.ts:112` (parsed after interpolation; interpolation never adds a key) | admit / admit / admit | refused | | `screen` | `fields[i].name` | `screen-nodes.ts:158` | admit / admit / admit | refused at `config.fields.i.name` | | `screen` | `fields[i].options[j].value` / `.label` | `screen-nodes.ts:158` | admit / admit / admit | refused | | `screen` | `fields[i].reference` on a `lookup` field (rule) | `screen-nodes.ts:158` | NOT MEASURED before (same arm) | refused, in the contract's words (spec pin) | | `script` | `function` | `screen-nodes.ts:321` | admit / admit / REFUSE (the lint callable check) | refused at all three | | `subflow` | `flowName` | `subflow-node.ts:71` | admit / admit / admit | refused | | `map` | `collection` | `map-node.ts:96` | admit / admit / admit | refused | | `map` | `flowName` | `map-node.ts:96` | admit / admit / admit | refused | | `loop` (with `body`) | `collection` | `loop-node.ts:85` (the no-`body` form returns at `:72` unparsed) | admit / admit / admit | refused | | `parallel` | `branches` | `parallel-node.ts:68` | admit / admit / admit | refused | | `try_catch` | `try` | `try-catch-node.ts:100` | admit / admit / admit | refused | | `decision` | a branch `label` (absent, `null`, blank, non-text) | `logic-nodes.ts:48-74` (`branchLabel: cond.label`) | admit / admit / admit | refused at `config.conditions.i.label` | | `decision` | a non-object branch; a non-array `conditions` | `logic-nodes.ts:48-51` | admit / admit / admit | refused | Controls that stay admitted: a legacy `loop` with no `body` and no `collection` (it still runs); a decision with no `conditions`, `conditions: null` or `[]`; `assignment`, `wait` and plugin node types; a present value of the wrong type (`objectName: 42`); an undeclared key. Outside this census's definition (no `parseNodeConfig`), measured, **not fixed here**: `connector_action` with no `connectorConfig` is admitted at all three doors and refused at run (`connectorConfig.connectorId and .actionId are required`). It belongs to the same family, but its input is a FlowNode sibling block, like `wait`'s `waitEventConfig`, which `FlowSchema` already requires. Reported to the seat. ### Producer census - `examples/**` at this head: app-crm 1 flow, 8 nodes; app-showcase 30 flows, 139 nodes; app-todo 4 flows, 26 nodes. **0 refusals.** app-multi-package declares no flows. - `packages/**`, non-test: no default flow carries a judged node. The Studio create seed for `flow` has `nodes: []`. - objectui at the `.objectui-sha` pin `f8a9d0fb` (not edited): - `FlowObjectListField` `rowsToList` drops a blank cell. So a decision branch row with an empty **Label** cell is written as `{ expression }`, which is now refused (`decision-branch-label-missing`). The same writer serves the screen **Fields** repeater, so a field row with an empty **Name** cell is written without `name`, also refused. - `previews/flow-canvas-parts.tsx` `defaultNodeExtras` seeds a new node with no config, or a partial one: `http` gets `{ method: 'GET' }` (no `url`), `notify` gets `{ channels, recipients: [] }` (no `title`), and CRUD / `script` / `subflow` / `map` / `parallel` / `try_catch` get nothing. So a node added and saved before it is configured is now a loud save error. The designer's live Zod pass (`clientValidation.ts` → `FlowSchema`) will locate it at the node's config path once objectui takes this spec. - cloud: NOT MEASURED (no checkout in this container; the dispatch named examples, packages and objectui). ### Fixture triage (disposition per fixture, never a batch rename) - **Completed** (the fixture was never runnable; it now carries the key its executor requires): skeleton flows in `spec` `flow.test.ts`, `region-normalization.test.ts`, `flow-region-pause-and-end.test.ts`, `api/zod-issues-to-fields.test.ts`; `service-automation` `engine.test.ts` and 7 sibling files where `script` / `notify` / `map` nodes stand in for mock executors; `runtime` `automation-put-post-error-parity`, `automation-register-error-class` and `automation-flow-clone`; `types` `validation-failure.test.ts`; `verify` `automation-trigger-terminal-messages.test.ts`. The two `collectFlowGraphs` scope and path pins now chain through `try` as well as `catch`, because a `try_catch` without `try` is refused. - **Re-judged in place** (the pin was about the executor's refusal of a missing key, which the build door now answers first): `config-parse.test.ts` (7), `guard-refusal-inventory.test.ts` (7 rows), `http-nodes`, `notify-node` (2), `subflow-node`. Each now asserts the door refusal, then registers the node WHOLE and strips the key from the stored flow, so the executor's guard is still pinned. `screen-nodes.test.ts`: a stored `script` carrying only retired dispatch keys no longer registers (`leaves out function`), rather than registering and refusing at run. `conversions.test.ts`: the flow parse is still blind to the tombstones, and now refuses the stripped node for its absent `function`. - The lint's `script` callable tests are unchanged. That check keeps a `script`'s `function`, as described above. ### Observed, not filed - A pre-conversion alias spelling (`object`, `flow`, `functionName`, `to` / `subject`) is refused as the canonical key's absence at a DIRECT `FlowSchema.parse` / `defineFlow()`. `registerFlow`, `objectstack validate` and `defineStack` convert first, so no measured producer meets it. Carrier: whoever retires those D2 aliases at 18 (the object alias's own docblock says it retires then); otherwise none. - A present value of the wrong type (`objectName: 42`, `collection: ''`, `function: ''` outside the lint) is still admitted at the build doors and refused at run. That is the same family's type half, which this card's direction scoped out. Reported to the seat. ## ADR-0087 - One semantic D3 entry for the family, `flow-node-config-required-keys-refused` (major 18). It uses form D: the decision is stated in words, with no tracker numbers in author-shown text. `registry.ts` is regenerated. - **No D2 conversion.** No lossless repair exists: the platform cannot know the object, URL, collection, function or out-edge label that was left out, and no value it could write would keep what the flow did. - Changeset `.changeset/20316-flow-node-config-required-keys-refused.md`: `@objectstack/spec` and `@objectstack/lint` `minor`, BREAKING, with the FROM → TO table. `service-automation` gets none, because its diff is test files only. ## Pins: each refusal at each door, with the lit control - `spec` `flow-node-config-required.test.ts` (`FlowSchema.parse`): - every census row, refused at its key with the judge's own message, beside its whole-config accept control; - the two rule-required keys, in the contract's own words; - region nesting; - the decision arm: six label values, four non-object branches, three non-array `conditions`; - controls: legacy loop, wrong type, undeclared key, value slot, `assignment` and plugin types, no-branch decisions. - `spec` `flow-slot-refusal-codes.test.ts`: one pin per new code (code, params, full message), the closed set split over three producers, a sweep that reaches every node-config code, and type-level pins. - `service-automation` `node-config-required-keys.test.ts` (`registerFlow`): - the same census, and the decision arm; - on the real executors: the refused shapes failed the run, and a matched branch with no label ran GREEN down both out-edges while its labelled twin took one. - `service-automation` `builtin/node-config-contract-ledger.test.ts`: the ratchet. The map equals every executor's `parseNodeConfig` call, schema by identity; `loop` alone parses on a condition; every builtin type is classified. - `service-automation` `config-expression-ledger.test.ts`: the cross-check described above. - `lint` `validate-expressions.test.ts` (`validateStackExpressions`): the same judge through the third door's pass. ## Ablation: the pins can fail The ablation ran once, on the committed state `7f3b9b6742`, in one `os-verify-lock` hold. It went through `scripts/ablation-replace.mjs` in wrap mode, with the restore trapped on EXIT, INT and TERM. - **Mutation.** `flowNodeConfigRefusals` returns `[]` unless a global named `ABLATION_20316_OFF` is set. The anchor went from 1 hit to 0, and the blob from `bd1d620a25b6` to `353f92191f9d`. After `pnpm --filter @objectstack/spec build`, `ablation-dist-preflight.mjs @objectstack/spec ABLATION_20316_OFF` found the marker in 20 built files. - **Red, in the expected direction:** - spec, the two pin files: 41 failed, 35 passed. Every refused row at `FlowSchema.parse` went red, as did the five new-code pins and the sweep. Every accept control and CONTROL block stayed green. - service-automation, three files: 21 failed, 47 passed. Every refused census and decision row at `registerFlow` went red, as did the ledger cross-check. The accept controls, the run-time "what it did" pins and the contract-ledger ratchet stayed green. - lint `validate-expressions.test.ts`: 7 failed, 345 passed. The new rows went red; the callable-check rows stayed green. - **Restore leg.** The blob is back to `bd1d620a25b6`, equal to HEAD, and `git diff HEAD` is empty. After a rebuild, `--absent` found the marker in none of the 222 built files, and the working tree is clean. Then spec 76/76, service-automation 68/68, lint 352/352. ## Verification Branch head `8171d8537e`: `origin/main` `15bf186f50` merged as `725ffa325e`, plus test-only commits after it. Every heavy run went through `os-verify-lock`, on a closure built from the merge head (`turbo run build --filter=@objectstack/cli...`, 59/59). The box was shared. - **spec**, full suite, both projects: 593 files, 17196 passed, 1 todo. Run on `725ffa325e`; no spec source changed after it. - **service-automation**, full: 149 files, 1818 passed. Run on `725ffa325e`; no source changed after it. - **lint**, full: 113 files, 4712 passed, on `7f3b9b6742`. `validate-expressions.test.ts` again on `8171d8537e`: 352/352. - **runtime** `src/domains`: 77 files, 1436 passed, on `7f3b9b6742`. - **Smaller suites.** types: 23 files, 692 passed. verify: 15 files, 116 passed. metadata-protocol, its 10 flow files: 207 passed. plugin-approvals, its 5 flow files: 344 passed. cli `--project unit`, its flow-building files: 8 files, 112 passed. - **Typecheck** exits 0 for spec (test layer included), service-automation, lint, runtime, types and verify. - **Gates.** `dispatch-gates.mjs --commands` on `8171d8537e` derives 102 families; 100 exit 0. `--ran` accounts for all 102: 100 run, 2 NOT MEASURED. - `check:dual-build-cjs-loads`: PREREQUISITE NOT MET. 9 packages outside this diff have no `dist/`. - `check:type-check-debt`: its `--re-measure` runs a whole-tree build outside the lock. This diff adds no package. - **eslint.** `eslint --no-inline-config --format json` over this branch's 36 changed `.ts` files: 36 files, 0 errors, 0 warnings. - Population: `eslint.config.mjs` has `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, and none of the 36 files is ignored. - Invariance: the config sets no `parserOptions.project`, so no linting is type-aware, and this diff cannot move a verdict on an untouched file. - **Declared narrowing.** Consumer packages beyond these ran on their flow-bearing test files only. The rest of the consumer farm is CI's: cli integration tests, dogfood, and the whole suites of runtime, metadata-protocol and plugin-approvals. - **Behind `main`.** The branch is 7 commits behind `origin/main` `7db1332f19`. A merge-tree probe with no merge driver (a bare shared clone) merges clean. `check:migration-registry` on that probe merge reports `registry.ts` current. ## Governed surface `skills/objectstack-automation/references/_index.md` is regenerated by `gen:skill-refs` (byte-equal to the generator). `flow.zod.ts` now reaches `schemaless-node-config.zod.ts` transitively, so the index gains that one dependency row. Readings: that file 42 → 43 lines; the published catalog's `SKILL.md` total 4402 → 4402, unchanged. ## 维护者速读(草稿) - **改了什么**:流程节点的配置如果缺了执行器必需的字段(比如查询节点没写对象名、循环节点没写要遍历的集合、决策分支没写标签),现在保存或校验时就会直接报错,不再等到流程运行时才失败或走错分支。 - **为什么改**:以前这类流程能正常保存、正常发布,运行到那个节点才失败;决策分支没写标签更糟,流程会"成功"但同时走了所有分支,业务上悄悄出错。 - **风险与代价(含回滚)**:已有流程里若有这类残缺节点,升级后会被拒绝注册(启动日志会点名),需要补上字段;Studio 设计器里新加的节点在配置完成前保存会报错。回滚即撤销本 PR。 - **席位意见**: - **你要做的**:无需操作;如需确认,关注 objectui 设计器对"空标签行、未配置新节点"的保存报错体验(另立卡跟进)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2c7eef commit 7dc45eb

40 files changed

Lines changed: 1831 additions & 143 deletions
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
fix(spec)!: a flow node config its executor cannot run — a key its contract requires, left out, or a decision branch list it cannot read — is refused at authoring (#20316)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered flow-node-config-required-keys-refused -->
11+
12+
**BREAKING** — an accept-set narrowing on authored flow-node `config`, shipped as
13+
`minor` under the launch-window convention (`check-changeset-no-major` refuses
14+
`major` until GA; breaking-ness is carried by this banner and the ADR-0087
15+
disposition above, not by the level).
16+
17+
**What changed.** A flow node's `config` is an open record, so what its executor
18+
requires was checked by no build door. `FlowSchema.parse`, `AutomationEngine.registerFlow`
19+
and `objectstack validate` all admitted a node that left out a key its executor
20+
contract requires — and the executor's own contract parse then refused the node on
21+
every run that reached it. A `decision` branch with no `label` was worse: it never
22+
failed, the matched branch reported no label, and traversal took EVERY out-edge, so
23+
the flow ran green down the wrong paths. All three doors now refuse these shapes
24+
through one judge, `flowNodeConfigRefusals` (new in `@objectstack/spec/automation`):
25+
26+
- **A key a builtin's executor contract requires, left out.** Each builtin node's
27+
config is parsed against the very contract its executor parses against
28+
(`getBuiltinNodeConfigContracts()`, new, reconciled against the executors' own parse
29+
calls), and only the keys left out are kept — a present value of the wrong type, and
30+
an undeclared key, are judged where they were before. The keys: `objectName` on
31+
`get_record` / `create_record` / `update_record` / `delete_record`; `recipients` on
32+
`notify` (and `title` when there is no `template`); `url` on `http`; `function` on
33+
`script`; `flowName` on `subflow`; `collection` and `flowName` on `map`;
34+
`collection` on a `loop` that has a `body`; `branches` on `parallel`; `try` on
35+
`try_catch`; and on `screen`, each field's `name`, each option's `value` and
36+
`label`, and a `lookup` field's `reference`. A key a rule of the contract requires
37+
(the `notify` title, the `lookup` reference) is refused in the contract's own words.
38+
- **A `decision` branch list its executor cannot read.** `conditions` present and not
39+
`null` must be an array; every branch must be an object; every branch's `label` must
40+
be a non-blank string (absent, `null`, blank or non-text all name no out-edge).
41+
42+
Each refusal is a `custom` issue anchored at the key (`nodes.1.config.objectName`,
43+
`nodes.1.config.fields.0.name`, `nodes.1.config.conditions.0.label`, or the region
44+
path `nodes.1.config.body.nodes.0.config…`), met at `registerFlow` and
45+
`objectstack validate` through that same parse, and reported by
46+
`validateStackExpressions` for a stack handed to it directly. The refusal codes join
47+
`FLOW_SLOT_REFUSAL_CODES`: `node-config-key-missing`, `node-config-key-required-by-rule`,
48+
`decision-conditions-not-array`, `decision-branch-not-object`,
49+
`decision-branch-label-missing`.
50+
51+
The Studio flow designer writes refused shapes when a node is added and saved before
52+
it is configured, when a decision branch row's label cell is left empty, and when a
53+
screen field row's name cell is left empty. Where such a node already sits, the whole
54+
flow is refused: registered from the metadata registry or `sys_metadata` at boot, it
55+
is skipped with a `failed to register flow` warn naming it while the flows beside it
56+
register; a `defineStack({ flows })` source throws `StackSchemaInvalidError` for the
57+
whole stack; an artifact file is refused whole at load.
58+
59+
## FROM → TO
60+
61+
| you wrote | write instead |
62+
|:--|:--|
63+
| `{ type: 'get_record', config: { outputVariable: 'rows' } }` | the object it reads — `config: { objectName: 'account', outputVariable: 'rows' }` (the same for `create_record` / `update_record` / `delete_record`) |
64+
| `{ type: 'loop', config: { body: { … } } }` | the array it iterates — `config: { collection: '{rows}', body: { … } }` |
65+
| `{ type: 'map', config: { flowName: 'per_row' } }` | `config: { collection: '{rows}', flowName: 'per_row' }` |
66+
| `{ type: 'http', config: { method: 'GET' } }` | `config: { url: 'https://api.example.com/v1/items', method: 'GET' }` |
67+
| `{ type: 'script' }` | the registered function it calls — `config: { function: 'recalc_totals' }` |
68+
| `{ type: 'notify', config: { recipients: ['{record.owner}'] } }` | a content source — `title: 'Deal won'`, or a `template` |
69+
| `conditions: [{ expression: 'record.amount > 1000' }]` on a `decision` | the out-edge it routes to — `[{ label: 'large', expression: 'record.amount > 1000' }]`, beside an out-edge labelled `large` |
70+
| `conditions: ['record.amount > 1000']` | `[{ label: 'large', expression: 'record.amount > 1000' }]` |
71+
72+
**One-line fix:** write the key the node was meant to carry. To branch on the
73+
out-edges instead of on `conditions`, delete `conditions` and put each predicate on its
74+
edge's `condition`.
75+
76+
**Unchanged.** A node carrying every key its contract requires parses, registers and
77+
validates as before; a legacy flat-graph `loop` (no `body`) still needs no
78+
`collection`; a `decision` with no `conditions`, `conditions: null` or an empty list
79+
still routes by its out-edges; `assignment`, `wait`, `connector_action` and plugin node
80+
types are not judged by this rule; and a key spelled by a D2 alias (`object`, `flow`,
81+
`functionName`, …) is still canonicalized before `registerFlow` and `objectstack
82+
validate` judge it.

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

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ASSIGNMENT_VALUE_ENVELOPE_REFUSAL,
1616
PREDICATE_SLOT_STRING_REFUSAL,
1717
STRUCTURAL_CONDITION_SHAPE_REFUSAL,
18+
flowNodeConfigRefusals,
1819
predicateSlotRefusal,
1920
} from '@objectstack/spec/automation';
2021

@@ -2291,7 +2292,7 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
22912292
const badRegion = () => ({
22922293
nodes: [
22932294
{ id: 'gate', type: 'decision', config: { condition: '{record.rating} >= 4' } },
2294-
{ id: 'act', type: 'update_record' },
2295+
{ id: 'act', type: 'update_record', config: { objectName: 'crm_lead' } },
22952296
],
22962297
edges: [{ id: 'b1', source: 'gate', target: 'act', condition: '{record.status} == "open"' }],
22972298
});
@@ -2341,7 +2342,7 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
23412342
body: {
23422343
nodes: [
23432344
{ id: 'gate', type: 'decision', config: { condition: 'record.rating >= 4' } },
2344-
{ id: 'act', type: 'update_record' },
2345+
{ id: 'act', type: 'update_record', config: { objectName: 'crm_lead' } },
23452346
],
23462347
edges: [{ id: 'b1', source: 'gate', target: 'act', condition: 'record.status == "open"' }],
23472348
},
@@ -3010,6 +3011,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
30103011
// are `success` / `error`, SafeParseResult's own, so that excuse covers
30113012
// both locals for one reason and masks no metadata read either.
30123013
'blankRefusal',
3014+
// [#20316] The spec's node-config judge, one refusal at a time. Its keys
3015+
// are that helper's own `{ code, params, message, source, path }` —
3016+
// never metadata keys — and it is named to stay clear of the `message` /
3017+
// `source` receivers for the reason the two entries above record.
3018+
'configRefusal',
30133019
// [#14089] NOT a receiver at all — the tail of the `'./flow-variable-scope.js'`
30143020
// import specifier, which this scan cannot tell from `scope.j…`. The two
30153021
// entries above it in this set (`fields`, `guards`) are the same artefact
@@ -4468,6 +4474,66 @@ describe('a decision branch with no `expression` (#19961)', () => {
44684474
});
44694475
});
44704476

4477+
/**
4478+
* [#20316] What a node's executor needs its `config` to carry, at the THIRD
4479+
* door: `objectstack validate`'s expression pass.
4480+
*
4481+
* A key the node's executor contract requires, left out, and a `decision`
4482+
* branch list the executor cannot read, reported NOTHING here — the flow then
4483+
* registered and every run that reached the node failed there, or (a branch
4484+
* with no `label`) ran green down every out-edge. This pass now refuses them
4485+
* through `flowNodeConfigRefusals` — the same call, the same message, as
4486+
* `FlowSchema.parse` and `registerFlow`.
4487+
*
4488+
* ⚠️ Through the CLI, `objectstack validate` meets these shapes first at its
4489+
* schema step (`FlowSchema.parse` refuses them there, with the same message).
4490+
* This pass answers for a stack handed to `validateStackExpressions` directly,
4491+
* and it is what these pins drive.
4492+
*/
4493+
describe('node config an executor requires (#20316)', () => {
4494+
const stackWith = (node: Record<string, unknown>) => ({
4495+
flows: [{ name: 'config_flow', nodes: [{ id: 'start', type: 'start' }, { id: 'n', ...node }], edges: [] }],
4496+
});
4497+
const errorsOf = (stack: unknown) =>
4498+
validateStackExpressions(stack as never).filter((i) => (i.severity ?? 'error') === 'error');
4499+
4500+
it.each([
4501+
{ type: 'loop', whole: { collection: '{rows}', body: { nodes: [{ id: 'b', type: 'assignment' }], edges: [] } }, key: 'collection' },
4502+
{ type: 'map', whole: { collection: '{rows}', flowName: 'child_flow' }, key: 'collection' },
4503+
{ type: 'get_record', whole: { objectName: 'account' }, key: 'objectName' },
4504+
{ type: 'http', whole: { url: 'https://example.com/hook' }, key: 'url' },
4505+
{ type: 'subflow', whole: { flowName: 'child_flow' }, key: 'flowName' },
4506+
] as Array<{ type: string; whole: Record<string, unknown>; key: string }>)('$type without `$key` is refused; with it, nothing is', ({ type, whole, key }) => {
4507+
expect(errorsOf(stackWith({ type, config: whole }))).toHaveLength(0);
4508+
const authored = { ...whole };
4509+
delete authored[key];
4510+
const found = errorsOf(stackWith({ type, config: authored }));
4511+
expect(found.map((i) => [i.where, i.message, i.source])).toEqual([
4512+
[`flow 'config_flow' · node 'n' (${type}) config.${key}`, flowNodeConfigRefusals(type, authored)[0].message, ''],
4513+
]);
4514+
});
4515+
4516+
it('a decision branch with no `label` is refused at the label; its labelled twin is not', () => {
4517+
const found = errorsOf(stackWith({ type: 'decision', config: { conditions: [{ expression: 'true' }] } }));
4518+
expect(found.map((i) => [i.where, i.message])).toEqual([[
4519+
"flow 'config_flow' · node 'n' (decision) config.conditions[0].label",
4520+
flowNodeConfigRefusals('decision', { conditions: [{ expression: 'true' }] })[0].message,
4521+
]]);
4522+
expect(errorsOf(stackWith({ type: 'decision', config: { conditions: [{ label: 'y', expression: 'true' }] } }))).toHaveLength(0);
4523+
});
4524+
4525+
it('a decision branch that is a bare string is refused once, as a branch', () => {
4526+
const found = errorsOf(stackWith({ type: 'decision', config: { conditions: ['true'] } }));
4527+
expect(found.map((i) => i.where)).toEqual(["flow 'config_flow' · node 'n' (decision) config.conditions[0]"]);
4528+
});
4529+
4530+
it('a `script` with no `function` is ONE finding, the callable check\'s — it reads the pre-conversion spellings this pass may be handed', () => {
4531+
const found = errorsOf(stackWith({ type: 'script', config: {} }));
4532+
expect(found.map((i) => i.where)).toEqual(["flow 'config_flow' · node 'n' (script) callable"]);
4533+
expect(errorsOf(stackWith({ type: 'script', config: { functionName: 'recalc_totals' } }))).toHaveLength(0);
4534+
});
4535+
});
4536+
44714537
/**
44724538
* [#20078] A field-level predicate that reads THROUGH a reference field is
44734539
* refused at authoring, with the repair that is true for the root it reads.

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import {
8787
} from '@objectstack/formula';
8888
import {
8989
collectFlowGraphs,
90+
flowNodeConfigRefusals,
9091
predicateSlotRefusal,
9192
resolveFlowNodeExpressions,
9293
resolveFlowNodeValueSlots,
@@ -1620,6 +1621,28 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
16201621
// `loop.collection`). The ledger records them regardless, so the
16211622
// reconciliation ratchet still sees the marker.
16221623
const nodeType = typeof node.type === 'string' ? node.type : '';
1624+
// [#20316] What the node's executor needs its `config` to carry — a key
1625+
// its contract requires, left out, and a `decision` branch list it
1626+
// cannot read. The spec's one judge, the same call `FlowSchema.parse`
1627+
// makes (and `registerFlow` meets through that parse), so a stack
1628+
// handed to `validateStackExpressions` without a parse in front of it
1629+
// is held to the same bar. `error`: the flow would register and then
1630+
// refuse — or, for a branch with no label, misroute — every run.
1631+
const configRefusals = flowNodeConfigRefusals(nodeType, node.config)
1632+
// A `script`'s `function` stays the callable check's below (#1870,
1633+
// #4343): this pass may be handed a pre-conversion source, and that
1634+
// check reads what such a source spells — the `functionName` alias,
1635+
// the retired dispatch keys — and names each, where the judge would
1636+
// only see `function` absent.
1637+
.filter((configRefusal) => !(nodeType === 'script' && configRefusal.path === 'function'));
1638+
for (const configRefusal of configRefusals) {
1639+
issues.push({
1640+
where: `${at} · node '${node.id}' (${nodeType}) config.${configRefusal.path}`,
1641+
message: configRefusal.message,
1642+
source: configRefusal.source,
1643+
severity: 'error',
1644+
});
1645+
}
16231646
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
16241647
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
16251648
// [#15137] `value` slots are checkable too, by their own rule — see

‎packages/runtime/src/domains/automation-flow-clone.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ function packagedExemplar(): Record<string, unknown> {
100100
condition: 'record.stage == "negotiation"',
101101
},
102102
},
103-
{ id: 'notify', type: 'notify', label: 'Notify manager', config: { channel: 'email', to: '{record.manager_email}' } },
103+
{ id: 'notify', type: 'notify', label: 'Notify manager', config: { channel: 'email', to: '{record.manager_email}', title: 'Deal in negotiation' } },
104104
{ id: 'end', type: 'end', label: 'End' },
105105
],
106106
edges: [

‎packages/runtime/src/domains/automation-put-post-error-parity.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ import { FlowSchema, validateControlFlow } from '@objectstack/spec/automation';
4545
import { HttpDispatcher } from '../http-dispatcher.js';
4646

4747
/** Config keys the fake's `notify` descriptor declares (the #4277 legal set). */
48-
const NOTIFY_DECLARED_CONFIG_KEYS = ['message', 'recipients', 'channel'];
48+
// `title` joined the set when the flow parse began refusing a notify node with
49+
// neither `title` nor `template` (#20316) — the real notify descriptor declares it.
50+
const NOTIFY_DECLARED_CONFIG_KEYS = ['title', 'message', 'recipients', 'channel'];
4951

5052
/**
5153
* The #4277 refusal, reproduced from `service-automation/src/engine.ts`
@@ -127,12 +129,16 @@ function makeDispatcher() {
127129
*/
128130
const CTX = { request: {}, executionContext: { userId: 'user_1', systemPermissions: ['manage_metadata'] } } as any;
129131

130-
/** A definition that is legal at every gate the fake runs. */
132+
/**
133+
* A definition that is legal at every gate the fake runs. The notify node
134+
* carries the `recipients` and `title` its executor contract requires — the
135+
* flow parse refuses a node that leaves them out (#20316).
136+
*/
131137
const WELL_FORMED = {
132138
name: 'welcome_flow',
133139
label: 'Welcome',
134140
type: 'autolaunched',
135-
nodes: [{ id: 'n', type: 'notify', label: 'Notify', config: { message: 'hi' } }],
141+
nodes: [{ id: 'n', type: 'notify', label: 'Notify', config: { recipients: ['user_1'], title: 'Welcome', message: 'hi' } }],
136142
edges: [],
137143
};
138144

@@ -141,12 +147,12 @@ const BAD_BODIES = {
141147
/** 1 — a node with no `label` (`FlowSchema.parse`). */
142148
missingNodeLabel: {
143149
...WELL_FORMED,
144-
nodes: [{ id: 'n', type: 'notify', config: { message: 'hi' } }],
150+
nodes: [{ id: 'n', type: 'notify', config: { recipients: ['user_1'], title: 'Welcome', message: 'hi' } }],
145151
},
146152
/** 2 — a node key the schema does not declare (`unrecognized_keys`). */
147153
unknownNodeKey: {
148154
...WELL_FORMED,
149-
nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }],
155+
nodes: [{ id: 'n', type: 'notify', label: 'Notify', config: { recipients: ['user_1'], title: 'Welcome', message: 'hi' }, next: 'other' }],
150156
},
151157
/** 3 — a `try_catch` whose `try` region is an array, not a region object. */
152158
malformedRegion: {
@@ -161,7 +167,7 @@ const BAD_BODIES = {
161167
...WELL_FORMED,
162168
nodes: [{
163169
id: 'n', type: 'notify', label: 'Notify',
164-
config: { message: 'hi', totallyBogusKey: 'oops' },
170+
config: { recipients: ['user_1'], title: 'Welcome', message: 'hi', totallyBogusKey: 'oops' },
165171
}],
166172
},
167173
} as const;

‎packages/runtime/src/domains/automation-register-error-class.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ import { FlowSchema, validateControlFlow } from '@objectstack/spec/automation';
7474
import { HttpDispatcher } from '../http-dispatcher.js';
7575

7676
/** Config keys the fake's `notify` descriptor declares (the #4277 legal set). */
77-
const NOTIFY_DECLARED_CONFIG_KEYS = ['message', 'recipients', 'channel'];
77+
// `title` joined the set when the flow parse began refusing a notify node with
78+
// neither `title` nor `template` (#20316) — the real notify descriptor declares it.
79+
const NOTIFY_DECLARED_CONFIG_KEYS = ['title', 'message', 'recipients', 'channel'];
7880

7981
/**
8082
* The #4277 refusal, reproduced from `service-automation/src/engine.ts`
@@ -159,12 +161,16 @@ function makeDispatcher(options?: { registerFlow?: (name: string, definition: un
159161
*/
160162
const CTX = { request: {}, executionContext: { userId: 'user_1', systemPermissions: ['manage_metadata'] } } as any;
161163

162-
/** A definition that is legal at every gate the fake runs. */
164+
/**
165+
* A definition that is legal at every gate the fake runs. The notify node
166+
* carries the `recipients` and `title` its executor contract requires — the
167+
* flow parse refuses a node that leaves them out (#20316).
168+
*/
163169
const WELL_FORMED = {
164170
name: 'welcome_flow',
165171
label: 'Welcome',
166172
type: 'autolaunched',
167-
nodes: [{ id: 'n', type: 'notify', label: 'Notify', config: { message: 'hi' } }],
173+
nodes: [{ id: 'n', type: 'notify', label: 'Notify', config: { recipients: ['user_1'], title: 'Welcome', message: 'hi' } }],
168174
edges: [],
169175
};
170176

@@ -173,12 +179,12 @@ const BAD_BODIES = {
173179
/** 1 — a node with no `label`. */
174180
missingNodeLabel: {
175181
...WELL_FORMED,
176-
nodes: [{ id: 'n', type: 'notify', config: { message: 'hi' } }],
182+
nodes: [{ id: 'n', type: 'notify', config: { recipients: ['user_1'], title: 'Welcome', message: 'hi' } }],
177183
},
178184
/** 2 — a node key the schema does not declare. */
179185
unknownNodeKey: {
180186
...WELL_FORMED,
181-
nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }],
187+
nodes: [{ id: 'n', type: 'notify', label: 'Notify', config: { recipients: ['user_1'], title: 'Welcome', message: 'hi' }, next: 'other' }],
182188
},
183189
/** 3 — a `try_catch` whose `try` region is an array, not a region object. */
184190
malformedRegion: {
@@ -193,7 +199,7 @@ const BAD_BODIES = {
193199
...WELL_FORMED,
194200
nodes: [{
195201
id: 'n', type: 'notify', label: 'Notify',
196-
config: { message: 'hi', totallyBogusKey: 'oops' },
202+
config: { recipients: ['user_1'], title: 'Welcome', message: 'hi', totallyBogusKey: 'oops' },
197203
}],
198204
},
199205
} as const;

0 commit comments

Comments
 (0)