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,51 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': patch
'@objectstack/metadata-protocol': minor
---

A key a builtin flow node's executor contract does not declare is refused at parse, with a location, on every builtin but `try_catch`: a `notify` `bogusKey`, a screen field's `visibleIf`, a `create_record` `fieldValues`, an `http` `outputVariable` and the like no longer pass `objectstack validate` and `objectstack compile` and then get the whole flow refused at registration.

Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door)

<!-- adr-0087: registered flow-builtin-node-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.** Each of these executors parses the node's `config` against a strict contract before it acts. The build doors' executor-contract arm judged required keys and present values on these types, but held key membership back, because `registerFlow`'s undeclared-key walk judged it against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied the key into `dist/objectstack.json`), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered.

**What is refused.** A `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node, at any depth, whose config carries a key its executor contract does not declare: at the config itself, on a `screen` field, or on one of a field's `options`. That includes a body-less legacy `loop`, which is judged on key membership alone. 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`, `nodes.N.config.fields.0.visibleIf`). It comes from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share. Its message carries the contract's own sentence, with the contract's prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss), and closes with the remedy: rename the key to one the contract declares there, or remove it. 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 save door. `script` and `subflow` keys were already refused this way. The new `builtinNodeConfigKeysJudged(nodeType)` export names exactly the builtins the spec judges.

**`@objectstack/service-automation`.** `registerFlow`'s descriptor walk (`validateNodeConfigKeys`) stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. Before the move, the declared key sets were measured equal: on each of these types the descriptor's declared keys, at every position the walk descends to, equal the keys the contract accepts there. So registration refuses exactly what it refused before. The refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text.

**`@objectstack/metadata-protocol`.** The save door judges a flow in its canonical spelling on every path. When no flow canonicalizer resolved (a host with no automation service), or the canonicalizer threw (a draft with a temporary cycle), the schema gate and the runtime authoring gate now judge the body with the ADR-0087 D2 conversions applied (`applyConversionsToFlow`), for the verdict only. The stored body stays the raw request body, exactly as before. A D2 spelling the load path still rewrites (`filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`; `functionName` and `input` on a `script`; `flow` on a `subflow`) is therefore accepted at that door, as at every other one, and an undeclared key that no conversion rewrites is refused there, located. This widens the fallback path back for the `script` and `subflow` aliases the previous key arm had begun refusing there. `duplicatePackage` re-saves through the same gate.

**What stays as it was.**

- `try_catch`: its undeclared keys stay registration's, judged by the walk. Its contract's `retry` is the shared `RetryPolicySchema`, which strips an unknown key, while its descriptor closes `retry` to `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. Moving the judge would have widened registration.
- Every plugin node type (the spec does not declare its contract): judged by the walk against its descriptor's `configSchema`, with the walk's own prescriptions.
- A key at or under a region slot (a `loop` body or a `parallel` branch, and their nodes and edges): judged at registration by the region check, as before.
- A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged.
- `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged.
- A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names |
| `fieldValues` on `create_record` / `update_record` | `fields` |
| `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` |
| `visibleIf` on a screen field | `visibleWhen` |
| `itemVariable` on a `loop` | `iteratorVariable` |
| `outputVariable` on an `http` node | delete it: the http executor binds no output variable |
| a key nothing reads | delete it |

**The one-line fix: rename or delete the key the refusal names.** Registration already refused such a flow, so the fix changes nothing a running flow does.

**Who is affected, measured.** At `fbcbcf124`, every builtin flow node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (95 nodes) carries only keys its contract declares. The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys whenever the engine publishes the node's descriptor. Its fallback form (while the descriptor list is loading, or when it cannot be read) offers `outputVariable` on an `http` node and the `url` alias on a `notify` node. Registration already refused the first and the conversion rewrites the second; the designer fix is tracked in objectui. hotcrm, deployed metadata and other repositories were not measured.

### The kit

- **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, over every builtin in `getBuiltinNodeConfigContracts()` but `try_catch`, named by the new export `builtinNodeConfigKeysJudged`; no new code joins `FLOW_SLOT_REFUSAL_CODES`.
- **The ledger.** The D3 semantic entry `flow-builtin-node-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.
24 changes: 17 additions & 7 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Each node performs a specific action in the flow.
| `id` | `string` | ✅ | Unique node identifier — unique across the **whole flow**: the top-level `nodes[]` and every region body (`loop.body`, `parallel.branches[]`, `try_catch.try` / `.catch`, at any depth) share one id space, and `FlowSchema` refuses a reused id at parse (`Duplicate node id …`, naming both locations) |
| `type` | `string` | ✅ | Node type — a built-in id from the table above **or** a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry once that registry is complete — plugins contribute node types while they start, so flows registered during boot are checked in one pass when the vocabulary closes (all plugins started), and anything registered after that (Studio publish, dev reload) is checked immediately. Unknown types warn, never reject; executing one fails with `NO_EXECUTOR` |
| `label` | `string` | ✅ | Display label |
| `config` | `object` | optional | Type-specific configuration — the registered executor's `configSchema` owns its shape. Keys that schema does not declare are rejected at `registerFlow()`, an `approval` node's config is judged whole against its declared contract at the flow parse, and the built-in executors `parse()` the value against their Zod contract before running (#4277) |
| `config` | `object` | optional | Type-specific configuration — its node type owns its shape. On a built-in node type with an executor contract other than `try_catch`, a key that contract does not declare is refused at the flow parse, at the key's path; on `try_catch` and on a plugin node type, a key the published `configSchema` does not declare is rejected at `registerFlow()`. An `approval` node's config is judged whole against its declared contract at the flow parse, and the built-in executors `parse()` the value against their Zod contract before running |
| `connectorConfig` | `object` | ✅ on `connector_action` | `{ connectorId, actionId, input }` — the only input a `connector_action` node's executor reads. `FlowSchema` refuses a `connector_action` node without it, and one whose `connectorId` or `actionId` is blank (empty or whitespace only), at any depth including a region body. `connectorId` is the registered connector's `name`, `actionId` one of the action keys it declares; `input` is optional |
| `position` | `{ x, y }` | optional | Visual position on canvas |
| `timeoutMs` | `number` | optional | Per-node execution timeout |
Expand All @@ -149,13 +149,23 @@ Each node performs a specific action in the flow.
<Callout type="info">
The flow, node, edge, and variable **shells are `.strict()`** — a key they do not
declare is a parse error naming the likely intended key, not a silent strip. A
node's `config` is open at the flow-parse level (plugin node types keep their own
vocabulary), but it is not unchecked: `registerFlow()` rejects config keys the
node type's published `configSchema` does not declare — naming the path, the
declared key set, and a did-you-mean — and the contract-carrying builtins
node's `config` has no single shape at the flow-parse level (each node type owns
its vocabulary), but it is not unchecked. On every built-in node type with an
executor contract except `try_catch` — `get_record`, `create_record`,
`update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop`,
`parallel`, `script` and `subflow` — the flow parse refuses a config key that
contract does not declare, at the key's own path and in the contract's words
(its did-you-mean, or its prescription for a known slip such as `fieldValues` →
`fields`), so `objectstack validate`, `objectstack compile`, the metadata save
door and `registerFlow()` all refuse it before anything runs (a key on a `loop`
body or a `parallel` branch, or on a region's nodes and edges, is the region
check's at `registerFlow()`). A `try_catch` node
and a plugin node type are judged at `registerFlow()` instead, against the
`configSchema` the node type publishes — naming the path, the declared key set,
and a did-you-mean; a plugin node type that publishes no `configSchema` declares
nothing, so nothing can be undeclared. The contract-carrying builtins
additionally `parse()` their config at execute time, refusing the node on a type
or missing-`required` violation (#4277). A node type that publishes no
`configSchema` declares nothing, so nothing can be undeclared. An `approval`
or missing-`required` violation. An `approval`
node's config is judged whole by the flow parse, against the contract the spec
declares for it: an undeclared key or a value its executor would refuse
(`escalation.timeoutHours: 0.5` under its `>= 1`) refuses the flow at the
Expand Down
2 changes: 1 addition & 1 deletion packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4379,7 +4379,7 @@ describe('a blank string in a ledger predicate slot (#17493)', () => {
it('reaches a `decision` inside an ADR-0031 region body', () => {
const found = errorsOf(flowStack({
id: 'sweep', type: 'loop',
config: { collection: '{items}', itemVariable: 'item', body: { nodes: [decision(' ')], edges: [] } },
config: { collection: '{items}', iteratorVariable: 'item', body: { nodes: [decision(' ')], edges: [] } },
}));
expect(found).toHaveLength(1);
expect(found[0].where).toContain("loop 'sweep' body");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,63 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => {
expect(fallbackWarnings()).toHaveLength(0);
});

/**
* [#21982] On the fallback path — no canonicalizer resolved, or it threw —
* the gate judges the body in its ADR-0087 D2-converted spelling, for the
* verdict only. The flow parse refuses an undeclared config key on the
* builtin node types, so without the conversion a D2 spelling every other
* door converts first (`os validate`, `defineStack`, `registerFlow`) would
* be refused here, and only here: a verdict that depended on whether the
* host runs an automation service. What is STORED stays the raw body.
*/
describe('[#21982] the fallback gate judges the D2-converted body, and stores the raw one', () => {
const scriptBody = (config: Record<string, unknown>) => ({
...flowBody({}),
nodes: [{ id: 'n1', type: 'script', label: 'Triage', config }],
});
const issuesOf = async (promise: Promise<unknown>) => {
try {
await promise;
} catch (err) {
return err as { code?: unknown; status?: unknown; issues?: Array<{ path: string }> };
}
throw new Error('expected the save to be refused');
};

it('a legacy script `functionName` body saves on a host with no automation service, stored raw', async () => {
const { protocol, rows } = makeProtocol(new Map());

const result = await save(protocol, scriptBody({ functionName: 'helpdesk.triage' }));

expect(result.success).toBe(true);
// Judged as `function`, stored exactly as written.
expect(storedFlow(rows)!.body.nodes[0].config).toEqual({ functionName: 'helpdesk.triage' });
});

it('an undeclared key on delete_record is refused 422 INVALID_METADATA at its path — the alias beside it is not', async () => {
const { protocol, rows } = makeProtocol(new Map());

const err = await issuesOf(save(protocol, flowBody({ objectName: 'lead', filters: { status: 'stale' }, bogusKey: 1 })));

expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
expect((err.issues ?? []).map((i) => i.path)).toEqual(['nodes.0.config.bogusKey']);
expect(rows.size).toBe(0);
});

it('a canonicalizer throw still refuses a body the gate rejects after conversion', async () => {
const throwing = () => { throw new Error('cycle detected: n1 → n1'); };
const { protocol, rows } = makeProtocol(
new Map([['automation', { canonicalizeStoredFlow: throwing }]]),
);

const err = await issuesOf(save(protocol, flowBody({ objectName: 'lead', filters: { status: 'stale' }, bogusKey: 1 })));

expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
expect((err.issues ?? []).map((i) => i.path)).toEqual(['nodes.0.config.bogusKey']);
expect(rows.size).toBe(0);
});
});

it('non-flow saves never consult the canonicalizer', async () => {
const spy = vi.fn(canonicalizeStoredFlow);
const { protocol } = makeProtocol(
Expand Down
Loading
Loading