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
Conversation
…t the three build doors Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…he ADR-0087 D3 entry Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
… refuses Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…rence index Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…judge the executor-refusal pins as register-whole-then-strip Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…/ types / verify fixtures Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
…ow-node-config-build-doors
…eclares title Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aef8edecd93d24cd27b01be89e4d3bece6199890 && git checkout aef8edecd93d24cd27b01be89e4d3bece6199890
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 40b315b03345e334069dd454aecaf7016adbea4f 8171d8537e9efe68310733e9fa4cc9a51f5a2f4a && git checkout -B drift-repro 40b315b03345e334069dd454aecaf7016adbea4f && git merge --no-ff 8171d8537e9efe68310733e9fa4cc9a51f5a2f4a
node scripts/docs-audit/affected-docs.mjs --json 40b315b03345e334069dd454aecaf7016adbea4f
|
Contract reviewServed-tier: 84/84 ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
维护者速读PR #20416(修复 #20316,一并解决 #20317)· 改了什么 流程节点的配置如果缺了执行器必需的字段,现在在三个入口都会直接报错,不再等到流程运行时才失败或走错分支:
为什么改 以前这类流程能正常保存、发布,运行到那个节点才失败。决策分支没写标签更糟:流程"成功"了,但同时走了所有出边,业务上悄悄出错。执行器本来就要求这些字段,这次让构建期和运行期的要求一致。 风险与代价(含回滚)
席位意见 建议合并。at-tier 复核 PASS(记录
两处不阻塞的小问题,未改动已复核的 head:
另外, 你要做的 审阅后直接合并本 PR(Tier H 由你人工合并即为审核记录)。 |
…ch — no connectorConfig block, or a blank connectorId / actionId — at all three doors (objectstack-ai#20418) (objectstack-ai#20453) Fixes objectstack-ai#20418 Clause-②: no A `connector_action` flow node its executor cannot dispatch is now refused at all three build doors (`FlowSchema.parse`, `AutomationEngine.registerFlow`, `objectstack validate`), at any depth including an ADR-0031 region body: - **no `connectorConfig` block** — a `custom` issue at `nodes.N.connectorConfig`; - **`connectorId` or `actionId` blank** (empty, or only whitespace) — a `custom` issue at `nodes.N.connectorConfig.connectorId` / `.actionId`. The changeset carries `Clause-②: no (narrowing)` and the ADR-0087 disposition `registered connector-action-config-required`. ## What was wrong, measured on `origin/main` `e4d3f2ca` before the change Probes drive door 1 (`FlowSchema.safeParse`, spec `dist`), door 2 (`AutomationEngine.registerFlow` with `installBuiltinNodes` and a registered `probe` connector, 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`). | shape | door 1 | door 2 | door 3 | run | |:--|:--|:--|:--|:--| | (a) top level, no `connectorConfig` | `success=true` | registered | `valid: true`, exit 0 | `success=false`: `connector_action 'call': connectorConfig.connectorId and .actionId are required` | | (a) control: `{ connectorId: 'probe', actionId: 'ping', input: {} }` | `success=true` | registered | `valid: true`, exit 0 | `success=true` | | (b) the designer seed `{ connectorId: '', actionId: '', input: {} }` | `success=true` | registered | `valid: true`, exit 0 | `success=false`, the same guard message | | (b) `actionId: ''` only | `success=true` | registered | not probed | `success=false`, the same guard message | | (b) both ids `' '` | `success=true` | registered | not probed | `success=false`: `no handler for ' . ' — is the connector plugin registered?` | | (c) in a `loop` body, no `connectorConfig` | `success=true` | registered | `valid: true`, exit 0 | `success=false`, the same guard message | | (c) control: in a `loop` body, block complete | `success=true` | registered | not probed | `success=true` | ## After, measured on this branch (spec `dist` built from `c81e639dbd`; the merge of `main` since touched no spec, service-automation or CLI validate source) | shape | door 1 | door 2 | door 3 | |:--|:--|:--|:--| | (a) no block | `custom` at `nodes.1.connectorConfig` | throws `ZodError`, the same issue | `valid: false`, exit 1, `custom` at `flows.0.nodes.1.connectorConfig` | | (a) control | `success=true` | registered, run `success=true` | `valid: true`, exit 0 | | (b) designer seed | `custom` at `nodes.1.connectorConfig.connectorId` and `.actionId` | throws, the same two issues | `valid: false`, exit 1, the same two paths under `flows.0.` | | (b) `actionId: ''` only | `custom` at `nodes.1.connectorConfig.actionId` | throws, the same issue | not probed | | (b) both ids `' '` | `custom` at both ids | throws, the same two issues | not probed | | (c) in a `loop` body | `custom` at `nodes.1.config.body.nodes.0.connectorConfig` | throws, the same issue | `valid: false`, exit 1, `custom` at `flows.0.nodes.1.config.body.nodes.0.connectorConfig` | | (c) control | `success=true` | registered, run `success=true` | not probed | | (d) `config: { connectorId, actionId }`, no block | `custom` at `nodes.1.connectorConfig` (a direct parse meets the pre-conversion spelling) | registered: the `flow-node-connector-config-lift` D2 conversion lifts the complete pair first; run `success=true` | not probed | | (e) control: `connectorConfig: {}` | `invalid_type` at both ids only, as before (no second issue) | the same | not probed | Envelope per door: door 1 and door 2 answer the parse's Zod issue (`code` + `path`; `registerFlow` has no HTTP `status` of its own); door 3 answers `valid: false`, exit 1 and the same `code` + `path` under `flows.K.`. ## The fix - `packages/spec/src/automation/flow.zod.ts`: `connectorActionConfigRefusals(node)` (module-private, beside `requireTypeScopedConfig`), called from a new block in the `FlowSchema` superRefine that walks `collectFlowGraphs`, like the `flowNodeConfigRefusals` walk above it. It judges strings only, so a block the node shape already refuses (`{}`, a non-string id) gets no second issue. The messages carry no tracker number and prescribe a minimal block; the absent-block one also says keys left under `config` are not read. - `registerFlow` and `objectstack validate`: no change. Both parse through `FlowSchema` after the ADR-0087 conversions, so the parse's issue is what they answer. - The executor's guard in `connector-nodes.ts` is unchanged. It is still the refusal a node meets past the doors, and `guard-refusal-inventory.test.ts` still classifies it as un-routable. ### Route choices 1. **The rule runs in the flow walk, not in `requireTypeScopedConfig`.** That was the dispatch's suggested route, and a better route was measured. A node-level refusal does not reach a region-nested node at the flow parse, because `parseFlowNodeRegions` leaves a refused region raw. The control on this tree is a block-less `boundary_event`, which `requireTypeScopedConfig` refuses today. At the top level it answers `custom` at `nodes.1.boundaryConfig`. In a `loop` body, `FlowSchema.safeParse` answers `success=true`, and only `LoopConfigSchema` refuses it. So the suggested route would leave shape (c) admitted at all three doors. The walk refuses it at the path the author wrote. - Second effect: `FlowNodeSchema` alone still parses the designer seed, which objectui's seed ratchet (`flow-canvas-seeds.spec-parse.test.tsx`) requires of every seed. The flow the seed is saved into is refused. That is the posture objectstack-ai#20416 took for its `http` / `notify` seeds, and a test here pins the split. 2. **Blank ids are refused, not only an absent block. This is the rule objectstack-ai#20416 applied to a `decision` branch `label`.** objectstack-ai#20416 has two arms: - the executor-contract arm judges absence only (a present value stays with the contract's own run-time parse); - the decision arm serves an executor that reads its value raw, and refuses absent, blank (`NON_BLANK_STRING`) and non-text values. Its reason: "refusing only the absent key would leave `label: ''`, which misroutes identically". `connector_action`'s executor reads its block raw (`!cfg?.connectorId || !cfg?.actionId`) and parses no contract, so the decision-arm rule applies. Refusing absence alone would leave the designer seed admitted, and the seed fails every run identically (row (b) above). - Whitespace-only ids are refused with the empty string (the spec's one notion of blank). The executor's `!value` lets `' '` through, but a connector `name` must match `^[a-z_][a-z0-9_]*$`, so whitespace names nothing a dispatch can reach (row (b), `no handler`). - Boundary: a connector action `key` is `z.string()`, so an action keyed by whitespace alone would become unreachable. No such key is declared anywhere in this repo. 3. **No lint-side copy.** `validateStackExpressions` (lint) carries `flowNodeConfigRefusals` for a stack handed to it with no parse in front. The `wait` / `boundary_event` block rule has no lint copy either, and every door the card names parses first. ### Rider (same code table) `node-config-key-missing` in `flow-node-config-refusals.ts` now says the flow "used to register, and then every run that reached this node failed there". That is past tense, at a door that refuses the flow. Its one quoting pin, `KEY_MISSING` in `flow-slot-refusal-codes.test.ts`, moves with it. A repo-wide grep of `flow registers, and then` finds those two sites only. ### ADR-0087 kit - D3 entry `packages/spec/src/migrations/entries/semantic/18.connector-action-config-required.ts` (protocol 18; no tracker number in any author-shown field; no backticks in `surface`), and the step-18 tails of `registry.ts` regenerated by `gen:migration-registry`. - `.changeset/20418-connector-action-config-required.md` contains: - `@objectstack/spec` at `minor` (the launch-window convention); - `Clause-②: no (narrowing)` and the `registered` disposition marker; - a `**BREAKING**` banner, a FROM → TO table and a one-line fix. `check-adr-0087-registration` reads it as `[BREAKING+bang+clause-②-narrowing] registered connector-action-config-required`. - No D2 conversion: the platform cannot know the connector or the action the author left out. ## Acceptance notes ### Fixture triage (a disposition per fixture, not a batch rename) - **Completed** (never runnable; it now carries the block its executor reads): `spec` `flow.test.ts`, "should validate a complete parallel approval flow". Its two `connector_action` stand-ins get `connectorConfig: { connectorId: 'finance_desk' | 'legal_desk', actionId: 'request_review' }`. - **Replaced** (it pinned the path this change closes): `service-automation` `connector-nodes.test.ts`, "fails the step when connectorConfig is missing required fields". It registered a block-less node and asserted that the run failed. The new tests assert: - `registerFlow` refuses the block-less node (`custom` at `nodes.1.connectorConfig`, and the flow is absent from `listFlows()`); - it refuses the designer seed at both ids; - it registers the control; - the old run-time behaviour, measured by registering the block whole, deleting it from the stored node and asserting the guard's full message. - **Re-routed past the doors**: `guard-refusal-inventory.test.ts`, row "connector_action without connectorId/actionId". It registered `{ config: {} }`. It now registers a complete block and deletes it after registration (`stripBlock`, the sibling-block twin of objectstack-ai#20316's `strip`), so the row still classifies the executor's own guard. - **Unchanged, measured green**: - `run-summary.test.ts` spells the trio under `config` on three nodes, and `registerFlow`'s D2 lift completes the block. - Every connector plugin test carries the block. ### Producer census: who writes a `connector_action` node without a complete block Read at this head from every `type: 'connector_action'` literal repo-wide (`git grep`): - `examples/app-showcase/src/automation/flows/index.ts`: 4 nodes (`:330`, `:468`, `:526`, `:579`), all with a complete block. **0 refusals.** - `packages/connectors/connector-{slack,rest,mcp}` plugin tests: 4 nodes, all complete. - The dispatch's single-hit leads: - `trigger-record-change`, `service-messaging` and `create-objectstack`: CHANGELOG / README prose only; - `runtime/src`: a comment; - `qa/dogfood`: a test name and comment over the showcase flow, which carries the block. None of them writes a node. - `lint` `lint-flow-patterns.test.ts:2077` (`config: { connectorId: 'c', action: 'a' }`, no block) is a lint-pattern fixture that never meets `FlowSchema`. The lint suite is green, so it is left as is. - **`FlowSchema`'s own `@example` docblock** (`flow.zod.ts`): - its connector node had no block; - its `update_record` node had no `objectName`, which is already refused since objectstack-ai#20416 (measured on `e4d3f2ca`: `custom` at `nodes.2.config.objectName`). Both are fixed in the same literal. This is a bounded in-place fix: same defect family, same example, mechanical, a file in this claim, no new gate. Measured: the corrected literal parses `success=true`. - **The D2 conversion `flow-node-connector-config-lift`** (`conversions/registry.ts`): its completeness-guard comment said an incomplete pair keeps failing at run time rather than "fails to load". That is false after this change. The comment is corrected; behaviour is unchanged. - **objectui** (not edited), measured at `origin/main` `328abeb` and at `b120b66`: `defaultNodeExtras('connector_action')` seeds `connectorConfig: { connectorId: '', actionId: '', input: {} }` (`packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx:397`). - The block is present, so the absent-block refusal never fires on it (hypothesis confirmed). - The blank-id refusal does fire. Once objectui takes this spec, a connector node added and saved before it is configured is a loud save error, and the designer's live `FlowSchema` pass (`clientValidation.ts:681`) flags it at `nodes.N.connectorConfig.connectorId` / `.actionId`. - objectui's seed ratchet (`FlowNodeSchema.safeParse` per seed) stays green by construction. Reported for the seat; objectui#10948 carries the family. The pinned `.objectui-sha` `f8a9d0fb` is not in this container's shallow objectui clone, so it is NOT MEASURED there. - **cloud**: NOT MEASURED (no checkout in this container). ### Docs not edited `content/docs/automation/flows.mdx`'s node-key table lists `connectorConfig` as "optional", as it does `waitEventConfig`, which is required for `wait`. Per key and across node types, "optional" stays true. Tightening that table is a docs change outside this card's surface. ## Tests Final head `992656cea5`: - `spec`, targeted on `src/automation src/conversions src/migrations`: 39 files, 1484 tests passed. - `service-automation`, targeted on `connector-nodes`, `guard-refusal-inventory`, `run-summary`, `node-config-required-keys`, `connector-materialization` and `engine`: 6 files, 324 tests passed. - eslint over the 10 changed `.ts` files (`--no-inline-config --format json`): 10 files reported, 0 errors, 0 warnings. Three pieces of evidence for this narrowing: - The population is `eslint.config.mjs`'s `**/*.{ts,…}` blocks minus `NEVER_LINTED`, and all 10 files are inside it. - The count is read from the JSON output. - The config never enables type-aware linting (no `parserOptions.project`, no typed rules; stated in the config itself), so this diff cannot move any untouched file's verdict. Full package suites, at the branch's pre-merge heads. The merge of `main` touched none of these packages: - `@objectstack/spec` `vitest run --project local`: 565 files, 16651 passed (1 todo). `typecheck` (`tsc --noEmit`, scripts typecheck, test typecheck): exit 0. - `@objectstack/service-automation`: 149 files, 1837 passed. `typecheck`: exit 0. - `@objectstack/lint`: 115 files, 5331 passed. - `@objectstack/connector-slack` 3/10, `connector-rest` 4/26, `connector-mcp` 3/23, `connector-openapi` 4/36 (files/tests), all passed. - `@objectstack/example-showcase`: 29 files, 385 passed. The first attempt failed to resolve the unbuilt `@objectstack/connector-slack` (not a reading); it was rerun after building the showcase closure. - `@objectstack/dogfood` `test/showcase-declarative-mcp.dogfood.test.ts` (the flow `connector_action` dispatch end to end): 2 passed. - `@objectstack/spec` `check:generated`: all 15 generated artifacts up to date. **Ablation** (one-shot, not kept): run through `scripts/ablation-replace.mjs` on the committed tree, with a `trap` restore. - The walk's call `connectorActionConfigRefusals(node)` was replaced by `connectorActionConfigRefusals(null)`. The anchor count went 1 to 0, the replacement 0 to 1, and the blob `bae1a5cc` to `20017fad`. - `connector-action-config-required.test.ts` then read **7 failed, 4 passed**: every refused row red, every control green. - Restored: blob equals HEAD `bae1a5cc`, and `git diff HEAD` is empty. **Gates**: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `992656cea5` derived 90 commands. All 90 were run and all exit 0; `--ran` with recorded exit codes reports 90 derived, 90 run, 0 NOT-MEASURED. `check:dual-build-cjs-loads` and `check:type-check-debt` first answered PREREQUISITE NOT MET (exit 3, unbuilt packages). They exited 0 after those packages were built. The session that built this is linked in the footer. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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:flowNodeConfigRefusalsin@objectstack/spec/automation. It covers the whole family; #20317 is folded into this card:loop.collection([finding] a loop node with no config.collection registers and validates clean, then fails at run time with 'loop config does not satisfy the loop contract' #20317) andmap.collectionamong them;decisionbranch list its executor cannot read — a branch with nolabel(the card's shape), a branch that is not an object, andconditionsthat is not an array.What was wrong, measured on
origin/maina88a1bb399before the changeProbe scripts drive door 1 (
FlowSchema.safeParse, specdist), door 2 (AutomationEngine.registerFlowwithinstallBuiltinNodes, thenexecute), and door 3 (the BUILT CLI,node packages/cli/bin/run.js validate --jsonin a stack directory holding oneobjectstack.config.ts). The run leg swaps the two marker nodesy/xfor a recording executor, so the route taken is observable. The decision has out-edgesd → ylabelledyesandd → xwithisDefault: true.{ expression: 'true' }, nolabelsuccess=truevalid: true, exit 0success=true, visited["x","y"]: EVERY out-edge{ label: 'yes', expression: 'true' }success=truevalid: true, exit 0success=true, visited["y"]conditions: ['true']success=truevalid: true, exit 0success=false,condition evaluation error: A structural condition …loopwithiteratorVariableand a one-node body, nocollectionsuccess=truevalid: true, exit 0success=false,loop 'l': config does not satisfy the loop contract — config.collection: Invalid inputcollection: [1, 2]success=truevalid: true, exit 0success=true, the body ran twicemapwithflowName, nocollectionsuccess=truevalid: true, exit 0success=false,map 'm': config does not satisfy the map contract — config.collection: Invalid inputFurther shapes measured on the run leg only:
conditions: {}andconditions: 5fail withconditions is not iterable;conditions: 'x'fails with a condition evaluation error, because a string is iterated character by character; anullbranch fails withCannot read properties of null;label: null,'',0,' 'and42all run green down both out-edges. The last two also log the engine's unclaimed-label warn.conditions: nullroutes like noconditionsat all, which is legal and stays admitted.After, measured on the merged branch (spec
distbuilt from this head; the CLI reads it at run time)labelcustomatnodes.1.config.conditions.0.labelvalid: false, exit 1,customatflows.0.nodes.1.config.conditions.0.labelsuccess=true["y"]valid: true, exit 0conditions: ['true']customatnodes.1.config.conditions.0valid: false, exit 1, same pathcollectioncustomatnodes.1.config.collectionvalid: false, exit 1, same pathsuccess=truevalid: true, exit 0collectioncustomatnodes.1.config.collectionvalid: false, exit 1, same pathThe 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 importflow-node-expression-paths.ts. A static import there closed a cycle that readLEDGER_DECLARED_NODE_CONFIG_SCHEMASmid-evaluation (measured:z.toJSONSchema(undefined)in 4 spec tests).getBuiltinNodeConfigContracts()maps a node type to the very schema its executor handsparseNodeConfig, plusloop's parse condition (a legacy flat-graphloopwith nobodyis not parsed, so it stays exempt). It is built on first use, never at module load.flowNodeConfigRefusals(nodeType, config)has two arms: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 ledgervalueslot (fields.*,assignments.*), where a malformed envelope is the value-envelope pass's finding. A plain requirement getsnode-config-key-missingwith a prescription naming the key and the node type. A requirement a rule of the contract states getsnode-config-key-required-by-rule, carrying the contract's own message: anotifywith notemplateneeds atitle, and alookupscreen field needs itsreference.decisionis parsed by nothing at run time, so this arm states what its executor reads:conditionspresent and notnullis an array (decision-conditions-not-array); every branch is an object (decision-branch-not-object); every branch'slabelis a non-blank string (decision-branch-label-missing, withfound:absent,null,blank, or the kind).packages/spec/src/automation/flow-node-expression-paths.ts: the five new codes joinFLOW_SLOT_REFUSAL_CODESandFlowSlotRefusalParamsin feat(formula,spec): stable refusal codes and params beside every expression refusal message #20352's shape (a stable kebab-casecodeand typedparamsbeside the message).FlowNodeConfigRefusaladds thepathinsideconfig. One walk fix: an ARRAY element is no longer read as an object missing its slot, soconditions: [['true']]gets one refusal (a branch that is not an object), not also a missingexpression.packages/spec/src/automation/flow.zod.ts: aFlowSchema.superRefineblock calls the judge for every node, walked withcollectFlowGraphs, 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:validateStackExpressionscalls the same judge, for a stack handed to it with no parse in front. One exception: ascript's absentfunctionstays the existing callable check's finding. That check reads the pre-conversion spellings this pass may be handed (thefunctionNamealias, the retired dispatch keys) and names each.Route choices.
NON_BLANK_STRING). Refusing only the absent key would leavelabel: '', which misroutes identically.conditionsis 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 underservice-automation/src/builtin/, cross-checked against the expression-path ledger's required-key reconciliation. The channels requireloop.collectionandmap.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 ona88a1bb399(before) and on this branch (after), each row beside its whole-config accept control, which is admitted at every door both before and after.get_recordobjectNamecrud-nodes.ts:264config.objectNamecreate_recordobjectNamecrud-nodes.ts:335update_recordobjectNamecrud-nodes.ts:484delete_recordobjectNamecrud-nodes.ts:578notifyrecipientsnotify-node.ts:250notifytitle(notemplate; rule)notify-node.ts:250httpurlhttp-nodes.ts:112(parsed after interpolation; interpolation never adds a key)screenfields[i].namescreen-nodes.ts:158config.fields.i.namescreenfields[i].options[j].value/.labelscreen-nodes.ts:158screenfields[i].referenceon alookupfield (rule)screen-nodes.ts:158scriptfunctionscreen-nodes.ts:321subflowflowNamesubflow-node.ts:71mapcollectionmap-node.ts:96mapflowNamemap-node.ts:96loop(withbody)collectionloop-node.ts:85(the no-bodyform returns at:72unparsed)parallelbranchesparallel-node.ts:68try_catchtrytry-catch-node.ts:100decisionlabel(absent,null, blank, non-text)logic-nodes.ts:48-74(branchLabel: cond.label)config.conditions.i.labeldecisionconditionslogic-nodes.ts:48-51Controls that stay admitted: a legacy
loopwith nobodyand nocollection(it still runs); a decision with noconditions,conditions: nullor[];assignment,waitand 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_actionwith noconnectorConfigis 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, likewait'swaitEventConfig, whichFlowSchemaalready 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 forflowhasnodes: []..objectui-shapinf8a9d0fb(not edited):FlowObjectListFieldrowsToListdrops 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 withoutname, also refused.previews/flow-canvas-parts.tsxdefaultNodeExtrasseeds a new node with no config, or a partial one:httpgets{ method: 'GET' }(nourl),notifygets{ channels, recipients: [] }(notitle), and CRUD /script/subflow/map/parallel/try_catchget 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.Fixture triage (disposition per fixture, never a batch rename)
specflow.test.ts,region-normalization.test.ts,flow-region-pause-and-end.test.ts,api/zod-issues-to-fields.test.ts;service-automationengine.test.tsand 7 sibling files wherescript/notify/mapnodes stand in for mock executors;runtimeautomation-put-post-error-parity,automation-register-error-classandautomation-flow-clone;typesvalidation-failure.test.ts;verifyautomation-trigger-terminal-messages.test.ts. The twocollectFlowGraphsscope and path pins now chain throughtryas well ascatch, because atry_catchwithouttryis refused.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 storedscriptcarrying 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 absentfunction.scriptcallable tests are unchanged. That check keeps ascript'sfunction, as described above.Observed, not filed
object,flow,functionName,to/subject) is refused as the canonical key's absence at a DIRECTFlowSchema.parse/defineFlow().registerFlow,objectstack validateanddefineStackconvert 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.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
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.tsis regenerated..changeset/20316-flow-node-config-required-keys-refused.md:@objectstack/specand@objectstack/lintminor, BREAKING, with the FROM → TO table.service-automationgets none, because its diff is test files only.Pins: each refusal at each door, with the lit control
specflow-node-config-required.test.ts(FlowSchema.parse):conditions;assignmentand plugin types, no-branch decisions.specflow-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-automationnode-config-required-keys.test.ts(registerFlow):service-automationbuiltin/node-config-contract-ledger.test.ts: the ratchet. The map equals every executor'sparseNodeConfigcall, schema by identity;loopalone parses on a condition; every builtin type is classified.service-automationconfig-expression-ledger.test.ts: the cross-check described above.lintvalidate-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 oneos-verify-lockhold. It went throughscripts/ablation-replace.mjsin wrap mode, with the restore trapped on EXIT, INT and TERM.flowNodeConfigRefusalsreturns[]unless a global namedABLATION_20316_OFFis set. The anchor went from 1 hit to 0, and the blob frombd1d620a25b6to353f92191f9d. Afterpnpm --filter @objectstack/spec build,ablation-dist-preflight.mjs @objectstack/spec ABLATION_20316_OFFfound the marker in 20 built files.FlowSchema.parsewent red, as did the five new-code pins and the sweep. Every accept control and CONTROL block stayed green.registerFlowwent red, as did the ledger cross-check. The accept controls, the run-time "what it did" pins and the contract-ledger ratchet stayed green.validate-expressions.test.ts: 7 failed, 345 passed. The new rows went red; the callable-check rows stayed green.bd1d620a25b6, equal to HEAD, andgit diff HEADis empty. After a rebuild,--absentfound 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/main15bf186f50merged as725ffa325e, plus test-only commits after it. Every heavy run went throughos-verify-lock, on a closure built from the merge head (turbo run build --filter=@objectstack/cli..., 59/59). The box was shared.725ffa325e; no spec source changed after it.725ffa325e; no source changed after it.7f3b9b6742.validate-expressions.test.tsagain on8171d8537e: 352/352.src/domains: 77 files, 1436 passed, on7f3b9b6742.--project unit, its flow-building files: 8 files, 112 passed.dispatch-gates.mjs --commandson8171d8537ederives 102 families; 100 exit 0.--ranaccounts for all 102: 100 run, 2 NOT MEASURED.check:dual-build-cjs-loads: PREREQUISITE NOT MET. 9 packages outside this diff have nodist/.check:type-check-debt: its--re-measureruns a whole-tree build outside the lock. This diff adds no package.eslint --no-inline-config --format jsonover this branch's 36 changed.tsfiles: 36 files, 0 errors, 0 warnings.eslint.config.mjshasfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], and none of the 36 files is ignored.parserOptions.project, so no linting is type-aware, and this diff cannot move a verdict on an untouched file.main. The branch is 7 commits behindorigin/main7db1332f19. A merge-tree probe with no merge driver (a bare shared clone) merges clean.check:migration-registryon that probe merge reportsregistry.tscurrent.Governed surface
skills/objectstack-automation/references/_index.mdis regenerated bygen:skill-refs(byte-equal to the generator).flow.zod.tsnow reachesschemaless-node-config.zod.tstransitively, so the index gains that one dependency row. Readings: that file 42 → 43 lines; the published catalog'sSKILL.mdtotal 4402 → 4402, unchanged.维护者速读(草稿)
Generated by Claude Code