Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .changeset/script-branch-keys-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
'@objectstack/spec': major
'@objectstack/service-automation': minor
'@objectstack/lint': patch
---

feat(spec,automation)!: converge `script` to a function call — retire the `actionType` branches — and parse `script` / `subflow` config at execute time (#4343)

A `script` node had four ways to name what it ran and only one of them ran anything.
Protocol 17 keeps that one and retires the rest.

- **`config.actionType: 'email' | 'slack'`** were **logger-backed stubs**. They wrote a
line, reported success, and delivered nothing — under any configuration, installed
messaging service or not. Every bundled example used one; none of them ever sent
anything.
- **`config.template` / `.recipients` / `.variables`** fed those stubs, so they addressed
a message no channel sent. (The examples did not even reach them: they passed the
payload in `inputs`, which the built-in branch never read.)
- **inline `config.script`** was recognized and **never executed** — the built-in runtime
has no server-side JS sandbox, so the node warned and completed as a no-op.
- **any other `actionType`** was shorthand for a registered-function name — a second
spelling of `config.function` — and `'invoke_function'` was a marker that named nothing
on its own.

What remains is what worked: `config.function` (now **required**) names a registered
function, `config.inputs` feeds it, `config.outputVariable` binds its return value.

**The replacements are three different mechanisms, not one rename.**

| Retired | Use instead |
| --- | --- |
| `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a `notify` node — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed |
| `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook — `notify` has no Slack channel |
| `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you |
| `script: '…'` (inline JS) | move the logic into a registered function and call it via `config.function` |

**Execute-time parse.** `script` and `subflow` now run their config through the contract
before executing, the seam #4277 gave the flat builtins — a violation refuses the node as
a **guard** (wrong metadata; no `fault` edge may route it, #3863). `script` could not join
that seam while its legal key set depended on `actionType`: a flat parse would either
reject valid shapes or wave everything through. Converging the node is what made the
contract fit. `subflow`'s hand-written `flowName` check became the same parse, so its
message is now `subflow 'n1': config does not satisfy the subflow contract —
config.flowName: …`. `decision` deliberately stays export-only: its one key is optional,
so a parse would check nothing.

**Migration.** `os migrate meta --from 16` rewrites stored sources; authoring one of these
keys in TypeScript is a compile error carrying the same prescription. A shorthand
`actionType` **converts into `function`** — that is what it named — unless `function` is
already set, in which case it was dead metadata the executor never reached. The other four
keys are dropped outright: nothing read them, so there is no value to preserve, and
rebuilding the intent is an authoring decision (the table above) rather than something a
mechanical rewrite can guess.

The keys leave the **load path** (`retiredFromLoadPath`) with the rest of the keys retired
for *misdescribing themselves* rather than for being renamed: absorbing
`actionType: 'email'` silently would let an author keep believing the flow sends mail. The
one seam that still replays it is `registerFlow`, which rehydrates data at rest (#3903) —
a row in `sys_metadata` has no author for a tombstone to teach. So a stored email-stub node
arrives stripped of the keys nothing read and then **refuses for naming no callable**,
where it used to log a line and report success. That flip is the behavior change to expect.

**A build gap this surfaced, fixed here.** `FlowFunctionEntrySchema` now also accepts a
**lowered handler ref** (a non-empty string), the form `objectstack build` produces: the
CLI lowers every inline callable to a serialisable ref *before* the stack is parsed (it
must — `z.function()` wraps callables and would break the ref mapping), so a built
manifest holds `{ myFn: 'myFn' }`, which neither previous member accepted. The result was
that `defineStack({ functions })` — a documented, first-class mechanism — could not
survive a build at all. Nothing had noticed because no bundled example used it; #4343
turns that from latent into blocking, since `config.function` becomes the only thing a
`script` node can run. `Hook.handler` already declared exactly this pair (`z.union([
z.string(), <function> ])`, "string, post-build / inline function, pre-build"), so this
brings `functions` onto the platform's established shape rather than inventing one. A
string carries no callable and `normalizeFlowFunctionEntry` still drops it by design — the
real functions ride in the sibling ESM module the build emits, merged by name — so
hand-authoring one registers nothing and fails loudly at execute ("no function named '…'
is registered"), never silently.

Also in this change: the retired constants `SCRIPT_BUILTIN_ACTION_TYPES`,
`SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` and the `ScriptBuiltinActionType` type are removed
(they described the dispatch set that no longer exists); `os validate` names a retired key
and its replacement instead of reporting a generic missing callable; and the `#3796`
alias fixture, which carried `actionType: 'invoke_function'` through both sides, no longer
describes an end state protocol 17 can reach — the rename itself is untouched. No liveness
ledger row moves: the gate walks `FlowSchema`, whose `nodes[].config` is
`z.record(z.unknown())`, so these keys were never governed by one.
55 changes: 35 additions & 20 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Each node performs a specific action in the flow.
| `get_record` | Query records |
| `http` | Make an HTTP API call |
| `notify` | Send an outbound notification via the messaging service |
| `script` | Call a named callable — a registered function (`config.function`) or a built-in side-effect marker (`config.actionType`) |
| `script` | Call a registered function named by `config.function` |
| `screen` | Display a user form/screen (durable pause) |
| `wait` | Pause for a timer or named signal (durable pause; timers auto-resume) |
| `subflow` | Invoke another flow — a pause inside the child suspends both runs as a linked chain |
Expand Down Expand Up @@ -214,25 +214,40 @@ or missing-`required` violation (#4277). A node type that publishes no
**Script:**

The built-in `script` executor never evaluates an arbitrary JavaScript string —
it **names a callable**. Two forms:

- **`config.function`** — a function registered through
`defineStack({ functions })`. `config.inputs` is `{var}`-interpolated and
handed to it; `config.outputVariable` binds the returned value as a flow
variable, so a later declarative node persists it. This is the supported way
to run server logic.
- **`config.actionType`** — one of the two built-in side-effect markers,
`'email'` or `'slack'`. These are **logger-backed**: they record the intent
and succeed, they do not deliver anything — reach for a `notify` node when you
want real delivery. Any other `actionType` value is treated as a
registered-function name — except the marker `'invoke_function'`, which means
"call the function named in `config.function`" and errors if that key is
missing.

Inline `config.script` (a JS source body) is *recognized* but **not executed** —
the built-in runtime has no server-side JS sandbox, so such a node warns and
no-ops. A script node that names neither a built-in action nor a registered
function fails the step loudly rather than passing silently.
it **calls a registered function**, and that is the whole of what it does.

**`config.function`** names a function registered through
`defineStack({ functions })`, and it is **required**. `config.inputs` is
`{var}`-interpolated and handed to it; `config.outputVariable` binds the
returned value as a flow variable, so a later declarative node persists it. A
node that names no function refuses before it runs; one naming a function
nothing registered fails the step loudly rather than passing silently.

<Callout type="warn" title="The other dispatch forms were retired in spec 17">

`config.actionType`, `config.template`, `config.recipients`, `config.variables`
and inline `config.script` were removed in `@objectstack/spec` 17 ([#4343]).
None of them ran: the `'email'` / `'slack'` action types were **logger-backed
stubs** that recorded the intent, reported success and delivered nothing under
any configuration, and an inline JS body was recognized but never executed (the
built-in runtime has no server-side sandbox). Every other `actionType` value was
shorthand for a registered-function name.

Replace them per branch — they are different mechanisms, not one rename:

| Retired shape | Use instead |
| --- | --- |
| `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a [`notify` node](#notify) — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed |
| `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook |
| `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you |
| inline `config.script` | move the logic into a registered function and call it via `config.function` |

Stored flows are rewritten by `os migrate meta --from 16`; authoring one of
these keys in TypeScript is now a compile error carrying the same prescription.

[#4343]: https://github.com/objectstack-ai/objectstack/issues/4343

</Callout>

```typescript
{
Expand Down
86 changes: 56 additions & 30 deletions content/docs/references/automation/schemaless-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,27 @@ form lives ONLY in objectui's hand-written `FLOW_NODE_CONFIG` table —

with each member's reason: `decision`'s virtual Target column is derived from

the out-edges, `script`'s form switches on `actionType`, `subflow` carries a
the out-edges, `subflow` carries a top-level `timeoutMs` — a published

top-level `timeoutMs` — a published partial schema would DROP those editors
partial schema would DROP those editors (the #4210 `connector_action`

(the #4210 `connector_action` incident). So the Studio form for these types
incident). So the Studio form for these types is objectui's hand-written

is objectui's hand-written group, and until #4278 **nothing reconciled that
group, and until #4278 **nothing reconciled that hand-written table against

hand-written table against the executors**: `script`'s form offered an
the executors**: `script`'s form offered an `outputVariables` key nothing

`outputVariables` key nothing reads, two `actionType` options that fail every
reads, two `actionType` options that fail every run, a no-op default — and

run, a no-op default — and could not author the `function`/`inputs`/
could not author the `function`/`inputs`/`outputVariable` path that works.

`outputVariable` path that works.
`script`'s own reason for staying schemaless was that its form switched on

`actionType`. #4343 retired that switch, so the node is now three flat keys

and could graduate to a published descriptor `configSchema` the way `map`

did — a follow-up, deliberately not folded into the retirement.

These schemas are the machine-readable half of that reconciliation. They are

Expand All @@ -59,37 +65,57 @@ entry here: their contracts are the spec-structured sibling blocks on

same objectui test reconciles directly.

## What these schemas are (and are not) wired to
## What these schemas are wired to

`script` and `subflow` are **parsed at execute time** since #4343, through

the same `parseNodeConfig()` seam #4277 gave the flat builtins

(`service-automation`'s `parse-config.ts`): a config that fails its contract

refuses the node as a GUARD — wrong metadata, so a rerun cannot help and no

`fault` edge may route it (#3863).

`script` could not be parsed while its legal key set depended on

`actionType`; #4343 removed that dependence instead of modelling it.

Converging the node to its one real path — call a registered function — left

a flat three-key contract a flat parse fits exactly, and the five keys the

other branches read became `retiredKey` tombstones.

Contract exports only — no engine path `parse()`s a node config with them,
The two halves reach different audiences, which is why they shipped together:

so registering a flow behaves exactly as before. This is where they differ
- the **tombstones** teach whoever authors the key — `tsc` types it `never`,

from their `builtin-node-config.zod.ts` siblings, which #4277 wired into
and a direct parse raises the prescription. They do NOT reach a stored

execute-time parsing (`service-automation`'s `parse-config.ts`) and into the
flow: `FlowNodeSchema.config` is `z.record(z.unknown())`, so no load-path

`registerFlow()` unknown-key rejection.
parse ever descends into a node's config;

That difference is deliberate, and it is the same reason these three publish
- the **execute-time parse** is what a stored flow meets. `registerFlow`

no descriptor `configSchema`: **their key set is not the whole contract.**
canonicalizes data at rest through the retired conversion too (#3903), so

`script`'s legal keys depend on `actionType` (a built-in side effect reads
a stored `actionType: 'email'` node arrives here stripped of the keys

`template`/`recipients`/`variables`; the function path reads
nothing read — and then refuses, naming the `function` it does not have,

`function`/`inputs`/`outputVariable`), and `decision` may carry no
instead of logging a line and reporting success as it used to.

`conditions` at all when it branches purely on edge predicates. A flat parse
`decision` stays export-only, deliberately: it may carry no `conditions` at

would either reject those shapes or wave everything through — neither is the
all when it branches purely on edge predicates (a plain BPMN exclusive

contract. Wiring them in needs a discriminated form first; until then the
gateway), and `conditions` is its only key — so a parse would have nothing

enforcement they DO get is the objectui reconciliation test, which is what
left to check. Its enforcement remains the objectui reconciliation test,

#4278 was actually about (a form authoring keys nothing reads).
which is what #4278 was actually about (a form authoring keys nothing reads).

Undeclared aliases are NOT part of these contracts: `subflow`'s historical

Expand Down Expand Up @@ -144,14 +170,14 @@ const result = DecisionCondition.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **actionType** | `string` | optional | How this step runs: a built-in side effect ('email' \| 'slack'), the 'invoke_function' marker, or shorthand for a registered-function name |
| **function** | `string` | optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType. Contractually pure — it returns a value a later declarative node persists |
| **function** | `string` | ✅ | Registered function to call (defineStack(`{ functions }`)). Contractually pure — it returns a value a later declarative node persists |
| **inputs** | `Record<string, any>` | optional | Inputs passed to the function (values interpolate `{token}` templates) |
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to |
| **template** | `string` | optional | Built-in side effects only: message template id |
| **recipients** | `string[]` | optional | Built-in side effects only: recipients (user ids, field refs, or addresses) |
| **variables** | `Record<string, any>` | optional | Built-in side effects only: values injected into the template |
| **script** | `string` | optional | Inline JS source — recognized but not executed by the built-in runtime; use a registered function via `function` instead |
| **actionType** | `any` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 (#4343) — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to rewrite it automatically. |
| **template** | `any` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 (#4343) — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and stored templates live in the messaging service (`sys_notification_template`), not on the node. Run `os migrate meta --from 16` to rewrite it automatically. |
| **recipients** | `any` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 (#4343) — the addresses were logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a `notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging service for real. Run `os migrate meta --from 16` to rewrite it automatically. |
| **variables** | `any` | optional | [REMOVED] `script.config.variables` was removed in @objectstack/spec 17 (#4343) — it injected values into a template no side effect ever rendered. Delete the key. A `notify` node carries structured data in `payload`; a registered function takes it in `config.inputs`. Run `os migrate meta --from 16` to rewrite it automatically. |
| **script** | `any` | optional | [REMOVED] `script.config.script` was removed in @objectstack/spec 17 (#4343) — the built-in runtime has no server-side JS sandbox, so an inline body was recognized and never executed: the node warned and completed as a no-op. Move the logic into a registered function (`defineStack({ functions })`) and name it in `config.function`. Run `os migrate meta --from 16` to rewrite it automatically. |


---
Expand Down
Loading
Loading