fix(spec)!: refuse a connector_action node its executor cannot dispatch — no connectorConfig block, or a blank connectorId / actionId — at all three doors (#20418) - #20453
Conversation
…h at the flow parse (wip) Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…ector_action node; the guard row reaches the executor past the doors Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…lock requirement Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…nnector-action-config-required
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 137 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 0eb5b2b728cc7a7793abdac1508ac9727427674e && git checkout 0eb5b2b728cc7a7793abdac1508ac9727427674e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7fa3e3e07cc67877ae2f5d49eac42665c0515fda c23d0a32119f795ef7c1344981d542bb0c5f66c2 && git checkout -B drift-repro 7fa3e3e07cc67877ae2f5d49eac42665c0515fda && git merge --no-ff c23d0a32119f795ef7c1344981d542bb0c5f66c2
node scripts/docs-audit/affected-docs.mjs --json 7fa3e3e07cc67877ae2f5d49eac42665c0515fda
|
…itEventConfig and boundaryConfig are required on their node type Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
|
Seat note for reviewers — REWORK round 1 at The PR body predates round 1 and is not edited. Round 1 adds one file:
The seat's verdicts are on #20418: REWORK Generated by Claude Code |
Contract reviewServed-tier: Inputs, and nothing else: card #20418 (body and all six comments — triage Check-runs on the head: 35 runs — 33 ① Derived judgments
② Semver level
③ Boundary flagsThe dev's report
Out-of-scope findings, one line each: [0] the objectui designer seed — carrier objectui#10948, the seat's disposition stands. [1] lint One reading for the seat, non-blocking and outside this card: the dev's control — a Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20418
Clause-②: no
A
connector_actionflow 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:connectorConfigblock — acustomissue atnodes.N.connectorConfig;connectorIdoractionIdblank (empty, or only whitespace) — acustomissue atnodes.N.connectorConfig.connectorId/.actionId.The changeset carries
Clause-②: no (narrowing)and the ADR-0087 dispositionregistered connector-action-config-required.What was wrong, measured on
origin/maine4d3f2cabefore the changeProbes drive door 1 (
FlowSchema.safeParse, specdist), door 2 (AutomationEngine.registerFlowwithinstallBuiltinNodesand a registeredprobeconnector, thenexecute), and door 3 (the BUILT CLI,node packages/cli/bin/run.js validate --json, in a stack directory holding oneobjectstack.config.ts).connectorConfigsuccess=truevalid: true, exit 0success=false:connector_action 'call': connectorConfig.connectorId and .actionId are required{ connectorId: 'probe', actionId: 'ping', input: {} }success=truevalid: true, exit 0success=true{ connectorId: '', actionId: '', input: {} }success=truevalid: true, exit 0success=false, the same guard messageactionId: ''onlysuccess=truesuccess=false, the same guard message' 'success=truesuccess=false:no handler for ' . ' — is the connector plugin registered?loopbody, noconnectorConfigsuccess=truevalid: true, exit 0success=false, the same guard messageloopbody, block completesuccess=truesuccess=trueAfter, measured on this branch (spec
distbuilt fromc81e639dbd; the merge ofmainsince touched no spec, service-automation or CLI validate source)customatnodes.1.connectorConfigZodError, the same issuevalid: false, exit 1,customatflows.0.nodes.1.connectorConfigsuccess=truesuccess=truevalid: true, exit 0customatnodes.1.connectorConfig.connectorIdand.actionIdvalid: false, exit 1, the same two paths underflows.0.actionId: ''onlycustomatnodes.1.connectorConfig.actionId' 'customat both idsloopbodycustomatnodes.1.config.body.nodes.0.connectorConfigvalid: false, exit 1,customatflows.0.nodes.1.config.body.nodes.0.connectorConfigsuccess=truesuccess=trueconfig: { connectorId, actionId }, no blockcustomatnodes.1.connectorConfig(a direct parse meets the pre-conversion spelling)flow-node-connector-config-liftD2 conversion lifts the complete pair first; runsuccess=trueconnectorConfig: {}invalid_typeat both ids only, as before (no second issue)Envelope per door: door 1 and door 2 answer the parse's Zod issue (
code+path;registerFlowhas no HTTPstatusof its own); door 3 answersvalid: false, exit 1 and the samecode+pathunderflows.K..The fix
packages/spec/src/automation/flow.zod.ts:connectorActionConfigRefusals(node)(module-private, besiderequireTypeScopedConfig), called from a new block in theFlowSchemasuperRefine that walkscollectFlowGraphs, like theflowNodeConfigRefusalswalk 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 underconfigare not read.registerFlowandobjectstack validate: no change. Both parse throughFlowSchemaafter the ADR-0087 conversions, so the parse's issue is what they answer.connector-nodes.tsis unchanged. It is still the refusal a node meets past the doors, andguard-refusal-inventory.test.tsstill classifies it as un-routable.Route choices
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, becauseparseFlowNodeRegionsleaves a refused region raw. The control on this tree is a block-lessboundary_event, whichrequireTypeScopedConfigrefuses today. At the top level it answerscustomatnodes.1.boundaryConfig. In aloopbody,FlowSchema.safeParseanswerssuccess=true, and onlyLoopConfigSchemarefuses it. So the suggested route would leave shape (c) admitted at all three doors. The walk refuses it at the path the author wrote.FlowNodeSchemaalone 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 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 took for itshttp/notifyseeds, and a test here pins the split.Blank ids are refused, not only an absent block. This is the rule 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 applied to a
decisionbranchlabel. 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 has two arms:NON_BLANK_STRING) and non-text values. Its reason: "refusing only the absent key would leavelabel: '', 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).!valuelets' 'through, but a connectornamemust match^[a-z_][a-z0-9_]*$, so whitespace names nothing a dispatch can reach (row (b),no handler).keyisz.string(), so an action keyed by whitespace alone would become unreachable. No such key is declared anywhere in this repo.No lint-side copy.
validateStackExpressions(lint) carriesflowNodeConfigRefusalsfor a stack handed to it with no parse in front. Thewait/boundary_eventblock rule has no lint copy either, and every door the card names parses first.Rider (same code table)
node-config-key-missinginflow-node-config-refusals.tsnow 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_MISSINGinflow-slot-refusal-codes.test.ts, moves with it. A repo-wide grep offlow registers, and thenfinds 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 insurface), and the step-18 tails ofregistry.tsregenerated bygen:migration-registry..changeset/20418-connector-action-config-required.mdcontains:@objectstack/specatminor(the launch-window convention);Clause-②: no (narrowing)and theregistereddisposition marker;**BREAKING**banner, a FROM → TO table and a one-line fix.check-adr-0087-registrationreads 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)
specflow.test.ts, "should validate a complete parallel approval flow". Its twoconnector_actionstand-ins getconnectorConfig: { connectorId: 'finance_desk' | 'legal_desk', actionId: 'request_review' }.service-automationconnector-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:registerFlowrefuses the block-less node (customatnodes.1.connectorConfig, and the flow is absent fromlistFlows());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 [finding] a decision branch with no label registers and validates clean, then at run time the decision takes EVERY out-edge; a non-object conditions element also registers #20316'sstrip), so the row still classifies the executor's own guard.run-summary.test.tsspells the trio underconfigon three nodes, andregisterFlow's D2 lift completes the block.Producer census: who writes a
connector_actionnode without a complete blockRead 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-messagingandcreate-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.
lintlint-flow-patterns.test.ts:2077(config: { connectorId: 'c', action: 'a' }, no block) is a lint-pattern fixture that never meetsFlowSchema. The lint suite is green, so it is left as is.FlowSchema's own@exampledocblock (flow.zod.ts):update_recordnode had noobjectName, which is already refused since 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 (measured one4d3f2ca:customatnodes.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/main328abeband atb120b66:defaultNodeExtras('connector_action')seedsconnectorConfig: { connectorId: '', actionId: '', input: {} }(packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx:397).FlowSchemapass (clientValidation.ts:681) flags it atnodes.N.connectorConfig.connectorId/.actionId.FlowNodeSchema.safeParseper seed) stays green by construction.Reported for the seat; objectui#10948 carries the family. The pinned
.objectui-shaf8a9d0fbis 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 listsconnectorConfigas "optional", as it doeswaitEventConfig, which is required forwait. 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 onsrc/automation src/conversions src/migrations: 39 files, 1484 tests passed.service-automation, targeted onconnector-nodes,guard-refusal-inventory,run-summary,node-config-required-keys,connector-materializationandengine: 6 files, 324 tests passed..tsfiles (--no-inline-config --format json): 10 files reported, 0 errors, 0 warnings. Three pieces of evidence for this narrowing:eslint.config.mjs's**/*.{ts,…}blocks minusNEVER_LINTED, and all 10 files are inside it.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
maintouched none of these packages:@objectstack/specvitest 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-slack3/10,connector-rest4/26,connector-mcp3/23,connector-openapi4/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/dogfoodtest/showcase-declarative-mcp.dogfood.test.ts(the flowconnector_actiondispatch end to end): 2 passed.@objectstack/speccheck:generated: all 15 generated artifacts up to date.Ablation (one-shot, not kept): run through
scripts/ablation-replace.mjson the committed tree, with atraprestore.connectorActionConfigRefusals(node)was replaced byconnectorActionConfigRefusals(null). The anchor count went 1 to 0, the replacement 0 to 1, and the blobbae1a5ccto20017fad.connector-action-config-required.test.tsthen read 7 failed, 4 passed: every refused row red, every control green.bae1a5cc, andgit diff HEADis empty.Gates:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat992656cea5derived 90 commands. All 90 were run and all exit 0;--ranwith recorded exit codes reports 90 derived, 90 run, 0 NOT-MEASURED.check:dual-build-cjs-loadsandcheck:type-check-debtfirst 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