Flow Function — Automation Protocol reference
The contract for a named handler function a script node invokes — contributed by defineStack({ functions }) and resolved by name at execute time.
The contract for a named handler function a script node invokes —
contributed by defineStack({ functions }) and resolved by name at execute
time (#1870).
The rule, and why it lives here instead of in a comment
A flow function is a PURE compute step: it receives its mapped input,
RETURNS a value, and the node's outputVariable exposes that value as a flow
variable so a later DECLARATIVE node persists it (update_record fields: { ai_category: '{aiResult.ai_category}' }). Data I/O stays on the flow graph.
That is not style advice. #4354's per-run summary reports what a run did to
the data, and the script node reports NO record metrics because of this
rule: every write a script causes is a downstream create_record /
update_record that counts itself, so "this node touched no records" is the
accurate answer rather than a guess. A function that writes anyway makes its
run under-report — selected: 30, acted: 0 on a run that wrote 30 invoices,
which reads exactly like the broken sweep #4354 exists to detect, and the
durable sys_automation_run row says so permanently.
Until #4396 that rule lived ONLY in a comment inside the executor, so neither an author, a lint, nor the runtime could see the contract the summary was relying on. It is now declared in two halves:
ActionDescriptor.handlerContract—scriptpublishes'pure', so the action catalog and the designer palette carry the rule an author reads.FlowFunctionEffectSchema— a function that legitimately writes DECLARES it, and its step then reportsunmeasuredEffect, so the run says "cannot count" instead of claiming it wrote nothing.
What is deliberately not here
A blanket unmeasuredEffect on every script step (the escape hatch #4354
gave connector_action) was rejected: it would drop every flow that calls
any function out of the broken-sweep FIRST FILTER
(selected > 0 AND acted = 0 AND unmeasured = 0), in order to accommodate
the flows that break the rule — paying for a rule-breaker with everyone
else's measurement, and fossilizing the violation as supported behaviour.
That predicate is a first filter and not a verdict (#12685: a healthy
idempotent sweep that re-selects the same records and gates each one on
"already handled" matches it on every run too, and what discriminates is the
per-node fold in FlowRunSummary.nodes[] / gates[]). Dropping out of it is
still costly even so: a run the filter never selects is never folded over
either, so the escape hatch is declared per function rather than granted to
every script step.
Nor is this enforcement. The runtime hands a function no data reach —
FlowFunctionContext in @objectstack/service-automation carries
input / variables / automation / logger and no engine handle — but a
function is ordinary host code and can close over a client at module scope.
What the declaration buys is that the honest case is now expressible, and
the platform's own counters stop being wrong for it.
Source: packages/spec/src/automation/flow-function.zod.ts
TypeScript Usage
import { FlowFunctionEffectSchema, FlowFunctionLoweredDeclarationSchema } from '@objectstack/spec/automation';
import type { FlowFunctionEffect, FlowFunctionLoweredDeclaration } from '@objectstack/spec/automation';
// Validate data
const result = FlowFunctionEffectSchema.parse(data);FlowFunctionEffect
What a script-node function does to data: 'pure' (computes and returns — the contract) or 'writes' (performs uncountable writes/effects, reported as unmeasured)
Allowed Values
purewrites
FlowFunctionLoweredDeclaration
A lowered functions declaration: what the function declared about itself, with its callable replaced by a handler ref
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| handler | string | ✅ | The lowered handler ref (built artifacts) — the callable rides in the sibling ESM module |
| effect | Enum<'pure' | 'writes'> | optional (default: "pure") | What the function does to data — omit for the pure default |