Uh oh!
There was an error while loading. Please reload this page.
feat(service-automation): CEL-mirrored function table for flow value expressions, with a loud unknown-function diagnostic - #11347
Conversation
…d function table (round/floor/ceil/abs/min/max) and a loud unknown-function diagnostic Maintainer ruling on #11060 (2026-08-23): option A lands now — the template evaluator's value expressions support exactly round, floor, ceil, abs, min, max, every name and semantic mirrored 1:1 from @objectstack/formula's CEL stdlib (pinned by a parity test driving both engines over one grid), with no second semantics invented. The silent-null rewrite of an identifier in call position becomes FlowExpressionFunctionError — a named, guard-marked (#3863) authoring diagnostic — instead of a swallowed TypeError writing the field as undefined. Oracle (hotcrm#1206 shape, in-tree): a discounted money write within the field's declared scale succeeds end to end on a real ObjectQL + sqlite stack; the raw product stays refused (#7501 max_scale). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift Check9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b9074d54c0ad3457e5b9c7773a2c8f96b6fde4bc && git checkout b9074d54c0ad3457e5b9c7773a2c8f96b6fde4bc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d39569239b46f6ef68e8af438a6e78ccb62d6c92 a4a42120393af8824386d0b6b706b3089229357e && git checkout -B drift-repro d39569239b46f6ef68e8af438a6e78ccb62d6c92 && git merge --no-ff a4a42120393af8824386d0b6b706b3089229357e
node scripts/docs-audit/affected-docs.mjs --json d39569239b46f6ef68e8af438a6e78ccb62d6c92 |
…irrored functions and the loud unknown-function failure mode PM review round on #11347: the 'Expressions in flows' table row for field values enumerated the dialect's whole vocabulary, so landing the function table without it would ship the capability undiscoverable — and the failure the loud diagnostic replaced belongs in the failure-modes callout, stated as the transition it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11060
Clause-②: yes — this PR expands the accepted expression vocabulary of the flow value surface (contract-review tier). It is draft and stays draft;
needs:contract-reviewis attached to the card and to this PR, and this seat will not flip ready, enqueue, or arm auto-merge.What lands (maintainer ruling on #11060, 2026-08-23 — option A)
The flow value-expression evaluator (
resolveToken()inpackages/services/service-automation/src/builtin/template.ts, reached bycreate_record/update_recordconfig.fieldsandassignmentconfig.assignmentsthroughinterpolate(...)) gains exactly six functions —round,floor,ceil,abs,min,max— every name and semantic mirrored 1:1 from@objectstack/formula's CEL stdlib (src/stdlib.ts, "Numbers" block). No second semantics invented:round/floor/ceilare integer-only, exactly like their CEL registrations (round(dyn): int— there is no precision form;round(x, 2)is refused with the prescription to writeround(x * 100) / 100, the same authoring pattern CEL requires). JSMath.roundhalf-toward-+∞ IS the mirrored mode (it is what the stdlib calls).min/maxare exactly binary and return the operand verbatim (type preserved) — the stdlib lambdas copied.intas BigInt; the CEL engine's public boundary (cel-engine.tscoerce) hands callers a plain number within the safe-integer range. The table returns that same post-coercion value, because this dialect's operators are plain JS (a BigInt would throw on the next/). Two edges cannot be mirrored into JS arithmetic and are named errors instead of silent corruption: a non-finite argument toround/floor/ceil(CEL faults there too —BigInt(NaN)throws inside the stdlib) and a result beyondNumber.MAX_SAFE_INTEGER(CEL's boundary switches carrier to string there). Both divergence edges are pinned in the parity test as declared behavior.The loud half, same PR: an identifier in call position that is not a supported function —
ROUND(...),Math.round(...),(x).toFixed(2),Number(...), or the seventh name anyone types — now throwsFlowExpressionFunctionError(named, with a did-you-mean and the supported set; guard-marked per #3863 so afaultedge cannot swallow it) instead of being silently rewritten tonull, whose swallowedTypeErrorwrote the field asundefined. Non-call resolution is unchanged: unresolved plain tokens still fail soft, andNOW()/TODAY()whole-token macros behave exactly as before (pinned as over-denial controls).Docs half (PM review round): the "Expressions in flows" reference in
content/docs/automation/flows.mdxenumerates the value dialect's vocabulary, so this PR falsified it. Three anchored edits keep it honest: the table row for field values now lists the six functions with the integer-only shape and theround(x * 100) / 100idiom stated in the row; the "failure modes to memorize" callout gains a third entry naming the loud error and stating plainly that it replaced a silentundefinedwrite (and that afaultedge does not catch it); the section's framing sentence gains one clause (value-dialect numeric functions are mirrored from CEL). No two-argumentrounddocumented anywhere;content/docs/releases/**untouched. Sweep result: the enumeration appears on no other docs page (otherNOW()hits are RLS/pushdown/defaultValuecontexts); the only other teaching surface is the governedskills/objectstack-automation/SKILL.md, which is #11348's narrowed scope.Mirror evidence
template-functions.test.tsproves the mirror by parity, not restatement: one input grid drives BOTH engines —ExpressionEngine.evaluate({dialect:'cel', ...})and the template evaluator — and results must agree underObject.is(which caught a real drift during development: JSMath.round(-0.5)is-0while CEL's BigInt path collapses it to+0; the table now performs the same collapse). Arity parity (round(1.5, 2)refused by both), domain parity (round("x")faults in both;abs("x")is NaN in both), and the two declared divergence edges are pinned in the same file.Oracle (hotcrm quote-flow shape, objectstack-ai/hotcrm#1206)
That repo is not reachable from this environment, so the shape is reproduced in-tree (
flow-field-expression-scale.integration.test.ts) on a real stack — ObjectKernel + ObjectQLPlugin + better-sqlite3:memory:+ AutomationServicePlugin, with ascale: 2currency field:180000 * (1 - 30/100)=125999.99999999999is refused (max_scale, A number field's declaredscaleis never enforced — values with more decimals are accepted and stored verbatim (min/max on the same field are enforced) #7501's enforcement — "must have at most 2 decimal places (got 11)"), proving the gate is live in the harness;{round(amount * (1 - discount / 100) * 100) / 100}runs end to end and the persisted row readstotal = 126000;assignmentsurface persists identically;faultedge cannot route it.Ablation (predicted in writing before running)
Mutation: restore the pre-ruling silent-
nullrewrite (replace the unknown-call throw), function table left intact. Predicted: exactly 7 unit tests red (the 5 unknown-function cases, the embedded-token case, the NOW/TODAY-misuse case) + 1 integration test red (the loud-half run succeeds silently again — the resurrected bug), all six-function/arity/domain tests green. Observed: 8 failed / 18 passed across the 2 files, failures all in the diagnostic family ("expected undefined to be an instance of FlowExpressionFunctionError"). Mutation was confirmed on disk by anchored grep (throw-line count 1→0, injected marker 0→1) and the trap-protected restore re-verified (1→0 back).File surface — declared carrier plus one PM-permitted addition
packages/services/service-automation/src/builtin/template.ts— the evaluator (declared carrier).packages/services/service-automation/src/builtin/template-functions.test.ts— unit + CEL-parity + diagnostic tests (declared: "its tests").packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts— the end-to-end oracle; lives atsrc/beside the package's other kernel-booting integration tests (runas-system-stamping.integration.test.tsis the pattern) because it needs the real ObjectQL + driver stack, not the builtin unit harness (declared: "its tests")..changeset/flow-expression-function-table.md— minor for@objectstack/service-automation(declared).content/docs/automation/flows.mdx— added by the PM review round on this PR: its "Expressions in flows" table enumerates the exact vocabulary this PR extends, so landing without it ships the capability undiscoverable and the reference false.packages/specandpackages/formulaare untouched. No new ADR-0112 error code: the flow engine's expression diagnostics are thrownErrors by package convention (evaluateCondition,validateFlowExpressions), and registering a code would require editing the spec ledger, which this lane does not own.Verification at
a4a4212039(final commit)pnpm --filter @objectstack/service-automation test— 86 files / 1024 tests passed (includes the 22 new unit/parity tests and 4 integration tests), re-run at this head.tsc --noEmit -p packages/services/service-automation/tsconfig.json— only the 3 pre-existingnested-region-parity.test.tsprivate-property errors (file untouched, identical to base); the new files are clean. (The package has notypecheckscript; it sits in the measured debt ledger, which re-measured green below.)node scripts/pm/dispatch-gates.mjs(no hand-supplied paths; thecontent/docs/**path pulled the docs families in), every gate run to a real verdict, exit captured before any pipe — all exit 0 (33 runs):check:changeset-gate-self-tests,check:cross-package-test-inputs,check:doc-anchors("278 internal fragment link(s) … all resolve"),check:doc-authoring("389 files clean"),@objectstack/lint check:doc-formula-expressions("22 record-scoped formula example(s) … judged clean"),@objectstack/lint check:doc-security-posture,check:docs-audit-scope,check:docs-redirects,@objectstack/spec check:empty-state,@objectstack/spec check:liveness,check:objectui-changeset,check:published-files,check:published-readme-links,check:role-word,check:slot-lookup,@objectstack/spec check:strictness-ledger,check:test-source-alias,check:type-source-resolution,@objectstack/spec check:variant-docs,check-adr-0087-registration,check-changeset-no-major,check-ci-filter-parity,check-doc-frontmatter("403 page(s) … parse"),check-empty-changeset,check-plugin-teardown-shape,check-section-landing-index,docs-audit/check-affected-docs,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:nul-bytes,check:type-check-coverage, andcheck:type-check-debt --re-measure("33 ledger entr(ies) re-measured … none above its recorded number") after the turbo packages closure build (FULL TURBO at this head).Option B (assignment expressions onto the CEL engine) stays deferred to #11182 — nothing here moves toward it; #11182 remains open.
Generated by Claude Code