Skip to content

Commit eb4204b

Browse files
os-zhuangclaude
andauthored
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>
1 parent 2bafe62 commit eb4204b

38 files changed

Lines changed: 1515 additions & 83 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/service-automation": minor
6+
"@objectstack/cli": minor
7+
---
8+
9+
feat(automation): a `script` node's purity contract is declared, and a function that writes can say so (#4396)
10+
11+
The `script` executor's contract — *the named function returns a value; data I/O
12+
stays on the flow graph* — existed only as a comment inside the executor, while
13+
#4354's run summary depended on it. That summary reports no record metrics for a
14+
`script` step precisely because a pure function's writes are downstream
15+
`create_record` / `update_record` nodes counting themselves. A function that
16+
wrote anyway made its run report `selected: 30, acted: 0` — indistinguishable
17+
from the broken sweep the counters exist to detect, recorded permanently on
18+
`sys_automation_run`.
19+
20+
**The rule is now visible.** `ActionDescriptor` carries
21+
`handlerContract: 'none' | 'pure'`, and the `script` descriptor publishes
22+
`'pure'`, so the action catalog, the designer palette and the reference docs
23+
state the rule an author has to follow instead of an executor holding it
24+
privately.
25+
26+
**And a legitimate writer can opt out honestly.** A `defineStack({ functions })`
27+
entry may declare what it does, in either shape:
28+
29+
```ts
30+
defineStack({
31+
functions: {
32+
scoreLead: (ctx) => ({ score: 42 }), // pure — the default
33+
syncBilling: { handler: syncBilling, effect: 'writes' }, // declared writer
34+
},
35+
});
36+
```
37+
38+
A step calling a declared writer reports `unmeasuredEffect`, so the run's
39+
`unmeasured` tally keeps the broken-sweep query
40+
(`selected > 0 AND acted = 0 AND unmeasured = 0`) off that flow — and only that
41+
flow. Marking *every* `script` step unmeasured was rejected: it would blind the
42+
detector on every flow that calls any function in order to cover the few that
43+
break the rule.
44+
45+
Nothing here is retired or renamed: a bare `functions: { fn }` entry is
46+
unchanged and means `effect: 'pure'`. The declaration is carried end to end —
47+
`ObjectQL.registerFunction` accepts `{ packageId, effect }` alongside the
48+
existing `packageId` string and exposes `resolveFunctionEntry(name)`,
49+
`objectstack build` lowers a declared entry without dropping it, and the
50+
artifact loader re-attaches the module's callable to the declaration the JSON
51+
carried.
52+
53+
**Also fixed:** `bindHooksToEngine` returned before registering a bundle's
54+
functions when the stack declared no hooks, so a flow-only app's
55+
`defineStack({ functions })` reached the engine as nothing and every `script`
56+
node calling one failed with "no function named 'x' is registered".

content/docs/automation/flows.mdx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,44 @@ function fails the step loudly rather than passing silently.
245245
}
246246
```
247247

248+
<Callout type="info" title="A flow function is a pure compute step">
249+
250+
It takes its `inputs`, **returns** a value, and a later **declarative** node
251+
persists that value. Data I/O stays on the flow graph — the function itself does
252+
no writes, and the runtime hands it nothing to write with (`ctx` carries
253+
`input` / `variables` / `automation` / `logger`, no data engine). The `script`
254+
action publishes this as `handlerContract: 'pure'` on its
255+
[action descriptor](/docs/references/automation/node-executor).
256+
257+
The rule is load-bearing, not stylistic. [Run summaries](#run-summaries) report
258+
what a run did to the data, and a `script` step reports **no** record metrics
259+
precisely because of it: every write a pure function causes is a downstream
260+
`create_record` / `update_record` that counts itself. A function that writes
261+
behind the platform's back makes its run claim it wrote nothing — the durable
262+
`sys_automation_run` row then reads exactly like a broken sweep, permanently.
263+
264+
If a function genuinely must write (an upstream billing system, a legacy API),
265+
**declare it** so the run stays honest:
266+
267+
```typescript
268+
defineStack({
269+
functions: {
270+
scoreLead: (ctx) => ({ score: 42 }), // pure — the default
271+
syncBilling: { handler: syncBilling, effect: 'writes' }, // declared writer
272+
},
273+
});
274+
```
275+
276+
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+
248286
**Screen (flat fields):**
249287

250288
The default shape. Each field is collected as a **bare flow variable**, so a
@@ -623,7 +661,15 @@ instead:
623661
| `http`, mutating method, rejected / timed out | `unmeasured` — a 500 can arrive after the write landed |
624662
| `http`, `durable: true` | `acted: 1` — the outbox row is a real, durable effect |
625663
| `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.
627673

628674
`unmeasured` propagates through `subflow` and `map` roll-ups, so a parent whose
629675
child dispatched an uncountable effect knows its own `acted` is incomplete.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
---
2+
title: Flow Function
3+
description: Flow Function protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
@module automation/flow-function
9+
10+
The contract for a **named handler function a `script` node invokes**
11+
12+
contributed by `defineStack(\{ functions \})` and resolved by name at execute
13+
14+
time (#1870).
15+
16+
## The rule, and why it lives here instead of in a comment
17+
18+
A flow function is a PURE compute step: it receives its mapped `input`,
19+
20+
RETURNS a value, and the node's `outputVariable` exposes that value as a flow
21+
22+
variable so a later DECLARATIVE node persists it (`update_record fields: \{
23+
24+
ai_category: '\{aiResult.ai_category\}' \}`). Data I/O stays on the flow graph.
25+
26+
That is not style advice. #4354's per-run summary reports what a run did to
27+
28+
the data, and the `script` node reports NO record metrics *because* of this
29+
30+
rule: every write a script causes is a downstream `create_record` /
31+
32+
`update_record` that counts itself, so "this node touched no records" is the
33+
34+
accurate answer rather than a guess. A function that writes anyway makes its
35+
36+
run under-report — `selected: 30, acted: 0` on a run that wrote 30 invoices,
37+
38+
which reads exactly like the broken sweep #4354 exists to detect, and the
39+
40+
durable `sys_automation_run` row says so permanently.
41+
42+
Until #4396 that rule lived ONLY in a comment inside the executor, so neither
43+
44+
an author, a lint, nor the runtime could see the contract the summary was
45+
46+
relying on. It is now declared in two halves:
47+
48+
1. `ActionDescriptor.handlerContract``script` publishes `'pure'`, so the
49+
50+
action catalog and the designer palette carry the rule an author reads.
51+
52+
2. `FlowFunctionEffectSchema` — a function that legitimately writes
53+
54+
DECLARES it, and its step then reports `unmeasuredEffect`, so the run
55+
56+
says "cannot count" instead of claiming it wrote nothing.
57+
58+
## What is deliberately not here
59+
60+
A blanket `unmeasuredEffect` on every `script` step (the escape hatch #4354
61+
62+
gave `connector_action`) was rejected: it would suppress the broken-sweep
63+
64+
signal on every flow that calls any function, in order to accommodate the
65+
66+
flows that break the rule — paying for a rule-breaker with everyone else's
67+
68+
signal, and fossilizing the violation as supported behaviour.
69+
70+
Nor is this *enforcement*. The runtime hands a function no data reach —
71+
72+
`FlowFunctionContext` in `@objectstack/service-automation` carries
73+
74+
`input` / `variables` / `automation` / `logger` and no engine handle — but a
75+
76+
function is ordinary host code and can close over a client at module scope.
77+
78+
What the declaration buys is that the honest case is now *expressible*, and
79+
80+
the platform's own counters stop being wrong for it.
81+
82+
<Callout type="info">
83+
**Source:** `packages/spec/src/automation/flow-function.zod.ts`
84+
</Callout>
85+
86+
## TypeScript Usage
87+
88+
```typescript
89+
import { FlowFunctionEffect } from '@objectstack/spec/automation';
90+
import type { FlowFunctionEffect } from '@objectstack/spec/automation';
91+
92+
// Validate data
93+
const result = FlowFunctionEffect.parse(data);
94+
```
95+
96+
---
97+
98+
## FlowFunctionEffect
99+
100+
What a script-node function does to data: 'pure' (computes and returns — the contract) or 'writes' (performs uncountable writes/effects, reported as unmeasured)
101+
102+
### Allowed Values
103+
104+
* `pure`
105+
* `writes`
106+
107+
108+
---
109+

content/docs/references/automation/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
1313
<Card href="/docs/references/automation/etl" title="Etl" description="Source: packages/spec/src/automation/etl.zod.ts" />
1414
<Card href="/docs/references/automation/execution" title="Execution" description="Source: packages/spec/src/automation/execution.zod.ts" />
1515
<Card href="/docs/references/automation/flow" title="Flow" description="Source: packages/spec/src/automation/flow.zod.ts" />
16+
<Card href="/docs/references/automation/flow-function" title="Flow Function" description="Source: packages/spec/src/automation/flow-function.zod.ts" />
1617
<Card href="/docs/references/automation/io-node-config" title="Io Node Config" description="Source: packages/spec/src/automation/io-node-config.zod.ts" />
1718
<Card href="/docs/references/automation/node-executor" title="Node Executor" description="Source: packages/spec/src/automation/node-executor.zod.ts" />
1819
<Card href="/docs/references/automation/schemaless-node-config" title="Schemaless Node Config" description="Source: packages/spec/src/automation/schemaless-node-config.zod.ts" />

content/docs/references/automation/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"job",
2222
"---More---",
2323
"builtin-node-config",
24+
"flow-function",
2425
"io-node-config",
2526
"schemaless-node-config"
2627
]

content/docs/references/automation/node-executor.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018)
7676
| **supportsRetry** | `boolean` || Supports retry on failure |
7777
| **needsOutbox** | `boolean` || Dispatch via service-messaging outbox (retry/idempotency/dead-letter) |
7878
| **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) |
7980
| **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) |
8081
| **maturity** | `Enum<'ga' \| 'beta' \| 'reserved'>` || Runtime maturity: ga (shipped), beta, or reserved (contract only — designers grey this out) |
8182
| **source** | `Enum<'builtin' \| 'plugin'>` || builtin = platform baseline; plugin = third-party contributed |

content/docs/references/automation/schemaless-node-config.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ const result = DecisionCondition.parse(data);
145145
| Property | Type | Required | Description |
146146
| :--- | :--- | :--- | :--- |
147147
| **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 |
149149
| **inputs** | `Record<string, any>` | optional | Inputs passed to the function (values interpolate `{token}` templates) |
150150
| **outputVariable** | `string` | optional | Flow variable the function's return value is bound to |
151151
| **template** | `string` | optional | Built-in side effects only: message template id |

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
178178
| `external-catalog.zod.ts` | 4 | wire (p) | |
179179
| `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | |
180180

181-
### `automation/`98 sites
181+
### `automation/`99 sites
182182

183183
| File | Sites | Class | Note |
184184
|---|---|---|---|
@@ -195,6 +195,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
195195
| `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` |
196196
| `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` |
197197
| `webhook.zod.ts` | 1 | authorable (p) | spec-only (#3461) |
198+
| `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 |
198199

199200
### `security/` — 20 sites
200201

packages/cli/src/utils/build-runtime.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,13 @@ function collect(cfg) {
7676
}
7777
}
7878
}
79-
// Top-level functions map
79+
// Top-level functions map — a value is the handler, or a declaration
80+
// record stating its effect (\`{ handler, effect: 'writes' }\`).
8081
if (cfg.functions && !Array.isArray(cfg.functions) && typeof cfg.functions === 'object') {
8182
for (const [k, v] of Object.entries(cfg.functions)) {
82-
if (typeof v === 'function' && REFS.has(k)) out[k] = v;
83+
if (!REFS.has(k)) continue;
84+
if (typeof v === 'function') out[k] = v;
85+
else if (v && typeof v === 'object' && typeof v.handler === 'function') out[k] = v.handler;
8386
}
8487
}
8588
// Top-level functions array

packages/cli/src/utils/lower-callables.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,40 @@ describe('lowerCallables — only `target` binds a handler (#3855)', () => {
8787
expect(result.functions[ref]()).toBe('preferred');
8888
});
8989
});
90+
91+
// ── #4396: a function's DECLARATION survives lowering ───────────────────────
92+
//
93+
// `functions: { syncBilling: { handler, effect: 'writes' } }` is how a function
94+
// that legitimately writes keeps its run's metrics honest. Lowering is the
95+
// first place a built artifact could lose that: the map branch bound only bare
96+
// callables, so a declared entry was dropped entirely — the function then went
97+
// missing from `objectstack.json` AND from the runtime bundle, and every
98+
// `script` node calling it failed on a built deployment while working from
99+
// source.
100+
describe('lowerCallables — declared `functions` entries (#4396)', () => {
101+
const functionsOf = (result: { lowered: Record<string, unknown> }) =>
102+
(result.lowered as { functions: Record<string, unknown> }).functions;
103+
104+
it('lowers a bare handler to its ref, unchanged', () => {
105+
const result = lowerCallables({ functions: { scoreLead: () => 'scored' } });
106+
expect(functionsOf(result).scoreLead).toBe('scoreLead');
107+
expect((result.functions.scoreLead as () => string)()).toBe('scored');
108+
});
109+
110+
it('lowers a declared entry and keeps what it declared', () => {
111+
const result = lowerCallables({
112+
functions: { syncBilling: { handler: () => 'synced', effect: 'writes' } },
113+
});
114+
expect(functionsOf(result).syncBilling).toEqual({ handler: 'syncBilling', effect: 'writes' });
115+
expect((result.functions.syncBilling as () => string)()).toBe('synced');
116+
expect(result.count).toBe(1);
117+
});
118+
119+
it('keeps `effect` on the array form too', () => {
120+
const result = lowerCallables({
121+
functions: [{ name: 'syncBilling', handler: () => 'synced', effect: 'writes' }],
122+
});
123+
const [entry] = functionsOf(result) as unknown as Array<Record<string, unknown>>;
124+
expect(entry).toEqual({ name: 'syncBilling', handler: 'syncBilling', effect: 'writes' });
125+
});
126+
});

0 commit comments

Comments
 (0)