From fc4144d4cad8c92a6becce9757bcdc6036927b73 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 17:23:53 +0000 Subject: [PATCH] feat(spec,automation,objectql,runtime,cli): declare the script-function purity contract; a writer opts out honestly (#4396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017nwaAedz4jxRsy63nW8bGq --- .changeset/flow-function-declared-effect.md | 56 ++++++ content/docs/automation/flows.mdx | 48 ++++- .../references/automation/flow-function.mdx | 109 ++++++++++++ content/docs/references/automation/index.mdx | 1 + content/docs/references/automation/meta.json | 1 + .../references/automation/node-executor.mdx | 1 + .../automation/schemaless-node-config.mdx | 2 +- .../2026-07-unknown-key-strictness-ledger.md | 3 +- packages/cli/src/utils/build-runtime.ts | 7 +- .../cli/src/utils/lower-callables.test.ts | 37 ++++ packages/cli/src/utils/lower-callables.ts | 12 +- packages/objectql/src/engine.ts | 60 ++++++- .../src/function-registry-effect.test.ts | 136 ++++++++++++++ packages/objectql/src/hook-binder.ts | 70 ++++++-- .../fixtures/flow-function-effect-fixture.ts | 78 ++++++++ .../test/flow-function-effect.dogfood.test.ts | 80 +++++++++ packages/runtime/src/app-plugin.ts | 59 ++++-- .../artifact-function-declarations.test.ts | 71 ++++++++ .../src/bundle-function-entries.test.ts | 79 ++++++++ packages/runtime/src/index.ts | 2 +- packages/runtime/src/load-artifact-bundle.ts | 14 +- packages/runtime/vitest.config.ts | 3 + .../src/builtin/screen-nodes.test.ts | 129 ++++++++++++++ .../src/builtin/screen-nodes.ts | 58 ++++-- .../services/service-automation/src/engine.ts | 52 +++++- .../services/service-automation/src/plugin.ts | 20 ++- packages/spec/api-surface.json | 12 ++ packages/spec/authorable-surface.json | 1 + packages/spec/json-schema.manifest.json | 1 + .../spec/src/automation/flow-function.test.ts | 106 +++++++++++ .../spec/src/automation/flow-function.zod.ts | 168 ++++++++++++++++++ packages/spec/src/automation/index.ts | 1 + .../spec/src/automation/node-executor.test.ts | 20 +++ .../spec/src/automation/node-executor.zod.ts | 28 +++ .../automation/schemaless-node-config.zod.ts | 17 +- .../spec/src/contracts/objectql-engine.ts | 15 +- packages/spec/src/stack.zod.ts | 26 ++- skills/objectstack-automation/SKILL.md | 15 ++ 38 files changed, 1515 insertions(+), 83 deletions(-) create mode 100644 .changeset/flow-function-declared-effect.md create mode 100644 content/docs/references/automation/flow-function.mdx create mode 100644 packages/objectql/src/function-registry-effect.test.ts create mode 100644 packages/qa/dogfood/test/fixtures/flow-function-effect-fixture.ts create mode 100644 packages/qa/dogfood/test/flow-function-effect.dogfood.test.ts create mode 100644 packages/runtime/src/artifact-function-declarations.test.ts create mode 100644 packages/runtime/src/bundle-function-entries.test.ts create mode 100644 packages/spec/src/automation/flow-function.test.ts create mode 100644 packages/spec/src/automation/flow-function.zod.ts diff --git a/.changeset/flow-function-declared-effect.md b/.changeset/flow-function-declared-effect.md new file mode 100644 index 0000000000..ef1463ce5a --- /dev/null +++ b/.changeset/flow-function-declared-effect.md @@ -0,0 +1,56 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/runtime": minor +"@objectstack/service-automation": minor +"@objectstack/cli": minor +--- + +feat(automation): a `script` node's purity contract is declared, and a function that writes can say so (#4396) + +The `script` executor's contract — *the named function returns a value; data I/O +stays on the flow graph* — existed only as a comment inside the executor, while +#4354's run summary depended on it. That summary reports no record metrics for a +`script` step precisely because a pure function's writes are downstream +`create_record` / `update_record` nodes counting themselves. A function that +wrote anyway made its run report `selected: 30, acted: 0` — indistinguishable +from the broken sweep the counters exist to detect, recorded permanently on +`sys_automation_run`. + +**The rule is now visible.** `ActionDescriptor` carries +`handlerContract: 'none' | 'pure'`, and the `script` descriptor publishes +`'pure'`, so the action catalog, the designer palette and the reference docs +state the rule an author has to follow instead of an executor holding it +privately. + +**And a legitimate writer can opt out honestly.** A `defineStack({ functions })` +entry may declare what it does, in either shape: + +```ts +defineStack({ + functions: { + scoreLead: (ctx) => ({ score: 42 }), // pure — the default + syncBilling: { handler: syncBilling, effect: 'writes' }, // declared writer + }, +}); +``` + +A step calling a declared writer 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. Marking *every* `script` step unmeasured was rejected: it would blind the +detector on every flow that calls any function in order to cover the few that +break the rule. + +Nothing here is retired or renamed: a bare `functions: { fn }` entry is +unchanged and means `effect: 'pure'`. The declaration is carried end to end — +`ObjectQL.registerFunction` accepts `{ packageId, effect }` alongside the +existing `packageId` string and exposes `resolveFunctionEntry(name)`, +`objectstack build` lowers a declared entry without dropping it, and the +artifact loader re-attaches the module's callable to the declaration the JSON +carried. + +**Also fixed:** `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". diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index faede6a16f..0db5623b40 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -245,6 +245,44 @@ function fails the step loudly rather than passing silently. } ``` + + +It takes its `inputs`, **returns** a value, and a later **declarative** node +persists that value. Data I/O stays on the flow graph — the function itself does +no writes, and the runtime hands it nothing to write with (`ctx` carries +`input` / `variables` / `automation` / `logger`, no data engine). The `script` +action publishes this as `handlerContract: 'pure'` on its +[action descriptor](/docs/references/automation/node-executor). + +The rule is load-bearing, not stylistic. [Run summaries](#run-summaries) report +what a run did to the data, and a `script` step reports **no** record metrics +precisely because of it: every write a pure function causes is a downstream +`create_record` / `update_record` that counts itself. A function that writes +behind the platform's back makes its run claim it wrote nothing — the durable +`sys_automation_run` row then reads exactly like a broken sweep, permanently. + +If a function genuinely must write (an upstream billing system, a legacy API), +**declare it** so the run stays honest: + +```typescript +defineStack({ + functions: { + scoreLead: (ctx) => ({ score: 42 }), // pure — the default + syncBilling: { handler: syncBilling, effect: 'writes' }, // declared writer + }, +}); +``` + +A step that calls a declared writer is counted as an effect the platform cannot +measure (`unmeasured`), never as zero — so the broken-sweep query +`selected > 0 AND acted = 0 AND unmeasured = 0` stops firing on that flow, and +keeps working on every other flow that calls a function. Declaring changes what +is *reported*, not what is *allowed*: an undeclared writer is still counted as +having written nothing, and no runtime check can catch it — a function is +ordinary host code and may close over a client at module scope. + + + **Screen (flat fields):** The default shape. Each field is collected as a **bare flow variable**, so a @@ -623,7 +661,15 @@ instead: | `http`, mutating method, rejected / timed out | `unmeasured` — a 500 can arrive after the write landed | | `http`, `durable: true` | `acted: 1` — the outbox row is a real, durable effect | | `connector_action` | `unmeasured` | -| `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 | +| `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 | +| `script`, function declared `effect: 'writes'` | `unmeasured` — the function said it writes where the platform cannot see, so the run says the count is incomplete | + +The `script` row is a contract, not a measurement: nothing stops a registered +function from writing, so an **undeclared** writer still makes its run report +`acted: 0`. That is why the declaration exists and why it is worth using — see +[the purity callout](#node-examples). It is also why the reverse fix was +rejected: marking *every* `script` step `unmeasured` would blind the detector on +every flow that calls any function, to cover the few that break the rule. `unmeasured` propagates through `subflow` and `map` roll-ups, so a parent whose child dispatched an uncountable effect knows its own `acted` is incomplete. diff --git a/content/docs/references/automation/flow-function.mdx b/content/docs/references/automation/flow-function.mdx new file mode 100644 index 0000000000..40e4f938bd --- /dev/null +++ b/content/docs/references/automation/flow-function.mdx @@ -0,0 +1,109 @@ +--- +title: Flow Function +description: Flow Function protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +@module automation/flow-function + +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: + +1. `ActionDescriptor.handlerContract` — `script` publishes `'pure'`, so the + +action catalog and the designer palette carry the rule an author reads. + +2. `FlowFunctionEffectSchema` — a function that legitimately writes + +DECLARES it, and its step then reports `unmeasuredEffect`, 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 suppress the broken-sweep + +signal on every flow that calls any function, in order to accommodate the + +flows that break the rule — paying for a rule-breaker with everyone else's + +signal, and fossilizing the violation as supported behaviour. + +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 + +```typescript +import { FlowFunctionEffect } from '@objectstack/spec/automation'; +import type { FlowFunctionEffect } from '@objectstack/spec/automation'; + +// Validate data +const result = FlowFunctionEffect.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 + +* `pure` +* `writes` + + +--- + diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index 11d43f78da..fe6cef7bf8 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -13,6 +13,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta + diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index 9a54bd03d2..30a557be1d 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -21,6 +21,7 @@ "job", "---More---", "builtin-node-config", + "flow-function", "io-node-config", "schemaless-node-config" ] diff --git a/content/docs/references/automation/node-executor.mdx b/content/docs/references/automation/node-executor.mdx index d4b5cb40d7..e6e35ff1b8 100644 --- a/content/docs/references/automation/node-executor.mdx +++ b/content/docs/references/automation/node-executor.mdx @@ -76,6 +76,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **supportsRetry** | `boolean` | ✅ | Supports retry on failure | | **needsOutbox** | `boolean` | ✅ | Dispatch via service-messaging outbox (retry/idempotency/dead-letter) | | **isAsync** | `boolean` | ✅ | Suspends the flow awaiting an external reply | +| **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) | | **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) | | **maturity** | `Enum<'ga' \| 'beta' \| 'reserved'>` | ✅ | Runtime maturity: ga (shipped), beta, or reserved (contract only — designers grey this out) | | **source** | `Enum<'builtin' \| 'plugin'>` | ✅ | builtin = platform baseline; plugin = third-party contributed | diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx index 32c7b4ceef..ab971fe805 100644 --- a/content/docs/references/automation/schemaless-node-config.mdx +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -145,7 +145,7 @@ 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 | +| **function** | `string` | optional | Registered function to call (defineStack(`{ functions }`)); takes precedence over actionType. Contractually pure — it returns a value a later declarative node persists | | **inputs** | `Record` | 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 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 2654d2afb5..12b9463af4 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -165,7 +165,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `external-catalog.zod.ts` | 4 | wire (p) | | | `field-value.zod.ts` / `seed.zod.ts` / `validation.zod.ts` | 1 ea | mixed (p) | | -### `automation/` — 98 sites +### `automation/` — 99 sites | File | Sites | Class | Note | |---|---|---|---| @@ -182,6 +182,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). | `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` | | `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` | | `webhook.zod.ts` | 1 | authorable (p) | spec-only (#3461) | +| `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 | ### `security/` — 20 sites diff --git a/packages/cli/src/utils/build-runtime.ts b/packages/cli/src/utils/build-runtime.ts index ceda474b61..3b969ce3ba 100644 --- a/packages/cli/src/utils/build-runtime.ts +++ b/packages/cli/src/utils/build-runtime.ts @@ -76,10 +76,13 @@ function collect(cfg) { } } } - // Top-level functions map + // Top-level functions map — a value is the handler, or a declaration + // record stating its effect (\`{ handler, effect: 'writes' }\`). if (cfg.functions && !Array.isArray(cfg.functions) && typeof cfg.functions === 'object') { for (const [k, v] of Object.entries(cfg.functions)) { - if (typeof v === 'function' && REFS.has(k)) out[k] = v; + if (!REFS.has(k)) continue; + if (typeof v === 'function') out[k] = v; + else if (v && typeof v === 'object' && typeof v.handler === 'function') out[k] = v.handler; } } // Top-level functions array diff --git a/packages/cli/src/utils/lower-callables.test.ts b/packages/cli/src/utils/lower-callables.test.ts index f6ec974a6b..2d637bcd38 100644 --- a/packages/cli/src/utils/lower-callables.test.ts +++ b/packages/cli/src/utils/lower-callables.test.ts @@ -87,3 +87,40 @@ describe('lowerCallables — only `target` binds a handler (#3855)', () => { expect(result.functions[ref]()).toBe('preferred'); }); }); + +// ── #4396: a function's DECLARATION survives lowering ─────────────────────── +// +// `functions: { syncBilling: { handler, effect: 'writes' } }` is how a function +// that legitimately writes keeps its run's metrics honest. Lowering is the +// first place a built artifact could lose that: the map branch bound only bare +// callables, so a declared entry was dropped entirely — the function then went +// missing from `objectstack.json` AND from the runtime bundle, and every +// `script` node calling it failed on a built deployment while working from +// source. +describe('lowerCallables — declared `functions` entries (#4396)', () => { + const functionsOf = (result: { lowered: Record }) => + (result.lowered as { functions: Record }).functions; + + it('lowers a bare handler to its ref, unchanged', () => { + const result = lowerCallables({ functions: { scoreLead: () => 'scored' } }); + expect(functionsOf(result).scoreLead).toBe('scoreLead'); + expect((result.functions.scoreLead as () => string)()).toBe('scored'); + }); + + it('lowers a declared entry and keeps what it declared', () => { + const result = lowerCallables({ + functions: { syncBilling: { handler: () => 'synced', effect: 'writes' } }, + }); + expect(functionsOf(result).syncBilling).toEqual({ handler: 'syncBilling', effect: 'writes' }); + expect((result.functions.syncBilling as () => string)()).toBe('synced'); + expect(result.count).toBe(1); + }); + + it('keeps `effect` on the array form too', () => { + const result = lowerCallables({ + functions: [{ name: 'syncBilling', handler: () => 'synced', effect: 'writes' }], + }); + const [entry] = functionsOf(result) as unknown as Array>; + expect(entry).toEqual({ name: 'syncBilling', handler: 'syncBilling', effect: 'writes' }); + }); +}); diff --git a/packages/cli/src/utils/lower-callables.ts b/packages/cli/src/utils/lower-callables.ts index 3c084d6264..b9b8a91f1d 100644 --- a/packages/cli/src/utils/lower-callables.ts +++ b/packages/cli/src/utils/lower-callables.ts @@ -148,13 +148,23 @@ export function lowerCallables(input: Record): LoweringResult { }); (lowered as Record).functions = arr; } else if (isPlainObject(fnsField)) { - const out: Record = {}; + const out: Record = {}; for (const [key, value] of Object.entries(fnsField)) { if (typeof value === 'function') { const ref = uniqueName(key, taken); taken.add(ref); functions[ref] = value as AnyFn; out[ref] = ref; + } else if (isPlainObject(value) && typeof value.handler === 'function') { + // A DECLARED entry (`{ handler, effect: 'writes' }`, #4396). Lower the + // callable exactly like the bare form and keep the declaration beside + // it, so what the function said about itself survives into the + // artifact — dropping it here would silently un-declare the function + // on every built deployment while it kept working from source. + const ref = uniqueName(key, taken); + taken.add(ref); + functions[ref] = value.handler as AnyFn; + out[ref] = { ...value, handler: ref }; } } // Preserve any pre-existing string entries (legacy bundles). diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1851170138..34f243233d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -25,6 +25,7 @@ import { isDataMigrationFlagVerified, } from '@objectstack/spec/system'; import { ExecutionContext, ExecutionContextInput, ExecutionContextSchema } from '@objectstack/spec/kernel'; +import type { FlowFunctionEffect } from '@objectstack/spec/automation'; import { IDataDriver, IDataEngine, @@ -433,9 +434,33 @@ export interface HookEntry { } /** Function registry entry — see `registerFunction`. */ -interface FunctionEntry { +export interface FunctionEntry { handler: HookHandler; packageId?: string; + /** + * What this function does to data, as DECLARED at registration (#4396). + * Only a `script`-node caller reads it: a flow function is contractually + * pure, and the run summary counts on that, so a function that legitimately + * writes declares `'writes'` and its step is reported as an effect the + * platform cannot count rather than as none. Absent ⇒ `'pure'`. + * + * Carried on the registry entry rather than beside it because this IS part of + * the registration — one registry, and what a caller needs to know about a + * function travels with the function. + */ + effect?: FlowFunctionEffect; +} + +/** + * Declarations that may accompany a `registerFunction` call. The bare + * `packageId` string is still accepted in that position (every existing caller + * passes one), so this widens the signature without a migration. + */ +export interface FunctionRegistrationOptions { + /** Owning package — the unit `unregisterFunctionsByPackage` removes. */ + packageId?: string; + /** Declared data effect (#4396); omit for the pure default. */ + effect?: FlowFunctionEffect; } /** @@ -757,14 +782,27 @@ export class ObjectQL implements IObjectQLEngine { /** * Register a named function handler that can later be referenced by - * string from a `Hook.handler` field. This is the JSON-safe form of + * string from a `Hook.handler` field, an `Action.target`, or a flow + * `script` node's `config.function`. This is the JSON-safe form of * handler binding — declarative metadata persisted to disk or shipped * over the wire only carries the name. + * + * The third parameter accepts either the owning `packageId` (its original + * shape, unchanged for every existing caller) or a + * {@link FunctionRegistrationOptions} record that also carries what the + * function DECLARES about itself — today its data `effect` (#4396). */ - registerFunction(name: string, handler: HookHandler, packageId?: string): void { + registerFunction( + name: string, + handler: HookHandler, + packageIdOrOptions?: string | FunctionRegistrationOptions, + ): void { if (!name || typeof handler !== 'function') return; - this.functions.set(name, { handler, packageId }); - this.logger.debug('Registered function', { name, packageId }); + const opts: FunctionRegistrationOptions = + typeof packageIdOrOptions === 'string' ? { packageId: packageIdOrOptions } : (packageIdOrOptions ?? {}); + const { packageId, effect } = opts; + this.functions.set(name, { handler, packageId, ...(effect ? { effect } : {}) }); + this.logger.debug('Registered function', { name, packageId, effect }); } /** Look up a registered function by name. */ @@ -772,6 +810,18 @@ export class ObjectQL implements IObjectQLEngine { return this.functions.get(name)?.handler; } + /** + * Look up a registered function's FULL entry — the handler plus whatever it + * declared about itself (#4396). `resolveFunction` above answers "can I call + * it"; a caller that must also report what the call did (the automation + * engine's `script` node, feeding the #4354 run summary) needs the + * declaration, and reading it off the same registry keeps the two from + * drifting. + */ + resolveFunctionEntry(name: string): Readonly | undefined { + return this.functions.get(name); + } + /** Remove all functions registered under a given `packageId`. */ unregisterFunctionsByPackage(packageId: string): number { if (!packageId) return 0; diff --git a/packages/objectql/src/function-registry-effect.test.ts b/packages/objectql/src/function-registry-effect.test.ts new file mode 100644 index 0000000000..4d1545a156 --- /dev/null +++ b/packages/objectql/src/function-registry-effect.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The function registry carries what a function DECLARED about itself (#4396). + * + * One registry serves hooks, actions and flow `script` nodes, so the + * declaration has to travel WITH the handler: the automation engine reads it + * back through this registry to report whether a run's `acted` count covers + * everything the run did. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL } from './engine.js'; +import { bindHooksToEngine } from './hook-binder.js'; +import type { Hook, HookContext } from '@objectstack/spec/data'; + +describe('ObjectQL.registerFunction — declarations (#4396)', () => { + it('keeps the packageId-string form working unchanged', () => { + const engine = new ObjectQL(); + const fn = () => {}; + engine.registerFunction('scoreLead', fn, 'app:demo'); + expect(engine.resolveFunction('scoreLead')).toBe(fn); + expect(engine.resolveFunctionEntry('scoreLead')).toEqual({ handler: fn, packageId: 'app:demo' }); + }); + + it('records a declared effect and hands it back on the entry', () => { + const engine = new ObjectQL(); + const fn = () => {}; + engine.registerFunction('syncBilling', fn, { packageId: 'app:demo', effect: 'writes' }); + expect(engine.resolveFunction('syncBilling')).toBe(fn); + expect(engine.resolveFunctionEntry('syncBilling')).toEqual({ + handler: fn, + packageId: 'app:demo', + effect: 'writes', + }); + }); + + it('leaves the effect absent when nothing was declared — absent reads as pure', () => { + const engine = new ObjectQL(); + engine.registerFunction('scoreLead', () => {}, { packageId: 'app:demo' }); + expect(engine.resolveFunctionEntry('scoreLead')?.effect).toBeUndefined(); + }); + + it('still unregisters by package with the options form', () => { + const engine = new ObjectQL(); + engine.registerFunction('syncBilling', () => {}, { packageId: 'app:demo', effect: 'writes' }); + expect(engine.unregisterFunctionsByPackage('app:demo')).toBe(1); + expect(engine.resolveFunctionEntry('syncBilling')).toBeUndefined(); + }); + + it('answers undefined for an unknown name', () => { + expect(new ObjectQL().resolveFunctionEntry('nope')).toBeUndefined(); + }); +}); + +describe('bindHooksToEngine — function entries (#4396)', () => { + it('registers a declared entry with both its handler and its effect', () => { + const engine = new ObjectQL(); + const syncBilling = () => {}; + bindHooksToEngine(engine, [], { + packageId: 'app:demo', + functions: { syncBilling: { handler: syncBilling, effect: 'writes' } }, + }); + expect(engine.resolveFunctionEntry('syncBilling')).toEqual({ + handler: syncBilling, + packageId: 'app:demo', + effect: 'writes', + }); + }); + + it('resolves a string-named hook handler through a declared entry', async () => { + const engine = new ObjectQL(); + const calls: string[] = []; + const hook: Hook = { + name: 'h1', + object: ['account'], + events: ['beforeInsert'], + handler: 'syncBilling', + } as Hook; + + const result = bindHooksToEngine(engine, [hook], { + packageId: 'app:demo', + functions: { syncBilling: { handler: () => { calls.push('ran'); }, effect: 'writes' } }, + }); + + expect(result.errors).toEqual([]); + expect(result.registered).toBe(1); + await engine.triggerHooks('beforeInsert', { + object: 'account', + event: 'beforeInsert', + input: { data: {} }, + } as HookContext); + expect(calls).toEqual(['ran']); + }); + + it('warns and keeps the run measurable when an effect spelling is unreadable', () => { + const engine = new ObjectQL(); + const warn = vi.fn(); + bindHooksToEngine(engine, [], { + packageId: 'app:demo', + // 'write' is not a member — read as an uncountable write rather than as + // the pure default, which would silently under-report the run. + functions: { syncBilling: { handler: () => {}, effect: 'write' } as never }, + logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }, + }); + expect(engine.resolveFunctionEntry('syncBilling')?.effect).toBe('writes'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('unrecognized function effect'), + expect.objectContaining({ name: 'syncBilling', effect: 'write' }), + ); + }); +}); + +/** + * `functions` is not a hook accessory (found while wiring #4396). + * + * The binder bailed out on an empty `hooks` array BEFORE registering the + * bundle's functions, so a stack that declared functions and no hooks — the + * shape a flow-only app has — registered none of them, and every `script` node + * calling one failed with "no function named 'x' is registered". + */ +describe('bindHooksToEngine — functions without hooks', () => { + it('registers the bundle functions even when the stack declares no hooks', () => { + const engine = new ObjectQL(); + const scoreLead = () => {}; + bindHooksToEngine(engine, [], { packageId: 'app:demo', functions: { scoreLead } }); + expect(engine.resolveFunction('scoreLead')).toBe(scoreLead); + }); + + it('registers them for an undefined hooks list too', () => { + const engine = new ObjectQL(); + const scoreLead = () => {}; + bindHooksToEngine(engine, undefined, { packageId: 'app:demo', functions: { scoreLead } }); + expect(engine.resolveFunction('scoreLead')).toBe(scoreLead); + }); +}); diff --git a/packages/objectql/src/hook-binder.ts b/packages/objectql/src/hook-binder.ts index 6026723006..535cead1d4 100644 --- a/packages/objectql/src/hook-binder.ts +++ b/packages/objectql/src/hook-binder.ts @@ -22,6 +22,7 @@ */ import type { Hook } from '@objectstack/spec/data'; +import { normalizeFlowFunctionEntry, type FlowFunctionEntry } from '@objectstack/spec/automation'; import type { ObjectQL, HookHandler } from './engine.js'; import { wrapDeclarativeHook } from './hook-wrappers.js'; import type { HookMetricsRecorder } from './hook-metrics.js'; @@ -34,8 +35,14 @@ export interface BindHooksOptions { * Optional name → function map for resolving string `handler` references. * Typically supplied by `defineStack({ functions })` and merged with any * functions previously registered on the engine. + * + * A value may be the handler itself or a declaration record stating what the + * function does (`{ handler, effect: 'writes' }`, #4396) — the same two + * spellings `defineStack({ functions })` accepts. The declaration is stored + * on the registry entry, where the `script` node reads it to report the run's + * metrics honestly; hook binding itself only ever needs the handler. */ - functions?: Record; + functions?: Record; /** * Optional factory that converts a metadata-only `Hook.body` (L1 expression @@ -104,6 +111,45 @@ export function bindHooksToEngine( const logger = opts.logger ?? noopLogger; const result: BindHooksResult = { registered: 0, skipped: 0, errors: [] }; + // Pre-load any inline functions supplied via `bundle.functions` so + // string-handler resolution works. An entry may declare its effect (#4396); + // that declaration is registered WITH the handler, so a later `script`-node + // caller reads both off the one registry. + // + // BEFORE the no-hooks bail-out below, deliberately: `functions` is not a + // hook accessory. It also feeds `Action.target` string refs and a flow + // `script` node's `config.function`, so a stack that declares functions and + // no hooks — the shape #4396's example has — registered NOTHING and every + // such `script` node failed with "no function named 'x' is registered", + // naming the one thing the author had actually done. + if (opts.functions && typeof (engine as any).registerFunction === 'function') { + for (const [name, entry] of Object.entries(opts.functions)) { + const fn = normalizeFlowFunctionEntry(entry); + if (!fn) { + logger.warn('[hook-binder] skipping function entry with no callable handler', { name }); + continue; + } + if (fn.unrecognizedEffect !== undefined) { + logger.warn('[hook-binder] unrecognized function effect — counted as an uncountable write', { + name, + effect: fn.unrecognizedEffect, + expected: "'pure' | 'writes'", + }); + } + try { + (engine as any).registerFunction(name, fn.handler, { + packageId: opts.packageId, + effect: fn.effect, + }); + } catch (err: any) { + logger.warn('[hook-binder] failed to register function', { + name, + error: err?.message, + }); + } + } + } + if (!Array.isArray(hooks) || hooks.length === 0) { return result; } @@ -121,21 +167,6 @@ export function bindHooksToEngine( } } - // Pre-load any inline functions supplied via `bundle.functions` so - // string-handler resolution works. - if (opts.functions && typeof (engine as any).registerFunction === 'function') { - for (const [name, fn] of Object.entries(opts.functions)) { - try { - (engine as any).registerFunction(name, fn, opts.packageId); - } catch (err: any) { - logger.warn('[hook-binder] failed to register function', { - name, - error: err?.message, - }); - } - } - } - for (const hook of hooks) { try { const resolved = resolveHandler(engine, hook, opts); @@ -276,9 +307,10 @@ function resolveHandler( if (typeof h === 'function') return h as HookHandler; if (typeof h === 'string' && h.length > 0) { // Try the per-bundle map first (hot path during initial bind), - // then fall back to whatever the engine already knows. - const fromBundle = opts.functions?.[h]; - if (typeof fromBundle === 'function') return fromBundle; + // then fall back to whatever the engine already knows. A declaration + // record resolves to its handler — a hook cares only about the callable. + const fromBundle = normalizeFlowFunctionEntry(opts.functions?.[h]); + if (fromBundle) return fromBundle.handler as HookHandler; if (typeof (engine as any).resolveFunction === 'function') { const fn = (engine as any).resolveFunction(h); if (typeof fn === 'function') return fn as HookHandler; diff --git a/packages/qa/dogfood/test/fixtures/flow-function-effect-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-function-effect-fixture.ts new file mode 100644 index 0000000000..bda1b73a31 --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/flow-function-effect-fixture.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Flow-function EFFECT fixture (#4396) — the declaration a run's metrics depend on. +// +// A `script` node's function is contractually pure: it returns a value and a +// later declarative node persists it. #4354's run summary counts on that — a +// `script` step reports no record metrics because every write a pure function +// causes is a downstream node counting itself. A function that writes anyway +// makes its run report `acted: 0`, which is exactly what a dead sweep looks +// like. +// +// So a function that legitimately writes DECLARES it, and this fixture is the +// end-to-end proof that the declaration survives every seam between the author +// and the counter: `defineStack({ functions })` → AppPlugin's bundle collection +// → the ObjectQL function registry → the automation service's resolver bridge → +// the `script` executor → the run summary on the trigger response. +// +// Two flows, identical but for the function they call, so the difference in the +// reported summary can only come from the declaration. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** One object, so the sweep flow has something real to select. */ +export const FxInvoice = ObjectSchema.create({ + name: 'fxn_invoice', + // [ADR-0090 D1] grandfather stamp: the gate under test is run METRICS, not + // owner-sharing. + sharingModel: 'public_read_write', + label: 'Invoice', + pluralLabel: 'Invoices', + fields: { + name: Field.text({ label: 'Name', required: true }), + status: Field.text({ label: 'Status' }), + }, +}); + +/** start → get_record → script → end. `fn` is the only thing that varies. */ +const sweepFlow = (name: string, fn: string) => ({ + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'select', + type: 'get_record', + label: 'Select invoices', + config: { objectName: 'fxn_invoice', filter: { status: 'open' }, outputVariable: 'invoices' }, + }, + { id: 'run', type: 'script', label: 'Run', config: { function: fn, inputs: { count: '{invoices}' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'select' }, + { id: 'e2', source: 'select', target: 'run' }, + { id: 'e3', source: 'run', target: 'end' }, + ], +}); + +export const flowFunctionEffectStack = defineStack({ + manifest: { + id: 'com.dogfood.flow_function_effect', + namespace: 'fxn', + version: '0.0.0', + type: 'app', + name: 'Flow Function Effect Fixture', + description: "Proves a flow function's declared effect reaches the run summary (#4396).", + }, + objects: [FxInvoice], + flows: [sweepFlow('fxn_pure_sweep', 'scoreInvoices'), sweepFlow('fxn_writing_sweep', 'syncBilling')], + functions: { + // Undeclared ⇒ pure, the contract's default and the short spelling of it. + scoreInvoices: () => ({ scored: true }), + // Declared: writes somewhere the platform cannot see or count. + syncBilling: { handler: () => ({ ok: true }), effect: 'writes' }, + }, +}); diff --git a/packages/qa/dogfood/test/flow-function-effect.dogfood.test.ts b/packages/qa/dogfood/test/flow-function-effect.dogfood.test.ts new file mode 100644 index 0000000000..92508caed7 --- /dev/null +++ b/packages/qa/dogfood/test/flow-function-effect.dogfood.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// FLOW FUNCTION EFFECT proof (#4396), through the real HTTP + automation stack. +// +// The claim under test is a counting claim, and it spans four packages: an +// author writes `functions: { syncBilling: { handler, effect: 'writes' } }` in +// `defineStack`; AppPlugin collects it; ObjectQL's function registry stores it +// beside the handler; the automation service bridges it into the engine; and +// the `script` executor turns it into `unmeasuredEffect` on the step, which +// #4354's summary folds into the run's `unmeasured` tally. +// +// Any one of those seams can drop the declaration silently — the run still +// succeeds, it just reports `acted: 0, unmeasured: 0` on a run that wrote, which +// is indistinguishable from a dead sweep and is the whole defect #4396 is +// about. Only an end-to-end assertion can tell the difference, so this boots the +// app, triggers both flows over HTTP, and reads the summary the run reports. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { flowFunctionEffectStack } from './fixtures/flow-function-effect-fixture.js'; + +interface RunSummary { + selected: number; + acted: number; + unmeasured?: number; + nodes: Array<{ nodeId: string; unmeasured?: number; acted?: number; selected?: number }>; +} + +describe('objectstack verify FLOW: a function\'s declared effect reaches the run summary (#4396)', () => { + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + stack = await bootStack(flowFunctionEffectStack, { automation: true }); + token = await stack.signIn(); + // Two open invoices, so `selected > 0` — the first half of the + // broken-sweep signal the `unmeasured` tally has to keep honest. + for (const name of ['inv-1', 'inv-2']) { + const res = await stack.apiAs(token, 'POST', '/data/fxn_invoice', { name, status: 'open' }); + expect(res.status, `seed ${name} failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300); + } + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + async function runSummaryOf(flow: string): Promise { + const res = await stack.apiAs(token, 'POST', `/automation/${flow}/trigger`, {}); + expect(res.status, `trigger ${flow} failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300); + const body = (await res.json()) as { data?: { success?: boolean; error?: string; summary?: RunSummary } }; + // A `script` node whose function never registered fails LOUDLY (#1870), so + // this assertion is also the proof that `defineStack({ functions })` + // reached the engine at all. + expect(body.data?.success, `run failed: ${body.data?.error}`).toBe(true); + expect(body.data?.summary, 'run reported no summary').toBeTruthy(); + return body.data!.summary!; + } + + it('a PURE function leaves the run fully measured — `acted: 0` means zero', async () => { + const summary = await runSummaryOf('fxn_pure_sweep'); + expect(summary.selected).toBeGreaterThan(0); + expect(summary.acted).toBe(0); + // Nothing uncountable happened, so the broken-sweep query + // (`selected > 0 AND acted = 0 AND unmeasured = 0`) is free to fire on this + // run — which is correct: a pure function that writes nothing IS a sweep + // that did nothing. + expect(summary.unmeasured).toBe(0); + expect(summary.nodes.find((n) => n.nodeId === 'run')?.unmeasured).toBeUndefined(); + }); + + it("a DECLARED writer's run says the count is incomplete instead of claiming zero", async () => { + const summary = await runSummaryOf('fxn_writing_sweep'); + expect(summary.selected).toBeGreaterThan(0); + expect(summary.acted).toBe(0); + // The one bit that must survive all four seams. + expect(summary.unmeasured).toBe(1); + expect(summary.nodes.find((n) => n.nodeId === 'run')?.unmeasured).toBe(1); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 95a7a34359..a0a4a7eadc 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -8,6 +8,7 @@ import { recordSeedOutcome } from './seed-summary.js'; import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; import { loadDisabledPackageIds } from './package-state-store.js'; import type { IJobService, IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts'; +import { normalizeFlowFunctionEntry, type NormalizedFlowFunction } from '@objectstack/spec/automation'; import { readServiceSelfInfo } from '@objectstack/spec/api'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; @@ -638,7 +639,19 @@ export class AppPlugin implements Plugin { // cases — both will coexist on the engine. try { const hooks = collectBundleHooks(this.bundle); - const functions = collectBundleFunctions(this.bundle); + // Entries, not bare handlers: each function's declared `effect` + // (#4396) rides along to the registry, where a `script` node reads + // it to report what its run actually did. + const functions = collectBundleFunctionEntries(this.bundle); + for (const [name, fn] of Object.entries(functions)) { + if (fn.unrecognizedEffect === undefined) continue; + ctx.logger.warn('[AppPlugin] unrecognized function effect — counted as an uncountable write', { + appId, + name, + effect: fn.unrecognizedEffect, + expected: "'pure' | 'writes'", + }); + } if (hooks.length > 0 || Object.keys(functions).length > 0) { if (typeof ql.bindHooks === 'function') { ql.bindHooks(hooks, { @@ -1384,27 +1397,33 @@ export function collectBundleActions( } /** - * Collect a name → handler map from `bundle.functions`. Accepted shapes: + * Collect a name → {@link NormalizedFlowFunction} map from `bundle.functions`, + * keeping each entry's DECLARATION (its data `effect`, #4396) attached to the + * handler. Accepted shapes: * - * - `{ functions: { foo: fn, bar: fn } }` ← preferred map form - * - `{ functions: [{ name: 'foo', handler: fn }] }` ← array of records + * - `{ functions: { foo: fn, bar: fn } }` ← preferred map form + * - `{ functions: { foo: { handler: fn, effect: 'writes' } } }` ← declared map entry + * - `{ functions: [{ name: 'foo', handler: fn, effect: … }] }` ← array of records * - * String-named hook handlers (`Hook.handler: 'foo'`) are resolved against - * this map (and the engine's persistent function registry). + * The declaration matters at exactly one consumer — a `script` node reporting + * what its run did to the data — but it has to survive the collection step to + * get there, and dropping it here is how the shape would silently become + * unsupported. */ -export function collectBundleFunctions(bundle: any): Record any> { - const out: Record any> = {}; +export function collectBundleFunctionEntries(bundle: any): Record { + const out: Record = {}; const merge = (src: any) => { if (!src) return; if (Array.isArray(src)) { for (const item of src) { - if (item && typeof item.name === 'string' && typeof item.handler === 'function') { - out[item.name] = item.handler; - } + if (!item || typeof item.name !== 'string') continue; + const fn = normalizeFlowFunctionEntry(item); + if (fn) out[item.name] = fn; } } else if (typeof src === 'object') { - for (const [name, fn] of Object.entries(src)) { - if (typeof fn === 'function') out[name] = fn as any; + for (const [name, entry] of Object.entries(src)) { + const fn = normalizeFlowFunctionEntry(entry); + if (fn) out[name] = fn; } } }; @@ -1412,3 +1431,17 @@ export function collectBundleFunctions(bundle: any): Record any> { + const out: Record any> = {}; + for (const [name, fn] of Object.entries(collectBundleFunctionEntries(bundle))) { + out[name] = fn.handler as (ctx: any) => any; + } + return out; +} diff --git a/packages/runtime/src/artifact-function-declarations.test.ts b/packages/runtime/src/artifact-function-declarations.test.ts new file mode 100644 index 0000000000..141e369c70 --- /dev/null +++ b/packages/runtime/src/artifact-function-declarations.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A function's DECLARATION survives the built-artifact path (#4396). + * + * `objectstack build` splits every callable in two: the JSON artifact keeps a + * serialisable `functions` entry, the sibling `objectstack-runtime.*.mjs` keeps + * the callable. The merge that puts them back together used to be a plain + * overwrite, which kept the handler and dropped everything the entry declared — + * so `effect: 'writes'` worked from source and silently vanished on every built + * deployment, taking the run's honest `unmeasured` count with it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mergeRuntimeModule } from './load-artifact-bundle.js'; +import { collectBundleFunctionEntries } from './app-plugin.js'; + +let dir: string; +let artifactPath: string; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'os-artifact-fns-')); + artifactPath = join(dir, 'objectstack.json'); + writeFileSync( + join(dir, 'objectstack-runtime.mjs'), + 'export const functions = {\n' + + ' scoreLead: () => ({ score: 1 }),\n' + + ' syncBilling: () => ({ ok: true }),\n' + + '};\n', + ); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('mergeRuntimeModule — declared functions', () => { + it('re-attaches the module callable to the declaration the JSON carried', async () => { + const bundle: any = { + runtimeModule: './objectstack-runtime.mjs', + // What `lowerCallables` emits: a ref string for a bare handler, a + // declaration record for a declared one. + functions: { + scoreLead: 'scoreLead', + syncBilling: { handler: 'syncBilling', effect: 'writes' }, + }, + }; + + await mergeRuntimeModule(bundle, artifactPath); + + expect(typeof bundle.functions.scoreLead).toBe('function'); + expect(typeof bundle.functions.syncBilling.handler).toBe('function'); + expect(bundle.functions.syncBilling.effect).toBe('writes'); + + // ...and the collector downstream reads both back. + const entries = collectBundleFunctionEntries(bundle); + expect(entries.scoreLead.effect).toBe('pure'); + expect(entries.syncBilling.effect).toBe('writes'); + expect((entries.syncBilling.handler as () => unknown)()).toEqual({ ok: true }); + }); + + it('leaves a bundle with no runtimeModule alone', async () => { + const handler = () => ({ ok: true }); + const bundle: any = { functions: { syncBilling: { handler, effect: 'writes' } } }; + await mergeRuntimeModule(bundle, artifactPath); + expect(bundle.functions.syncBilling).toEqual({ handler, effect: 'writes' }); + }); +}); diff --git a/packages/runtime/src/bundle-function-entries.test.ts b/packages/runtime/src/bundle-function-entries.test.ts new file mode 100644 index 0000000000..0b238a4bc1 --- /dev/null +++ b/packages/runtime/src/bundle-function-entries.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `bundle.functions` collection keeps each entry's DECLARATION (#4396). + * + * The declaration matters at one consumer — a `script` node reporting what its + * run did to the data — but the collector is the first place it can be lost, + * and losing it here is indistinguishable from never having supported the + * shape: the run then reports the writes as nothing. + */ + +import { describe, it, expect } from 'vitest'; +import { collectBundleFunctions, collectBundleFunctionEntries } from './app-plugin'; + +describe('collectBundleFunctionEntries (#4396)', () => { + it('reads a bare handler as the pure default', () => { + const scoreLead = () => ({ score: 1 }); + const entries = collectBundleFunctionEntries({ functions: { scoreLead } }); + expect(entries.scoreLead).toEqual({ handler: scoreLead, effect: 'pure' }); + }); + + it('keeps a declared writer’s effect off the map form', () => { + const syncBilling = () => ({ ok: true }); + const entries = collectBundleFunctionEntries({ + functions: { syncBilling: { handler: syncBilling, effect: 'writes' } }, + }); + expect(entries.syncBilling).toEqual({ handler: syncBilling, effect: 'writes' }); + }); + + it('keeps it off the array form too', () => { + const syncBilling = () => ({ ok: true }); + const entries = collectBundleFunctionEntries({ + functions: [{ name: 'syncBilling', handler: syncBilling, effect: 'writes' }], + }); + expect(entries.syncBilling).toEqual({ handler: syncBilling, effect: 'writes' }); + }); + + it('merges manifest.functions alongside bundle.functions', () => { + const a = () => 1; + const b = () => 2; + const entries = collectBundleFunctionEntries({ + functions: { a }, + manifest: { functions: { b: { handler: b, effect: 'writes' } } }, + }); + expect(Object.keys(entries).sort()).toEqual(['a', 'b']); + expect(entries.b.effect).toBe('writes'); + }); + + it('drops entries with no callable, and flags an unreadable effect as a write', () => { + const syncBilling = () => ({ ok: true }); + const entries = collectBundleFunctionEntries({ + functions: { + broken: { effect: 'writes' }, + typo: { handler: syncBilling, effect: 'write' }, + }, + }); + expect(entries.broken).toBeUndefined(); + expect(entries.typo).toEqual({ + handler: syncBilling, + effect: 'writes', + unrecognizedEffect: 'write', + }); + }); +}); + +describe('collectBundleFunctions', () => { + it('still answers with plain callables — hooks and jobs need nothing else', () => { + const scoreLead = () => ({ score: 1 }); + const syncBilling = () => ({ ok: true }); + const functions = collectBundleFunctions({ + functions: { scoreLead, syncBilling: { handler: syncBilling, effect: 'writes' } }, + }); + expect(functions.scoreLead).toBe(scoreLead); + // The declared form resolves to its handler rather than being dropped: + // before #4396 a `{ handler, effect }` entry was silently skipped here, + // which would have made a string-named hook unresolvable. + expect(functions.syncBilling).toBe(syncBilling); + }); +}); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 59b9336f5d..880d3e080d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -19,7 +19,7 @@ export type { DefaultHostConfigOptions, DefaultHostConfigResult } from './defaul export { DriverPlugin } from './driver-plugin.js'; export { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js'; -export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleActions } from './app-plugin.js'; +export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFunctionEntries, collectBundleActions } from './app-plugin.js'; export { SeedLoaderService } from './seed-loader.js'; // Boot-summary seed outcome accumulator (#3415/#3430) — the single writer // contract shared by AppPlugin and the marketplace rehydrate/heal path. diff --git a/packages/runtime/src/load-artifact-bundle.ts b/packages/runtime/src/load-artifact-bundle.ts index c5ff425ef1..eb318b5ed4 100644 --- a/packages/runtime/src/load-artifact-bundle.ts +++ b/packages/runtime/src/load-artifact-bundle.ts @@ -145,7 +145,19 @@ export async function mergeRuntimeModule(bundle: any, artifactAbsPath: string, t const existing = (bundle.functions && typeof bundle.functions === 'object' && !Array.isArray(bundle.functions)) ? bundle.functions as Record : {}; - bundle.functions = { ...existing, ...fns }; + // The module supplies the CALLABLE; the JSON supplies what the function + // DECLARED about itself (`{ handler: '', effect: 'writes' }`, + // #4396). A plain overwrite would keep the first and drop the second, + // silently un-declaring every writer on the artifact path. + const merged: Record = { ...existing }; + for (const [name, fn] of Object.entries(fns as Record)) { + const declared = merged[name]; + merged[name] = + typeof fn === 'function' && declared && typeof declared === 'object' && !Array.isArray(declared) + ? { ...(declared as Record), handler: fn } + : fn; + } + bundle.functions = merged; } catch (err: any) { // eslint-disable-next-line no-console console.warn(`${tag} runtime module load FAILED: path='${moduleAbsPath}' error=${err?.message ?? err}`); diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index 5eb4520ac4..9ce1b9ab6a 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ '@objectstack/rest': path.resolve(__dirname, '../rest/src/index.ts'), '@objectstack/spec/ai': path.resolve(__dirname, '../spec/src/ai/index.ts'), '@objectstack/spec/api': path.resolve(__dirname, '../spec/src/api/index.ts'), + // `AppPlugin` reads a bundle function's declared effect off this + // namespace (#4396). + '@objectstack/spec/automation': path.resolve(__dirname, '../spec/src/automation/index.ts'), '@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'), '@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'), // Reached via `@objectstack/platform-objects` (sys-user.object.ts), which diff --git a/packages/services/service-automation/src/builtin/screen-nodes.test.ts b/packages/services/service-automation/src/builtin/screen-nodes.test.ts index c856742282..6b2652f95c 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.test.ts @@ -304,3 +304,132 @@ describe('screen node — the field wire payload (#3528)', () => { expect(paused.screen!.fields[1].visibleWhen).toBe('tier == "gold" && "{recordId}" != ""'); }); }); + +/** + * #4396 — a `script` function is contractually pure, and the run summary counts + * on it: the node reports NO record metrics because every write a pure function + * causes is a downstream declarative node counting itself. A function that + * legitimately writes DECLARES it, and only then does its step report an effect + * the platform cannot count. + */ +describe('script node — the purity contract and its declared exception (#4396)', () => { + let engine: AutomationEngine; + + beforeEach(() => { + engine = new AutomationEngine(createTestLogger()); + registerScreenNodes(engine, createCtx()); + }); + + it("publishes the contract on the action descriptor, where an author can read it", () => { + const script = engine.getActionDescriptors().find((d) => d.type === 'script'); + expect(script?.handlerContract).toBe('pure'); + }); + + it('reports NO metrics for a pure function — absent is the accurate answer, not a guess', async () => { + engine.setFunctionResolver(() => () => ({ ai_category: 'billing' })); + engine.registerFlow('script_flow', scriptFlow({ function: 'triage', outputVariable: 'ai' })); + + const r = await engine.execute('script_flow', {} as any); + expect(r.success).toBe(true); + expect(r.summary?.unmeasured).toBe(0); + const node = r.summary?.nodes.find((n) => n.nodeId === 'run'); + expect(node).toMatchObject({ runs: 1, status: 'success' }); + expect(node?.selected).toBeUndefined(); + expect(node?.acted).toBeUndefined(); + }); + + it("counts a declared writer's step as an effect it cannot measure", async () => { + // The under-report this closes: without the declaration the run below + // says `acted: 0` — indistinguishable from a sweep that did nothing. + engine.setFunctionResolver((name) => + name === 'syncBilling' ? { handler: () => ({ ok: true }), effect: 'writes' } : undefined); + engine.registerFlow('script_flow', scriptFlow({ function: 'syncBilling' })); + + const r = await engine.execute('script_flow', {} as any); + expect(r.success).toBe(true); + expect(r.summary?.acted).toBe(0); + // ...but NOT "measured as zero": the third answer keeps the + // broken-sweep query (`acted = 0 AND unmeasured = 0`) off this run. + expect(r.summary?.unmeasured).toBe(1); + expect(r.summary?.nodes.find((n) => n.nodeId === 'run')?.unmeasured).toBe(1); + }); + + it('still reports the effect when a declared writer throws — it may have written first', async () => { + engine.setFunctionResolver(() => ({ + handler: () => { throw new Error('upstream 500'); }, + effect: 'writes', + })); + engine.registerFlow('script_flow', scriptFlow({ function: 'syncBilling' })); + + const r = await engine.execute('script_flow', {} as any); + expect(r.success).toBe(false); + expect(r.summary?.unmeasured).toBe(1); + }); + + it('leaves every OTHER flow measurable — declaring is per function, not per node type', async () => { + // The rejected fix was a blanket `unmeasuredEffect` on every script + // step, which would have blinded the detector on every flow that calls + // any function. A pure call in the same run must still count as + // measured. + engine.setFunctionResolver((name) => + name === 'syncBilling' + ? { handler: () => ({ ok: true }), effect: 'writes' } + : () => ({ score: 1 })); + engine.registerFlow('mixed', { + name: 'mixed', label: 'Mixed', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'pure', type: 'script', label: 'Pure', config: { function: 'scoreLead' } }, + { id: 'writer', type: 'script', label: 'Writer', config: { function: 'syncBilling' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'pure' }, + { id: 'e2', source: 'pure', target: 'writer' }, + { id: 'e3', source: 'writer', target: 'end' }, + ], + } as any); + + const r = await engine.execute('mixed', {} as any); + expect(r.success).toBe(true); + expect(r.summary?.unmeasured).toBe(1); + expect(r.summary?.nodes.find((n) => n.nodeId === 'pure')?.unmeasured).toBeUndefined(); + expect(r.summary?.nodes.find((n) => n.nodeId === 'writer')?.unmeasured).toBe(1); + }); + + it('reads a bare handler as the pure default — the short spelling of the contract', async () => { + const calls: unknown[] = []; + engine.setFunctionResolver(() => (c: any) => { calls.push(c.input); return 1; }); + engine.registerFlow('script_flow', scriptFlow({ function: 'scoreLead', inputs: { r: 'x' } })); + + const r = await engine.execute('script_flow', {} as any); + expect(r.success).toBe(true); + expect(calls).toEqual([{ r: 'x' }]); + expect(r.summary?.unmeasured).toBe(0); + }); +}); + +/** + * The one part of the purity contract the runtime CAN hold: a flow function is + * handed nothing to write with. Not enforcement — a function may close over a + * client at module scope — but the day someone adds `ctx.api` here, the + * contract stops being about author discipline and starts being a lie, so the + * context's shape is pinned rather than left incidental (#4396, issue option 3). + */ +describe('FlowFunctionContext carries no data reach (#4396)', () => { + it('hands the function input / variables / automation / logger and nothing else', async () => { + const engine = new AutomationEngine(createTestLogger()); + registerScreenNodes(engine, createCtx()); + + let seen: Record | undefined; + engine.setFunctionResolver(() => (c: any) => { seen = c; return 1; }); + engine.registerFlow('script_flow', scriptFlow({ function: 'inspect' })); + await engine.execute('script_flow', {} as any); + + expect(Object.keys(seen ?? {}).sort()).toEqual(['automation', 'input', 'logger', 'variables']); + // `automation` is the trigger/run context (record, identity, params) — + // provenance, not a handle on the data engine. + expect(seen!.automation).not.toHaveProperty('api'); + expect(seen!.automation).not.toHaveProperty('objectql'); + }); +}); diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index e6d572cd00..25b1003f26 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -27,6 +27,10 @@ import { parseNodeConfig } from './parse-config.js'; * which the host bridges to `bundle.functions` / `defineStack({ functions })`. * A target that resolves to neither fails the step LOUDLY rather than the old * silent "no-op handler" success, so an unwired callable can't quietly skip. + * The named function is contractually PURE — it returns a value and the flow + * graph persists it — which the descriptor publishes as + * `handlerContract: 'pure'` and a writing function opts out of by declaring + * `effect: 'writes'` where it is registered (#4396). */ /** @@ -204,6 +208,11 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext type: 'script', version: '1.0.0', name: 'Script', description: 'Run a custom script action.', icon: 'code', category: 'logic', source: 'builtin', + // #4396 — the purity rule this executor relies on, published where an + // author, a designer and the action catalog can read it. `category: + // 'logic'` says nothing about it, so until this field the contract + // existed only as a comment inside the call below. + handlerContract: 'pure', }), async execute(node, variables, context) { const cfg = (node.config ?? {}) as Record; @@ -259,8 +268,8 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext }; } - const handler = engine.resolveFunction(target); - if (!handler) { + const registration = engine.resolveFunction(target); + if (!registration) { return { success: false, error: @@ -276,27 +285,42 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext const input = interpolate(cfg.inputs ?? {}, variables, context) as Record; const outputVariable = typeof cfg.outputVariable === 'string' && cfg.outputVariable.trim() ? cfg.outputVariable.trim() : undefined; + // Pure-function pattern: the function RETURNS its result; `outputVariable` + // exposes it as a flow variable so a later declarative node persists it + // (e.g. `update_record fields: { ai_category: '{aiResult.ai_category}' }`). + // Data I/O stays on the flow graph — the function itself does no writes. + // The descriptor above publishes that as `handlerContract: 'pure'`, and + // `FlowFunctionContext` hands the function no data engine to write with. + // + // #4354 — which is why a pure function's step reports NO metrics: by that + // contract every write it causes is a downstream `update_record` counting + // itself, so "absent" (this kind of node touches no records) is the + // accurate answer, not a guess. + // + // #4396 — and a function that legitimately writes DECLARES it + // (`{ handler, effect: 'writes' }`), which is what turns this step's + // silence into an honest "cannot count". Declaring stays per FUNCTION: + // a blanket `unmeasuredEffect` on every script step would suppress the + // broken-sweep signal on every flow that calls any function, to cover + // the few that write. An UNDECLARED writer still under-reports — no + // runtime check can catch code that closed over a client at module + // scope, so the rule is published rather than policed. + const unmeasured = registration.effect === 'writes'; try { - const result = await handler({ input, variables, automation: context, logger: ctx.logger }); - // Pure-function pattern: the function RETURNS its result; `outputVariable` - // exposes it as a flow variable so a later declarative node persists it - // (e.g. `update_record fields: { ai_category: '{aiResult.ai_category}' }`). - // Data I/O stays on the flow graph — the function itself does no writes. - // - // #4354 — which is why this node reports NO metrics, deliberately: by - // that contract every write it causes is a downstream `update_record` - // counting itself, so "absent" (this kind of node touches no records) - // is the accurate answer, not a guess. Nothing ENFORCES the purity - // though, so a function that writes behind the platform's back makes - // its run under-report. Filed as #4396 rather than accommodated: a blanket - // `unmeasuredEffect` here would suppress the broken-sweep signal on - // every flow that calls any function, to cover a contract violation. + const result = await registration.handler({ input, variables, automation: context, logger: ctx.logger }); if (outputVariable) variables.set(outputVariable, result); - return { success: true, output: { function: target, result } }; + return { + success: true, + output: { function: target, result }, + ...(unmeasured ? { metrics: { unmeasuredEffect: true } } : {}), + }; } catch (err) { return { success: false, error: `script function '${target}' (node '${node.id}') failed: ${(err as Error).message}`, + // A declared writer that threw may have written before it did, so + // the run still cannot claim it counted everything. + ...(unmeasured ? { metrics: { unmeasuredEffect: true } } : {}), }; } }, diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 92ce98838b..7f805c97d8 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -6,6 +6,7 @@ import type { ActionDescriptor, ExecutionStepMetrics, ExecutionStepSkipReason, + FlowFunctionEffect, FlowRunSummary, } from '@objectstack/spec/automation'; import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, ScreenSpec } from '@objectstack/spec/contracts'; @@ -315,6 +316,14 @@ export interface RegisteredConnector { * (#1870). Mirrors {@link ConnectorActionContext} but carries the node's mapped * `input` so the function reads its arguments without reaching into the raw * variable map. The function's return value becomes the node output. + * + * Note what is NOT here: any handle on the data engine. A flow function is a + * pure compute step (`ActionDescriptor.handlerContract: 'pure'`, #4396) — it + * returns a value and a later declarative node persists it — so the context + * gives it nothing to write with. That is as far as the runtime can go: a + * function is ordinary host code and may close over a client at module scope, + * which is why one that legitimately writes DECLARES it + * (`FlowFunctionEffect`) instead of being detected. */ export interface FlowFunctionContext { /** Inputs mapped from the node's `config.inputs` (already in scope). */ @@ -334,14 +343,37 @@ export interface FlowFunctionContext { */ export type FlowFunctionHandler = (ctx: FlowFunctionContext) => unknown | Promise; +/** + * A resolved function: the callable plus what it DECLARED about itself at + * registration (#4396). + * + * The effect is what keeps the run summary honest for the one case the purity + * contract does not cover. A `script` step normally reports no record metrics — + * accurate, because a pure function's writes are downstream declarative nodes + * that count themselves — but a function declared `'writes'` gets its step + * marked `unmeasuredEffect`, so the run says "an effect I cannot count" + * rather than claiming it wrote nothing. + */ +export interface FlowFunctionRegistration { + readonly handler: FlowFunctionHandler; + /** Declared data effect. Absent ⇒ `'pure'`, the contract's default. */ + readonly effect?: FlowFunctionEffect; +} + /** * Resolves a function name to its handler. Injected by the host (the automation - * plugin bridges it to ObjectQL's `resolveFunction`, fed by `bundle.functions`), + * plugin bridges it to ObjectQL's function registry, fed by `bundle.functions`), * so the engine stays decoupled from any specific function registry. Returns * `undefined` for an unknown name, letting the `script` node fail the step * loudly instead of silently no-op'ing (#1870). + * + * A resolver may return the bare handler — which IS the declaration + * `effect: 'pure'`, written the short way — or a {@link FlowFunctionRegistration} + * carrying what the function declared. */ -export type FlowFunctionResolver = (name: string) => FlowFunctionHandler | undefined; +export type FlowFunctionResolver = ( + name: string, +) => FlowFunctionHandler | FlowFunctionRegistration | undefined; /** * Resolves the schema of the object a flow's conditions bind against — its field @@ -1396,12 +1428,18 @@ export class AutomationEngine implements IAutomationService { } /** - * Resolve a named function for a `script` node. Returns `undefined` when no - * resolver is wired or the name is unregistered — the node then fails the - * step with a clear error rather than silently no-op'ing. + * Resolve a named function for a `script` node, normalized to + * {@link FlowFunctionRegistration} so the caller reads the handler and its + * declared effect the same way whichever shape the host's resolver returned. + * Returns `undefined` when no resolver is wired or the name is unregistered + * — the node then fails the step with a clear error rather than silently + * no-op'ing. */ - resolveFunction(name: string): FlowFunctionHandler | undefined { - return this.functionResolver?.(name) ?? undefined; + resolveFunction(name: string): FlowFunctionRegistration | undefined { + const resolved = this.functionResolver?.(name); + if (!resolved) return undefined; + if (typeof resolved === 'function') return { handler: resolved }; + return typeof resolved.handler === 'function' ? resolved : undefined; } /** Get all registered connector names. */ diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index ecf987ae56..bfb8990794 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -3,6 +3,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveUserAuthzGrants } from '@objectstack/core'; import type { IJobService } from '@objectstack/spec/contracts'; +import type { FlowFunctionEffect } from '@objectstack/spec/automation'; import type { Connector, ConnectorInstanceAuth, @@ -477,13 +478,24 @@ export class AutomationServicePlugin implements Plugin { try { const fnRegistry = ctx.getService<{ resolveFunction?: (name: string) => ((c: unknown) => unknown) | undefined; + // #4396 — the same lookup, with what the function declared about + // itself. Optional: an older/foreign `objectql` service exposes + // only the handler, and every such function is then read as the + // contract's default (pure), exactly as before this existed. + resolveFunctionEntry?: (name: string) => { + handler?: (c: unknown) => unknown; + effect?: FlowFunctionEffect; + } | undefined; }>('objectql'); if (fnRegistry && typeof fnRegistry.resolveFunction === 'function') { this.engine.setFunctionResolver((name) => { - const fn = fnRegistry.resolveFunction!(name); - return typeof fn === 'function' - ? (fnCtx) => (fn as (c: unknown) => unknown)(fnCtx) - : undefined; + const entry = fnRegistry.resolveFunctionEntry?.(name); + const fn = entry?.handler ?? fnRegistry.resolveFunction!(name); + if (typeof fn !== 'function') return undefined; + return { + handler: (fnCtx) => (fn as (c: unknown) => unknown)(fnCtx), + ...(entry?.effect ? { effect: entry.effect } : {}), + }; }); ctx.logger.debug('[Automation] script-node function registry bridged to objectql.resolveFunction'); } diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index dd28ce4b22..6b97500628 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2087,6 +2087,7 @@ "CreateRecordConfig (type)", "CreateRecordConfigParsed (type)", "CreateRecordConfigSchema (const)", + "DEFAULT_FLOW_FUNCTION_EFFECT (const)", "DEPRECATED_APPROVER_TYPES (const)", "DataDestinationConfig (type)", "DataDestinationConfigSchema (const)", @@ -2149,6 +2150,14 @@ "FlowEdge (type)", "FlowEdgeParsed (type)", "FlowEdgeSchema (const)", + "FlowFunctionCallable (type)", + "FlowFunctionDeclaration (type)", + "FlowFunctionDeclarationInput (type)", + "FlowFunctionDeclarationSchema (const)", + "FlowFunctionEffect (type)", + "FlowFunctionEffectSchema (const)", + "FlowFunctionEntry (type)", + "FlowFunctionEntrySchema (const)", "FlowGraph (interface)", "FlowNode (type)", "FlowNodeAction (const)", @@ -2193,6 +2202,7 @@ "NON_AUTHORABLE_APPROVER_TYPES (const)", "NodeExecutorDescriptor (type)", "NodeExecutorDescriptorSchema (const)", + "NormalizedFlowFunction (interface)", "NotifyConfig (type)", "NotifyConfigParsed (type)", "NotifyConfigSchema (const)", @@ -2283,8 +2293,10 @@ "flowForm (const)", "getApprovalNodeConfigJsonSchema (function)", "importBpmnToConstructs (function)", + "isFlowFunctionEffect (function)", "normalizeControlFlowRegions (function)", "normalizeDecisionOutputs (function)", + "normalizeFlowFunctionEntry (function)", "resolveFlowNodeExpressions (function)", "validateControlFlow (function)" ], diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 07e77a1d6d..db6b7a2f1f 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -2128,6 +2128,7 @@ "automation/ActionDescriptor:configSchema", "automation/ActionDescriptor:deprecated", "automation/ActionDescriptor:description", + "automation/ActionDescriptor:handlerContract", "automation/ActionDescriptor:icon", "automation/ActionDescriptor:isAsync", "automation/ActionDescriptor:maturity", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index f0061d0498..fb2d896eaa 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -554,6 +554,7 @@ "automation/ExecutionStepSkipReason", "automation/Flow", "automation/FlowEdge", + "automation/FlowFunctionEffect", "automation/FlowNode", "automation/FlowNodeAction", "automation/FlowRegion", diff --git a/packages/spec/src/automation/flow-function.test.ts b/packages/spec/src/automation/flow-function.test.ts new file mode 100644 index 0000000000..613b9accdd --- /dev/null +++ b/packages/spec/src/automation/flow-function.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_FLOW_FUNCTION_EFFECT, + FlowFunctionDeclarationSchema, + FlowFunctionEffectSchema, + FlowFunctionEntrySchema, + isFlowFunctionEffect, + normalizeFlowFunctionEntry, +} from './flow-function.zod'; +import { defineStack } from '../stack.zod'; + +describe('FlowFunctionEffectSchema (#4396)', () => { + it('declares exactly the two effects the runtime acts on', () => { + expect(FlowFunctionEffectSchema.options).toEqual(['pure', 'writes']); + }); + + it('rejects a near-miss spelling instead of reading it as the pure default', () => { + // 'write' singular would otherwise silently mean "wrote nothing" — the + // under-report this whole declaration exists to stop. + expect(FlowFunctionEffectSchema.safeParse('write').success).toBe(false); + expect(isFlowFunctionEffect('write')).toBe(false); + }); + + it('assumes purity when nothing is declared', () => { + expect(DEFAULT_FLOW_FUNCTION_EFFECT).toBe('pure'); + expect(FlowFunctionDeclarationSchema.parse({ handler: () => 1 }).effect).toBe('pure'); + }); +}); + +describe('normalizeFlowFunctionEntry', () => { + it('reads a bare handler as the pure declaration, written the short way', () => { + const fn = () => 1; + expect(normalizeFlowFunctionEntry(fn)).toEqual({ handler: fn, effect: 'pure' }); + }); + + it('carries a declared effect through', () => { + const fn = () => 1; + expect(normalizeFlowFunctionEntry({ handler: fn, effect: 'writes' })) + .toEqual({ handler: fn, effect: 'writes' }); + }); + + it('drops an entry with no callable — there is nothing to invoke', () => { + expect(normalizeFlowFunctionEntry(undefined)).toBeUndefined(); + expect(normalizeFlowFunctionEntry({ effect: 'writes' })).toBeUndefined(); + expect(normalizeFlowFunctionEntry('nope')).toBeUndefined(); + }); + + it('reads an UNRECOGNIZED effect as an uncountable write, and says which value it was', () => { + // Reachable only off the `defineStack` parse (a hand-built bundle). The safe + // direction is "cannot say this ran clean", never "it wrote nothing" — + // and the raw value is surfaced so the typo gets fixed. + const fn = () => 1; + expect(normalizeFlowFunctionEntry({ handler: fn, effect: 'write' })).toEqual({ + handler: fn, + effect: 'writes', + unrecognizedEffect: 'write', + }); + }); +}); + +describe('FlowFunctionEntrySchema', () => { + it('accepts both spellings of one entry', () => { + expect(FlowFunctionEntrySchema.safeParse(() => 1).success).toBe(true); + expect(FlowFunctionEntrySchema.safeParse({ handler: () => 1, effect: 'writes' }).success).toBe(true); + }); + + it('rejects a declaration whose handler is not callable', () => { + expect(FlowFunctionEntrySchema.safeParse({ handler: 'scoreLead' }).success).toBe(false); + }); +}); + +describe('defineStack({ functions }) — the authoring surface (#4396)', () => { + const base = { + manifest: { id: 'com.example.demo', name: 'demo', version: '1.0.0', type: 'app' as const }, + }; + + it('accepts a bare handler and a declared writer side by side', () => { + const stack = defineStack({ + ...base, + functions: { + scoreLead: () => ({ score: 1 }), + syncBilling: { handler: () => ({ ok: true }), effect: 'writes' }, + }, + }); + const functions = stack.functions as Record; + expect(typeof functions.scoreLead).toBe('function'); + expect(normalizeFlowFunctionEntry(functions.syncBilling)?.effect).toBe('writes'); + }); + + it('accepts `effect` on the array form too', () => { + const stack = defineStack({ + ...base, + functions: [{ name: 'syncBilling', handler: () => ({ ok: true }), effect: 'writes' }], + }); + expect((stack.functions as Array<{ effect?: string }>)[0].effect).toBe('writes'); + }); + + it('rejects an effect the runtime has no meaning for, at authoring time', () => { + expect(() => defineStack({ + ...base, + functions: { syncBilling: { handler: () => ({ ok: true }), effect: 'sometimes' } }, + } as never)).toThrow(); + }); +}); diff --git a/packages/spec/src/automation/flow-function.zod.ts b/packages/spec/src/automation/flow-function.zod.ts new file mode 100644 index 0000000000..a5ce3ab843 --- /dev/null +++ b/packages/spec/src/automation/flow-function.zod.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @module automation/flow-function + * + * 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: + * + * 1. `ActionDescriptor.handlerContract` — `script` publishes `'pure'`, so the + * action catalog and the designer palette carry the rule an author reads. + * 2. {@link FlowFunctionEffectSchema} — a function that legitimately writes + * DECLARES it, and its step then reports `unmeasuredEffect`, 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 suppress the broken-sweep + * signal on every flow that calls any function, in order to accommodate the + * flows that break the rule — paying for a rule-breaker with everyone else's + * signal, and fossilizing the violation as supported behaviour. + * + * Nor is this *enforcement*. The runtime hands a function no data reach — + * {@link 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. + */ + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; + +/** + * What a `script`-node function does to data (#4396). + * + * - `'pure'` (the default, and the contract) — it computes and returns. The + * step reports no record metrics, which is exact: the writes it leads to are + * downstream declarative nodes that count themselves. + * - `'writes'` — it performs writes, or dispatches effects, that the platform + * cannot see or count. The step reports + * `ExecutionStepMetrics.unmeasuredEffect`, so the run's `unmeasured` tally + * keeps the broken-sweep query + * (`selected > 0 AND acted = 0 AND unmeasured = 0`) off it — and only off + * the flows that actually call such a function. + * + * There is deliberately no `'reads'` member: an under-reported `selected` can + * only make the broken-sweep alert quieter, never wrong, so declaring it would + * buy nothing the runtime acts on. + */ +export const FlowFunctionEffectSchema = lazySchema(() => z.enum([ + 'pure', + 'writes', +]).describe("What a script-node function does to data: 'pure' (computes and returns — the contract) or 'writes' (performs uncountable writes/effects, reported as unmeasured)")); + +export type FlowFunctionEffect = z.infer; + +/** + * The effect a function is assumed to have when it declares none — the + * contract's default, and the reason a bare `functions: { fn }` entry stays + * exactly as cheap to write as it was. + */ +export const DEFAULT_FLOW_FUNCTION_EFFECT: FlowFunctionEffect = 'pure'; + +/** + * The **declared** form of a `defineStack({ functions })` entry: the handler + * plus what it does. + * + * ```ts + * defineStack({ + * functions: { + * scoreLead: (ctx) => ({ score: 42 }), // pure (default) + * syncBilling: { handler: syncBilling, effect: 'writes' }, // declared writer + * }, + * }); + * ``` + * + * Both spellings are first-class; the bare-function form IS the declaration + * `effect: 'pure'`, written the short way. Declaring `'writes'` does not grant + * a function anything — it changes what the platform *reports* about the runs + * that call it, which is the whole point: an undeclared writer is counted as + * having written nothing. + */ +export const FlowFunctionDeclarationSchema = lazySchema(() => z.object({ + handler: z.function().describe('The function invoked by name (a `script` node, a string-named Hook/Action handler)'), + effect: FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT) + .describe("What the function does to data — omit for the pure default"), +}).describe('A named handler function plus its declared effect (#4396)')); + +export type FlowFunctionDeclaration = z.infer; +export type FlowFunctionDeclarationInput = z.input; + +/** + * One entry of the `functions` map as authors may write it: the handler alone + * (pure), or a {@link FlowFunctionDeclarationSchema} that states its effect. + */ +export const FlowFunctionEntrySchema = lazySchema(() => z.union([ + z.function(), + FlowFunctionDeclarationSchema, +]).describe('A named handler function, or a declaration record stating its effect')); + +export type FlowFunctionEntry = z.infer; + +/** Any callable registerable under a name (a hook body, a flow function). */ +export type FlowFunctionCallable = (...args: never[]) => unknown; + +/** A `functions` entry read back in one shape, whichever way it was written. */ +export interface NormalizedFlowFunction { + readonly handler: FlowFunctionCallable; + readonly effect: FlowFunctionEffect; + /** + * Set to the raw value when `effect` was present but not a member of + * {@link FlowFunctionEffectSchema} — a typo (`'write'`) on a path that + * skipped `defineStack`'s parse. `effect` then reads `'writes'`, because a + * declaration the platform cannot read means "cannot say this ran clean", + * never "it wrote nothing"; the caller surfaces the raw value so the typo is + * fixable rather than permanently costing the run its measurement. + */ + readonly unrecognizedEffect?: unknown; +} + +/** True for a member of {@link FlowFunctionEffectSchema}. */ +export function isFlowFunctionEffect(value: unknown): value is FlowFunctionEffect { + return value === 'pure' || value === 'writes'; +} + +/** + * Read a `functions` map entry — the bare handler or the declaration record — + * into one normalized shape. Returns `undefined` when the entry carries no + * callable at all (the value every collector already drops). + * + * Deliberately hand-written rather than a `FlowFunctionEntrySchema.parse()`: + * the entry holds a live function, and the collectors that call this run on the + * boot path where re-parsing every handler buys nothing. + */ +export function normalizeFlowFunctionEntry(entry: unknown): NormalizedFlowFunction | undefined { + if (typeof entry === 'function') { + return { handler: entry as FlowFunctionCallable, effect: DEFAULT_FLOW_FUNCTION_EFFECT }; + } + if (!entry || typeof entry !== 'object') return undefined; + const record = entry as { handler?: unknown; effect?: unknown }; + if (typeof record.handler !== 'function') return undefined; + const handler = record.handler as FlowFunctionCallable; + if (record.effect === undefined) return { handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }; + if (isFlowFunctionEffect(record.effect)) return { handler, effect: record.effect }; + return { handler, effect: 'writes', unrecognizedEffect: record.effect }; +} diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index df4f6a8f3a..7abfa49c33 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -7,6 +7,7 @@ export * from './control-flow.zod'; export * from './io-node-config.zod'; export * from './builtin-node-config.zod'; export * from './schemaless-node-config.zod'; +export * from './flow-function.zod'; export { flowForm } from './flow.form'; export * from './execution.zod'; export * from './webhook.zod'; diff --git a/packages/spec/src/automation/node-executor.test.ts b/packages/spec/src/automation/node-executor.test.ts index 4c32187503..2c4694b6c4 100644 --- a/packages/spec/src/automation/node-executor.test.ts +++ b/packages/spec/src/automation/node-executor.test.ts @@ -274,3 +274,23 @@ describe('ActionDescriptorSchema.resumeAuthority', () => { expect(() => ActionDescriptorSchema.parse({ ...base, resumeAuthority: 'admin' })).toThrow(); }); }); + +// --------------------------------------------------------------------------- +// ActionDescriptorSchema — handlerContract (#4396) +// --------------------------------------------------------------------------- +describe('ActionDescriptorSchema.handlerContract', () => { + const base = { type: 'demo', version: '1.0.0', name: 'Demo' }; + + it("defaults to 'none' — most actions invoke no author-supplied code", () => { + expect(defineActionDescriptor(base).handlerContract).toBe('none'); + }); + + it("accepts 'pure' for an action whose author code must not write (script)", () => { + const desc = defineActionDescriptor({ ...base, type: 'script', handlerContract: 'pure' }); + expect(desc.handlerContract).toBe('pure'); + }); + + it('rejects an unknown contract rather than publishing a rule nothing defines', () => { + expect(() => ActionDescriptorSchema.parse({ ...base, handlerContract: 'sandboxed' })).toThrow(); + }); +}); diff --git a/packages/spec/src/automation/node-executor.zod.ts b/packages/spec/src/automation/node-executor.zod.ts index 49273e47c7..5d495a9541 100644 --- a/packages/spec/src/automation/node-executor.zod.ts +++ b/packages/spec/src/automation/node-executor.zod.ts @@ -291,6 +291,34 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({ isAsync: z.boolean().default(false) .describe('Suspends the flow awaiting an external reply'), + /** + * The effect contract this action places on the AUTHOR-SUPPLIED code it + * invokes (#4396). + * + * - `'none'` (default) — the action invokes no author code, so there is no + * such contract to state. Nearly every action. + * - `'pure'` — it does, and that code must not perform data I/O: it takes + * its inputs, RETURNS a value, and a later DECLARATIVE node persists the + * result. `script` is the built-in that declares this, and the rule is + * load-bearing rather than stylistic — the node reports no record metrics + * precisely because every write it causes is a downstream `create_record` + * / `update_record` counting itself (#4354). A function that writes anyway + * makes its run under-report. + * + * It is declared HERE because the alternative — the state this field was + * added to leave — was a rule visible only inside the executor's own source: + * `category: 'logic'` said nothing about it, so no author, lint or designer + * could read the contract the run summary was relying on. A function that + * legitimately writes says so on its own registration + * (`FlowFunctionEffectSchema`, `defineStack({ functions })`) and its step is + * then counted as `unmeasuredEffect` rather than as nothing. + * + * Declaration, not enforcement: author code can close over a data client at + * module scope, and no descriptor field stops that. + */ + handlerContract: z.enum(['none', 'pure']).default('none') + .describe("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)"), + /** * WHO may resume a run this node suspended (#3801). The generic resume * route (`POST /automation/:name/runs/:runId/resume`) validates machine diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index df18fa189d..084d7d4e0e 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -97,14 +97,27 @@ export const SCRIPT_INVOKE_FUNCTION_ACTION_TYPE = 'invoke_function'; * 4. Anything left in `actionType` (except the * {@link SCRIPT_INVOKE_FUNCTION_ACTION_TYPE} marker) is shorthand for a * function name; a name that resolves to nothing fails the step LOUDLY. + * + * The invoked function is contractually PURE — it returns its result and the + * flow graph persists it (`FlowFunctionEffectSchema`, #4396). The descriptor + * publishes that as `handlerContract: 'pure'`, and it is what lets the node + * report no record metrics without guessing. */ export const ScriptConfigSchema = lazySchema(() => z.object({ /** Built-in side-effect id, the `invoke_function` marker, or (shorthand) a registered-function name. */ actionType: z.string().optional() .describe("How this step runs: a built-in side effect ('email' | 'slack'), the 'invoke_function' marker, or shorthand for a registered-function name"), - /** Registered function to call (`defineStack({ functions })`) — always wins over `actionType`. */ + /** + * Registered function to call (`defineStack({ functions })`) — always wins + * over `actionType`. + * + * Contractually pure: it takes `inputs`, RETURNS a value, and does no data + * I/O of its own. A function that legitimately writes declares + * `effect: 'writes'` where it is registered, so the run reports an effect it + * cannot count instead of reporting none (#4396). + */ function: z.string().optional() - .describe('Registered function to call (defineStack({ functions })); takes precedence over actionType'), + .describe('Registered function to call (defineStack({ functions })); takes precedence over actionType. Contractually pure — it returns a value a later declarative node persists'), /** Inputs passed to the function; values interpolate `{token}` templates against the live flow variables. */ inputs: z.record(z.string(), z.unknown()).optional() .describe('Inputs passed to the function (values interpolate {token} templates)'), diff --git a/packages/spec/src/contracts/objectql-engine.ts b/packages/spec/src/contracts/objectql-engine.ts index 9afb16d403..b5470e8655 100644 --- a/packages/spec/src/contracts/objectql-engine.ts +++ b/packages/spec/src/contracts/objectql-engine.ts @@ -47,6 +47,7 @@ import type { IDataEngine } from './data-engine'; import type { IDataDriver } from './data-driver'; +import type { FlowFunctionEffect, FlowFunctionEntry } from '../automation/flow-function.zod'; /** * The engine's schema-registry view — the eight members reached through the @@ -111,7 +112,16 @@ export interface IObjectQLEngine extends IDataEngine { options?: { object?: string | string[]; priority?: number; packageId?: string }, ): void; unregisterHooksByPackage(packageId: string): number; - registerFunction(name: string, handler: (context: any) => Promise | void, packageId?: string): void; + /** + * The third parameter is the owning `packageId`, or a record that also + * carries what the function DECLARES about itself (#4396) — today its data + * `effect`, which a `script` node reads back to report its run honestly. + */ + registerFunction( + name: string, + handler: (context: any) => Promise | void, + packageIdOrOptions?: string | { packageId?: string; effect?: FlowFunctionEffect }, + ): void; registerMiddleware( fn: (opCtx: any, next: () => Promise) => Promise, options?: { object?: string }, @@ -121,7 +131,8 @@ export interface IObjectQLEngine extends IDataEngine { hooks: unknown[] | undefined, opts?: { packageId?: string; - functions?: Record Promise | void>; + /** Handlers, or declaration records stating each function's effect (#4396). */ + functions?: Record; bodyRunner?: unknown; strict?: boolean; warnLegacyHandler?: boolean; diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index c9cb34a99e..9f9270631a 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -30,6 +30,7 @@ import { ThemeSchema } from './ui/theme.zod'; // Automation Protocol import { FlowSchema } from './automation/flow.zod'; +import { FlowFunctionEntrySchema, FlowFunctionEffectSchema } from './automation/flow-function.zod'; import { JobSchema } from './system/job.zod'; // Security Protocol @@ -333,23 +334,36 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ /** * Named handler functions for declarative metadata that references * handlers by string name (`Hook.handler: 'my_handler'`, - * `Action.target: 'my_handler'`). Two accepted shapes: + * `Action.target: 'my_handler'`, a `script` node's `config.function`). + * Two accepted shapes: * * - Map form (preferred): `{ my_handler: (ctx) => {...} }` * - Array form: `[{ name: 'my_handler', handler: (ctx) => {...} }]` * - * Functions live in code only; they are not serialized into project - * artifacts. The `AppPlugin` registers them on the engine before - * binding hooks so `string` handlers resolve at startup. + * Either shape may state what the function DOES instead of just naming it + * (#4396) — `{ my_handler: { handler, effect: 'writes' } }`, or `effect` on + * an array entry. A `script` node's function is contractually pure (it + * returns a value; the flow graph persists it), and the run summary counts on + * that: an undeclared function is counted as having written nothing. A + * function that legitimately writes declares `effect: 'writes'` and its step + * is reported as an effect the platform cannot count, rather than as none. + * See `FlowFunctionEffectSchema` in `@objectstack/spec/automation`. + * + * The CALLABLE lives in code only — `objectstack build` lowers it to a + * handler ref and carries the function itself in the sibling runtime module + * (what it declared rides along in the artifact and is re-attached on load). + * The `AppPlugin` registers them on the engine before binding hooks so + * `string` handlers resolve at startup. */ functions: z.union([ - z.record(z.string(), z.function()), + z.record(z.string(), FlowFunctionEntrySchema), z.array(z.object({ name: z.string(), handler: z.function(), packageId: z.string().optional(), + effect: FlowFunctionEffectSchema.optional(), })), - ]).optional().describe('Named handler functions referenced by hooks/actions'), + ]).optional().describe('Named handler functions referenced by hooks/actions/script nodes (optionally declaring their effect)'), mappings: z.array(MappingSchema).optional().describe('Data Import/Export Mappings'), analyticsCubes: z.array(CubeSchema).optional().describe('Analytics Semantic Layer Cubes'), diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 3660efd888..868fbbd8b2 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -885,6 +885,21 @@ them right the first time: If you genuinely need data-lifecycle **side effects** (read/write other records), that's an L2 **hook** (objectstack-data) — hooks get `ctx.api`; flow functions don't. + The rule is load-bearing: a `script` step reports **no** record metrics in the + run summary *because* every write a pure function causes is a downstream node + that counts itself. A function that writes anyway makes its run report + `acted: 0` — indistinguishable from a sweep that silently did nothing. When a + function must write somewhere the platform cannot see (an upstream billing + API), **declare it** so the run stays honest — its step is then counted as an + effect that cannot be measured, never as zero: + + ```ts + defineStack({ functions: { + 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other' }), // pure — the default + 'billing.sync': { handler: syncBilling, effect: 'writes' }, // declared writer + } }); + ``` + 10. **Conditions are bare CEL — only the stdlib is callable.** `now()`, `today()`, `daysFromNow(n)`, `daysAgo(n)`, `daysBetween(a, b)`, `isBlank(v)`, `coalesce(a, b)`, `abs/round/min/max`, `upper/lower/contains/matches`, plus CEL