Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@objectstack/spec': minor
'@objectstack/lint': patch
---

A `script` or `subflow` flow node whose `config` carries a key its executor contract does not declare is refused at parse, with a location, in the contract's own words: a `script` `bogusKey`, a `subflow` `timeoutMs` written inside `config`, and the like no longer pass the build doors and registration and then fail every run.

Clause-②: no (narrowing)

<!-- adr-0087: registered flow-script-subflow-config-undeclared-keys-refused -->

**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.

**Why.** The `script` and `subflow` executors parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check reads the node type descriptor's `configSchema`, and these two descriptors publish none, while the build doors' executor-contract arm judged required keys and present values but not key membership. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: ``script 'n': config does not satisfy the script contract — config: Unrecognized key(s) on this script node config: `bogusKey` ``.

**What is refused.** A `script` node, at any depth, whose config carries a key other than `function`, `inputs` and `outputVariable`, or a `subflow` node whose config carries a key other than `flowName`, `input` and `outputVariable`. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.<key>`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door.

**What stays as it was.**

- Every other builtin node type: its undeclared keys are judged at registration against its descriptor's `configSchema`, with that check's own prescriptions, and the build doors do not judge them.
- `decision`: it publishes no descriptor `configSchema` either, but its executor parses no contract, so an undeclared key fails no run and stays unjudged.
- A retired `script` key (`actionType`, `template`, `recipients`, `variables`, `script`) keeps its tombstone path.
- A spelling an ADR-0087 D2 conversion still rewrites at load (`functionName` and `input` on a `script`, `flow` on a `subflow`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key, as its missing canonical key already was.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| a typo of a declared key (`funtion`, `outputVariabel`) | the declared key: `function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow` |
| a value the function or child flow should receive, as its own config key (`config: { function: 'f', taskId: '{record.id}' }`) | inside the input map: `config: { function: 'f', inputs: { taskId: '{record.id}' } }` (`input` on a `subflow`) |
| a `subflow` `config.timeoutMs` | on the node: `{ id, type: 'subflow', timeoutMs: 30000, config: { … } }` |
| a key nothing reads | delete it |

**The one-line fix: rename, move or delete the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does.

**Who is affected, measured.** At `15ec50e528`, every `script` and `subflow` node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (8 nodes: 6 `script`, 2 `subflow`) carries only declared keys, and so does every one in hotcrm at `c9678036d9` (5 `subflow`, no `script`). The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys for both types (its `timeoutMs` field writes the node, not `config`), and seeds a new node with an empty `config`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register.

**`@objectstack/lint`.** `validateStackExpressions` keeps the pre-conversion tolerance it declares: on a raw source, a `script` node's `functionName` alias stays the callable check's to read, not an undeclared-key error, while every other undeclared `script` key is refused there as at the build doors.

### The kit

- **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, judged for the builtins in the spec's schemaless class (`SCHEMALESS_NODE_CONFIG_SCHEMAS`) that have an executor contract; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries.
- **The ledger.** The D3 semantic entry `flow-script-subflow-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be.
15 changes: 15 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4535,6 +4535,21 @@ describe('node config an executor requires (#20316)', () => {
expect(found.map((i) => i.where)).toEqual(["flow 'config_flow' · node 'n' (script) callable"]);
expect(errorsOf(stackWith({ type: 'script', config: { functionName: 'recalc_totals' } }))).toHaveLength(0);
});

it('a `script` key its contract does not declare is still refused — only the `functionName` alias is the callable check\'s', () => {
const config = { function: 'recalc_totals', bogusKey: 1 };
expect(errorsOf(stackWith({ type: 'script', config })).map((i) => [i.where, i.message, i.source])).toEqual([
["flow 'config_flow' · node 'n' (script) config.bogusKey", flowNodeConfigRefusals('script', config)[0].message, ''],
]);
// On a raw alias source the judge names `function`, `functionName` and
// `bogusKey`; the pass hands the first two to the callable check and
// keeps the third.
const aliased = { functionName: 'recalc_totals', bogusKey: 1 };
expect(flowNodeConfigRefusals('script', aliased).map((r) => r.path).sort()).toEqual(['bogusKey', 'function', 'functionName']);
expect(errorsOf(stackWith({ type: 'script', config: aliased })).map((i) => i.where)).toEqual([
"flow 'config_flow' · node 'n' (script) config.bogusKey",
]);
});
});

/**
Expand Down
11 changes: 9 additions & 2 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1714,8 +1714,15 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
// #4343): this pass may be handed a pre-conversion source, and that
// check reads what such a source spells — the `functionName` alias,
// the retired dispatch keys — and names each, where the judge would
// only see `function` absent.
.filter((configRefusal) => !(nodeType === 'script' && configRefusal.path === 'function'));
// only see `function` absent. Since the judge also refuses a key a
// `script`'s contract does not declare (#21982), it would name that
// same `functionName` alias undeclared too, so that one refusal is
// the callable check's as well: the pass keeps the pre-conversion
// tolerance it declares. Every other undeclared key stays refused.
.filter((configRefusal) => !(nodeType === 'script' && (
configRefusal.path === 'function'
|| (configRefusal.code === 'node-config-refused-by-contract' && configRefusal.path === 'functionName')
)));
for (const configRefusal of configRefusals) {
issues.push({
where: `${at} · node '${node.id}' (${nodeType}) config.${configRefusal.path}`,
Expand Down
18 changes: 12 additions & 6 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2337,8 +2337,11 @@ describe('AutomationEngine - Parallel Branch Execution', () => {
// sequential engine satisfies just as well.
const trace: string[] = [];

// A test double under a type of its own, never the builtin `script`:
// `delay` is this double's key, and the `script` contract refuses an
// undeclared key at the flow parse that `registerFlow` runs first.
engine.registerNodeExecutor({
type: 'script',
type: 'probe_step',
async execute(node) {
trace.push(`enter:${node.id}`);
const delay = (node.config as any)?.delay ?? 0;
Expand All @@ -2355,8 +2358,8 @@ describe('AutomationEngine - Parallel Branch Execution', () => {
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'branch_a', type: 'script', label: 'Branch A', config: { function: 'noop', delay: 10 } },
{ id: 'branch_b', type: 'script', label: 'Branch B', config: { function: 'noop', delay: 10 } },
{ id: 'branch_a', type: 'probe_step', label: 'Branch A', config: { delay: 10 } },
{ id: 'branch_b', type: 'probe_step', label: 'Branch B', config: { delay: 10 } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
Expand Down Expand Up @@ -2442,8 +2445,11 @@ describe('AutomationEngine - Node Input Schema Validation', () => {
});

it('should fail when parameter type is wrong', async () => {
// A test double under a type of its own: `inputSchema` reads TOP-LEVEL
// config keys, and the builtin `script` contract refuses an undeclared
// `count` at the flow parse before this check could run.
engine.registerNodeExecutor({
type: 'script',
type: 'probe_step',
async execute() {
return { success: true };
},
Expand All @@ -2457,9 +2463,9 @@ describe('AutomationEngine - Node Input Schema Validation', () => {
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'validated',
type: 'script',
type: 'probe_step',
label: 'Validated',
config: { function: 'noop', count: 'not_a_number' },
config: { count: 'not_a_number' },
inputSchema: {
count: { type: 'number', required: true },
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ function countingFlowEngine(opts: {
const engine = new AutomationEngine(createTestLogger());
const runs = { count: 0 };

// A test double under a type of its own, never the builtin `script`:
// `inputSchema` reads TOP-LEVEL config keys, and the `script` contract
// refuses an undeclared key at the flow parse `registerFlow` runs first.
engine.registerNodeExecutor({
type: 'script',
type: 'probe_step',
async execute() {
runs.count++;
return opts.executeResult ? opts.executeResult(runs.count) : { success: true };
Expand All @@ -72,11 +75,9 @@ function countingFlowEngine(opts: {
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'work',
type: 'script' as any,
type: 'probe_step',
label: 'Work',
// `function` is the key the script executor contract requires;
// the flow parse refuses a script node without it (#20316).
config: { function: 'noop', ...opts.config },
config: { ...opts.config },
inputSchema: opts.inputSchema,
},
{ id: 'end', type: 'end', label: 'End' },
Expand Down
Loading
Loading