Skip to content

Commit 54fb60a

Browse files
fix(service-automation)!: a flow the kernel:ready cold-boot bind refuses is withdrawn, not left registered and active from the boot pull (#21897)
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: - `NodeExecutor.configContract` (the optional published member) is removed, and so are `AutomationEngine.validateNodeConfigValues` and its call in `registerFlow`. `engine.ts` is byte-identical to `main` (blob `597e506a7711`). - The `formatIssuePath` export in `builtin/parse-config.ts` is removed (nothing else needed it). The file is byte-identical to `main`. - `plugin-approvals` ends with no change. `approval-node.ts` is byte-identical to `main` (blob `e2027bb0c675`). `approval-node-config-contract.test.ts` is deleted: everything it pinned depended on `configContract`, and the engine-level refusal it also covered is #21893's pin. - The changeset no longer names `@objectstack/plugin-approvals`. **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 #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 2ef0c61..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](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6dc7a2 commit 54fb60a

5 files changed

Lines changed: 530 additions & 2 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation)!: a flow the `kernel:ready` cold-boot bind refuses is no longer left registered and `active` from the boot pull
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a narrowing of what stays loaded at boot: a flow whose registration the kernel:ready cold-boot bind refuses is now withdrawn instead of staying registered from the boot pull. What is refused is unchanged, it is the same refusal registerFlow already made; only its outcome at boot changes. No authorable key, spelling, export or stored shape moves: FlowSchema and every node schema parse exactly what they parsed, the package exports the same names with the same types, no stored row is read or rewritten, and which value an author meant is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered or already-registered); and the change narrows what boot keeps registered, not a runtime interface or a type surface alone (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING**: a flow that loaded `active` before can now be absent after boot. It ships as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
**What was kept before.** A boot registers a package's flows twice. The boot pull runs before a plugin that contributes a node type has registered its executor from its own `start()`, so it cannot check that node's config keys against the descriptor's `configSchema`, and it registers the flow and arms its trigger. The `kernel:ready` bind then re-registers every flow once the executor exists. When it refused one, for an undeclared config key for instance, it logged `[Automation] cold-boot flow bind: failed to register flow` and nothing else: the boot pull's registration stayed, `active` and bound to its trigger, so every run reached the node the refusal located.
14+
15+
**What happens now.** A flow the `kernel:ready` bind refuses is withdrawn: it is not registered, its trigger is unbound, and the same warning names the flow and the refusal. Only that flow is withdrawn; the rest of the package loads, as it already did for a flow the boot pull refuses. A failed or empty read of the flow list still tears nothing down.
16+
17+
**What an author sees now.** The flow is absent (`GET /automation/:name` answers `404`), and the boot warning carries the located refusal. The handling is to correct the config the warning locates; the flow then registers as before.
18+
19+
**Unchanged.** What `registerFlow` refuses, at any door. A flow refused through the `/automation` write doors keeps the definition the engine already held, and a runtime reload that brings a refused body keeps the registered one.

‎content/docs/automation/flows.mdx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Each node performs a specific action in the flow.
138138
| `id` | `string` | ✅ | Unique node identifier — unique across the **whole flow**: the top-level `nodes[]` and every region body (`loop.body`, `parallel.branches[]`, `try_catch.try` / `.catch`, at any depth) share one id space, and `FlowSchema` refuses a reused id at parse (`Duplicate node id …`, naming both locations) |
139139
| `type` | `string` | ✅ | Node type — a built-in id from the table above **or** a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry once that registry is complete — plugins contribute node types while they start, so flows registered during boot are checked in one pass when the vocabulary closes (all plugins started), and anything registered after that (Studio publish, dev reload) is checked immediately. Unknown types warn, never reject; executing one fails with `NO_EXECUTOR` |
140140
| `label` | `string` | ✅ | Display label |
141-
| `config` | `object` | optional | Type-specific configuration — the registered executor's `configSchema` owns its shape. Keys that schema does not declare are rejected at `registerFlow()`, and the built-in executors `parse()` the value against their Zod contract before running (#4277) |
141+
| `config` | `object` | optional | Type-specific configuration — the registered executor's `configSchema` owns its shape. Keys that schema does not declare are rejected at `registerFlow()`, an `approval` node's config is judged whole against its declared contract at the flow parse, and the built-in executors `parse()` the value against their Zod contract before running (#4277) |
142142
| `connectorConfig` | `object` | ✅ on `connector_action` | `{ connectorId, actionId, input }` — the only input a `connector_action` node's executor reads. `FlowSchema` refuses a `connector_action` node without it, and one whose `connectorId` or `actionId` is blank (empty or whitespace only), at any depth including a region body. `connectorId` is the registered connector's `name`, `actionId` one of the action keys it declares; `input` is optional |
143143
| `position` | `{ x, y }` | optional | Visual position on canvas |
144144
| `timeoutMs` | `number` | optional | Per-node execution timeout |
@@ -155,7 +155,14 @@ node type's published `configSchema` does not declare — naming the path, the
155155
declared key set, and a did-you-mean — and the contract-carrying builtins
156156
additionally `parse()` their config at execute time, refusing the node on a type
157157
or missing-`required` violation (#4277). A node type that publishes no
158-
`configSchema` declares nothing, so nothing can be undeclared.
158+
`configSchema` declares nothing, so nothing can be undeclared. An `approval`
159+
node's config is judged whole by the flow parse, against the contract the spec
160+
declares for it: an undeclared key or a value its executor would refuse
161+
(`escalation.timeoutHours: 0.5` under its `>= 1`) refuses the flow at the
162+
config path, so `registerFlow()` answers it at the `/automation` write doors
163+
(`400 VALIDATION_FAILED`) and at boot. At boot a refused flow is skipped with a
164+
warning and not left registered — including one refused only once a plugin's
165+
node type has registered, after the boot pull had registered it.
159166
</Callout>
160167

161168
### Node Examples
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// A flow whose node config breaks the node's contract is refused where it
4+
// registers — through both registration doors an operator has: the package a
5+
// stack ships (package load) and the admin write door (`POST /automation`) —
6+
// and a flow refused at load is not left registered.
7+
//
8+
// ## What was broken
9+
//
10+
// Registration checked only the config's key NAMES (against the node type's
11+
// descriptor `configSchema`); the values were parsed for the first time when a
12+
// run reached the node. An approval node with `escalation.timeoutHours: 0.5`
13+
// (the contract says `>= 1`) therefore registered and loaded `active`, every
14+
// record the trigger matched was created, and every run then failed at the
15+
// approval node: no approval request opened, so the record existed without
16+
// the gate it was meant to pass, and the user who saved it saw nothing. The
17+
// flow parse (`FlowSchema`, which `registerFlow` runs first) now judges an
18+
// approval node's config against its declared contract, whole.
19+
//
20+
// And at package load, a refusal could fail to hold. The boot pull registers a
21+
// package's flows before a plugin that contributes a node type has registered
22+
// its executor, so it cannot check that node's config keys; the `kernel:ready`
23+
// bind re-registers every flow once the executor exists, and when it refused
24+
// one it only warned — the boot pull's registration stayed `active`.
25+
//
26+
// ## What each case pins
27+
//
28+
// - package load: an approval node's out-of-range escalation and its
29+
// undeclared escalation key are refused (not registered), and the boot names
30+
// the flow and the located config path;
31+
// - package load, the control: a valid escalation registers and RUNS — a
32+
// record of the trigger's object opens a pending approval request;
33+
// - package load, a plugin node type whose executor registers after the boot
34+
// pull: an undeclared config key is refused by the `kernel:ready` bind, and
35+
// the flow is not left registered; its valid sibling is;
36+
// - the admin door: both approval refusals answer `400 VALIDATION_FAILED`
37+
// located at the config path, and nothing is registered under either name;
38+
// - the admin door, the control: a valid escalation registers.
39+
//
40+
// The fixture is built with `strict: false`, on purpose: the build door judges
41+
// the same flows on its own, and this file pins the two runtime doors behind
42+
// it, so the invalid bodies have to reach them. Everything else about the stack
43+
// is an ordinary authored package.
44+
45+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
46+
import { bootStack, type VerifyStack } from '@objectstack/verify';
47+
import { defineStack } from '@objectstack/spec';
48+
import { defineActionDescriptor } from '@objectstack/spec/automation';
49+
import { ObjectSchema, Field } from '@objectstack/spec/data';
50+
import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals';
51+
import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change';
52+
53+
const OBJECT = 'esc_value_request';
54+
/** A position nothing in this fixture staffs: the request opens and waits. */
55+
const UNSTAFFED = 'esc_value_unstaffed';
56+
57+
const PACKAGED_SUB_HOUR = 'esc_value_packaged_sub_hour';
58+
const PACKAGED_UNKNOWN_KEY = 'esc_value_packaged_unknown_key';
59+
const PACKAGED_VALID = 'esc_value_packaged_valid';
60+
const PACKAGED_PLUGIN_TYPO = 'esc_value_packaged_plugin_typo';
61+
const PACKAGED_PLUGIN_VALID = 'esc_value_packaged_plugin_valid';
62+
const DOOR_SUB_HOUR = 'esc_value_door_sub_hour';
63+
const DOOR_UNKNOWN_KEY = 'esc_value_door_unknown_key';
64+
const DOOR_VALID = 'esc_value_door_valid';
65+
66+
/** The config path the flow parse locates, on the approval node (`nodes[1]`). */
67+
const SUB_HOUR_PATH = 'nodes.1.config.escalation.timeoutHours';
68+
const UNKNOWN_KEY_PATH = 'nodes.1.config.escalation.bogusKey';
69+
70+
/** An active, record-triggered flow whose one approval node carries `escalation`. */
71+
function gatedFlow(name: string, escalation: Record<string, unknown>) {
72+
return {
73+
name,
74+
label: `Escalation gate ${name}`,
75+
type: 'autolaunched',
76+
status: 'active',
77+
nodes: [
78+
{
79+
id: 'start',
80+
type: 'start',
81+
label: 'On Create',
82+
config: { objectName: OBJECT, triggerType: 'record-after-create' },
83+
},
84+
{
85+
id: 'gate',
86+
type: 'approval',
87+
label: 'Gate',
88+
config: {
89+
approvers: [{ type: 'position', value: UNSTAFFED }],
90+
behavior: 'first_response',
91+
escalation,
92+
},
93+
},
94+
{ id: 'approved', type: 'end', label: 'Approved' },
95+
{ id: 'rejected', type: 'end', label: 'Rejected' },
96+
],
97+
edges: [
98+
{ id: 'e1', source: 'start', target: 'gate' },
99+
{ id: 'e2', source: 'gate', target: 'approved', label: 'approve' },
100+
{ id: 'e3', source: 'gate', target: 'rejected', label: 'reject' },
101+
],
102+
};
103+
}
104+
105+
const SUB_HOUR = { enabled: true, timeoutHours: 0.5, action: 'notify' };
106+
const UNKNOWN_KEY = { enabled: true, timeoutHours: 4, action: 'notify', bogusKey: 1 };
107+
const VALID = { enabled: true, timeoutHours: 4, action: 'notify' };
108+
109+
/**
110+
* A plugin node type the spec knows nothing about, contributed the way a
111+
* plugin contributes one: its executor (and the descriptor whose
112+
* `configSchema` declares `count`) registers from the plugin's own `start()`,
113+
* after the automation plugin's boot pull.
114+
*/
115+
const STAMP = 'esc_value_stamp';
116+
117+
function stampPlugin() {
118+
return {
119+
name: 'com.dogfood.esc-value-stamp',
120+
version: '0.0.0',
121+
async init() {},
122+
async start(ctx: { getService<T>(name: string): T }) {
123+
ctx.getService<{ registerNodeExecutor(executor: unknown): void }>('automation').registerNodeExecutor({
124+
type: STAMP,
125+
descriptor: defineActionDescriptor({
126+
type: STAMP,
127+
version: '0.0.0',
128+
name: 'Stamp',
129+
category: 'custom',
130+
paradigms: ['flow'],
131+
source: 'plugin',
132+
configSchema: { type: 'object', properties: { count: { type: 'number' } } },
133+
}),
134+
async execute() {
135+
return { success: true };
136+
},
137+
});
138+
},
139+
};
140+
}
141+
142+
/** A flow run by hand whose one node is the plugin node type, carrying `config`. */
143+
function stampFlow(name: string, config: Record<string, unknown>) {
144+
return {
145+
name,
146+
label: `Stamp ${name}`,
147+
type: 'autolaunched',
148+
status: 'active',
149+
nodes: [
150+
{ id: 'start', type: 'start', label: 'Start', config: {} },
151+
{ id: 'stamp', type: STAMP, label: 'Stamp', config },
152+
{ id: 'end', type: 'end', label: 'End' },
153+
],
154+
edges: [
155+
{ id: 'e1', source: 'start', target: 'stamp' },
156+
{ id: 'e2', source: 'stamp', target: 'end' },
157+
],
158+
};
159+
}
160+
161+
const fixtureStack = defineStack(
162+
{
163+
manifest: {
164+
id: 'com.dogfood.escalation-values',
165+
namespace: 'esc_value',
166+
version: '0.0.0',
167+
type: 'app',
168+
name: 'Escalation Values Fixture',
169+
description: 'One object, approval flows with escalation values their contract refuses, and plugin-node flows.',
170+
},
171+
// ADR-0097: a record-change trigger registers only when the app declares it.
172+
requires: ['automation', 'triggers'],
173+
objects: [
174+
ObjectSchema.create({
175+
name: OBJECT,
176+
label: 'Escalation Value Request',
177+
pluralLabel: 'Escalation Value Requests',
178+
sharingModel: 'public_read_write',
179+
fields: { name: Field.text({ label: 'Name', required: true }) },
180+
}),
181+
],
182+
flows: [
183+
gatedFlow(PACKAGED_SUB_HOUR, SUB_HOUR),
184+
gatedFlow(PACKAGED_UNKNOWN_KEY, UNKNOWN_KEY),
185+
gatedFlow(PACKAGED_VALID, VALID),
186+
// `cuont` is a key the plugin node type's descriptor does not declare.
187+
stampFlow(PACKAGED_PLUGIN_TYPO, { cuont: 2 }),
188+
stampFlow(PACKAGED_PLUGIN_VALID, { count: 2 }),
189+
] as never,
190+
},
191+
{ strict: false },
192+
);
193+
194+
interface DispatcherEnvelope {
195+
success?: boolean;
196+
data?: Record<string, unknown>;
197+
error?: {
198+
code?: string;
199+
message?: string;
200+
details?: { fields?: Array<{ field?: string; code?: string; message?: string }> };
201+
};
202+
}
203+
204+
describe('a flow node config its contract refuses is refused at registration, and not left registered at load', () => {
205+
let stack: VerifyStack;
206+
let token: string;
207+
/** Everything the platform logger wrote while the stack booted. */
208+
let bootOutput = '';
209+
210+
const call = async (method: string, path: string, body?: unknown) => {
211+
const res = await stack.apiAs(token, method, path, body);
212+
const json = (await res.json().catch(() => ({}))) as DispatcherEnvelope;
213+
return { status: res.status, json };
214+
};
215+
216+
/** The boot lines that name `flow` and carry `fragment`. */
217+
const bootLines = (flow: string, fragment: string) =>
218+
bootOutput.split('\n').filter((line) => line.includes(flow) && line.includes(fragment));
219+
220+
beforeAll(async () => {
221+
// The core logger writes through the process streams, not `console.*`.
222+
const lines: string[] = [];
223+
const sink = (chunk: unknown): boolean => {
224+
lines.push(String(chunk));
225+
return true;
226+
};
227+
const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(sink as never);
228+
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(sink as never);
229+
try {
230+
stack = await bootStack(fixtureStack as unknown as Parameters<typeof bootStack>[0], {
231+
automation: true,
232+
extraPlugins: [new RecordChangeTriggerPlugin(), new ApprovalsServicePlugin(), stampPlugin()],
233+
});
234+
} finally {
235+
outSpy.mockRestore();
236+
errSpy.mockRestore();
237+
bootOutput = lines.join('');
238+
}
239+
token = await stack.signIn();
240+
}, 120_000);
241+
242+
afterAll(async () => {
243+
await stack?.stop();
244+
});
245+
246+
it('package load: the sub-hour and the undeclared-key approval flows are not registered', async () => {
247+
for (const name of [PACKAGED_SUB_HOUR, PACKAGED_UNKNOWN_KEY]) {
248+
const read = await call('GET', `/automation/${name}`);
249+
expect(read.status, `${name}: ${JSON.stringify(read.json)}`).toBe(404);
250+
}
251+
});
252+
253+
it('package load: the boot names each refused flow and the config path it refused', () => {
254+
expect(bootLines(PACKAGED_SUB_HOUR, 'nodes[1].config.escalation.timeoutHours').length, bootOutput.slice(-4000))
255+
.toBeGreaterThan(0);
256+
expect(bootLines(PACKAGED_UNKNOWN_KEY, 'nodes[1].config.escalation.bogusKey').length, bootOutput.slice(-4000))
257+
.toBeGreaterThan(0);
258+
});
259+
260+
it('package load, the control: a valid escalation registers active and runs', async () => {
261+
const read = await call('GET', `/automation/${PACKAGED_VALID}`);
262+
expect(read.status, JSON.stringify(read.json)).toBe(200);
263+
expect(read.json.data?.status).toBe('active');
264+
265+
const created = await stack.apiAs(token, 'POST', `/data/${OBJECT}`, { name: 'gated' });
266+
expect(created.status, await created.clone().text()).toBe(201);
267+
const createdJson = (await created.json()) as { id?: string; record?: { id?: string } };
268+
const recordId = String(createdJson.id ?? createdJson.record?.id);
269+
270+
// The run reached the approval node and the node opened its request:
271+
// only an escalation its executor accepts gets that far.
272+
const pending = await stack.apiAs(token, 'GET', '/approvals/requests?status=pending');
273+
expect(pending.status).toBe(200);
274+
const rows = ((await pending.json()) as { data: Array<Record<string, unknown>> }).data;
275+
const opened = rows.filter((row) => String(row.record_id) === recordId);
276+
expect(opened.length, JSON.stringify(rows)).toBe(1);
277+
expect(opened[0].pending_approvers).toEqual([`position:${UNSTAFFED}`]);
278+
});
279+
280+
it('package load: a flow the kernel:ready bind refuses is not left registered from the boot pull', async () => {
281+
const read = await call('GET', `/automation/${PACKAGED_PLUGIN_TYPO}`);
282+
expect(read.status, JSON.stringify(read.json)).toBe(404);
283+
expect(bootLines(PACKAGED_PLUGIN_TYPO, 'config.cuont').length, bootOutput.slice(-4000)).toBeGreaterThan(0);
284+
285+
// Only that flow: its valid sibling of the same node type is registered.
286+
const sibling = await call('GET', `/automation/${PACKAGED_PLUGIN_VALID}`);
287+
expect(sibling.status, JSON.stringify(sibling.json)).toBe(200);
288+
});
289+
290+
it('the admin door: both approval refusals answer 400 VALIDATION_FAILED at the config path, and nothing registers', async () => {
291+
for (const [name, escalation, path] of [
292+
[DOOR_SUB_HOUR, SUB_HOUR, SUB_HOUR_PATH],
293+
[DOOR_UNKNOWN_KEY, UNKNOWN_KEY, UNKNOWN_KEY_PATH],
294+
] as const) {
295+
const refused = await call('POST', '/automation', gatedFlow(name, escalation));
296+
expect(refused.status, JSON.stringify(refused.json)).toBe(400);
297+
expect(refused.json.error?.code).toBe('VALIDATION_FAILED');
298+
const fields = refused.json.error?.details?.fields ?? [];
299+
expect(fields.map((f) => f.field), JSON.stringify(refused.json)).toContain(path);
300+
301+
const read = await call('GET', `/automation/${name}`);
302+
expect(read.status, JSON.stringify(read.json)).toBe(404);
303+
}
304+
});
305+
306+
it('the admin door, the control: a valid escalation registers', async () => {
307+
const created = await call('POST', '/automation', gatedFlow(DOOR_VALID, VALID));
308+
expect(created.status, JSON.stringify(created.json)).toBe(200);
309+
const read = await call('GET', `/automation/${DOOR_VALID}`);
310+
expect(read.status, JSON.stringify(read.json)).toBe(200);
311+
});
312+
});

0 commit comments

Comments
 (0)