Skip to content

feat(service-automation): CEL-mirrored function table for flow value expressions, with a loud unknown-function diagnostic - #11347

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-11060-flow-expression-function-table
Aug 23, 2026
Merged

feat(service-automation): CEL-mirrored function table for flow value expressions, with a loud unknown-function diagnostic#11347
os-sam merged 3 commits into
mainfrom
claude/issue-11060-flow-expression-function-table

Conversation

@os-sam

@os-samos-sam commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

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-review is 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() in packages/services/service-automation/src/builtin/template.ts, reached by create_record/update_recordconfig.fields and assignmentconfig.assignments through interpolate(...)) 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/ceil are integer-only, exactly like their CEL registrations (round(dyn): int — there is no precision form; round(x, 2) is refused with the prescription to write round(x * 100) / 100, the same authoring pattern CEL requires). JS Math.round half-toward-+∞ IS the mirrored mode (it is what the stdlib calls).
  • min/max are exactly binary and return the operand verbatim (type preserved) — the stdlib lambdas copied.
  • Carrier: cel-js carries CEL int as 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 to round/floor/ceil (CEL faults there too — BigInt(NaN) throws inside the stdlib) and a result beyond Number.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 throws FlowExpressionFunctionError (named, with a did-you-mean and the supported set; guard-marked per #3863 so a fault edge cannot swallow it) instead of being silently rewritten to null, whose swallowed TypeError wrote the field as undefined. Non-call resolution is unchanged: unresolved plain tokens still fail soft, and NOW()/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 the round(x * 100) / 100 idiom 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 silent undefined write (and that a fault edge does not catch it); the section's framing sentence gains one clause (value-dialect numeric functions are mirrored from CEL). No two-argument round documented anywhere; content/docs/releases/** untouched. Sweep result: the enumeration appears on no other docs page (other NOW() hits are RLS/pushdown/defaultValue contexts); the only other teaching surface is the governed skills/objectstack-automation/SKILL.md, which is #11348's narrowed scope.

Mirror evidence

template-functions.test.ts proves the mirror by parity, not restatement: one input grid drives BOTH engines — ExpressionEngine.evaluate({dialect:'cel', ...}) and the template evaluator — and results must agree under Object.is (which caught a real drift during development: JS Math.round(-0.5) is -0 while 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 a scale: 2 currency field:

Ablation (predicted in writing before running)

Mutation: restore the pre-ruling silent-null rewrite (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 at src/ beside the package's other kernel-booting integration tests (runas-system-stamping.integration.test.ts is 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/spec and packages/formula are untouched. No new ADR-0112 error code: the flow engine's expression diagnostics are thrown Errors 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 test86 files / 1024 tests passed (includes the 22 new unit/parity tests and 4 integration tests), re-run at this head.
  • Ad-hoc tsc --noEmit -p packages/services/service-automation/tsconfig.json — only the 3 pre-existing nested-region-parity.test.ts private-property errors (file untouched, identical to base); the new files are clean. (The package has no typecheck script; it sits in the measured debt ledger, which re-measured green below.)
  • Gate union re-derived at this head from node scripts/pm/dispatch-gates.mjs (no hand-supplied paths; the content/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, and check: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

…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
@github-actions

github-actionsBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d39569239b46f6ef68e8af438a6e78ccb62d6c92packageMentionDocs.

Which tree this was computed on

This run read content/docs from b9074d54c0ad3457e5b9c7773a2c8f96b6fde4bc — the merge of head a4a42120393af8824386d0b6b706b3089229357e into base d39569239b46f6ef68e8af438a6e78ccb62d6c92, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…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
@os-sam
os-sam marked this pull request as ready for review August 23, 2026 16:12
@os-sam
os-sam added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit 8155855Aug 23, 2026
45 checks passed
@os-sam
os-sam deleted the claude/issue-11060-flow-expression-function-table branch August 23, 2026 16:38
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sam@claude