Skip to content

Commit 2f70c22

Browse files
fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type (#22319)
Fixes #21982 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) That line is the claim's, as revised by the seat answer `6063587988`, copied verbatim. The narrowing covers 10 builtins; `try_catch` stays on the descriptor walk, as the seat ruled, and is named below with its measured key difference. The widening is the save door's flow fallback (below). ## Seat answers to the build round, comment 6063587988 - **Q1: A.** On the save door's flow fallback (no canonicalizer resolved, or it threw), the gate judges the D2-converted body. The stored body stays the raw request body, exactly as before. This keeps the 6 tests in `protocol.save-flow-canonicalization.test.ts` that the build round turned red green as written. - **Q2: A.** The two spec controls that pinned 'a builtin's key membership is nobody's at the build doors' are re-pointed, test-only. Each keeps an assertion of code and location. ## What changes - **Spec (`flow-node-config-refusals.ts`).** The key arm of `flowNodeConfigRefusals` now judges key membership on every builtin in `getBuiltinNodeConfigContracts()` except `try_catch`. Before, it covered only `script` and `subflow` (pass 1, PR #22129). - The new export `builtinNodeConfigKeysJudged(nodeType)` names exactly the types it judges. - An undeclared key is refused as `node-config-refused-by-contract`, `params: { nodeType, key }`, one refusal per key, anchored where the author wrote it (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). - The message carries the contract's own sentence, including its prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss). It now closes with the remedy the walk's rejection carried: rename the key to one the contract declares there, or remove it. - A body-less legacy `loop` is judged on key membership alone. Presence and values keep `parsedWhen`. - A key at or under a region slot belongs to `validateControlFlow` at registration, the same carve-out the value arm has. That covers a key on a `loop` body or a `parallel` branch, and the keys of their nodes and edges. - **`metadata-protocol` (`protocol.ts`, `domain:engine`), the save door's flow fallback.** When no flow canonicalizer resolved, or it threw, the gates judge `applyConversionsToFlow(raw body)` from `@objectstack/spec`. No `reservedNodeTypes` are passed, because no engine is on that path; the result is used for the verdict only and is never persisted. The stored body is the raw request body, as before. The canonicalized path is untouched. Two gates read that verdict body: - the schema gate (the surface the seat named); - the runtime authoring gate. This is one line, `body: flowGateVerdictBody ?? gatedItem`, declared here as a measured extension. On an active save the lint's config judge refused the raw `filters` alias there too: 5 of the 6 tests stayed red with the schema gate alone, failing at `failed author-time validation … config.filters`. The credential walk keeps the raw body. - `duplicatePackage` needs no edit. With no canonicalizer it copies the raw body (`item = body`, about `:24693`) and re-saves through `this.saveMetaItem` (about `:24779`), which reaches the same fallback. - **`service-automation` (`engine.ts`).** `validateNodeConfigKeys` stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. It keeps `try_catch` and every plugin node type. - **Ledger.** One step-18 D3 entry, `flow-builtin-node-config-undeclared-keys-refused`, with rationale order 89 (the highest on `main` at `dc4a5c630` is 88). `registry.ts` is regenerated. `api-surface/` and `export-origins/` are regenerated for the new export. - **Changeset.** `.changeset/21982-flow-builtin-node-config-undeclared-keys-refused.md`: `@objectstack/spec` minor (BREAKING, ADR-0087 `registered`), `@objectstack/service-automation` patch, and `@objectstack/metadata-protocol` minor. It carries the revised `Clause-②` line verbatim. Pre mode is on at `main` `dc4a5c630` (`.changeset/pre.json` mode `pre`, tag `next`). The grade follows pass 1 and #21898's launch-window convention for accept-set narrowings. - **Comments only (`flow.zod.ts`).** The `FlowSchema` superRefine comment (the surface the seat named), and the module header's key-membership paragraph, which the change also made false. Both are in the same file and are comment-only. ## P1: descriptor key sets against contract key sets Measured at `fbcbcf124` (tsx probe, descriptors from `installBuiltinNodes`, contracts from the spec dist): | type | descriptor walk positions and keys | contract keys at those positions | equal? | |:--|:--|:--|:--| | get_record | config {fields, filter, limit, objectName, outputVariable} | same, strict | yes | | create_record | config {fields, objectName, outputVariable} | same, strict | yes | | update_record | config {fields, filter, multi, objectName} | same, strict | yes | | delete_record | config {filter, multi, objectName} | same, strict | yes | | notify | config {actionUrl, actorId, channels, message, payload, recipients, severity, sourceId, sourceObject, template, templateData, title, topic} | same, strict | yes | | http | config {body, durable, headers, method, signingSecret, timeoutMs, url} | same, strict | yes | | screen | config (9 keys); fields[] (12 keys); fields[].options[] {label, value} | same at all three, strict | yes | | map | config {collection, flowName, indexVariable, input, itemObject, iteratorVariable, outputVariable} | same, strict | yes | | loop | config {body, collection, indexVariable, iteratorVariable, maxIterations}; body {edges, nodes} | same, strict | yes (the walk also judged a body-less loop: kept, see H2) | | parallel | config {branches}; branches[] {edges, name, nodes} | same, strict | yes | | **try_catch** | config {catch, errorVariable, retry, try}; try/catch {edges, nodes}; retry {maxRetries, backoffMs, backoffMultiplier, maxRetryDelayMs, jitter} | config/try/catch same and strict; **`retry` is `RetryPolicySchema`, a plain `z.object` that strips an unknown key**, plus the `retryDelayMs` tombstone | **no, at `retry`**: not moved | **`try_catch` (named, not moved).** Moving it would have widened registration: `retry.bogusKey` would have registered. So it stays on the walk, whole type, one judge. `os validate` and `os compile` still pass an undeclared `try_catch` key that registration refuses. The seat files the `RetryPolicySchema` strictness follow-up at landing. ## Hypotheses, measured - **H1, confirmed.** All 13 contracts answer `unrecognized_keys` at the root for `{ bogusKey: 1 }`. Nested positions are strict too, except `try_catch.retry`. - **H2, falsified as stated; route per the seat.** Today the walk refuses an undeclared key on a body-less `loop`. So the key arm judges loop key membership whatever `parsedWhen` says. Pinned: a body-less loop with `bogusKey` is still refused at `registerFlow` (`config-unknown-keys.test.ts`), so `registerFlow` widens nowhere. - **H3, confirmed.** `assignment` is in neither the contract map nor the walk. - **H4, measured.** No alias tolerance is needed in `validate-expressions.ts`. The whole lint suite had one red, a fixture writing `itemVariable` (a wrong key, not a D2 alias) on a loop with a body. Fixed in the test, and `validate-expressions.ts` is untouched. ## Registration verdicts, before and after (59-variant probe) Every variant that `registerFlow` refused before is still refused, and every one it registered still registers. - On the 10 moved types, the refusal now comes from the `FlowSchema.parse` that `registerFlow` makes first. That covers a top-level `bogusKey` on each type, the walk's 6 guidance keys, `screen` `fields[0]` and `fields[0].options[0]`, and a body-less loop's `bogusKey` / `flowName`. - Region-object keys (`loop` `body.bogusKey`) and region-node keys are refused by `validateControlFlow`, as before. - `try_catch` keys are refused by the walk, as before. - Free-form-map keys and D2 aliases (converted first) register, as before. ## Measured at the CLI doors On `examples/app-showcase`, node `notify` in `showcase_task_completed`. The mutation went through `scripts/ablation-replace.mjs` (blob `562e884310` → `87b20a9570`, restored to `562e884310` == HEAD). | door | control | `message: '{summary}' , bogusKey: 1,` | |:--|:--|:--| | `objectstack validate` | exit 0 | exit 1, path `nodes,2,config,bogusKey`, the spec refusal's text | | `objectstack compile` | exit 0, artifact without `bogusKey` | exit 2, no artifact | The first attempt used an anchor that is a substring of its replacement. `ablation-replace` refused it before running anything (anchor count 1 → 1) and restored the file, so nothing was measured on that attempt. ## Ablation At HEAD `b9a3295d1`, `builtinNodeConfigKeysJudged`'s body was replaced by `return false;` via `ablation-replace` (blob `12eb374153` → `6ab82dce15`, restored to `12eb374153` == HEAD, `git diff HEAD` empty). On `flow-builtin-node-config-keys.test.ts` plus `flow-approval-node-config-contract.test.ts`, 21 of 50 tests went red and 29 held. The spec tests import `src/`, so no dist preflight applies. ## Tests (real readings) **Patch round, at `d7466a01b`** (merged with `origin/main` `4e4111ca0` through `os-regen-merge.sh`): - `@objectstack/metadata-protocol`, whole suite: 221 files passed, 3 skipped; 28283 tests passed, 19 skipped, 0 failed. - Pre-change red set: the build round's 6 tests in `protocol.save-flow-canonicalization.test.ts` (at `b9a3295d1`), all `flow/purge_flow failed spec validation: nodes.0.config.filters`. - At `72b8d3e97`, with the schema-gate edit alone, 5 of those 6 were still red at `failed author-time validation … config.filters`. Only the draft-mode test had gone green. - At `d7466a01b` all 6 are green as written, plus the 3 new pins: 19/19 in the file. - `@objectstack/spec`, `--project local`: 626/626 files, 18732 passed, 1 todo. The two re-pointed controls (Q2) are green. - Typecheck, exit 0: `metadata-protocol` (`tsc --noEmit`) and `spec` (with `check:test-typecheck`). **Build round, unchanged by the patch** (at `b9a3295d1`; pin files re-run at `414fc860c`): - `service-automation` whole suite 176/176 files, 2163/2163. - `lint` whole suite 127/127, 5843/5843. - `runtime --project local` 336/336, 4752 passed, 19 skipped. - `trigger-record-change` 11/11, 114/114. - `dogfood` 17 flow pin files, 105/105. - Examples: showcase 408/408, todo 238/238, crm 45/45. - Typecheck exit 0: service-automation, runtime, lint. ## Ablation of the fallback edit (patch round) At `d7466a01b`, through `ablation-replace`. Each run was restored to blob `f15e4802b5` == HEAD with `git diff HEAD` empty. - **Schema gate reverted** to `schema.safeParse(request.item)` (blob `f15e4802b5` → `a0cc936d94`): 9 of 19 red in `protocol.save-flow-canonicalization.test.ts`. That is the 6 fallback tests and all 3 new pins: the `functionName` body is refused, and the `filters` + `bogusKey` bodies report two paths instead of one. - **Authoring gate reverted** to `body: gatedItem` (blob `f15e4802b5` → `19e8bbb4ed`): 5 of 19 red, the active-mode fallback tests. The new pins hold: the lint already tolerates the script `functionName` alias, and `bogusKey` is refused either way. ## Acceptance notes - `approval` has two judges: the spec arm, which judges it whole, and the walk against `getApprovalNodeConfigJsonSchema()`. `registerFlow`'s parse throws first, so the walk never decides an approval key. Carrier: none. - `FLOW_NODE_UNKNOWN_KEY_GUIDANCE` in `engine.ts` is now unread: every type it keys is spec-judged, and each entry's prescription lives in that type's contract. It is kept and marked UNREAD, because removing it also needs the two comments in `builtin-node-config.zod.ts` that name it updated. Carrier: the next PR to touch `builtin-node-config.zod.ts`. - The `node-config-refused-by-contract` docblock in `flow-node-expression-paths.ts` still names only `script` / `subflow` for the key half. It is incomplete, not false. Carrier: the next PR to touch that file. - `objectstack validate` prints the raw issue array at 'Loading configuration…' for a refused flow. This predates the PR and was noted on pass 1. - The designer's fallback writer (`http` `outputVariable`, `notify` `url`) is tracked at objectstack-ai/objectui#11968, not changed here. - Open PR #22268 also edits `packages/lint/src/validate-expressions.test.ts`. This PR changes one fixture line there (`itemVariable` → `iteratorVariable`). Whichever lands later merges `main`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run with no paths, at `d7466a01b` against merge base `4e4111ca0`, derived 96 commands. Two families joined for the `metadata-protocol` edit: `check:durability-log-level` and `check:filter-alias-parity`. All 96 were run, each exit code captured before any pipe. 95 exited 0. `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET (packages outside the built closure have no `dist`), so it is NOT MEASURED and CI answers it. `--ran`: `✓ dispatch-gates --ran: 96 derived famil(ies) accounted for — 95 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).` `check:adr-0087-registration`: `[BREAKING+clause-②-narrowing] registered flow-builtin-node-config-undeclared-keys-refused`. eslint, narrowed (`--no-inline-config --format json`) over the diff's 20 `.ts` files: 20 files, 0 errors, 0 warnings. The population is the 20 `.ts` paths of `git diff --name-only 4e4111c HEAD`, and the file count is read from the json. Invariance: `parserOptions.project` and `projectService` are null (checked with `--print-config`), so no type-aware linting runs and no untouched file's verdict can move. ## Size 23 files, +955 / -203 vs merge base `4e4111ca0` (1158 changed lines). 0 governed paths. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e36ee53 commit 2f70c22

24 files changed

Lines changed: 972 additions & 210 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': patch
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
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.
8+
9+
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)
10+
11+
<!-- adr-0087: registered flow-builtin-node-config-undeclared-keys-refused -->
12+
13+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
14+
15+
**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.
16+
17+
**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.
18+
19+
**`@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.
20+
21+
**`@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.
22+
23+
**What stays as it was.**
24+
25+
- `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.
26+
- 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.
27+
- 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.
28+
- A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged.
29+
- `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged.
30+
- 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.
31+
32+
## FROM → TO
33+
34+
| you wrote | write instead |
35+
|:--|:--|
36+
| a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names |
37+
| `fieldValues` on `create_record` / `update_record` | `fields` |
38+
| `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` |
39+
| `visibleIf` on a screen field | `visibleWhen` |
40+
| `itemVariable` on a `loop` | `iteratorVariable` |
41+
| `outputVariable` on an `http` node | delete it: the http executor binds no output variable |
42+
| a key nothing reads | delete it |
43+
44+
**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.
45+
46+
**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.
47+
48+
### The kit
49+
50+
- **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`.
51+
- **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.

‎content/docs/automation/flows.mdx‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Each node performs a specific action in the flow.
138138
| `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) |
139139
| `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` |
140140
| `label` | `string` | ✅ | Display label |
141-
| `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) |
141+
| `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 |
142142
| `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 |
143143
| `position` | `{ x, y }` | optional | Visual position on canvas |
144144
| `timeoutMs` | `number` | optional | Per-node execution timeout |
@@ -149,13 +149,23 @@ Each node performs a specific action in the flow.
149149
<Callout type="info">
150150
The flow, node, edge, and variable **shells are `.strict()`** — a key they do not
151151
declare is a parse error naming the likely intended key, not a silent strip. A
152-
node's `config` is open at the flow-parse level (plugin node types keep their own
153-
vocabulary), but it is not unchecked: `registerFlow()` rejects config keys the
154-
node type's published `configSchema` does not declare — naming the path, the
155-
declared key set, and a did-you-mean — and the contract-carrying builtins
152+
node's `config` has no single shape at the flow-parse level (each node type owns
153+
its vocabulary), but it is not unchecked. On every built-in node type with an
154+
executor contract except `try_catch` — `get_record`, `create_record`,
155+
`update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop`,
156+
`parallel`, `script` and `subflow` — the flow parse refuses a config key that
157+
contract does not declare, at the key's own path and in the contract's words
158+
(its did-you-mean, or its prescription for a known slip such as `fieldValues` →
159+
`fields`), so `objectstack validate`, `objectstack compile`, the metadata save
160+
door and `registerFlow()` all refuse it before anything runs (a key on a `loop`
161+
body or a `parallel` branch, or on a region's nodes and edges, is the region
162+
check's at `registerFlow()`). A `try_catch` node
163+
and a plugin node type are judged at `registerFlow()` instead, against the
164+
`configSchema` the node type publishes — naming the path, the declared key set,
165+
and a did-you-mean; a plugin node type that publishes no `configSchema` declares
166+
nothing, so nothing can be undeclared. The contract-carrying builtins
156167
additionally `parse()` their config at execute time, refusing the node on a type
157-
or missing-`required` violation (#4277). A node type that publishes no
158-
`configSchema` declares nothing, so nothing can be undeclared. An `approval`
168+
or missing-`required` violation. An `approval`
159169
node's config is judged whole by the flow parse, against the contract the spec
160170
declares for it: an undeclared key or a value its executor would refuse
161171
(`escalation.timeoutHours: 0.5` under its `>= 1`) refuses the flow at the

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4379,7 +4379,7 @@ describe('a blank string in a ledger predicate slot (#17493)', () => {
43794379
it('reaches a `decision` inside an ADR-0031 region body', () => {
43804380
const found = errorsOf(flowStack({
43814381
id: 'sweep', type: 'loop',
4382-
config: { collection: '{items}', itemVariable: 'item', body: { nodes: [decision(' ')], edges: [] } },
4382+
config: { collection: '{items}', iteratorVariable: 'item', body: { nodes: [decision(' ')], edges: [] } },
43834383
}));
43844384
expect(found).toHaveLength(1);
43854385
expect(found[0].where).toContain("loop 'sweep' body");

‎packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,63 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => {
397397
expect(fallbackWarnings()).toHaveLength(0);
398398
});
399399

400+
/**
401+
* [#21982] On the fallback path — no canonicalizer resolved, or it threw —
402+
* the gate judges the body in its ADR-0087 D2-converted spelling, for the
403+
* verdict only. The flow parse refuses an undeclared config key on the
404+
* builtin node types, so without the conversion a D2 spelling every other
405+
* door converts first (`os validate`, `defineStack`, `registerFlow`) would
406+
* be refused here, and only here: a verdict that depended on whether the
407+
* host runs an automation service. What is STORED stays the raw body.
408+
*/
409+
describe('[#21982] the fallback gate judges the D2-converted body, and stores the raw one', () => {
410+
const scriptBody = (config: Record<string, unknown>) => ({
411+
...flowBody({}),
412+
nodes: [{ id: 'n1', type: 'script', label: 'Triage', config }],
413+
});
414+
const issuesOf = async (promise: Promise<unknown>) => {
415+
try {
416+
await promise;
417+
} catch (err) {
418+
return err as { code?: unknown; status?: unknown; issues?: Array<{ path: string }> };
419+
}
420+
throw new Error('expected the save to be refused');
421+
};
422+
423+
it('a legacy script `functionName` body saves on a host with no automation service, stored raw', async () => {
424+
const { protocol, rows } = makeProtocol(new Map());
425+
426+
const result = await save(protocol, scriptBody({ functionName: 'helpdesk.triage' }));
427+
428+
expect(result.success).toBe(true);
429+
// Judged as `function`, stored exactly as written.
430+
expect(storedFlow(rows)!.body.nodes[0].config).toEqual({ functionName: 'helpdesk.triage' });
431+
});
432+
433+
it('an undeclared key on delete_record is refused 422 INVALID_METADATA at its path — the alias beside it is not', async () => {
434+
const { protocol, rows } = makeProtocol(new Map());
435+
436+
const err = await issuesOf(save(protocol, flowBody({ objectName: 'lead', filters: { status: 'stale' }, bogusKey: 1 })));
437+
438+
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
439+
expect((err.issues ?? []).map((i) => i.path)).toEqual(['nodes.0.config.bogusKey']);
440+
expect(rows.size).toBe(0);
441+
});
442+
443+
it('a canonicalizer throw still refuses a body the gate rejects after conversion', async () => {
444+
const throwing = () => { throw new Error('cycle detected: n1 → n1'); };
445+
const { protocol, rows } = makeProtocol(
446+
new Map([['automation', { canonicalizeStoredFlow: throwing }]]),
447+
);
448+
449+
const err = await issuesOf(save(protocol, flowBody({ objectName: 'lead', filters: { status: 'stale' }, bogusKey: 1 })));
450+
451+
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
452+
expect((err.issues ?? []).map((i) => i.path)).toEqual(['nodes.0.config.bogusKey']);
453+
expect(rows.size).toBe(0);
454+
});
455+
});
456+
400457
it('non-flow saves never consult the canonicalizer', async () => {
401458
const spy = vi.fn(canonicalizeStoredFlow);
402459
const { protocol } = makeProtocol(

0 commit comments

Comments
 (0)