feat(spec,automation,objectql,runtime,cli): declare the script-function purity contract; a writer opts out honestly (#4396) - #4421
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 6 package(s): 121 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 31, 2026 23:38
59 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4396.
The gap
A
scriptnode's function is contractually pure — it takes itsinputs,returns a value, and a later declarative node persists it — and #4354's run
summary depends on that. A
scriptstep reports no record metrics preciselybecause every write a pure function causes is a downstream
create_record/update_recordcounting itself, so "this node touched no records" is exactrather than a guess.
The contract lived only in a comment inside the executor.
ActionDescriptorsaid
category: 'logic'and nothing about purity, so no author, lint ordesigner could read the rule the summary was relying on — and a function that
wrote anyway made its run report
selected: 30, acted: 0, which isindistinguishable 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.
ActionDescriptorcarrieshandlerContract: 'none' | 'pure'; thescriptdescriptor publishes'pure'.The rule now reaches the action catalog, the designer palette, the generated
reference docs (
content/docs/references/automation/flow-function), the flowsguide and the automation skill.
2. Let a function opt in to declaring its effect. Either
functionsshapemay state what a function does:
A step calling a declared writer reports
unmeasuredEffect, so the run'sunmeasuredtally keeps the broken-sweep query(
selected > 0 AND acted = 0 AND unmeasured = 0) off that flow — and onlythat flow. The rejected alternative was marking every
scriptstepunmeasured, 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 andmeans
effect: 'pure'.The seams the declaration crosses
Each one could drop it silently, so each one carries it:
defineStack({ functions }){ handler, effect };effectalso allowed on the array formAppPlugincollectBundleFunctionEntries()keeps the declaration;collectBundleFunctions()still answers plain callables for hooks/jobsregisterFunction(name, handler, packageId | { packageId, effect }), plusresolveFunctionEntry(name){ handler, effect }; a registry without the new lookup reads as pure, exactly as beforescriptexecutoreffect: 'writes'→metrics: { unmeasuredEffect: true }, on success and on a throw (a writer that threw may have written first)objectstack build{ handler: '<ref>', effect }instead of being droppedmergeRuntimeModulere-attaches the module's callable to the declaration the JSON carriedAlso fixed
bindHooksToEnginereturned before registering a bundle's functions when thestack declared no hooks. A flow-only app's
defineStack({ functions })thereforereached the engine as nothing, and every
scriptnode 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 —
FlowFunctionContexthandsthe 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
effectreading aswrites, never as pure), bothdefineStackshapes, andhandlerContractdefaults/rejection.packages/objectql— registry entries carry the declaration;bindHooksregistersfunctions 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 reportsunmeasured(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 identicalsweeps differing only in the function they call, and asserts the summary the
trigger returns (
unmeasured: 0vsunmeasured: 1).All eight
packages/specgenerated-artifact gates regenerated and green(
check:generated), pluscheck:liveness,check:exported-any,check:skill-examples,check:strictness-ledger(the new schema file isdeclared in the ledger) and
check:type-check-coverage.Generated by Claude Code