Skip to content

Commit e8afd1e

Browse files
fix(lint)!: a flow condition's text-vs-number comparison gets a true verdict on every flow, record.x read and decision branch (#30015)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBNYbz7uNdbM42gPou4qdDn8)_ Fixes #29959 ## 维护者速读 - **改了什么**:流程条件里「文本或布尔字段和数字比大小」(如 `name > 5`)现在每个位置都拿到真实判词。记录触发流程里写成 `record.name > 5`、或写在 `decision` 分支里,发布时和裸字段名一样被拒(error,文案说运行在该条件处失败、触发写入照常落库);非记录触发流程(定时、API、手动/动作启动)仍是 warning,但文案从「静默得到 null」改为如实说明:条件出错时运行失败。MCP `validate_expression` 新增可选输入 `severity`(校验规则)和 `triggerKind`(流程触发方式),答案与发布门一致。 - **为什么改**:#29926 之后还有三处判词缺失或失真:非记录触发流程的 warning 说「得到 null」(实际 `evaluateCondition` 在每种流程上都抛错、运行失败);`record.x` 从不做类型判断;`decision` 分支没有字段类型,`name > 5` 运行必失败却无任何提示。MCP 工具也无法表达规则严重级别和流程触发方式,答案与发布门不一致。 - **风险与回滚**:收窄发布门:已有记录触发流程若在 `record.x` 读取或 decision 分支里有这种比较,下次发布会被拒(它们每次运行本来就会失败)。流程条件上的 warning 代码由 `type-mismatch` 改为 `type-mismatch-run-may-fail`(18.3.0 的 formula / mcp 在流程条件上发的是 `type-mismatch`,按代码识别的使用方需改认)。MCP 新输入均可选,不传时答案与之前相同。回滚即 revert 本 PR,无数据迁移。 - **先看**:`packages/formula/src/cel-engine.ts`(`firstTypeMismatch` 的 record 根第二遍)、`packages/formula/src/validate.ts`(`type-mismatch-run-may-fail` 分支)、`packages/lint/src/validate-expressions.ts`(`checkDeclaredPredicate` 的 decision 分支走 `check()`)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/publish-type-mismatch-fail-closed.test.ts`,预期 9 条全过:记录触发流程 `record.name > 5` 与 decision 分支 `name > 5` 被拒且文案说运行失败;定时流程同样写法发布成功、warning 文案说运行失败而不说 null;类型正确的对照发布无判词。 - **需要你做的**:无,席位落地(收窄,席位在落地前做 contract review)。 Clause-②: yes (narrowing) — the author-time gate (`os build`, `os validate`, the package publish and the flow publish door) refuses, on a record-triggered flow, a `type-mismatch` in a `decision` branch and in a `record.FIELD` / `previous.FIELD` read of a start or edge condition, which it passed with no verdict before; refuses the same on a time-relative flow sweeping the object its conditions are typed against, where 18.3.0 served a warning; and a flow condition's `type-mismatch` warning is served under its own code `type-mismatch-run-may-fail` instead of `type-mismatch`. Added beside them (the widening half): the optional `severity` and `triggerKind` inputs of the `validate_expression` MCP tool, the code `type-mismatch-run-may-fail` in `@objectstack/formula`'s `ExpressionRefusalCode`, the optional `flowVariables` key of `@objectstack/formula`'s `ExprSchemaHint`, and a bare flow-condition name that a declared flow variable or node id holds is no longer typed as the field it shadows (no verdict where 18.3.0 served a `type-mismatch` warning). Metadata-protocol: narrowed Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx` (the type-soundness row names the decision branch, the `record.FIELD` read and the warning on other flows) and the MCP skill text in `packages/mcp/src/skill-md.ts` (the two new `validate_expression` inputs); `skills/**` teaches no text-vs-number comparison in a flow condition (grep for `type mismatch`, `evaluates to null`, `validate_expression` and the decision examples), so no skill or eval moves. ## What changes Re-measured on `main` at `73a3ef9f` (after #29953 `9037b13d`). The four sites, and what each now gets: | site | runtime on a fault (measured) | before | now | |:--|:--|:--|:--| | record-triggered flow: `record.FIELD` / `previous.FIELD` in a start or edge condition | `seedRunVariables` binds the trigger record as `record` AND flattened to bare names, so `record.name` is the bare `name`; `evaluateCondition` throws, the run fails, the write lands | no verdict (the flattened typing left `record` a `dyn` map) | **error** `type-mismatch-run-fails`, the bare name's verdict | | record-triggered flow: `decision` branch `config.conditions[].expression` | `builtin/logic-nodes.ts` evaluates it over the same run variables, through the same `evaluateCondition` | no verdict (`checkDeclaredPredicate` passed no schema hint); the null-guard twin alone refused | **error** `type-mismatch-run-fails` | | any other flow (no `triggerType`, `schedule`, time-relative, `api`), all three slots; an inert node `condition` | `evaluateCondition` throws on every flow, so "silently evaluates to null" was false; but the run holds what its entrance hands it | warning `type-mismatch`, "silently evaluates to null" (start/edge only) | **warning** `type-mismatch-run-may-fail`, worded for the failed run (start, edge, decision, `record.FIELD`) | | MCP `validate_expression` | — | `validation` always fail-closed, `flow_condition` always run-fails | optional `severity` / `triggerKind` inputs; each combination answers the gate's verdict | **Site 1, grading (seat's ruling: grade as #29953 did if the same runtime fact holds; keep the warning with true text if a reason is measured).** I kept the warning, and the reason is measured on the entrances, not assumed. The run does fail on a fault in every flow (`dag-traversal.ts` `evaluateCondition`, ADR-0032 §1c). What differs is whether the condition faults at all: on a record-triggered flow the bare name and `record.FIELD` are the materialized trigger record (`record-change-trigger.ts` `buildContext` → `seedRunVariables`), so a text field holds text and `name > 5` faults unless the text is numeric. On any other flow `seedRunVariables` binds whatever `context.record` the entrance hands over (`engine.ts` celScope docblock, #22642): the inbound hook's request body (`trigger-api`, a JSON body whose `name` may be a number), a `type: 'flow'` action's loaded row or an empty record carrying only `id`, a parent flow's record (`subflow` / `map`), or nothing for a cron schedule or the REST trigger route. So the object's field type is not proven to be what the name holds, and refusing there would refuse conditions that can run. This is the same measurement that keeps the nullable-comparison twin off those flows (`validate-null-guards.ts` ledger, "sparse or unknown"). The warning now says what happens when it does fault: "a flow condition that faults when it is evaluated fails the run: the flow stops at this condition and the work it exists to do is not done (it does not read null)", and why it is a warning. **Where the text is decided (`@objectstack/formula`).** A `'flattened'` scope is a flow condition, the only caller that passes one with field types (lint's flow walk, the engine's registration advisory pass, MCP `flow_condition`). So `typeSoundnessIssue` serves `type-mismatch-run-may-fail` for a flattened site with no declared outcome, and the `record`-scoped `type-mismatch` (formula fields, display predicates: there a fault reads null or never refuses) keeps its code and text. The engine's registration log line reads the new text too (it passes no outcome). **Site 2, `record.FIELD` (`cel-engine.ts` `firstTypeMismatch`).** The flattened typing binds fields bare and leaves `record` / `previous` `dyn`. When that first pass finds nothing and the source reads `record.` or `previous.`, a second pass types those two roots with the `record`-scope struct (`buildTypedEnv(…, flattenedRecordRoots)`), for the text/boolean mismatch only, and `offendingRecordRead` names the read as written (`record.title`). Same judge, same schema hint, same outcome; no second judge. The date verdicts stay the bare name's alone (see Acceptance notes). **Site 3, `decision` branches (`@objectstack/lint`).** `checkDeclaredPredicate` takes an optional `flowCondition` hint; a `decision` node passes `{ objectName, faultOutcome: flowFaultOutcome }` and the slot goes through the same `check()` an edge goes through. So the branch gets the object's fields and types and the flow's ledger outcome (`run-fails` on a record-triggered flow, none elsewhere). The other declared `predicate` slots (a screen field's `visibleWhen`) bind the screen's collected values and keep the hint-less call. The `faultOutcomeOf` ledger docblock now carries the decision row, the `record.FIELD` reading and the "any other flow" row with its reason. **Site 4, MCP (`mcp-http-tools.ts`).** I took the "add the inputs" option. `severity` (`error` | `warning` | `info`, default `error`) and `triggerKind` (`FLOW_TRIGGER_KINDS` from `@objectstack/spec/automation` plus `none`, default `record_change`). `VALIDATE_SITE_MAP[site].faultOutcome` is now a function of the two, mirroring lint's `faultOutcomeOf` (validation: `warning`/`info` → `fail-open`, else `fail-closed`; flow_condition: `record_change` → `run-fails`, else none). A call that passes neither answers exactly as before. An input on a site it does not describe is refused as a tool error ("`severity` describes a validation rule; it applies only with site \"validation\" …"). The tool description and the served skill text name both inputs. This is a widening of a published tool schema, declared in the `Clause-②` line. **`@objectstack/spec`.** New semantic entry `18.flow-record-read-and-decision-type-mismatch-refused`, the changeset's ADR-0087 disposition (`registered`). The #29953 entry `flow-condition-type-mismatch-refused` is left as is: its text (start and edge, bare name; other flows keep a warning) is still true. ## Tests - `packages/formula/src/expression-refusal-codes.test.ts`: `PINS` for `type-mismatch-run-may-fail` (bare text, `record.active` boolean: exact params and message). `SWEEP_SCHEMAS` gains a flattened deal schema with no outcome, so the sweep reaches the new code and holds its params to its message. - `packages/formula/src/validate.test.ts`: `record.name`, `previous.name`, `record.is_active` in a flattened `run-fails` site → `type-mismatch-run-fails` naming the read; controls `size(record.name) > 5`, `double(record.name) > 5`, `record.amount > 5`, `record.name == 'x'`, `record.is_active == true` clean. Flattened with no outcome → `type-mismatch-run-may-fail`, not "evaluates to null"; the record-scope warning keeps `type-mismatch`. - `packages/lint/src/validate-expressions.test.ts`: - #29953's pin "keeps the warning on a flow that is not record-triggered" is updated as the order asks: three flows (no `triggerType`, `schedule`, `type: 'api'`), start + decision + `record.title` edge all `warning`, every message "fails the run", none "evaluates to null" or "still lands"; - new: record-triggered flow, `record.title` start, two decision branches (bare text, `record.won` boolean), `previous.title` edge → four `error`s with their key paths and the run-fails text; - new control: the well-typed versions on the same sites → no type verdict and no error; - the #5017 receiver meta-test tables the `flowCondition` plumbing receiver. - `packages/mcp/src/mcp-validate-expression.test.ts`: gate parity. For each `severity` (omitted, `error`, `warning`, `info`) and each `triggerKind` (omitted, `record_change`, `time_relative`, `schedule`, `api`, `none`), the tool's `(severity, message)` list equals `validateStackExpressions`'s on the same rule or flow. An input on the wrong site is refused; well-typed conditions are clean at every input. - `packages/objectql/src/publish-type-mismatch-fail-closed.test.ts`, new top-level `describe` in the existing harness: through the real `saveMetaItem` (draft) + `publishPackageDrafts` door, a record-triggered `record.name > 5` start condition and a `name > 5` decision branch are refused with the run-fails text; the same comparisons on a `schedule` flow publish with two warnings saying the run fails; a well-typed control publishes clean. <details><summary>Suites (head 5a5fd5b, under os-verify-lock, shared box)</summary> ``` formula test Test Files 64 passed (64) Tests 1637 passed (1637) lint test Test Files 193 passed (193) Tests 7971 passed (7971) mcp test Test Files 55 passed (55) Tests 672 passed (672) objectql publish-type-mismatch-fail-closed.test.ts Tests 9 passed (9) spec src/migrations (after gen:migration-registry) Test Files 5 passed (5) Tests 197 passed (197) typecheck (tsc --noEmit + check:test-typecheck): formula 0, lint 0, mcp 0, objectql 0 (the first run caught 1 test-layer error in formula and 2 in mcp, in the new pins; fixed in 5a5fd5b) turbo run build (all) — 120/120 tasks, exit 0 ``` </details> <details><summary>Ablations (each through scripts/ablation-replace.mjs; each restored: blob == HEAD, git diff HEAD empty)</summary> ``` A1 formula: the record-root pass removed (`return null; /* ablated-29959-a1 */`) × judges a flattened condition's record.FIELD and previous.FIELD reads as its bare name (#29959) × warns with the run-may-fail wording on a flattened condition with no declared outcome (#29959) × type-mismatch-run-may-fail (PINS) · × the SWEEP_SCHEMAS sweep (code set unreached) Tests 4 failed | 166 passed (170) A2 formula: the flattened warning arm off (`if (false as boolean /* ablated-29959-a2 */)`) — the old "silently evaluates to null" warning comes back on a flow condition × warns with the run-may-fail wording … · × type-mismatch-run-may-fail (PINS) · × the sweep Tests 3 failed | 167 passed (170) A3 lint: the decision branch hint off (`const branchCondition = undefined; /* ablated-29959-a3 */`) × keeps the warning on a flow that is not record-triggered, worded for the run it fails × refuses a type mismatch in a record.x read and a decision branch of a record-triggered flow Tests 2 failed | 459 passed (461) A4 mcp: the two inputs ignored (severity 'error', triggerKind 'record_change' always) × site validation, severity warning · × severity info × site flow_condition, triggerKind time_relative · × schedule · × api · × none Tests 6 failed | 29 passed (35) ``` </details> ## Acceptance notes - **Date verdicts in a flow condition still say "silently evaluates to null".** `date-arithmetic` / `date-text-arithmetic` on a bare date field in a flow condition are errors whose text says the field reads null; in a flow the run fails instead. Pre-existing, and outside this card's type-mismatch scope, so the `record.FIELD` pass here deliberately does not extend the date verdicts to `record.d + 1` (it would serve that false sentence at a new site). Reported to the seat as a finding. - **A declared flow variable named like a text field.** A record-triggered flow that declares `variables: [{ name: 'title', type: 'number', defaultValue: 3 }]` over a text field `title` gets `title > 5` refused as `type-mismatch-run-fails` ("`title` holds text"), though the run seeds the declared variable first and the flattening skips a bound name, so the condition reads the number (measured through the built lint dist on this head; the shadowing warning fires beside it). Present on `main` since #29953, the same on this branch; the type judge reads the object's field types without the declared-variable exclusion the null-guard twin applies (`nullGuardBare`). Fixing it needs the hint to exclude names from the bare typing but not from `record.FIELD`, a new schema-hint key; reported to the seat as a finding, not fixed here. - **Advisory and inert sites unchanged in grading.** An inert node `condition` keeps a warning (now the run-may-fail text, which is true: it fails the run if it is ever evaluated, and `flow-inert-node-condition` says it is not). - **Serial check.** None of the 16 open PRs at 2026-11-04T05:20Z touches `packages/formula/src/{validate,cel-engine,expression-refusal}.ts`, `packages/lint/src/validate-expressions.ts` or `packages/mcp/src/mcp-http-tools.ts` (the nearest: #29998 metadata-protocol split, #29993 spec conversions registry split; #29963 is version packages). ## Rework after the contract review (head 6a3a1e2) - **A bare name a flow variable holds is not typed as the field.** `seedRunVariables` seeds the flow's declared variables and node outputs before it flattens the record, and the flattening skips a bound name. New optional `ExprSchemaHint.flowVariables` (a widening): lint passes the flow's `collectFlowVariableNames` set plus the engine-bound names at the start, edge, decision and inert node sites, and the type judge leaves those bare names `dyn` (date verdicts too), while `record.NAME` / `previous.NAME` stay typed. This also closes the start/edge instance on main from #29953. - **Time-relative sweeps are graded `run-fails`.** `time-relative-trigger.ts` launches each run with `{ record: row }`, a stored row of the swept object, so on a flow whose `timeRelative.object` is the start node's `objectName` (or is omitted) the ledger, `faultOutcomeOf` and the MCP `flow_condition` map answer `run-fails`. A sweep of another object keeps the warning. The null-guard twin stays on record-triggered flows. - **Wording.** The run-may-fail sentence no longer asserts the flow's kind (it is also served on inert node conditions and in the registration log); the stale `check()` and `ExprSchemaHint.fieldTypes` comments are refreshed. - **New pins.** lint: time-relative default / same object (errors) vs another object (warnings) at start, decision and edge; a record-triggered flow declaring `title` as a number variable: bare `title > 5` at start, edge and decision gets no type verdict (shadow warning only), `record.title > 5` stays refused; the #5017 table gains the `sweep` receiver against `TimeRelativeTriggerSchema`. formula: `flowVariables` leaves bare names untyped and keeps `record.` / `previous.` typed. mcp: each trigger kind's severity pinned, not only parity. - **Ablations.** B1 lint hint not passed: 1 failed. B2 lint time-relative grading off: 1 failed. B3 formula `bareShadowed` ignored: 1 failed. B4 mcp time-relative off: 1 failed. Each restored, blob == HEAD. ## Verification All at head `5a5fd5b8` (`git rev-parse --short HEAD`), the commit this PR opens on. - `node scripts/pm/dispatch-gates.mjs --commands`: 107 derived families, each run after the full build, all **exit 0**. Re-derived just before reconciling (same 107). `--ran`: "107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED". The clone was unshallowed first. Among them: `check-adr-0087-registration --base origin/main` (registered `flow-record-read-and-decision-type-mismatch-refused`, new here), `check-changeset-no-major`, `check-empty-changeset`, `spec check:spec-changes` / `check:upgrade-guide`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:objectql-double-limit`, `check:engine-double-contract`. - `pnpm lint` (`eslint . --no-inline-config`): exit 0. - Full dogfood (`pnpm --filter @objectstack/dogfood test` under the lock; narrowing arm): `VERDICT command-exit 0`, Test Files 416 passed | 3 skipped (419), Tests 3456 passed | 28 skipped (3484). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzf4UmgvS2cusNbWnvy <!-- signed: domain:server#6 · os-support-ai · session_01WGufzf4UmgvS2cusNbWnvy --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 63b86b3 commit e8afd1e

14 files changed

Lines changed: 903 additions & 103 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/formula': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/mcp': minor
5+
'@objectstack/spec': minor
6+
---
7+
8+
fix(lint)!: every flow condition's text-vs-number comparison gets a verdict that says what the run does — a record-triggered flow's `record.<field>` read and `decision` branch are refused like its bare name, a time-relative sweep of the flow's object is refused like a record-triggered flow, a bare name a flow variable holds is no longer typed as the field, and any other flow's warning says the run fails instead of "evaluates to null" (#29959)
9+
10+
Clause-②: yes (narrowing) — the author-time gate (`os build`, `os validate`, the package publish and the flow publish door) refuses, on a record-triggered flow, a `type-mismatch` in a `decision` branch and in a `record.<field>` / `previous.<field>` read of a start or edge condition, which it passed with no verdict before; refuses the same on a time-relative flow sweeping the object its conditions are typed against, where 18.3.0 served a warning; and a flow condition's `type-mismatch` warning is served under its own code `type-mismatch-run-may-fail` instead of `type-mismatch`. Added beside them (the widening half): the optional `severity` and `triggerKind` inputs of the `validate_expression` MCP tool, the code `type-mismatch-run-may-fail` in `@objectstack/formula`'s `ExpressionRefusalCode`, the optional `flowVariables` key of `@objectstack/formula`'s `ExprSchemaHint`, and a bare flow-condition name that a declared flow variable or node id holds is no longer typed as the field it shadows (no verdict where 18.3.0 served a `type-mismatch` warning).
11+
Metadata-protocol: narrowed
12+
Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx` (the type-soundness row names the decision branch, the `record.<field>` read and the warning on other flows) and the MCP skill text in `packages/mcp/src/skill-md.ts` (the two new `validate_expression` inputs); `skills/**` teaches no text-vs-number comparison in a flow condition (grep for `type mismatch`, `evaluates to null`, `validate_expression` and the decision examples), so no skill or eval moves.
13+
14+
<!-- adr-0087: registered flow-record-read-and-decision-type-mismatch-refused -->
15+
16+
**BREAKING** accept-set narrowing at the author-time expression gate, served by `os build`, `os validate` and `POST /api/v1/packages/:id/publish-drafts`.
17+
18+
- **Record-triggered flows — before.** The start and edge conditions refused `name > 5` on a text field (`type-mismatch-run-fails`), but `record.name > 5` in the same slot, and `name > 5` in a `decision` node's `config.conditions[].expression`, published with no verdict at all. Both fail the run the same way: the engine's `evaluateCondition` throws on the fault, the run binds the trigger record both as `record` and flattened to bare names, and a decision branch is evaluated over the same variables as an edge.
19+
- **Now.** Both are refused as `expression-invalid` at `severity: error`, code `type-mismatch-run-fails`, with the message the bare name already gets: the run fails at the condition and the triggering write still lands. `previous.<field>` is judged the same way. The date verdicts stay on the bare name only, as before.
20+
- **Time-relative flows.** A sweep launches each run with the swept row as its record, so on a flow whose `timeRelative.object` is the start node's `objectName` (or is omitted) the start, edge and `decision` conditions get the record-triggered verdict above, bare name included: `type-mismatch-run-fails`, where 18.3.0 served a `type-mismatch` warning. A sweep of another object keeps the warning below.
21+
- **Any other flow** (no `triggerType`, a `schedule` flow, an `api` flow) and an inert node `condition` keep a warning. Its text said the expression "silently evaluates to null", which is false on every flow: a condition that faults fails the run. It is now `type-mismatch-run-may-fail`, worded for the failed run, and a warning because such a flow is handed whatever its entrance carries (an API request body, an action's partial record, a parent flow's record, or nothing), so the field's type is not proven to be what the name holds. A `record.<field>` read and a `decision` branch there get the same warning. The flow registration log line reads the same text.
22+
- **A bare name a flow variable holds.** The run seeds a flow's declared variables (and each node's outputs, under its id) before it flattens the record, and the flattening skips a name already bound. So in a flow that declares `variables: [{ name: 'title', type: 'number' }]` over a text field `title`, a bare `title > 5` reads the number and runs; it was refused as `type-mismatch-run-fails` on the head that added that error, and warned on 18.3.0. It now gets no type verdict (the `flow-variable-shadows-field` warning still names the shadowing). `record.title > 5` reads the field and stays refused.
23+
- **`validate_expression` (MCP).** Two optional inputs, so the tool answers what the gate will: `severity` (`error` | `warning` | `info`, site `validation`; default `error`) gives an advisory rule the gate's `type-mismatch-fail-open` instead of the fail-closed verdict, and `triggerKind` (`record_change` | `time_relative` | `schedule` | `api` | `none`, site `flow_condition`; default `record_change`) gives a `schedule`, `api` or `none` flow the gate's warning instead of the run-fails error (`time_relative` answers run-fails, its record being a row of `objectName`). Either input on a site it does not describe is refused as a tool error. A call that passes neither answers as before.
24+
- **`@objectstack/spec`.** Upgrade guide entry `flow-record-read-and-decision-type-mismatch-refused` (semantic: no mechanical rewrite).
25+
26+
**FROM → TO.** In a record-triggered flow, or a time-relative flow sweeping its own object: `record.name > 5` (or a decision branch `name > 5`) on a text field → `size(record.name) > 5` for its length, `record.score > 5` on a number field for a value, or `double(record.name) > 5` only when every value is numeric text. A boolean: `record.active > 0` → `record.active == true`. A consumer keyed to the code of a flow condition's warning: `type-mismatch` → `type-mismatch-run-may-fail` (`@objectstack/formula@18.3.0` and `@objectstack/mcp@18.3.0` serve `type-mismatch` there; the record-scoped `type-mismatch`, on a formula field or a display predicate, is unchanged).

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ come at two severities: **errors** fail the build; **warnings** are advisory
242242
| **Qualify field references** — a formula / validation / predicate binds only the `record` namespace, so a bare `amount` resolves to nothing and evaluates to `null`. Write `record.amount`. | `amount > 100` → `record.amount > 100` | error |
243243
| **Field must exist** — a typo'd `record.<field>` is flagged with a did-you-mean. | `record.amont` → `record.amount` | error |
244244
| **Unknown function** — a misspelled or nonexistent stdlib call. | `isBlnk(record.x)` | error |
245-
| **Type soundness** — a text or boolean field used with an arithmetic (`+ - * / %`) or ordering (`< > <= >=`) operator against a number faults at runtime unless the text is numeric. In a formula it evaluates to `null`. Where a fault does something else it is an error, worded for what happens: a validation rule, a field `requiredWhen` / `readonlyWhen`, an option `visibleWhen` or a hook `condition` refuses the write; a record-triggered flow's start or edge condition fails the run (the write that fired it still lands); an advisory (`severity: 'warning'` / `'info'`) validation rule is skipped, so it never gives its advisory. Store it as a number field, compare text with text, or convert explicitly (`size(record.name)` for the length; `double(record.name)`, which itself faults on text that is not a number). | `record.title * 2`; `record.name > 5` in a validation rule → `size(record.name) > 5` | warning in a formula or a display predicate; error at the sites named |
245+
| **Type soundness** — a text or boolean field used with an arithmetic (`+ - * / %`) or ordering (`< > <= >=`) operator against a number faults at runtime unless the text is numeric. In a formula it evaluates to `null`. Where a fault does something else it is an error, worded for what happens: a validation rule, a field `requiredWhen` / `readonlyWhen`, an option `visibleWhen` or a hook `condition` refuses the write; a record-triggered flow's start, edge or `decision` branch condition fails the run (the write that fired it still lands), whether it reads the field bare or as `record.<field>`, and so does a time-relative flow's that sweeps the same object; a bare name that a declared flow variable holds reads the variable, so it is not judged as the field; an advisory (`severity: 'warning'` / `'info'`) validation rule is skipped, so it never gives its advisory. Store it as a number field, compare text with text, or convert explicitly (`size(record.name)` for the length; `double(record.name)`, which itself faults on text that is not a number). | `record.title * 2`; `record.name > 5` in a validation rule → `size(record.name) > 5` | warning in a formula or a display predicate, and in a condition of any other flow, where the warning says the run fails if the condition faults (what such a flow binds under the name depends on how it is started); error at the sites named |
246246
| **Date arithmetic** — a `date` / `datetime` field used with `+` / `-` against a number (a `YYYY-MM-DD` value is a string at runtime, so this always faulted to `null`). Use the date helpers instead. | `record.end_date - record.start_date + 1` → `daysBetween(record.start_date, record.end_date) + 1`; `today() + 30` → `daysFromNow(30)`; `record.date + n` → `addDays(record.date, n)` | error (#3306) |
247247
| **Date subtraction** — the runtime holds a `date` / `datetime` field as its text (`YYYY-MM-DD`, or an ISO-8601 instant), so subtracting two of them faults and the formula reads `null`; a timestamp minus one computes a duration, which a field reads as `{}`. Use `daysBetween`. A condition may still compare a duration (`today() - record.start_date > duration("72h")`). | `record.end_date - record.start_date` → `daysBetween(record.start_date, record.end_date)`; `today() - record.start_date` → `daysBetween(record.start_date, today())` | error |
248248

‎packages/formula/src/cel-engine.ts‎

Lines changed: 113 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,14 @@ function buildTypedEnv(
966966
fieldCelTypes: Readonly<Record<string, FieldCelType>>,
967967
scope: 'record' | 'flattened',
968968
temporalAsText = false,
969+
/**
970+
* [#29959] Flattened scope only: the field types to type `record.<field>` /
971+
* `previous.<field>` with. Absent, those roots stay `map` (dyn members). It
972+
* is a map of its own, not `fieldCelTypes`, because a bare name a flow
973+
* variable holds is left out of the bare typing while `record.<name>` still
974+
* reads the field.
975+
*/
976+
recordRootTypes?: Readonly<Record<string, FieldCelType>>,
969977
): Environment {
970978
// `temporalAsText` declares a `date`/`datetime` field as the text the runtime
971979
// holds ({@link DATE_TEXT_CEL_TYPE}) instead of a Timestamp — see
@@ -981,6 +989,18 @@ function buildTypedEnv(
981989
registerStdLib(env, () => new Date(0));
982990
registerNullComparisons(env);
983991
if (temporalAsText) registerDateTextOverloads(env);
992+
if (recordRootTypes) {
993+
// [#29959] A flow run binds the record it was handed as `record` too
994+
// (`seedRunVariables`), beside the flattened bare names, so
995+
// `record.<field>` is the same value as the bare `<field>` and is typed
996+
// the same: the `record`-scope struct, on `record` and `previous`.
997+
const fields: Record<string, string> = {};
998+
for (const [name, t] of Object.entries(recordRootTypes)) fields[name] = typeName(t);
999+
try { env.registerType('OsRecordScope', { fields }); } catch { /* invalid field name — ignore */ }
1000+
for (const root of ['record', 'previous']) {
1001+
try { env.registerVariable(root, 'OsRecordScope'); } catch { /* duplicate — ignore */ }
1002+
}
1003+
}
9841004
for (const root of SCOPE_ROOTS) {
9851005
try { env.registerVariable(root, 'map'); } catch { /* duplicate — ignore */ }
9861006
}
@@ -1038,6 +1058,39 @@ function offendingField(
10381058
return null;
10391059
}
10401060

1061+
/**
1062+
* [#29959] `fieldCelTypes` without `names` — the bare typing of a flattened
1063+
* condition whose run binds those names as variables, which win over the
1064+
* field (see {@link firstTypeMismatch}'s `bareShadowed`).
1065+
*/
1066+
function withoutNames(
1067+
fieldCelTypes: Readonly<Record<string, FieldCelType>>,
1068+
names: ReadonlySet<string>,
1069+
): Record<string, FieldCelType> {
1070+
const out: Record<string, FieldCelType> = {};
1071+
for (const [name, t] of Object.entries(fieldCelTypes)) if (!names.has(name)) out[name] = t;
1072+
return out;
1073+
}
1074+
1075+
/**
1076+
* [#29959] The `record.<field>` / `previous.<field>` read of a field of
1077+
* `celType` in a flattened condition, as written — what
1078+
* {@link firstTypeMismatch}'s record-root pass names. `null` when no such read
1079+
* is spelled member-wise (an index read, say), so the message names no field.
1080+
*/
1081+
function offendingRecordRead(
1082+
source: string,
1083+
fieldCelTypes: Readonly<Record<string, FieldCelType>>,
1084+
celType: FieldCelType,
1085+
): { field: string; root: 'record' | 'previous' } | null {
1086+
for (const [name, t] of Object.entries(fieldCelTypes)) {
1087+
if (t !== celType) continue;
1088+
const m = new RegExp(`(?<![\\w$.])(record|previous)\\.${name}(?![\\w$])`).exec(source);
1089+
if (m) return { field: name, root: m[1] as 'record' | 'previous' };
1090+
}
1091+
return null;
1092+
}
1093+
10411094
/**
10421095
* Tier-4 type-soundness (#1928): detect a `record`-scoped expression that
10431096
* type-checks structurally but faults a runtime operator overload because a
@@ -1074,46 +1127,85 @@ function offendingField(
10741127
* `scope` selects how fields are bound: `'record'` (default) for
10751128
* `record.<field>` sites; `'flattened'` for bare-field flow/automation
10761129
* conditions.
1130+
*
1131+
* `bareShadowed` (flattened scope only, #29959): names the run binds BEFORE it
1132+
* flattens the record's fields — a flow's declared variables and node ids.
1133+
* `seedRunVariables` seeds those first and the flattening skips a bound name,
1134+
* so a bare read of one holds the variable, not the field: it stays `dyn`
1135+
* here. `record.<name>` still reads the field and stays typed.
10771136
*/
10781137
export function firstTypeMismatch(
10791138
source: string,
10801139
fieldCelTypes: Readonly<Record<string, FieldCelType>>,
10811140
scope: 'record' | 'flattened' = 'record',
1082-
): { operator: string; operands: string; celType: FieldCelType; field: string | null; category: 'date-arith' | 'type-mismatch' } | null {
1141+
bareShadowed?: ReadonlySet<string>,
1142+
): {
1143+
operator: string;
1144+
operands: string;
1145+
celType: FieldCelType;
1146+
field: string | null;
1147+
/** [#29959] Set when a flattened condition's mismatch is a `record.<field>` / `previous.<field>` read. */
1148+
root?: 'record' | 'previous';
1149+
category: 'date-arith' | 'type-mismatch';
1150+
} | null {
10831151
if (typeof source !== 'string' || !source.trim()) return null;
10841152
// An all-`dyn` record can never fault an overload — skip the parse entirely.
10851153
if (!Object.values(fieldCelTypes).some((t) => t === 'string' || t === 'bool' || t === 'timestamp')) return null;
1086-
try {
1154+
/** The faulting overload `source` meets in `env`'s typing, or `null`. */
1155+
const unsoundOverload = (env: Environment): RegExpExecArray | null => {
10871156
// A null-guarded numeric branch (`cond ? n : null`) faults cel-js's ternary
10881157
// unifier *before* the check reaches an inner date-arith overload; rewrite it
10891158
// first (same pass the runtime uses) so the real fault surfaces (#3306).
1090-
const env = buildTypedEnv(fieldCelTypes, scope);
10911159
const result = env.parse(rewriteNullableTernary(rewriteIndexedHas(source))).check?.() as
10921160
| { valid?: boolean; error?: { message?: string } }
10931161
| undefined;
10941162
if (!result || result.valid !== false) return null;
1095-
const m = UNSOUND_OVERLOAD_RE.exec(result.error?.message ?? '');
1096-
if (!m) return null;
1097-
const operator = m[2];
1098-
const operands = `${m[1]} ${operator} ${m[3]}`;
1099-
// #3306 — date/duration ARITHMETIC against a number: always nulls, hard error.
1100-
if (ARITH_OPS.has(operator)
1101-
&& ((DATE_CEL_TYPES.has(m[1]) && NUMERIC_CEL_TYPES.has(m[3]))
1102-
|| (NUMERIC_CEL_TYPES.has(m[1]) && DATE_CEL_TYPES.has(m[3])))) {
1163+
return UNSOUND_OVERLOAD_RE.exec(result.error?.message ?? '');
1164+
};
1165+
/** The text/boolean operand type of a faulting overload, or `null`. */
1166+
const textOrBool = (m: RegExpExecArray): FieldCelType | null =>
1167+
m[1] === 'string' || m[1] === 'bool' ? (m[1] as FieldCelType)
1168+
: m[3] === 'string' || m[3] === 'bool' ? (m[3] as FieldCelType)
1169+
: null;
1170+
const bareTypes = scope === 'flattened' && bareShadowed && bareShadowed.size > 0
1171+
? withoutNames(fieldCelTypes, bareShadowed)
1172+
: fieldCelTypes;
1173+
try {
1174+
const m = unsoundOverload(buildTypedEnv(bareTypes, scope));
1175+
if (m) {
1176+
const operator = m[2];
1177+
const operands = `${m[1]} ${operator} ${m[3]}`;
1178+
// #3306 — date/duration ARITHMETIC against a number: always nulls, hard error.
1179+
if (ARITH_OPS.has(operator)
1180+
&& ((DATE_CEL_TYPES.has(m[1]) && NUMERIC_CEL_TYPES.has(m[3]))
1181+
|| (NUMERIC_CEL_TYPES.has(m[1]) && DATE_CEL_TYPES.has(m[3])))) {
1182+
return {
1183+
operator, operands, celType: 'timestamp', category: 'date-arith',
1184+
field: offendingField(source, bareTypes, 'timestamp', scope),
1185+
};
1186+
}
1187+
// #1928 — text/boolean field in arithmetic/ordering against a number: warning.
1188+
const celType = textOrBool(m);
1189+
if (!celType) return null;
11031190
return {
1104-
operator, operands, celType: 'timestamp', category: 'date-arith',
1105-
field: offendingField(source, fieldCelTypes, 'timestamp', scope),
1191+
operator, operands, celType, category: 'type-mismatch',
1192+
field: offendingField(source, bareTypes, celType, scope),
11061193
};
11071194
}
1108-
// #1928 — text/boolean field in arithmetic/ordering against a number: warning.
1109-
const celType: FieldCelType | null =
1110-
m[1] === 'string' || m[1] === 'bool' ? (m[1] as FieldCelType)
1111-
: m[3] === 'string' || m[3] === 'bool' ? (m[3] as FieldCelType)
1112-
: null;
1113-
if (!celType) return null;
1195+
// [#29959] A flattened condition reads the same field as `record.<field>`
1196+
// (and `previous.<field>`), which the bare typing above leaves `dyn`. A
1197+
// second pass types those roots too, for the text/boolean mismatch only:
1198+
// the same verdict the bare name gets. The date verdicts stay the bare
1199+
// name's alone, as before.
1200+
if (scope !== 'flattened' || !/(?<![\w$.])(?:record|previous)\s*\./.test(source)) return null;
1201+
const r = unsoundOverload(buildTypedEnv(bareTypes, 'flattened', false, fieldCelTypes));
1202+
const celType = r ? textOrBool(r) : null;
1203+
if (!r || !celType) return null;
1204+
const read = offendingRecordRead(source, fieldCelTypes, celType);
11141205
return {
1115-
operator, operands, celType, category: 'type-mismatch',
1116-
field: offendingField(source, fieldCelTypes, celType, scope),
1206+
operator: r[2], operands: `${r[1]} ${r[2]} ${r[3]}`, celType, category: 'type-mismatch',
1207+
field: read?.field ?? null,
1208+
...(read ? { root: read.root } : {}),
11171209
};
11181210
} catch {
11191211
// A parse/other fault is the syntax checker's job (celEngine.compile); this

0 commit comments

Comments
 (0)