Skip to content

Commit 9037b13

Browse files
fix(lint)!: a type mismatch in a record-triggered flow condition or an advisory validation rule is served a verdict that says what really happens (#29953)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7J3TmFTYMYQrfpye6GQNg9)_ Fixes #29926 ## 维护者速读 - **改了什么**:文本或布尔字段和数字比大小(如 `name > 5`)写在「记录触发流程」的开始条件或连线条件里,现在发布时直接拒绝(error),文案说明真实后果:流程运行在该条件处失败,触发它的写入照常落库。写在建议性校验规则(`severity: 'warning' | 'info'`)里仍被拒,但两条判词(类型不匹配和可空比较)都改成如实说明:规则被跳过、写入照常进行、建议永远不会出现。 - **为什么改**:#29923 之后这两个角色拿到的判词是假的:流程条件说「静默得到 null」,实际是运行失败;建议性规则说「写入会被拒」,实际写入照常进行(#24782)。另外 `type-mismatch` 一个代码对应两句话,违反「一个代码一句模板」的契约,现在每种结局各有自己的代码。 - **风险与回滚**:收窄发布门:已存在的记录触发流程若条件里有这种比较,下次发布会被拒(它们本来每次运行都会失败);`type-mismatch` 带 `outcome: 'fail-closed'` 的那一支改名为 `type-mismatch-fail-closed`,并去掉 `outcome` 参数。这一支已随 18.3.0 发出(`@objectstack/formula@18.3.0` 的类型和 `@objectstack/mcp@18.3.0` 的 `validate_expression` 都在用,但 18.3.0 的 changelog 里没有记录),所以按 18.3.0 的 `type-mismatch` + `outcome` 识别它的使用方需要改认新代码。回滚即 revert 本 PR,无数据迁移。 - **先看**:`packages/lint/src/validate-expressions.ts`(`faultOutcomeOf` 账本,两条判词共用)、`packages/formula/src/validate.ts`(`TYPE_MISMATCH_FAULT_ARMS`:每种结局的代码和文案)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/publish-type-mismatch-fail-closed.test.ts`,预期 5 条全过:校验规则被拒、公式仍发布带 warning、可空孪生照旧被拒、建议性规则被拒但文案不说写入被拒、记录触发流程条件被拒且文案说运行失败。 - **需要你做的**:无,席位落地(收窄,席位在落地前做 contract review)。 Clause-②: no (narrowing) — the author-time gate refuses, in a record-triggered flow's start or edge condition, a `type-mismatch` it used to pass with a warning; and the fail-closed arm of the verdict is served under its own code `type-mismatch-fail-closed` instead of `type-mismatch` + `params.outcome`. Added beside them (not counted as the arm): the codes `type-mismatch-run-fails` and `type-mismatch-fail-open`, and the exported type `ExprFaultOutcome` from `@objectstack/formula`. Metadata-protocol: narrowed Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx` (the type-soundness row names each outcome) and the MCP skill text in `packages/mcp/src/skill-md.ts` (`flow_condition` now errors too). `skills/**` teaches no text-vs-number comparison in a flow condition or an advisory rule (grep for `type mismatch`, `evaluates to null` and the flow condition examples), so no skill or eval moves. ## What changes **One ledger for both verdicts (`@objectstack/lint`).** `FAIL_CLOSED` becomes `faultOutcomeOf(site)`, the one function every call site asks what a site's runtime does on a fault. Its docblock carries the ledger, read off each evaluator. The answer goes to the shared validator as `ExprSchemaHint.faultOutcome` (the type-mismatch verdict) and to `checkNullGuards` as its `NullGuardOutcome` (the nullable-comparison verdict). The tokens are the same three, so one rule cannot get two different stories. | site | runtime on a fault | outcome | type-mismatch before → now | |:--|:--|:--|:--| | validation rule `condition` / `when` / nested branches, `severity: 'error'` | write rejected (#4649) | `fail-closed` | error → error (code renamed) | | the same, `severity: 'warning'` / `'info'` (top-level rule's severity; branches inherit it) | logged, "write proceeded: the rule is advisory" (#24782) | `fail-open` | error "the write is refused" → error, true wording | | field `requiredWhen` / `readonlyWhen`, option `visibleWhen`, hook `condition` | refuses the write / aborts the operation | `fail-closed` | unchanged | | record-triggered flow: start node `condition`, edge `condition` | `evaluateCondition` throws; the run fails, the write lands | `run-fails` | **warning "evaluates to null" → error** | | any other flow; a non-start node's (inert) `condition` | — | none | warning, unchanged | | formula, display predicates | reads null / never refuses | none | warning, unchanged | **Item 1, flow conditions.** The twin refuses there (`checkNullGuards(..., 'run-fails')`, severity `error`, #29026), so the type mismatch does too. Its message: "… here a flow condition that cannot be evaluated fails the run: the flow stops at this condition, the write that fired it still lands, and the work the flow exists to do is not done." It applies on exactly the twin's sites: the top-level start node and every edge of a `record-*` flow. The twin's `decision` branch row has no type-mismatch counterpart: the branch slot is validated without the object's field types (`checkDeclaredPredicate`), so no type mismatch reaches it, before or after. **Item 2, advisory validation rules.** Measured on the twin: it refuses every validation rule regardless of `severity`, with "the write is rejected fail-closed". So the type mismatch keeps the refusal (`error`) and both verdicts now word it as `fail-open`. The twin's `'fail-open'` outcome was unused (its clause still described the pre-ADR-0137 `requiredWhen`), so its clause is reworded for the surface that really skips: "so the rule is SKIPPED fail-open: it is advisory, so the write proceeds, the rule is never applied (its advisory never fires), and the only trace is a log line saying it could not be evaluated". The type-mismatch arm says the same. Neither claims a refused write. **Item 3, refusal codes (`@objectstack/formula`).** I took the "own code per arm" option. `type-mismatch` (warning, reads null), `type-mismatch-fail-closed`, `type-mismatch-run-fails` and `type-mismatch-fail-open` (errors) each name one template, chosen from a table keyed by the outcome (`TYPE_MISMATCH_FAULT_ARMS`), so a new outcome without its own code does not compile. The `outcome` param is gone. The fail-closed sentence is byte-identical to #29923's (the new pin's message is that text). Pinned in `PINS` (four new cases), and `SWEEP_SCHEMAS` gains the deal schema once per outcome, so the sweep reaches every code and holds its params to its message. Readers of the renamed code: - MCP `validate_expression` passes the code through unchanged. Its `site: 'validation'` now answers `type-mismatch-fail-closed`, and `site: 'flow_condition'` answers `type-mismatch-run-fails` as an error. Fields bound bare is the record-triggered shape, and the gate refuses there. Its description and the served skill text say so. - Studio: no reader in this repo. `git grep` for `type-mismatch` and `EXPRESSION_REFUSAL_CODES` outside `packages/formula` finds only the MCP test and unrelated `sdui-parser` codes. A Studio catalogue in objectui keyed to `type-mismatch` + `outcome` would need the new code. - ~~The `type-mismatch` + `outcome` arm has not shipped in a release: the #29904 changeset is still pending in `.changeset/`.~~ [Edit, rework r1: false. `@objectstack/formula@18.3.0` (npm, 2026-11-03T15:09Z) ships `outcome: 'fail-closed'` in `ExpressionRefusalParams`, and `@objectstack/mcp@18.3.0` serves it on the `validation` site. The #29904 changeset is still pending only because version-packages #26517 branched before `4c2cd29a`, so 18.3.0's changelog has no line for it. This PR renames a served, released code: an 18.3.0 consumer keyed to `type-mismatch` + `params.outcome === 'fail-closed'` moves to `type-mismatch-fail-closed` (FROM → TO in the changeset).] - Docs: `formulas.mdx` row (above). **Item 4.** `18.validation-type-mismatch-refused` `acceptanceCriteria`: "answers 200" → "answers 201". **`@objectstack/spec`.** New semantic entry `18.flow-condition-type-mismatch-refused`, registered as the ADR-0087 disposition. The changeset says what 18.3.0 already serves (the `type-mismatch` + `outcome` arm, shipped without a changelog line) and what this release changes about it, and that the #29904 changeset's "flow conditions keep the warning" no longer holds. ## Why Measured on `main` at `21510bba` (after #29923 `4c2cd29a`), through the real `saveMetaItem` (draft) + `publishPackageDrafts` door. This is the objectql test below, run with the ledger ablated back to #29923's behaviour (ablation A5): ``` record-triggered flow, start condition `name > 5` on a required text field → outcome "published", advisory expression-invalid severity=warning: "type mismatch `string > int` — `name` holds text … This faults at runtime, so the expression silently evaluates to null (unless the value happens to be numeric)…" advisory rule (severity 'warning') `record.name > 5` → refused, "… here an expression that cannot be evaluated fails closed: the write (or the operation a hook guards) is refused, not skipped." ``` Both are false for that role. The runtime readings: `evaluateCondition` throws on a fault (ADR-0032 §1c), and the record-change trigger isolates the write. `evaluateValidationRules` runs an advisory rule with `advisory: true` and logs `unevaluableAdvisoryMessage` ("write proceeded: the rule is advisory, so it was not applied to this write"). ## Tests - `packages/formula/src/validate.test.ts`: fail-closed now asserts code `type-mismatch-fail-closed` with exact params (no `outcome`). Two new tests cover `run-fails` (flattened, bare `name`, conversions validate clean) and `fail-open`. - `packages/formula/src/expression-refusal-codes.test.ts`: `PINS` for the three codes (exact params and message, a boolean case on fail-closed). `SWEEP_SCHEMAS` adds one schema per outcome. - `packages/lint/src/validate-expressions.test.ts`: - record-triggered start + edge: `error` with paths and the run-fails text; - a flow with no `triggerType` and a `schedule` flow keep the warning on the start and the edge condition; an inert node condition of a record-triggered flow keeps it too (rework r1); - advisory rule (`warning` and `info`, a nested branch included): `error` with the fail-open text; - twin agreement on one advisory rule and one `error` rule; - the #5017 key meta-test tables `rule.severity` and the `site` plumbing receiver. - `packages/mcp/src/mcp-validate-expression.test.ts`: `validation` → `type-mismatch-fail-closed`; `flow_condition` → `type-mismatch-run-fails` error (was asserted a warning). - `packages/objectql/src/publish-type-mismatch-fail-closed.test.ts`: two new door cases, the advisory rule and the record-triggered flow. <details><summary>Suites (at the final head, under os-verify-lock, shared box)</summary> ``` formula test Test Files 64 passed (64) Tests 1634 passed (1634) lint test Test Files 192 passed (192) Tests 7953 passed (7953) mcp test Test Files 55 passed (55) Tests 660 passed (660) spec test Test Files 808 passed (808) Tests 24277 passed | 1 todo (24278) spec test:repo Test Files 89 passed (89) Tests 1524 passed (1524) objectql publish-type-mismatch-fail-closed.test.ts Tests 5 passed (5) typecheck + check:test-typecheck: formula, lint, mcp, objectql — exit 0; spec typecheck — exit 0 (spec typecheck OOM'd once at the default heap on the shared box; exit 0 with --max-old-space-size=8192. objectql's test typecheck caught 4 errors in the new flow case — `raw` untyped — fixed in the final head.) ``` </details> <details><summary>Ablations (each restored: blob == HEAD, git diff HEAD empty)</summary> ``` A1 lint: record-triggered flag forced false (`recordTriggered: false /* ablated-29926-a1 */`) × refuses a type mismatch in a record-triggered flow condition with the run-fails wording (#29926) A2 lint: validation rule outcome forced `fail-closed` × refuses a type mismatch in an advisory validation rule without claiming the write is refused (#29926) × gives the nullable twin the same outcome on one advisory rule, and keeps fail-closed on an error rule (#29926) AssertionError: expected 'type mismatch `string > int` — `recor…' to match /advisory/ A3 formula: every outcome arm served as `type-mismatch` again × type-mismatch-fail-closed · × type-mismatch-run-fails · × type-mismatch-fail-open (PINS) × every refusal validateExpression reports on the sweep carries a code from its set … (SWEEP_SCHEMAS) × 3 validate.test.ts outcome tests — Tests 7 failed | 160 passed (167) A4 mcp: `faultOutcome: 'run-fails'` removed from the flow_condition site × validates a flattened flow condition … — expected true to be false (ok) A5 objectql through the built lint dist (marker `ablated-29926-a5` proven in dist/index.js, index.cjs, runtime.js, runtime.cjs): validation rules forced `fail-closed`, flow conditions no outcome × refuses the comparison in an advisory rule … — message says "is refused, not skipped" × refuses the comparison in a record-triggered flow condition … — outcome "published", advisory severity=warning "… silently evaluates to null …" (the card's premise, reproduced) Tests 2 failed | 3 passed (5); the #29904 cases stayed green (control) Restore: each via ablation-replace (blob == HEAD, git diff HEAD empty); lint dist rebuilt clean by the full build afterwards. ``` </details> ## Acceptance notes - **Flows that are not record-triggered** keep the "evaluates to null" warning. Their condition also faults the run, so that text is not true there either. The card and order scope item 1 to record-triggered flows, where the twin's grading exists; the other flows have no twin grading to follow. Left as a reading for the seat. - **`record.x` in a flow condition is not judged for type soundness** at all: the flattened-scope check types bare names only, so `record.title > 5` in an edge draws no verdict, before or after (the new tests use the bare spelling). Pre-existing, out of this card. - **Advisory rules stay refused**, as the twin refuses them. The triage audit reads that refusal as a publish "it should allow"; the order says to follow the twin. Exempting both verdicts together would be a widening for a separate ruling. - **Serial check.** No open PR references #29898. Open PRs touching `packages/lint/src/validate-expressions.ts`, `packages/lint/src/validate-null-guards.ts`, `packages/formula/src/validate.ts` or `packages/mcp/src/mcp-http-tools.ts`: none of the 18 open PRs at 2026-11-03T17:12Z (the nearest, #29948, touches objectql / metadata-protocol / runtime only; #29938 is the release notes). ## Verification Rework r1 (head `06abf5ac`, changeset text + one lint test): `check-adr-0087-registration --base origin/main` and `--self-test`, `check-changeset-no-major --base origin/main`, `check-empty-changeset --base origin/main` and `--self-test`, `check:changeset-gate-self-tests`: exit 0. `validate-expressions.test.ts` 459/459, lint `check:test-typecheck` and ESLint on the file: exit 0. `dispatch-gates --commands` derives the same 106-family list as before. Everything below was measured at `61a4cdbf`, which differs only in those two files. - `node scripts/pm/dispatch-gates.mjs --commands`: 106 derived families, each run, all **exit 0**. `--ran` reconciliation: "106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN". Four exited 3 on the first pass for missing prerequisites (`check-plugin-teardown-shape --self-test` on a shallow clone; `check:skill-examples`, `check:dual-build-cjs-loads` and `check:lean-entry-closure` before a full build). They were run again after `git fetch --unshallow` and a full `turbo run build` (119/119 tasks): exit 0. The final head `61a4cdbf` differs from the swept commit only in the new objectql test's type annotation (and the commit message). `check:objectql-double-limit` and `check:engine-double-contract` were run again on it: exit 0. - `check-adr-0087-registration --base origin/main`: "registered flow-condition-type-mismatch-refused (new here)". - `pnpm lint` (`eslint . --no-inline-config`): exit 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzfu5kYQW45XjsF79Rp <!-- signed: domain:server#6 · os-support-ai · session_01WGufzfu5kYQW45XjsF79Rp --> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent a7293f7 commit 9037b13

16 files changed

Lines changed: 581 additions & 102 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)!: a text-vs-number comparison in a record-triggered flow condition is refused with what really happens (the run fails, the write lands), and in an advisory validation rule the refusal no longer says the write is refused (#29926)
9+
10+
Clause-②: no (narrowing) — the author-time gate (`os build`, `os validate`, the package publish and the flow publish door) refuses a `type-mismatch` in a record-triggered flow's start or edge condition that it used to pass with a warning; and the fail-closed arm of the verdict is served under its own code `type-mismatch-fail-closed` instead of `type-mismatch` with `params.outcome`. Added beside them: the codes `type-mismatch-run-fails` and `type-mismatch-fail-open`, and the exported type `ExprFaultOutcome`.
11+
Metadata-protocol: narrowed
12+
Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx` (the type-soundness row names each outcome) and the MCP skill text in `packages/mcp/src/skill-md.ts` (`flow_condition` now errors too); `skills/**` teaches no text-vs-number comparison in a flow condition or an advisory rule, so no skill or eval moves.
13+
14+
<!-- adr-0087: registered flow-condition-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 flow conditions — before.** `name > 5` on a text field, as the start node's `condition` or an edge `condition` of a flow whose start `triggerType` is `record-*`, published with an `expression-invalid` warning saying the expression "silently evaluates to null". It does not: the engine's `evaluateCondition` throws on the fault, so the run fails at that condition, and the record-change trigger isolates the write that fired it, so the write lands and the flow's work is not done.
19+
- **Now.** The same condition is refused as `expression-invalid` at `severity: error`, code `type-mismatch-run-fails`, with a message saying the run fails at the condition and the triggering write still lands. This is the severity and outcome the nullable-comparison verdict already has on those conditions (#29026). A flow that is not record-triggered, and a node `condition` other than the start node's (inert), keep the warning. The `validate_expression` MCP tool answers the same for `site: 'flow_condition'`.
20+
- **Advisory validation rules** (`severity: 'warning'` / `'info'`). Still refused at publish, as the nullable twin refuses them, but under `type-mismatch-fail-open` with a message that says what happens: the rule is skipped, the write proceeds, and the rule never gives its advisory. The nullable twin's message on the same rule now says the same instead of "the write is rejected fail-closed". A `severity: 'error'` rule keeps both refusals and their wording.
21+
- **Refusal codes (`@objectstack/formula`).** A code names one message template, so each outcome has its own: `type-mismatch` (warning, reads null), `type-mismatch-fail-closed`, `type-mismatch-run-fails`, `type-mismatch-fail-open` (all errors). `ExprSchemaHint.faultOutcome` takes `'fail-closed' | 'run-fails' | 'fail-open'` (exported as `ExprFaultOutcome`); the `outcome` param on `type-mismatch` is gone.
22+
- **`@objectstack/spec`.** Upgrade guide entry `flow-condition-type-mismatch-refused` (semantic: no mechanical rewrite). The `validation-type-mismatch-refused` entry's acceptance criteria now say a create answers 201, the data door's status.
23+
24+
**FROM → TO.** Flow condition `name > 5` on a text field → `size(name) > 5` for its length, `score > 5` on a number field for a value, or `double(name) > 5` only when every value is numeric text. A boolean: `active > 0` → `active == true`. A consumer keyed to the code: `type-mismatch` with `params.outcome === 'fail-closed'` → `type-mismatch-fail-closed`.
25+
26+
**The renamed code shipped in 18.3.0.** `@objectstack/formula@18.3.0` and `@objectstack/mcp@18.3.0` already serve #29904's fail-closed refusal as `type-mismatch` with `params.outcome: 'fail-closed'` (the `outcome` param is in the 18.3.0 `ExpressionRefusalParams` type), although 18.3.0's changelog has no line for it: its changeset (`29904-type-mismatch-fail-closed.md`) was not consumed by that release. This release renames that arm `type-mismatch-fail-closed` and removes the `outcome` param, so a consumer keyed to 18.3.0's `type-mismatch` + `outcome` moves to the new code (FROM → TO above). The same changeset also says flow conditions keep the warning; a record-triggered flow's start and edge conditions are now refused, as above.

‎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 refuses the write instead — a validation rule, a field `requiredWhen` / `readonlyWhen`, an option `visibleWhen`, a hook `condition` — it is an error. 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; error where a fault refuses the write |
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 |
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/expression-refusal-codes.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,62 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
371371
'field, or drop the arithmetic/comparison.',
372372
},
373373
],
374+
// [#29926] One code per fault outcome the caller declares (`faultOutcome`):
375+
// the three sentences differ in what the runtime does, so they are three
376+
// codes. `type-mismatch-fail-closed` is the sentence #29904 served as
377+
// `type-mismatch` with `outcome: 'fail-closed'`, byte for byte.
378+
'type-mismatch-fail-closed': [
379+
{
380+
produce: () =>
381+
onlyError('predicate', 'record.name > 5', { scope: 'record', fieldTypes: { name: 'text' }, faultOutcome: 'fail-closed' }),
382+
params: { operands: 'string > int', operator: '>', held: 'text', reference: 'record.name' },
383+
message:
384+
'type mismatch `string > int` — `record.name` holds text but is used with `>` against a number. CEL has no such operation, so ' +
385+
'wherever the value is not numeric text (an empty value included) the expression faults, and here ' +
386+
'an expression that cannot be evaluated fails closed: the write (or the operation a hook guards) is refused, not ' +
387+
'skipped. ' +
388+
'Compare like with like: use a number field, compare text with text, or convert explicitly — `size(record.name)` for its ' +
389+
'length, `double(record.name)` for its value (which itself faults on text that is not a number).',
390+
},
391+
{
392+
produce: () =>
393+
onlyError('predicate', 'record.active > 0', { scope: 'record', fieldTypes: { active: 'boolean' }, faultOutcome: 'fail-closed' }),
394+
params: { operands: 'bool > int', operator: '>', held: 'boolean', reference: 'record.active' },
395+
message:
396+
'type mismatch `bool > int` — `record.active` holds a boolean but is used with `>` against a number. CEL has no such operation, so ' +
397+
'wherever the value is not numeric text (an empty value included) the expression faults, and here ' +
398+
'an expression that cannot be evaluated fails closed: the write (or the operation a hook guards) is refused, not ' +
399+
'skipped. Compare a boolean with `true` / `false`, or use a number field.',
400+
},
401+
],
402+
'type-mismatch-run-fails': [
403+
{
404+
produce: () =>
405+
onlyError('predicate', 'name > 5', { scope: 'flattened', fields: ['name'], fieldTypes: { name: 'text' }, faultOutcome: 'run-fails' }),
406+
params: { operands: 'string > int', operator: '>', held: 'text', reference: 'name' },
407+
message:
408+
'type mismatch `string > int` — `name` holds text but is used with `>` against a number. CEL has no such operation, so ' +
409+
'wherever the value is not numeric text (an empty value included) the expression faults, and here ' +
410+
'a flow condition that cannot be evaluated fails the run: the flow stops at this condition, the write that fired ' +
411+
'it still lands, and the work the flow exists to do is not done. ' +
412+
'Compare like with like: use a number field, compare text with text, or convert explicitly — `size(name)` for its ' +
413+
'length, `double(name)` for its value (which itself faults on text that is not a number).',
414+
},
415+
],
416+
'type-mismatch-fail-open': [
417+
{
418+
produce: () =>
419+
onlyError('predicate', 'record.name > 5', { scope: 'record', fieldTypes: { name: 'text' }, faultOutcome: 'fail-open' }),
420+
params: { operands: 'string > int', operator: '>', held: 'text', reference: 'record.name' },
421+
message:
422+
'type mismatch `string > int` — `record.name` holds text but is used with `>` against a number. CEL has no such operation, so ' +
423+
'wherever the value is not numeric text (an empty value included) the expression faults, and here ' +
424+
'the rule is advisory, so a rule that cannot be evaluated is skipped, not refused: the write proceeds, the rule is ' +
425+
'never applied (its advisory never fires), and the only trace is a log line saying it could not be evaluated. ' +
426+
'Compare like with like: use a number field, compare text with text, or convert explicitly — `size(record.name)` for its ' +
427+
'length, `double(record.name)` for its value (which itself faults on text that is not a number).',
428+
},
429+
],
374430
'field-near-miss': [
375431
{
376432
produce: () => onlyWarning('predicate', "stauts == 'x'", { fields: ['status'], objectName: 'deal' }),
@@ -546,6 +602,15 @@ const SWEEP_SCHEMAS: readonly (ExprSchemaHint | undefined)[] = [
546602
optionFields: ['status'],
547603
},
548604
{ fields: ['status', 'close_date'], objectName: 'deal', fieldTypes: { close_date: 'date' } },
605+
// [#29926] The deal schema once per declared fault outcome, so the sweep
606+
// reaches each `type-mismatch-*` code and holds its params to its message.
607+
...(['fail-closed', 'run-fails', 'fail-open'] as const).map((faultOutcome): ExprSchemaHint => ({
608+
scope: 'record',
609+
objectName: 'deal',
610+
fields: ['amount', 'name', 'active'],
611+
fieldTypes: { name: 'text', active: 'boolean' },
612+
faultOutcome,
613+
})),
549614
{ fields: ['status'] },
550615
{ fieldTypes: { account: 'lookup' }, traversalHydration: true },
551616
];

‎packages/formula/src/expression-refusal.ts‎

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ export type CelFieldRole = Exclude<FieldRole, 'template'>;
5757
*/
5858
export type ExpressionSourceKind = 'array' | 'object' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'function';
5959

60+
/**
61+
* The params every `type-mismatch*` code interpolates: the CEL operand types,
62+
* the `operator`, what the field holds, and the field as written when known.
63+
* The four codes differ in the outcome their sentence states, never in params.
64+
*/
65+
interface TypeMismatchParams {
66+
readonly operands: string;
67+
readonly operator: string;
68+
readonly held: 'text' | 'boolean';
69+
readonly reference?: string;
70+
}
71+
6072
/** Code → the params its message interpolates. The keys ARE the closed set. */
6173
export interface ExpressionRefusalParams {
6274
/** An envelope whose `source` is present but is not text. */
@@ -169,18 +181,30 @@ export interface ExpressionRefusalParams {
169181
*/
170182
'date-duration-value': { readonly reference?: string };
171183
/**
172-
* A text or boolean field used with an arithmetic or ordering `operator`
173-
* against a number. A WARNING (the expression reads null) unless `outcome`
174-
* is `'fail-closed'`: the site refuses the write it guards when the
175-
* expression faults, and the verdict is an ERROR (#29904).
184+
* WARNING, never an error: a text or boolean field used with an arithmetic
185+
* or ordering `operator` against a number, at a site where a fault reads
186+
* null (a formula field) or whose fault outcome the caller did not declare.
176187
*/
177-
'type-mismatch': {
178-
readonly operands: string;
179-
readonly operator: string;
180-
readonly held: 'text' | 'boolean';
181-
readonly reference?: string;
182-
readonly outcome?: 'fail-closed';
183-
};
188+
'type-mismatch': TypeMismatchParams;
189+
/**
190+
* [#29926] ERROR: the same mismatch at a site that FAILS CLOSED — the
191+
* expression faulting refuses the write (or aborts the operation a hook
192+
* guards). Served as `type-mismatch` with `outcome: 'fail-closed'` by #29904
193+
* until this code gave the sentence its own name.
194+
*/
195+
'type-mismatch-fail-closed': TypeMismatchParams;
196+
/**
197+
* [#29926] ERROR: the same mismatch in a record-triggered flow's start or
198+
* edge condition — the run fails at the condition, the write that fired it
199+
* lands.
200+
*/
201+
'type-mismatch-run-fails': TypeMismatchParams;
202+
/**
203+
* [#29926] ERROR: the same mismatch in an advisory (`warning` / `info`)
204+
* validation rule — the rule is skipped, the write proceeds, and the
205+
* advisory never fires.
206+
*/
207+
'type-mismatch-fail-open': TypeMismatchParams;
184208
/**
185209
* WARNING, never an error: a bare identifier in a flattened condition that
186210
* is one typo away from a field. Absent `objectName` reads "the trigger object".
@@ -243,6 +267,9 @@ const CODE_TABLE = {
243267
'date-text-arithmetic': true,
244268
'date-duration-value': true,
245269
'type-mismatch': true,
270+
'type-mismatch-fail-closed': true,
271+
'type-mismatch-run-fails': true,
272+
'type-mismatch-fail-open': true,
246273
'field-near-miss': true,
247274
'traversal-bare-and-traversed': true,
248275
'traversal-multi-hop': true,

‎packages/formula/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export type { UnknownFunctionCall } from './unknown-function';
165165
// ADR-0032 — shared validator + introspection (one validator for build,
166166
// registration, and the agent-callable validate_expression tool).
167167
export { validateExpression, introspectScope, expectedDialect, inferExpressionType, nearestName, CEL_STDLIB_FUNCTIONS } from './validate';
168-
export type { FieldRole, ExprInput, ExprSchemaHint, ExprValidationError, ExprValidationResult, InferredValueType } from './validate';
168+
export type { FieldRole, ExprInput, ExprSchemaHint, ExprFaultOutcome, ExprValidationError, ExprValidationResult, InferredValueType } from './validate';
169169
// #22727 — the declaration authoring stamps onto a formula field: its
170170
// `returnType`, and for a provably-money result its currency, copied from the
171171
// source field's own `currencyConfig`. The producer behind `returnType: 'currency'`.

‎packages/formula/src/validate.test.ts‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -771,20 +771,50 @@ describe('validateExpression (ADR-0032)', () => {
771771
expect(r.ok).toBe(false);
772772
expect(r.warnings).toHaveLength(0);
773773
expect(r.errors).toHaveLength(1);
774-
expect(r.errors[0]).toMatchObject({
775-
code: 'type-mismatch',
776-
params: { operands: 'string > int', operator: '>', held: 'text', reference: 'record.name', outcome: 'fail-closed' },
774+
// [#29926] Its own code: the fail-closed sentence is not the warning's.
775+
expect(r.errors[0]).toEqual({
776+
source: 'record.name > 5',
777+
message: expect.any(String),
778+
code: 'type-mismatch-fail-closed',
779+
params: { operands: 'string > int', operator: '>', held: 'text', reference: 'record.name' },
777780
});
778781
expect(r.errors[0].message).not.toMatch(/evaluates to null/);
779782
const bool = validateExpression('predicate', 'record.is_active > 0', { ...schema, faultOutcome: 'fail-closed' });
780-
expect(bool.errors.map((e) => e.code)).toEqual(['type-mismatch']);
783+
expect(bool.errors.map((e) => e.code)).toEqual(['type-mismatch-fail-closed']);
781784
// The conversions the message names validate clean in this dialect.
782785
for (const fixed of ['size(record.name) > 5', 'double(record.name) > 5']) {
783786
const ok = validateExpression('predicate', fixed, { ...schema, faultOutcome: 'fail-closed' });
784787
expect([ok.errors, ok.warnings], fixed).toEqual([[], []]);
785788
}
786789
});
787790

791+
// [#29926] The two other outcomes a caller can declare, each an error
792+
// under its own code, worded for what that runtime does.
793+
it('errors with the run-fails wording where the caller declares a flow condition (#29926)', () => {
794+
const flat = { ...schema, scope: 'flattened' as const, faultOutcome: 'run-fails' as const };
795+
const r = validateExpression('predicate', 'name > 5', flat);
796+
expect(r.warnings).toHaveLength(0);
797+
expect(r.errors.map((e) => [e.code, e.params])).toEqual([
798+
['type-mismatch-run-fails', { operands: 'string > int', operator: '>', held: 'text', reference: 'name' }],
799+
]);
800+
expect(r.errors[0].message).toContain('fails the run');
801+
expect(r.errors[0].message).toContain('the write that fired it still lands');
802+
expect(r.errors[0].message).not.toMatch(/evaluates to null|is refused/);
803+
for (const fixed of ['size(name) > 5', 'double(name) > 5']) {
804+
const ok = validateExpression('predicate', fixed, flat);
805+
expect([ok.errors, ok.warnings], fixed).toEqual([[], []]);
806+
}
807+
});
808+
809+
it('errors with the skipped-advisory wording where the caller declares fail-open (#29926)', () => {
810+
const r = validateExpression('predicate', 'record.name > 5', { ...schema, faultOutcome: 'fail-open' });
811+
expect(r.warnings).toHaveLength(0);
812+
expect(r.errors.map((e) => e.code)).toEqual(['type-mismatch-fail-open']);
813+
expect(r.errors[0].message).toContain('the write proceeds');
814+
expect(r.errors[0].message).toContain('its advisory never fires');
815+
expect(r.errors[0].message).not.toMatch(/evaluates to null|is refused/);
816+
});
817+
788818
it('warns on a text field ordered against a number', () => {
789819
const r = validateExpression('predicate', 'record.title >= 5', schema);
790820
expect(r.ok).toBe(true);

0 commit comments

Comments
 (0)