Skip to content

fix(service-automation)!: a flow the kernel:ready cold-boot bind refuses is withdrawn, not left registered and active from the boot pull - #21897

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21848-node-config-values-at-registration
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21848-node-config-values-at-registration

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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 of FlowSchema: the flow parse now judges an approval node's config against ApprovalNodeConfigSchema, whole. registerFlow parses FlowSchema first, 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 decision 5998929933, there is now one judge in the spec, so this PR drops its own:

Remains.

  • The cold-boot withdraw (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 descriptor configSchema the key check reads, so the pull registers such a flow unchecked and arms it. The kernel:ready bind then re-registers the flow and refuses it, but used to only warn, so the flow stayed active and 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.
  • The door pins (packages/qa/dogfood/test/flow-node-config-values-at-registration.dogfood.test.ts) now pass through the spec's judge. Re-measured at 7333fd869d: 6 passed.
    • Package load. The sub-hour flow and the undeclared-key flow both answer 404. The boot line names each flow and the located path (nodes[1].config.escalation.timeoutHours, nodes[1].config.escalation.bogusKey). The valid escalation registers active and runs: it opens one pending approval request.
    • Package load, plugin node type. A flow with an undeclared key on that node type is refused by the kernel:ready bind and answers 404. Its valid sibling answers 200.
    • Admin door. Both approval refusals answer 400 VALIDATION_FAILED, with details.fields carrying nodes.1.config.escalation.timeoutHours and nodes.1.config.escalation.bogusKey respectively. GET answers 404 for both. The valid escalation registers.
    • Only the assertions that named this PR's old message text changed: node 'gate' became the spec's located path.
  • Unit pin service-automation/src/flow-cold-boot-refusal-withdraw.test.ts replaces node-config-values-at-registration.test.ts. The withdraw is tested on a real LiteKernel, with the plugin node type registered from a later plugin's start(). The control shows that the same body registers when no plugin contributes the type.
  • Ablation (scripts/ablation-replace.mjs): the withdraw call was replaced by a marker statement (blob e36e7077781b to 5f67fd9d27bd) and present in dist/ per ablation-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 and git diff HEAD is empty. After a rebuild, --absent reads the marker absent from all 6 built files, with the tree clean.
  • Clause-② returns to no (narrowing). Nothing published widens now, and the withdraw narrows what stays loaded at boot. The changeset is minor with the ! banner for @objectstack/service-automation only. The ADR-0087 marker stays not-required (no-migration-prescription).
  • Docs. content/docs/automation/flows.mdx now 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:services seat 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 names NodeExecutor.configContract, validateNodeConfigValues, the formatIssuePath export or the approval executor's declaration, it is superseded (seat decision 5998929933 on #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 configSchema for its key NAMES only, so an approval node with escalation.timeoutHours: 0.5 (the contract says >= 1) registered through POST /automation, loaded active from a package, and then failed every trigger-fired run at the approval node with no approval request opened.

  • service-automation/src/engine.ts. NodeExecutor gains an optional configContract (the structural safeParse view the builtin executors already parse through, NodeConfigContract). registerFlow runs a new validateNodeConfigValues right after the key-name check: every node whose executor declares a contract has its config ?? {} 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 plain Error (no code or status of 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 declares configContract: ApprovalNodeConfigSchema, the very object its execute already parses. ApprovalAutomationSurface gains the matching optional member. execute is unchanged.
  • service-automation/src/plugin.ts (measured necessity, H5). The kernel:ready cold-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. formatIssuePath is exported from the module so both refusals spell paths alike. The package entry (index.ts, the only exports entry) does not re-export it, so no symbol is added to any package entry.
  • content/docs/automation/flows.mdx. The config row and the config callout now say registration also judges values for a node type whose executor declares its contract.
  • Changeset .changeset/21848-node-config-values-at-registration.md: minor for both packages, ! banner, Clause-②: no (narrowing), handling = correct the value at the located path.

Mechanism hypotheses, measured

  • H1, confirmed. A red pin on origin/main (the dogfood file below at be7450c78e, before the fix): GET /automation/esc_value_packaged_sub_hour answered 200 with status: active; POST /automation with the same body answered 200; the trigger-fired run failed with Approval node 'gate' has invalid config: escalation.timeoutHours: Too small: expected number to be >=1. 3 failed, 2 passed (the two controls).
  • H2. Registration learned a plugin node's config keys only from descriptor.configSchema, a JSON Schema (the approval node publishes getApprovalNodeConfigJsonSchema(), a z.toJSONSchema projection). No node type registered the Zod schema execute parses. The smallest change that reaches it is an optional executor member, so it touches service-automation (the member and the judge) and plugin-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 (a superRefine refusing fallbackApprovers beside a policy that never reads it), which this PR refuses at registration and pins.
  • H3. Node types that declare no contract keep today's behaviour (key names only, where a descriptor publishes configSchema). After this PR that is every node type except approval: all builtins (get_record, create_record, update_record, delete_record, notify, http, screen, script, subflow, map, loop, parallel, try_catch parse their contract inside execute; decision, assignment, wait, connector_action are 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.
  • H4, confirmed. Located refusal, same class as the key-name refusal. Through the admin door the body is 400 with error.code: VALIDATION_FAILED, message Flow '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, and details.fields[0] = { field: '(body)', code: 'invalid_value' }, exactly the envelope the undeclared-key refusal gets (the runtime domain's flowDefinitionRefusal maps any plain error from registerFlow this way).
  • H5, measured, and it needed a second change. On main the boot pull (AutomationServicePlugin.start()) registers package flows BEFORE ApprovalsServicePlugin.start() registers the approval executor (boot log: the three Flow registered: esc_value_packaged_* lines, then Node executor registered: approval about 12 ms later), so the pull cannot judge approval nodes. The kernel:ready bind re-registers every flow once the executor exists, and refused the flow with a WARN, but the boot pull's registration stayed: measured with an escalation.bogusKey flow (the existing key-name refusal) that the WARN refused while GET /automation/... kept answering 200 active and 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 answer 404 after 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 (plain Error, no code, no status, same prototype); a valid escalation registers unchanged; the superRefine rule refuses at config.onEmptyApprovers; a region-body node is judged and named with its region; an executor without a contract keeps key-name-only judgement. Boot pin (real LiteKernel, boot pull plus protocol view serving the same packaged flows, the executor registered from a later plugin's start()): 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 is ApprovalNodeConfigSchema itself (identity), execute refuses what it refuses, approval_revise declares none; on the real engine 0.5 is refused, located, and timeoutHours: 1 registers.
  • packages/qa/dogfood, new file test/flow-node-config-values-at-registration.dogfood.test.ts (vitest run --project isolated --maxWorkers=2): 5 passed. Package load: the sub-hour flow answers 404, and a boot line names the flow, node 'gate' and escalation.timeoutHours; the valid escalation in the same package registers active and 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' and escalation.timeoutHours, and GET answers 404; a valid escalation registers.
  • Full suites at 2ef0c612b2 (the code is byte-identical at 5fdd32adc9: git diff 2ef0c612b2..HEAD -- packages/services packages/plugins packages/qa is empty): @objectstack/service-automation 173 files, 2117 tests passed; @objectstack/plugin-approvals 61 files, 897 tests passed.
  • pnpm --filter @objectstack/service-automation --filter @objectstack/plugin-approvals run typecheck: both Done (tsc --noEmit plus check:test-typecheck, which compiles the test layer). pnpm --filter @objectstack/dogfood run typecheck: exit 0, and tsc --listFilesOnly shows the new dogfood file in the program.
  • Census of every approval node in the example stacks and dogfood fixtures (showcase, crm, multi-package, the four approval fixtures), parsed with 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-automation rebuilt inside the leg and scripts/ablation-dist-preflight.mjs proving the marker in dist/ before any suite ran.

  • A: the key-name-only check put back (the validateNodeConfigValues call replaced by a marker statement; anchor 1 to 0, blob b5e94fb23b67 to bd7099e9875b). 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).
  • B: the cold-boot withdraw removed (anchor 1 to 0, blob fb06fd8ad148 to 0d523279cf1c). Preflight: marker present in dist/. Results: service-automation 1 failed / 6 passed (only the boot pin); plugin-approvals 2 passed; dogfood 1 failed / 4 passed (only package-load 404; the boot line still locates the value, so the two halves are independent).
  • Restore, both legs: blob after restore equals the HEAD blob and git diff HEAD is empty (the tool's own reading); then service-automation rebuilt (exit 0) and ablation-dist-preflight --absent for both markers: absent from all 6 built files, working tree clean.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 5fdd32adc9 derived 95 commands; every one was run with its exit code recorded, and --ran answered 95 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-corpus read a scratch probe file mid-deletion (rerun 0); spec check:skill-examples refused on a packages/spec dist built before the main merge (spec rebuilt, all cache hits; rerun 0); check:dual-build-cjs-loads refused on 8 packages with no dist/ (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, and eslint.config.mjs enables 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 no packages/spec edit: no authorable key, spelling, export or stored shape moves, ApprovalNodeConfigSchema and FlowSchema parse exactly what they parsed, execute accepts 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 on claude/issue-21850-approval-node-config-build-refusal) is not addressed here: this PR does not touch objectstack validate / compile, flow-node-config-refusals.ts or any spec file. Its WIP judges the approval contract whole inside FlowSchema's superRefine, which registerFlow parses first; once it lands, an approval value refusal will surface from that parse (a Zod error mapped through fieldsFromZodIssues) 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 with strict: false so 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: on main the unknown-key flow was not dropped at boot, it stayed active and bound; after this PR it is dropped.

Acceptance notes

  • The builtin half of the same class (reported to the seat, not filed here): a builtin node with a present value its contract refuses still registers. Measured at 5fdd32adc9: POST /automation with a create_record node carrying outputVariable: 42 answered 200, and POST /automation/:name/trigger answered 400 FLOW_FAILED with config does not satisfy the create_record contract — config.outputVariable: Invalid input: expected string, received number. Wiring the builtins is not mechanical (http parses after interpolation, loop parses conditionally, the region containers' contracts contain their regions) and is not in this card's file surface.
  • Executors registered after the cold-boot bind: a third-party plugin that registers its executor from its own kernel:ready handler, 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 at init, approval at start). Not measured further.
  • The metadata save door (PUT /meta/flow/:name) re-arms through the mutation sync, whose refusal handling this PR does not change. Not measured.

Generated by Claude Code

claude added 5 commits October 5, 2026 15:24
… 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>
…declared config contract; changeset

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via AutomationServicePlugin (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-6.mdx (via AutomationServicePlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 — 6 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 e6dc7a240617eaeef9a64e788bf6e5561c107f1b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 771b0613a5d4cbc1638a6c1aa8b66631506ee272 — the merge of head f39f5933446ad58def242874f24b9be910ed9feb into base e6dc7a240617eaeef9a64e788bf6e5561c107f1b, 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 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

⚠️ 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 e6dc7a240617eaeef9a64e788bf6e5561c107f1b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 5, 2026 18:22
…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>
@github-actions github-actions Bot added size/m and removed size/l labels Oct 5, 2026
…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>
@github-actions github-actions Bot added size/l and removed size/m labels Oct 5, 2026
@objectstack-fleet objectstack-fleet Bot changed the title fix(service-automation)!: a flow node config value its executor refuses is refused at registration and package load (approval escalation timeoutHours 0.5) fix(service-automation)!: a flow the kernel:ready cold-boot bind refuses is withdrawn, not left registered and active from the boot pull Oct 5, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI note from domain:services seat 1: the reds at 7333fd869d are not this PR's

  • Test Core (4/6) (job 111930764434) failed on two @objectstack/objectql tests, engine-text-operator-declared-type-door.test.ts:198 and protocol-commit-history.test.ts:194. Both hit vitest's 5000 ms per-test timeout, with no assertion failing. That runner's vitest import phase took 5839 s of a 2249 s run. This PR touches no objectql file, and the same shard passed on PR fix(plugin-security): the packaged-permission-set lock refusal carries its guidance as userMessage #21902 in the same hour.
  • Test Core (the aggregator) was cancelled after 15 minutes in the queue, without a runner.
  • The repo's hosted runners have been starved since about 19:20Z: 55 to 80 runs queued, with 1 to 3 in progress. Jobs that wait 15 minutes without a runner end cancelled with "The job was not acquired by Runner of type hosted even after multiple attempts". The seat has no re-run channel. This PR gets a fresh run with its next push (a merge of origin/main once main moves) or with a re-run by a maintainer. A failure on that fresh run is treated as real.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 23:11
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/l tests tooling

Projects

None yet

2 participants