Skip to content

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

Merged
os-zhuang merged 9 commits into
mainfrom
claude/issue-20316-flow-node-config-build-doors
Sep 28, 2026
Merged

os-zhuang merged 9 commits into
mainfrom
claude/issue-20316-flow-node-config-build-doors

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20316

Clause-②: no (narrowing)

A flow node config its executor cannot run is now refused where the flow is built, at all three build doors (FlowSchema.parse, AutomationEngine.registerFlow, objectstack validate), by ONE judge: flowNodeConfigRefusals in @objectstack/spec/automation. It covers the whole family; #20317 is folded into this card:

What was wrong, measured on origin/main a88a1bb399 before the change

Probe scripts drive door 1 (FlowSchema.safeParse, spec dist), door 2 (AutomationEngine.registerFlow with installBuiltinNodes, then execute), and door 3 (the BUILT CLI, node packages/cli/bin/run.js validate --json in a stack directory holding one objectstack.config.ts). The run leg swaps the two marker nodes y / x for a recording executor, so the route taken is observable. The decision has out-edges d → y labelled yes and d → x with isDefault: true.

lit control door 1 door 2 door 3 run
(a) decision branch { expression: 'true' }, no label success=true registered valid: true, exit 0 success=true, visited ["x","y"]: EVERY out-edge
(a) control: { label: 'yes', expression: 'true' } success=true registered valid: true, exit 0 success=true, visited ["y"]
(b) conditions: ['true'] success=true registered valid: true, exit 0 success=false, condition evaluation error: A structural condition …
(c) loop with iteratorVariable and a one-node body, no collection success=true registered valid: true, exit 0 success=false, loop 'l': config does not satisfy the loop contract — config.collection: Invalid input
(c) control: the same loop with collection: [1, 2] success=true registered valid: true, exit 0 success=true, the body ran twice
(d) map with flowName, no collection success=true registered valid: true, exit 0 success=false, map 'm': config does not satisfy the map contract — config.collection: Invalid input

Further shapes measured on the run leg only: conditions: {} and conditions: 5 fail with conditions is not iterable; conditions: 'x' fails with a condition evaluation error, because a string is iterated character by character; a null branch fails with Cannot read properties of null; label: null, '', 0, ' ' and 42 all run green down both out-edges. The last two also log the engine's unclaimed-label warn. conditions: null routes like no conditions at all, which is legal and stays admitted.

After, measured on the merged branch (spec dist built from this head; the CLI reads it at run time)

lit control door 1 door 2 door 3
(a) no label custom at nodes.1.config.conditions.0.label throws the same issue valid: false, exit 1, custom at flows.0.nodes.1.config.conditions.0.label
(a) control success=true registered, run visits ["y"] valid: true, exit 0
(b) conditions: ['true'] custom at nodes.1.config.conditions.0 throws the same issue valid: false, exit 1, same path
(c) loop, no collection custom at nodes.1.config.collection throws the same issue valid: false, exit 1, same path
(c) control success=true registered, body ran twice valid: true, exit 0
(d) map, no collection custom at nodes.1.config.collection throws the same issue valid: false, exit 1, same path

The fix: one judge, three doors

  • packages/spec/src/automation/flow-node-config-refusals.ts (new). It lives beside the walk file, not inside it, because it reads the executor contracts, and two of their modules import flow-node-expression-paths.ts. A static import there closed a cycle that read LEDGER_DECLARED_NODE_CONFIG_SCHEMAS mid-evaluation (measured: z.toJSONSchema(undefined) in 4 spec tests).
    • getBuiltinNodeConfigContracts() maps a node type to the very schema its executor hands parseNodeConfig, plus loop's parse condition (a legacy flat-graph loop with no body is not parsed, so it stays exempt). It is built on first use, never at module load.
    • flowNodeConfigRefusals(nodeType, config) has two arms:
      • Contract arm. It parses config ?? {} against the contract, on the executor's own condition, and keeps an issue ONLY where the key it names is absent from what was authored. A present value of the wrong type, or an undeclared key, stays where it is judged today. It also skips issues inside an ADR-0031 region (walked as its own graph) and inside a ledger value slot (fields.*, assignments.*), where a malformed envelope is the value-envelope pass's finding. A plain requirement gets node-config-key-missing with a prescription naming the key and the node type. A requirement a rule of the contract states gets node-config-key-required-by-rule, carrying the contract's own message: a notify with no template needs a title, and a lookup screen field needs its reference.
      • Decision arm. decision is parsed by nothing at run time, so this arm states what its executor reads: conditions present and not null is an array (decision-conditions-not-array); every branch is an object (decision-branch-not-object); every branch's label is a non-blank string (decision-branch-label-missing, with found: absent, null, blank, or the kind).
  • packages/spec/src/automation/flow-node-expression-paths.ts: the five new codes join FLOW_SLOT_REFUSAL_CODES and FlowSlotRefusalParams in feat(formula,spec): stable refusal codes and params beside every expression refusal message #20352's shape (a stable kebab-case code and typed params beside the message). FlowNodeConfigRefusal adds the path inside config. One walk fix: an ARRAY element is no longer read as an object missing its slot, so conditions: [['true']] gets one refusal (a branch that is not an object), not also a missing expression.
  • packages/spec/src/automation/flow.zod.ts: a FlowSchema.superRefine block calls the judge for every node, walked with collectFlowGraphs, so a node in a region body is anchored where the author wrote it. It is a presence rule, not a key-set closure.
  • registerFlow: no engine change. It parses first (canonicalizeStoredFlow → FlowSchema.parse), after the ADR-0087 conversions, so the parse's issue is what it throws.
  • packages/lint/src/validate-expressions.ts: validateStackExpressions calls the same judge, for a stack handed to it with no parse in front. One exception: a script's absent function stays the existing callable check's finding. That check reads the pre-conversion spellings this pass may be handed (the functionName alias, the retired dispatch keys) and names each.

Route choices.

  1. The judge parses the executor's contract and keeps absences. It is not a hand-written table of required keys. Business need: the census is 18 keys over 13 node types, and the contract already states every one of them, rule-required keys included. Long-term design: a table beside the contracts would be a second statement of them, free to drift; the ratchet below reads the executors' own parse calls instead. Guarding AI authors: both forms refuse loudly, but this one uses the contract's own words where the contract has them. No scope growth: keeping absence only leaves every other contract finding exactly where it was.
  2. The label rule is "a non-blank string", wider than "the key is absent". Every one of those values sends the matched branch down every out-edge (measured above), and the repo already has one notion of blank (NON_BLANK_STRING). Refusing only the absent key would leave label: '', which misroutes identically.
  3. A non-array conditions is refused as well as a non-object branch. It is the container of the same branch walk, and every non-null non-array value fails the run (measured above).

Acceptance notes

Family census: every key a contract-parsing builtin's executor requires

Enumerated from the code: every parseNodeConfig(…) call under service-automation/src/builtin/, cross-checked against the expression-path ledger's required-key reconciliation. The channels require loop.collection and map.collection, which that ratchet had recorded as "no door refuses their absence". It now asserts the judge refuses both. Door status was measured by the probes above on a88a1bb399 (before) and on this branch (after), each row beside its whole-config accept control, which is admitted at every door both before and after.

node kind key left out runtime reader before: parse / register / validate after: all three doors
get_record objectName crud-nodes.ts:264 admit / admit / admit refused at config.objectName
create_record objectName crud-nodes.ts:335 admit / admit / admit refused
update_record objectName crud-nodes.ts:484 admit / admit / admit refused
delete_record objectName crud-nodes.ts:578 admit / admit / admit refused
notify recipients notify-node.ts:250 admit / admit / admit refused
notify title (no template; rule) notify-node.ts:250 admit / admit / admit refused, in the contract's words
http url http-nodes.ts:112 (parsed after interpolation; interpolation never adds a key) admit / admit / admit refused
screen fields[i].name screen-nodes.ts:158 admit / admit / admit refused at config.fields.i.name
screen fields[i].options[j].value / .label screen-nodes.ts:158 admit / admit / admit refused
screen fields[i].reference on a lookup field (rule) screen-nodes.ts:158 NOT MEASURED before (same arm) refused, in the contract's words (spec pin)
script function screen-nodes.ts:321 admit / admit / REFUSE (the lint callable check) refused at all three
subflow flowName subflow-node.ts:71 admit / admit / admit refused
map collection map-node.ts:96 admit / admit / admit refused
map flowName map-node.ts:96 admit / admit / admit refused
loop (with body) collection loop-node.ts:85 (the no-body form returns at :72 unparsed) admit / admit / admit refused
parallel branches parallel-node.ts:68 admit / admit / admit refused
try_catch try try-catch-node.ts:100 admit / admit / admit refused
decision a branch label (absent, null, blank, non-text) logic-nodes.ts:48-74 (branchLabel: cond.label) admit / admit / admit refused at config.conditions.i.label
decision a non-object branch; a non-array conditions logic-nodes.ts:48-51 admit / admit / admit refused

Controls that stay admitted: a legacy loop with no body and no collection (it still runs); a decision with no conditions, conditions: null or []; assignment, wait and plugin node types; a present value of the wrong type (objectName: 42); an undeclared key.

Outside this census's definition (no parseNodeConfig), measured, not fixed here: connector_action with no connectorConfig is admitted at all three doors and refused at run (connectorConfig.connectorId and .actionId are required). It belongs to the same family, but its input is a FlowNode sibling block, like wait's waitEventConfig, which FlowSchema already requires. Reported to the seat.

Producer census

  • examples/** at this head: app-crm 1 flow, 8 nodes; app-showcase 30 flows, 139 nodes; app-todo 4 flows, 26 nodes. 0 refusals. app-multi-package declares no flows.
  • packages/**, non-test: no default flow carries a judged node. The Studio create seed for flow has nodes: [].
  • objectui at the .objectui-sha pin f8a9d0fb (not edited):
    • FlowObjectListField rowsToList drops a blank cell. So a decision branch row with an empty Label cell is written as { expression }, which is now refused (decision-branch-label-missing). The same writer serves the screen Fields repeater, so a field row with an empty Name cell is written without name, also refused.
    • previews/flow-canvas-parts.tsx defaultNodeExtras seeds a new node with no config, or a partial one: http gets { method: 'GET' } (no url), notify gets { channels, recipients: [] } (no title), and CRUD / script / subflow / map / parallel / try_catch get nothing. So a node added and saved before it is configured is now a loud save error. The designer's live Zod pass (clientValidation.ts → FlowSchema) will locate it at the node's config path once objectui takes this spec.
  • cloud: NOT MEASURED (no checkout in this container; the dispatch named examples, packages and objectui).

Fixture triage (disposition per fixture, never a batch rename)

  • Completed (the fixture was never runnable; it now carries the key its executor requires): skeleton flows in spec flow.test.ts, region-normalization.test.ts, flow-region-pause-and-end.test.ts, api/zod-issues-to-fields.test.ts; service-automation engine.test.ts and 7 sibling files where script / notify / map nodes stand in for mock executors; runtime automation-put-post-error-parity, automation-register-error-class and automation-flow-clone; types validation-failure.test.ts; verify automation-trigger-terminal-messages.test.ts. The two collectFlowGraphs scope and path pins now chain through try as well as catch, because a try_catch without try is refused.
  • Re-judged in place (the pin was about the executor's refusal of a missing key, which the build door now answers first): config-parse.test.ts (7), guard-refusal-inventory.test.ts (7 rows), http-nodes, notify-node (2), subflow-node. Each now asserts the door refusal, then registers the node WHOLE and strips the key from the stored flow, so the executor's guard is still pinned. screen-nodes.test.ts: a stored script carrying only retired dispatch keys no longer registers (leaves out function), rather than registering and refusing at run. conversions.test.ts: the flow parse is still blind to the tombstones, and now refuses the stripped node for its absent function.
  • The lint's script callable tests are unchanged. That check keeps a script's function, as described above.

Observed, not filed

  • A pre-conversion alias spelling (object, flow, functionName, to / subject) is refused as the canonical key's absence at a DIRECT FlowSchema.parse / defineFlow(). registerFlow, objectstack validate and defineStack convert first, so no measured producer meets it. Carrier: whoever retires those D2 aliases at 18 (the object alias's own docblock says it retires then); otherwise none.
  • A present value of the wrong type (objectName: 42, collection: '', function: '' outside the lint) is still admitted at the build doors and refused at run. That is the same family's type half, which this card's direction scoped out. Reported to the seat.

ADR-0087

  • One semantic D3 entry for the family, flow-node-config-required-keys-refused (major 18). It uses form D: the decision is stated in words, with no tracker numbers in author-shown text. registry.ts is regenerated.
  • No D2 conversion. No lossless repair exists: the platform cannot know the object, URL, collection, function or out-edge label that was left out, and no value it could write would keep what the flow did.
  • Changeset .changeset/20316-flow-node-config-required-keys-refused.md: @objectstack/spec and @objectstack/lint minor, BREAKING, with the FROM → TO table. service-automation gets none, because its diff is test files only.

Pins: each refusal at each door, with the lit control

  • spec flow-node-config-required.test.ts (FlowSchema.parse):
    • every census row, refused at its key with the judge's own message, beside its whole-config accept control;
    • the two rule-required keys, in the contract's own words;
    • region nesting;
    • the decision arm: six label values, four non-object branches, three non-array conditions;
    • controls: legacy loop, wrong type, undeclared key, value slot, assignment and plugin types, no-branch decisions.
  • spec flow-slot-refusal-codes.test.ts: one pin per new code (code, params, full message), the closed set split over three producers, a sweep that reaches every node-config code, and type-level pins.
  • service-automation node-config-required-keys.test.ts (registerFlow):
    • the same census, and the decision arm;
    • on the real executors: the refused shapes failed the run, and a matched branch with no label ran GREEN down both out-edges while its labelled twin took one.
  • service-automation builtin/node-config-contract-ledger.test.ts: the ratchet. The map equals every executor's parseNodeConfig call, schema by identity; loop alone parses on a condition; every builtin type is classified.
  • service-automation config-expression-ledger.test.ts: the cross-check described above.
  • lint validate-expressions.test.ts (validateStackExpressions): the same judge through the third door's pass.

Ablation: the pins can fail

The ablation ran once, on the committed state 7f3b9b6742, in one os-verify-lock hold. It went through scripts/ablation-replace.mjs in wrap mode, with the restore trapped on EXIT, INT and TERM.

  • Mutation. flowNodeConfigRefusals returns [] unless a global named ABLATION_20316_OFF is set. The anchor went from 1 hit to 0, and the blob from bd1d620a25b6 to 353f92191f9d. After pnpm --filter @objectstack/spec build, ablation-dist-preflight.mjs @objectstack/spec ABLATION_20316_OFF found the marker in 20 built files.
  • Red, in the expected direction:
    • spec, the two pin files: 41 failed, 35 passed. Every refused row at FlowSchema.parse went red, as did the five new-code pins and the sweep. Every accept control and CONTROL block stayed green.
    • service-automation, three files: 21 failed, 47 passed. Every refused census and decision row at registerFlow went red, as did the ledger cross-check. The accept controls, the run-time "what it did" pins and the contract-ledger ratchet stayed green.
    • lint validate-expressions.test.ts: 7 failed, 345 passed. The new rows went red; the callable-check rows stayed green.
  • Restore leg. The blob is back to bd1d620a25b6, equal to HEAD, and git diff HEAD is empty. After a rebuild, --absent found the marker in none of the 222 built files, and the working tree is clean. Then spec 76/76, service-automation 68/68, lint 352/352.

Verification

Branch head 8171d8537e: origin/main 15bf186f50 merged as 725ffa325e, plus test-only commits after it. Every heavy run went through os-verify-lock, on a closure built from the merge head (turbo run build --filter=@objectstack/cli..., 59/59). The box was shared.

  • spec, full suite, both projects: 593 files, 17196 passed, 1 todo. Run on 725ffa325e; no spec source changed after it.
  • service-automation, full: 149 files, 1818 passed. Run on 725ffa325e; no source changed after it.
  • lint, full: 113 files, 4712 passed, on 7f3b9b6742. validate-expressions.test.ts again on 8171d8537e: 352/352.
  • runtime src/domains: 77 files, 1436 passed, on 7f3b9b6742.
  • Smaller suites. types: 23 files, 692 passed. verify: 15 files, 116 passed. metadata-protocol, its 10 flow files: 207 passed. plugin-approvals, its 5 flow files: 344 passed. cli --project unit, its flow-building files: 8 files, 112 passed.
  • Typecheck exits 0 for spec (test layer included), service-automation, lint, runtime, types and verify.
  • Gates. dispatch-gates.mjs --commands on 8171d8537e derives 102 families; 100 exit 0. --ran accounts for all 102: 100 run, 2 NOT MEASURED.
    • check:dual-build-cjs-loads: PREREQUISITE NOT MET. 9 packages outside this diff have no dist/.
    • check:type-check-debt: its --re-measure runs a whole-tree build outside the lock. This diff adds no package.
  • eslint. eslint --no-inline-config --format json over this branch's 36 changed .ts files: 36 files, 0 errors, 0 warnings.
    • Population: eslint.config.mjs has files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], and none of the 36 files is ignored.
    • Invariance: the config sets no parserOptions.project, so no linting is type-aware, and this diff cannot move a verdict on an untouched file.
  • Declared narrowing. Consumer packages beyond these ran on their flow-bearing test files only. The rest of the consumer farm is CI's: cli integration tests, dogfood, and the whole suites of runtime, metadata-protocol and plugin-approvals.
  • Behind main. The branch is 7 commits behind origin/main 7db1332f19. A merge-tree probe with no merge driver (a bare shared clone) merges clean. check:migration-registry on that probe merge reports registry.ts current.

Governed surface

skills/objectstack-automation/references/_index.md is regenerated by gen:skill-refs (byte-equal to the generator). flow.zod.ts now reaches schemaless-node-config.zod.ts transitively, so the index gains that one dependency row. Readings: that file 42 → 43 lines; the published catalog's SKILL.md total 4402 → 4402, unchanged.

维护者速读(草稿)

  • 改了什么:流程节点的配置如果缺了执行器必需的字段(比如查询节点没写对象名、循环节点没写要遍历的集合、决策分支没写标签),现在保存或校验时就会直接报错,不再等到流程运行时才失败或走错分支。
  • 为什么改:以前这类流程能正常保存、正常发布,运行到那个节点才失败;决策分支没写标签更糟,流程会"成功"但同时走了所有分支,业务上悄悄出错。
  • 风险与代价(含回滚):已有流程里若有这类残缺节点,升级后会被拒绝注册(启动日志会点名),需要补上字段;Studio 设计器里新加的节点在配置完成前保存会报错。回滚即撤销本 PR。
  • 席位意见:
  • 你要做的:无需操作;如需确认,关注 objectui 设计器对"空标签行、未配置新节点"的保存报错体验(另立卡跟进)。

Generated by Claude Code

…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>
…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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 28 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json, packages/spec/src/automation/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 40b315b03345e334069dd454aecaf7016adbea4f.

⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json, packages/spec/src/automation/index.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 40b315b03345e334069dd454aecaf7016adbea4f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from aef8edecd93d24cd27b01be89e4d3bece6199890 — the merge of head 8171d8537e9efe68310733e9fa4cc9a51f5a2f4a into base 40b315b03345e334069dd454aecaf7016adbea4f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 40b315b03345e334069dd454aecaf7016adbea4f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 84/84 CONTRACT_REVIEW_TIER
Head-sha: 8171d8537e9efe68310733e9fa4cc9a51f5a2f4a

① Derived judgments

  1. Judge true to executors. All 13 parseNodeConfig sites (crud ×4, notify, http, screen, script, subflow, map, loop, parallel, try_catch) parse unconditionally except loop, whose if (raw.body == null) return is mirrored exactly by parsedWhen: body != null (ratchet pins the source line and schema identity). Zod 4.6.1 probe: an absent z.unknown() key IS refused ("expected nonoptional"), so the options[j].value row holds. Live probe of the judge on an exported copy of the head: every census key refused at its path; wrong-type (objectName: 42, collection: '', function: ''), undeclared key, legacy loop, body: null, 1-branch parallel, template-only notify all stay admitted. No over-refusal, no missed key.
  2. Decision arm. Engine: if (result.branchLabel) (truthiness) then e.label === branchLabel; unclaimed → warn and EVERY out-edge. So absent/null/''/0/false/42/object all misroute; a blank string could only be claimed by an edge labelled blank (FlowEdgeSchema.label is z.string().optional()), so "non-blank string" is a sound necessary rule. Examples' 3 decisions route by edges; docs/README branches all carry labels; label: 'default' passes. Note (non-blocking): DecisionConditionSchema.label stays bare z.string(), so declared is now looser than enforced on blank.
  3. Three doors. Codes module stays a leaf (its only import is NON_BLANK_STRING); the judge reads the contract schemas only inside getBuiltinNodeConfigContracts(); FlowSchema.superRefine walks collectFlowGraphs (probe: loop-body and parallel-branch inner nodes anchored at nodes.1.config.body.nodes.0.config.url / …branches.0.nodes.0.config.flowName); lint applies the same call inside the per-graph node loop, with only the script/function exclusion. Not alias-aware at a direct parse (probe: object, functionName, flow, to/subject refused as the canonical key absent). registerFlow (canonicalizeStoredFlow → FlowSchema.parse) and CLI validate (normalizeStackInput) convert first. Sweep of the 20 in-repo defineFlow() callers: the only alias-shaped keys are start.config.timeRelative.object and a create_record.fields.subject, neither judged; Dogfood gates are green. objectui clientValidation.ts:681 parses FlowSchema with no conversion, so it relies on stored rows being canonical (the engine's persistence half).
  4. Codes. Five codes in FLOW_SLOT_REFUSAL_CODE_TABLE satisfies Record, typed params, kebab-case, path added; no tracker numbers in any message; prescriptions true. Nit: node-config-key-missing says "so the flow registers, and then every run … fails there" in present tense at the door that now refuses the flow; prescription still right. Smallest fix: "used to register".
  5. Fixture triage (20 files read). Completed: engine.test (script → function: 'noop' under a mock executor), fault-edge, activation-ledger (map collection), runtime ×3, types, verify, spec flow/region/pause, lint region fixtures. Re-judged with the executor pin kept via register-whole-then-strip: config-parse (7), guard-refusal-inventory (7 rows, expect fragments unchanged), http/notify/subflow. screen-nodes script 的 config 契约要接入 #4277 的执行期 parse,先得有判别式(actionType)形态 #4343 rows now pin the door refusal plus getFlow null; the stripped-script run guard is still pinned in config-parse. conversions.test keeps the tombstone-blindness pin and adds the function absence pin. No assertion weakened or deleted.
  6. D3 entry. Form D holds (only ADR refs, as fix(spec)!: refuse a decision branch with no expression — absent or null — at all three doors (#19961) #20315's entry); surface, replacement, reason true; the three boot-warn spellings exist in source; "no D2" is right, nothing lossless can supply the value.
  7. Skills hunk. Ran build-skill-references.ts --check (tsx) on an exported copy of the head: "9 generated files in sync", so _index.md is byte-equal. Only .zod.ts files are listed, which is why the judge module itself does not appear. SKILL.md line 65 ("decision routed by edge condition predicates, not node config") stays true.
  8. Changeset. Every keyed sentence checked true (options row, designer writers, unchanged list, boot behaviour); minor on spec and lint with the BREAKING banner; Clause-②: no (narrowing) stands alone; marker adr-0087: registered flow-node-config-required-keys-refused matches the entry id.

② Semver level

minor BREAKING on @objectstack/spec and @objectstack/lint is correct: the narrowing arm is BREAKING per AGENTS.md, major is refused pre-GA, and #20315 shipped the same shape the same way. On the value: api-surface/automation.json gains 6 rows and FLOW_SLOT_REFUSAL_CODES 5 codes; #20352 declared yes for exactly that kind of growth, #20315 grew no api-surface, and triage wrote yes (narrowing). So yes (narrowing) is the reading that states both facts; no (narrowing) understates the widening half. Level, banner and ADR-0087 arm are unaffected, so non-blocking; a one-word fix in the changeset and PR body.

③ Boundary flags

  • connector_action with no connectorConfig: same family (build admits, run refuses), but its input is a FlowNode sibling block like wait.waitEventConfig, which FlowSchema already requires (probe: custom@nodes.1.waitEventConfig); outside this card's parseNodeConfig census by definition; follow-up card should require connectorConfig on FlowNodeSchema the same way.
  • objectui at pin f8a9d0fb: rowsToList trims and drops blank string cells; defaultNodeExtras seeds http { method: 'GET' }, notify { channels, recipients: [] }, script {}; all become loud save errors located by the designer's FlowSchema pass; needs the objectui card and back-link, not this PR.
  • Present-but-wrong-type values (objectName: 42, collection: '', function: ''): still admitted at the doors (probe), refused at run; the family's type half, scoped out by the direction.
  • Driver-free merge-tree onto current origin/main (40b315b0) is clean (tree d7d4519fe3, no conflicted file); check:migration-registry run on that merged tree reports registry.ts current (305 semantic). CI at write time: 27 success, 3 skipped, 4 in progress (Test Core 1/5/6, Lint & Repo Gates), 0 failures.

Implemented-by: claude/issue-20316-flow-node-config-build-doors
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

PR #20416(修复 #20316,一并解决 #20317)· domain:spec 2 号座位(session_01QcAS3qiYYZNezaxZxaUdMV)· 2026-09-28T10:17Z

改了什么

流程节点的配置如果缺了执行器必需的字段,现在在三个入口都会直接报错,不再等到流程运行时才失败或走错分支:

  • 入口:FlowSchema 解析、registerFlow 注册、os validate 校验。
  • 例子:查询节点没写对象名、循环节点有循环体却没写要遍历的集合、决策分支没写标签。
  • 范围:共 13 种节点的 18 个必填键,外加决策分支的标签。

为什么改

以前这类流程能正常保存、发布,运行到那个节点才失败。决策分支没写标签更糟:流程"成功"了,但同时走了所有出边,业务上悄悄出错。执行器本来就要求这些字段,这次让构建期和运行期的要求一致。

风险与代价(含回滚)

席位意见

建议合并。at-tier 复核 PASS(记录 5867808391):

  • 18 个键逐个对照执行器源码,并做了实测,既没多拒也没漏拒;
  • 被改动的 20 个测试文件,没有一处断言被放宽或删除。

两处不阻塞的小问题,未改动已复核的 head:

  1. changeset 的 Clause-② 写成 no (narrowing),更准确应是 yes (narrowing):本 PR 也新增了 5 个导出的错误码。版本级别仍是 minor 带 BREAKING,不受影响。
  2. 一条报错文案仍是"流程会注册成功"的现在时,已随 objectstack#20418 一并修。

另外,connector_action 缺 connectorConfig 同属这一类问题,但不在本卡范围内,已另立 objectstack#20418。

你要做的

审阅后直接合并本 PR(Tier H 由你人工合并即为审核记录)。

Merged via the queue into main with commit 7dc45eb Sep 28, 2026
44 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-20316-flow-node-config-build-doors branch September 28, 2026 10:46
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

3 participants