Skip to content

feat(spec,automation,objectql,runtime,cli): declare the script-function purity contract; a writer opts out honestly (#4396) - #4421

Merged
os-zhuang merged 1 commit into
mainfrom
claude/script-node-purity-enforcement-19cjsy
Jul 31, 2026
Merged

feat(spec,automation,objectql,runtime,cli): declare the script-function purity contract; a writer opts out honestly (#4396)#4421
os-zhuang merged 1 commit into
mainfrom
claude/script-node-purity-enforcement-19cjsy

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4396.

The gap

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. A script step reports no record metrics precisely
because every write a pure function causes is a downstream create_record /
update_record counting itself, so "this node touched no records" is exact
rather than a guess.

The contract lived only in a comment inside the executor. ActionDescriptor
said category: 'logic' and nothing about purity, so no author, lint or
designer could read the rule the summary was relying on — and a function that
wrote anyway made its run report selected: 30, acted: 0, which is
indistinguishable from the broken sweep the counters exist to detect, recorded
permanently on sys_automation_run.

What this ships (issue options 1 and 2)

1. Declare the contract where it can be seen. ActionDescriptor carries
handlerContract: 'none' | 'pure'; the script descriptor publishes 'pure'.
The rule now reaches the action catalog, the designer palette, the generated
reference docs (content/docs/references/automation/flow-function), the flows
guide and the automation skill.

2. Let a function opt in to declaring its effect. Either functions shape
may state what a function does:

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
. The rejected alternative was marking every script step
unmeasured, which would blind the detector on every flow that calls any
function in order to cover the few that break the rule.

Nothing is retired or renamed: a bare functions: { fn } entry is unchanged and
means effect: 'pure'.

The seams the declaration crosses

Each one could drop it silently, so each one carries it:

Seam Change
defineStack({ functions }) map value may be a handler or { handler, effect }; effect also allowed on the array form
AppPlugin collectBundleFunctionEntries() keeps the declaration; collectBundleFunctions() still answers plain callables for hooks/jobs
ObjectQL registry registerFunction(name, handler, packageId | { packageId, effect }), plus resolveFunctionEntry(name)
automation bridge resolver returns { handler, effect }; a registry without the new lookup reads as pure, exactly as before
script executor effect: 'writes'metrics: { unmeasuredEffect: true }, on success and on a throw (a writer that threw may have written first)
objectstack build a declared entry lowers to { handler: '<ref>', effect } instead of being dropped
artifact load mergeRuntimeModule 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. A flow-only app's defineStack({ functions }) therefore
reached the engine as nothing, and every script node calling one failed with
"no function named 'x' is registered" — naming the one thing the author had
actually done. Found by the new tests; pinned by two of them.

What is deliberately NOT claimed

This is declaration, not enforcement. A flow function is ordinary host code and
can close over a data client at module scope; no descriptor field or context
shape stops that, so an undeclared writer still under-reports and the docs
say so plainly. The one part the runtime does hold — FlowFunctionContext hands
the function no data engine — is now pinned by a test instead of being
incidental.

Tests

  • packages/spec — the effect enum, declaration defaults, normalizeFlowFunctionEntry
    (including an unreadable effect reading as writes, never as pure), both
    defineStack shapes, and handlerContract defaults/rejection.
  • packages/objectql — registry entries carry the declaration; bindHooks registers
    functions with no hooks present.
  • packages/runtime — bundle collection keeps declarations; the artifact merge re-attaches.
  • packages/cli — lowering keeps a declared entry.
  • service-automation — pure step reports no metrics, declared writer reports
    unmeasured (success and throw), a mixed flow stays measurable per node,
    descriptor publishes the contract, context shape pinned.
  • packages/qa/dogfood — end-to-end: boots the app, triggers two identical
    sweeps differing only in the function they call, and asserts the summary the
    trigger returns (unmeasured: 0 vs unmeasured: 1).

All eight packages/spec generated-artifact gates regenerated and green
(check:generated), plus check:liveness, check:exported-any,
check:skill-examples, check:strictness-ledger (the new schema file is
declared in the ledger) and check:type-check-coverage.


Generated by Claude Code

…on purity contract; a writer opts out honestly (#4396)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nwaAedz4jxRsy63nW8bGq
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 31, 2026 5:25pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/cli, @objectstack/objectql, @objectstack/dogfood, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec.

121 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via packages/cli, @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, packages/runtime, @objectstack/spec)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/cli, @objectstack/runtime, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, @objectstack/runtime, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/runtime, packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli, @objectstack/runtime, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/service-automation, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli, @objectstack/objectql, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/qa/dogfood, packages/runtime, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa/dogfood)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/cli, @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/objectql, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/objectql, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Jul 31, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review July 31, 2026 23:38
@os-zhuang
os-zhuang merged commit eb4204b into main Jul 31, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/script-node-purity-enforcement-19cjsy branch July 31, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A script node's function is contractually pure, but nothing enforces it — a function that writes makes its run under-report

2 participants