You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(spec,automation,objectql,runtime,cli): declare the script-function purity contract; a writer opts out honestly (#4396) (#4421)
A `script` node's function is contractually pure — it takes its inputs, RETURNS
a value, and a later declarative node persists it — and #4354's run summary
depends on that: the step reports no record metrics because every write a pure
function causes is a downstream `create_record` / `update_record` counting
itself. The contract lived only in a comment inside the executor, so a function
that wrote anyway made its run report `selected: 30, acted: 0` — indistinguishable
from the broken sweep the counters exist to detect, and durable on
`sys_automation_run`.
Two halves, per the issue's options 1 and 2:
- DECLARE IT WHERE IT IS VISIBLE. `ActionDescriptor.handlerContract`
('none' | 'pure'); the `script` descriptor publishes 'pure', so the action
catalog, the designer palette, the generated reference docs, the flows guide
and the automation skill carry the rule an author reads.
- LET A WRITER SAY SO. `defineStack({ functions: { syncBilling: { handler,
effect: 'writes' } } })`. That step reports `unmeasuredEffect`, so the run's
`unmeasured` tally keeps the broken-sweep query
(`selected > 0 AND acted = 0 AND unmeasured = 0`) off that flow, and only that
flow. A blanket `unmeasuredEffect` on every script step was rejected: it would
blind the detector on every flow that calls any function, to cover the few
that break the rule.
Nothing is retired: a bare `functions: { fn }` entry is unchanged and means
`effect: 'pure'`. The declaration crosses every seam between the author and the
counter — `ObjectQL.registerFunction` accepts `{ packageId, effect }` beside the
existing packageId string and exposes `resolveFunctionEntry`; AppPlugin collects
entries rather than bare handlers; `objectstack build` lowers a declared entry
instead of dropping it; the artifact loader re-attaches the module's callable to
the declaration the JSON carried. A dogfood proof boots the app and asserts the
summary two otherwise-identical sweeps report.
Enforcement is NOT claimed. A flow function is ordinary host code and can close
over a data client at module scope; the runtime hands it no data reach (now
pinned by a test) but an undeclared writer still under-reports, and the docs say
so rather than implying a guarantee.
Also fixes: `bindHooksToEngine` returned before registering a bundle's functions
when the stack declared no hooks, so a flow-only app's `defineStack({ functions })`
reached the engine as nothing and every `script` node calling one failed with
"no function named 'x' is registered".
Closes#4396
Claude-Session: https://claude.ai/code/session_017nwaAedz4jxRsy63nW8bGq
Co-authored-by: Claude <noreply@anthropic.com>
A step that calls a declared writer is counted as an effect the platform cannot
277
+
measure (`unmeasured`), never as zero — so the broken-sweep query
278
+
`selected > 0 AND acted = 0 AND unmeasured = 0` stops firing on that flow, and
279
+
keeps working on every other flow that calls a function. Declaring changes what
280
+
is *reported*, not what is *allowed*: an undeclared writer is still counted as
281
+
having written nothing, and no runtime check can catch it — a function is
282
+
ordinary host code and may close over a client at module scope.
283
+
284
+
</Callout>
285
+
248
286
**Screen (flat fields):**
249
287
250
288
The default shape. Each field is collected as a **bare flow variable**, so a
@@ -623,7 +661,15 @@ instead:
623
661
|`http`, mutating method, rejected / timed out |`unmeasured` — a 500 can arrive after the write landed |
624
662
|`http`, `durable: true`|`acted: 1` — the outbox row is a real, durable effect |
625
663
|`connector_action`|`unmeasured`|
626
-
|`script`| nothing — a registered function is **contractually pure**: data I/O stays on the flow graph, so every write it causes is a downstream node that counts itself |
664
+
|`script`, function declared pure (the default) | nothing — a registered function is **contractually pure**: data I/O stays on the flow graph, so every write it causes is a downstream node that counts itself |
665
+
|`script`, function declared `effect: 'writes'`|`unmeasured` — the function said it writes where the platform cannot see, so the run says the count is incomplete |
666
+
667
+
The `script` row is a contract, not a measurement: nothing stops a registered
668
+
function from writing, so an **undeclared** writer still makes its run report
669
+
`acted: 0`. That is why the declaration exists and why it is worth using — see
670
+
[the purity callout](#node-examples). It is also why the reverse fix was
671
+
rejected: marking *every*`script` step `unmeasured` would blind the detector on
672
+
every flow that calls any function, to cover the few that break the rule.
627
673
628
674
`unmeasured` propagates through `subflow` and `map` roll-ups, so a parent whose
629
675
child dispatched an uncountable effect knows its own `acted` is incomplete.
What a script-node function does to data: 'pure' (computes and returns — the contract) or 'writes' (performs uncountable writes/effects, reported as unmeasured)
|**supportsRetry**|`boolean`| ✅ | Supports retry on failure |
77
77
|**needsOutbox**|`boolean`| ✅ | Dispatch via service-messaging outbox (retry/idempotency/dead-letter) |
78
78
|**isAsync**|`boolean`| ✅ | Suspends the flow awaiting an external reply |
79
+
|**handlerContract**|`Enum<'none' \| 'pure'>`| ✅ | Effect contract for author-supplied code this action invokes: 'none' (invokes none) or 'pure' (must not write — it returns a value and the flow graph persists it) |
79
80
|**resumeAuthority**|`Enum<'any' \| 'service'>`| ✅ | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals) |
80
81
|**maturity**|`Enum<'ga' \| 'beta' \| 'reserved'>`| ✅ | Runtime maturity: ga (shipped), beta, or reserved (contract only — designers grey this out) |
Copy file name to clipboardExpand all lines: content/docs/references/automation/schemaless-node-config.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -145,7 +145,7 @@ const result = DecisionCondition.parse(data);
145
145
| Property | Type | Required | Description |
146
146
| :--- | :--- | :--- | :--- |
147
147
|**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 |
148
-
|**function**|`string`| optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType |
148
+
|**function**|`string`| optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType. Contractually pure — it returns a value a later declarative node persists|
149
149
|**inputs**|`Record<string, any>`| optional | Inputs passed to the function (values interpolate `{token}` templates) |
150
150
|**outputVariable**|`string`| optional | Flow variable the function's return value is bound to |
151
151
|**template**|`string`| optional | Built-in side effects only: message template id |
@@ -195,6 +195,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
195
195
|`builtin-node-config.zod.ts`| 8 | authorable | Same family (#4045): the CRUD quartet, `screen`, `map`. Written from what the executors read rather than from the descriptors' `configSchema` literals, and reconciled bidirectionally by `builtin-node-form-zod-ledger.test.ts` — so unlike most rows here, this one already has a drift check of its own. Same candidacy note as `io-node-config`|
196
196
|`schemaless-node-config.zod.ts`| 4 | authorable | Same family, third panel (#4278): `script` / `subflow` / `decision` (+ the decision branch item) — the descriptor-schemaless nodes whose form lives in objectui's hand-written table. Written from the executors; the drift check is objectui's `flow-node-config.spec-reconciliation` test (cross-repo, via the published exports). Contract exports only — nothing parses node config with them yet, so strictness candidacy follows `io-node-config`|
|`flow-function.zod.ts`| 1 | authorable |`FlowFunctionDeclarationSchema` (#4396) — the `{ handler, effect }` form of a `defineStack({ functions })` entry. Authored, but note what an undeclared key here would be: a sibling of a **live function**, not data. `defineStack`'s union already rejects a record whose `handler` is not callable, and the boot-path reader is the hand-written `normalizeFlowFunctionEntry` rather than a `.parse()` (re-validating a live handler every boot buys nothing), so strictness would bind at authoring only. Candidate on the same verify-first rule as its `*-node-config` neighbours |
0 commit comments