fix(service-automation)!: a flow the kernel:ready cold-boot bind refuses is withdrawn, not left registered and active from the boot pull - #21897
Conversation
… both doors (red on main) Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…the executor-declared contract; the approval node declares its own Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…own path Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…declared config contract; changeset Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…de-config-values-at-registration
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 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 771b0613a5d4cbc1638a6c1aa8b66631506ee272 && git checkout 771b0613a5d4cbc1638a6c1aa8b66631506ee272
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e6dc7a240617eaeef9a64e788bf6e5561c107f1b f39f5933446ad58def242874f24b9be910ed9feb && git checkout -B drift-repro e6dc7a240617eaeef9a64e788bf6e5561c107f1b && git merge --no-ff f39f5933446ad58def242874f24b9be910ed9feb
node scripts/docs-audit/affected-docs.mjs --json e6dc7a240617eaeef9a64e788bf6e5561c107f1b
|
…de-config-values-at-registration
…odeExecutor.configContract — FlowSchema now judges the approval contract; keep the cold-boot withdraw Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…proval judge plus a plugin-node withdraw pin; changeset and docs say what the PR still does Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…de-config-values-at-registration
|
CI note from
Generated by Claude Code |
…de-config-values-at-registration
Fixes #21848
Clause-②: no (narrowing)
Current scope: patch round 1 (after #21893 landed), what was dropped and what remains
Dropped. #21893 (merged as
866683f96f) made the approval node's config contract part ofFlowSchema: the flow parse now judges an approval node's config againstApprovalNodeConfigSchema, whole.registerFlowparsesFlowSchemafirst, so the admin door and the package-load boot pull already refuse an out-of-range escalation and an undeclared escalation key, located at the config path. Per the seat's decision5998929933, there is now one judge in the spec, so this PR drops its own:NodeExecutor.configContract(the optional published member) is removed, and so areAutomationEngine.validateNodeConfigValuesand its call inregisterFlow.engine.tsis byte-identical tomain(blob597e506a7711).formatIssuePathexport inbuiltin/parse-config.tsis removed (nothing else needed it). The file is byte-identical tomain.plugin-approvalsends with no change.approval-node.tsis byte-identical tomain(blobe2027bb0c675).approval-node-config-contract.test.tsis deleted: everything it pinned depended onconfigContract, and the engine-level refusal it also covered is feat(spec)!: the build doors judge an approval node config against its declared contract, whole — an undeclared key or a refused value is refused with a location #21893's pin.@objectstack/plugin-approvals.Remains.
service-automation/src/plugin.ts). This is a separate, measured defect. The boot pull registers a package's flows before a plugin that contributes a node type has registered its executor, and with it the descriptorconfigSchemathe key check reads, so the pull registers such a flow unchecked and arms it. Thekernel:readybind then re-registers the flow and refuses it, but used to only warn, so the flow stayedactiveand bound. The bind now withdraws a flow it refuses, and only that flow. Since feat(spec)!: the build doors judge an approval node config against its declared contract, whole — an undeclared key or a refused value is refused with a location #21893, approval configs are refused at the boot pull itself, so the withdraw is measured on a plugin node type that the spec does not declare.packages/qa/dogfood/test/flow-node-config-values-at-registration.dogfood.test.ts) now pass through the spec's judge. Re-measured at7333fd869d: 6 passed.404. The boot line names each flow and the located path (nodes[1].config.escalation.timeoutHours,nodes[1].config.escalation.bogusKey). The valid escalation registersactiveand runs: it opens one pending approval request.kernel:readybind and answers404. Its valid sibling answers200.400 VALIDATION_FAILED, withdetails.fieldscarryingnodes.1.config.escalation.timeoutHoursandnodes.1.config.escalation.bogusKeyrespectively.GETanswers404for both. The valid escalation registers.node 'gate'became the spec's located path.service-automation/src/flow-cold-boot-refusal-withdraw.test.tsreplacesnode-config-values-at-registration.test.ts. The withdraw is tested on a realLiteKernel, with the plugin node type registered from a later plugin'sstart(). The control shows that the same body registers when no plugin contributes the type.scripts/ablation-replace.mjs): the withdraw call was replaced by a marker statement (blobe36e7077781bto5f67fd9d27bd) and present indist/perablation-dist-preflight. Results: unit 1 failed / 1 passed (the withdraw pin red, the control green); dogfood 1 failed / 5 passed (only the plugin-node package-load pin,200 active). Restore: blob equals HEAD andgit diff HEADis empty. After a rebuild,--absentreads the marker absent from all 6 built files, with the tree clean.no (narrowing). Nothing published widens now, and the withdraw narrows what stays loaded at boot. The changeset isminorwith the!banner for@objectstack/service-automationonly. The ADR-0087 marker staysnot-required (no-migration-prescription).content/docs/automation/flows.mdxnow says the flow parse judges an approval node's config whole, and that a flow refused at boot is not left registered.Seat's restructure (
domain:servicesseat 1, #6021): the section below is the dev's patch-round-1 notes and states what this PR ships now. Everything under Round 0 record describes the first build; where it namesNodeExecutor.configContract,validateNodeConfigValues, theformatIssuePathexport or the approval executor's declaration, it is superseded (seat decision5998929933on #21848).Round 0 record
What this changes
A flow node's config VALUES are now judged where the flow registers, by the schema the node's executor parses at run time. Before, registration read a node's config against the descriptor's JSON-Schema
configSchemafor its key NAMES only, so an approval node withescalation.timeoutHours: 0.5(the contract says>= 1) registered throughPOST /automation, loadedactivefrom a package, and then failed every trigger-fired run at the approval node with no approval request opened.service-automation/src/engine.ts.NodeExecutorgains an optionalconfigContract(the structuralsafeParseview the builtin executors already parse through,NodeConfigContract).registerFlowruns a newvalidateNodeConfigValuesright after the key-name check: every node whose executor declares a contract has itsconfig ?? {}parsed with it, in every graph (collectFlowGraphs, so region bodies too), and any finding refuses the flow. The refusal mirrors the key-name one: a plainError(nocodeorstatusof its own), the flow name, then one line per finding with the node id, its type and the config path in the execute-time spelling (config.escalation.timeoutHours), followed by the contract's own sentence. No hand-written value check and no JSON-Schema validation: the executor's own contract is the one judge.plugin-approvals/src/approval-node.ts. The approval executor declaresconfigContract: ApprovalNodeConfigSchema, the very object itsexecutealready parses.ApprovalAutomationSurfacegains the matching optional member.executeis unchanged.service-automation/src/plugin.ts(measured necessity, H5). Thekernel:readycold-boot bind withdraws a flow it refuses. See H5 below for why the package-load door needed this.service-automation/src/builtin/parse-config.ts.formatIssuePathis exported from the module so both refusals spell paths alike. The package entry (index.ts, the onlyexportsentry) does not re-export it, so no symbol is added to any package entry.content/docs/automation/flows.mdx. Theconfigrow and the config callout now say registration also judges values for a node type whose executor declares its contract..changeset/21848-node-config-values-at-registration.md:minorfor both packages,!banner,Clause-②: no (narrowing), handling = correct the value at the located path.Mechanism hypotheses, measured
origin/main(the dogfood file below atbe7450c78e, before the fix):GET /automation/esc_value_packaged_sub_houranswered200withstatus: active;POST /automationwith the same body answered200; the trigger-fired run failed withApproval node 'gate' has invalid config: escalation.timeoutHours: Too small: expected number to be >=1. 3 failed, 2 passed (the two controls).descriptor.configSchema, a JSON Schema (the approval node publishesgetApprovalNodeConfigJsonSchema(), az.toJSONSchemaprojection). No node type registered the Zod schemaexecuteparses. The smallest change that reaches it is an optional executor member, so it touchesservice-automation(the member and the judge) andplugin-approvals(the declaration). JSON-Schema validation was not used: the projection drops what JSON Schema cannot say, and the approval contract has such a rule (asuperRefinerefusingfallbackApproversbeside a policy that never reads it), which this PR refuses at registration and pins.configSchema). After this PR that is every node type exceptapproval: all builtins (get_record,create_record,update_record,delete_record,notify,http,screen,script,subflow,map,loop,parallel,try_catchparse their contract insideexecute;decision,assignment,wait,connector_actionare schemaless or read sibling blocks),approval_revise(schemaless, parses nothing; pinned that it declares no contract), and every third-party node type. No schema was invented for any of them.400witherror.code: VALIDATION_FAILED, messageFlow 'esc_probe_door' rejected: 1 config value(s) the node's own contract refuses.then- node 'gate' (approval): config.escalation.timeoutHours: Too small: expected number to be >=1, anddetails.fields[0]={ field: '(body)', code: 'invalid_value' }, exactly the envelope the undeclared-key refusal gets (the runtime domain'sflowDefinitionRefusalmaps any plain error fromregisterFlowthis way).mainthe boot pull (AutomationServicePlugin.start()) registers package flows BEFOREApprovalsServicePlugin.start()registers theapprovalexecutor (boot log: the threeFlow registered: esc_value_packaged_*lines, thenNode executor registered: approvalabout 12 ms later), so the pull cannot judge approval nodes. Thekernel:readybind re-registers every flow once the executor exists, and refused the flow with a WARN, but the boot pull's registration stayed: measured with anescalation.bogusKeyflow (the existing key-name refusal) that the WARN refused whileGET /automation/...kept answering200 activeand the record-change trigger stayed bound. The same hole would have swallowed this PR's value refusal at package load. The bind now withdraws a flow it refuses (withdrawFlow, only the refused name; a failed or empty READ still tears nothing down). After: the sub-hour flow and the bogus-key flow both answer404after boot, each with one located WARN ([Automation] cold-boot flow bind: failed to register flow), and the rest of the package loads: only the flow is refused, never the package, matching how the boot pull already treats a flow it refuses.Tests
All at
5fdd32adc9(the final commit) unless named otherwise:pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/node-config-values-at-registration.test.ts: 7 passed. Engine pins: 0.5 refused with flow, node and path, and nothing registered; the value refusal and the key-name refusal are the same class (plainError, nocode, nostatus, same prototype); a valid escalation registers unchanged; thesuperRefinerule refuses atconfig.onEmptyApprovers; a region-body node is judged and named with its region; an executor without a contract keeps key-name-only judgement. Boot pin (realLiteKernel, boot pull plus protocol view serving the same packaged flows, the executor registered from a later plugin'sstart()): the refused flow is withdrawn and unbound, the valid one stays bound.pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/approval-node-config-contract.test.ts: 2 passed. The declared contract isApprovalNodeConfigSchemaitself (identity),executerefuses what it refuses,approval_revisedeclares none; on the real engine 0.5 is refused, located, andtimeoutHours: 1registers.packages/qa/dogfood, new filetest/flow-node-config-values-at-registration.dogfood.test.ts(vitest run --project isolated --maxWorkers=2): 5 passed. Package load: the sub-hour flow answers404, and a boot line names the flow,node 'gate'andescalation.timeoutHours; the valid escalation in the same package registersactiveand runs (a record of its object opens one pending approval request,pending_approvers= the position). Admin door:400,VALIDATION_FAILED, the message names the flow,node 'gate'andescalation.timeoutHours, andGETanswers404; a valid escalation registers.2ef0c612b2(the code is byte-identical at5fdd32adc9:git diff 2ef0c612b2..HEAD -- packages/services packages/plugins packages/qais empty):@objectstack/service-automation173 files, 2117 tests passed;@objectstack/plugin-approvals61 files, 897 tests passed.pnpm --filter @objectstack/service-automation --filter @objectstack/plugin-approvals run typecheck: bothDone(tsc --noEmitpluscheck:test-typecheck, which compiles the test layer).pnpm --filter @objectstack/dogfood run typecheck: exit 0, andtsc --listFilesOnlyshows the new dogfood file in the program.ApprovalNodeConfigSchema: 36 flows, 19 approval nodes, 0 refused, so no shipped flow stops registering.Ablations (the refusal pins can fail)
Both through
scripts/ablation-replace.mjs(WRAP mode, its own restore trap) on the committed fix,service-automationrebuilt inside the leg andscripts/ablation-dist-preflight.mjsproving the marker indist/before any suite ran.validateNodeConfigValuescall replaced by a marker statement; anchor 1 to 0, blobb5e94fb23b67tobd7099e9875b). Preflight: marker present in 2 built files. The build's DTS step exited 1 (TS6133: the ablation leaves the private judge unused); the ESM and CJS bundles the suites load were emitted, which the preflight reading confirms. Results: service-automation 5 failed / 2 passed (the 2 are the valid-escalation and no-contract controls); plugin-approvals 1 failed / 1 passed; dogfood 3 failed / 2 passed (package-load 404, the boot line, the admin-door 400; the two controls pass).fb06fd8ad148to0d523279cf1c). Preflight: marker present indist/. Results: service-automation 1 failed / 6 passed (only the boot pin); plugin-approvals 2 passed; dogfood 1 failed / 4 passed (only package-load404; the boot line still locates the value, so the two halves are independent).git diff HEADis empty (the tool's own reading); thenservice-automationrebuilt (exit 0) andablation-dist-preflight --absentfor both markers: absent from all 6 built files, working tree clean.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat5fdd32adc9derived 95 commands; every one was run with its exit code recorded, and--rananswered95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN(exit 0). Three needed a rerun, each for a measured cause outside this diff:check-comment-mask-corpusread a scratch probe file mid-deletion (rerun 0);spec check:skill-examplesrefused on apackages/specdist built before themainmerge (spec rebuilt, all cache hits; rerun 0);check:dual-build-cjs-loadsrefused on 8 packages with nodist/(built, all cache hits; rerun 0). The 17 commands the dispatch named beyond that derivation (the spec export, ledger and doc families) were also run: all exit 0. ESLint on the 7 touched TypeScript files (--no-inline-config --format json): 7 files, 0 errors, 0 warnings; the config's own globs cover all 7, andeslint.config.mjsenables no type-aware linting, so this diff cannot move any untouched file's verdict. The repo-wide lint is CI's.ADR-0087 disposition
not-required (no-migration-prescription), in the changeset marker, with no D3 ledger entry and nopackages/specedit: no authorable key, spelling, export or stored shape moves,ApprovalNodeConfigSchemaandFlowSchemaparse exactly what they parsed,executeaccepts what it accepted, and which value an author meant is not something a ledger entry can rewrite. This is the disposition this repo's other runtime refusals of what already failed later declared.check-adr-0087-registration --base origin/main: exit 0.Overlap with the build-door card
#21850 (claimed in
domain:spec, in flight onclaude/issue-21850-approval-node-config-build-refusal) is not addressed here: this PR does not touchobjectstack validate/compile,flow-node-config-refusals.tsor any spec file. Its WIP judges the approval contract whole insideFlowSchema's superRefine, whichregisterFlowparses first; once it lands, an approval value refusal will surface from that parse (a Zod error mapped throughfieldsFromZodIssues) before this judge is reached, and this judge keeps covering every node type whose executor declares its contract without the spec knowing it. The dogfood pins assert the status, the code and the located path (escalation.timeoutHours,node 'gate'), not a full sentence, and build the fixture withstrict: falseso the build door does not stop the invalid body before the runtime doors it pins. The measured boot hole above also bears on that card's repro step 3: onmainthe unknown-key flow was not dropped at boot, it stayedactiveand bound; after this PR it is dropped.Acceptance notes
5fdd32adc9:POST /automationwith acreate_recordnode carryingoutputVariable: 42answered200, andPOST /automation/:name/triggeranswered400 FLOW_FAILEDwithconfig does not satisfy the create_record contract — config.outputVariable: Invalid input: expected string, received number. Wiring the builtins is not mechanical (httpparses after interpolation,loopparses conditionally, the region containers' contracts contain their regions) and is not in this card's file surface.kernel:readyhandler, after this plugin's bind, would leave a boot-pulled flow judged only on later registrations. No in-repo node type does this (the builtins register atinit,approvalatstart). Not measured further.PUT /meta/flow/:name) re-arms through the mutation sync, whose refusal handling this PR does not change. Not measured.Generated by Claude Code