Skip to content

Commit 67d5d53

Browse files
fix(formula,lint,mcp): a fail-closed date verdict says the write is refused, and a time-relative sweep's run-fails verdict names no firing write (#30492)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgEoPXTMomyUQtdV7LPhNsWK)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - **改了什么**:两处判词改成说真话。一是失败即拒的校验规则(及字段 requiredWhen/readonlyWhen、选项 visibleWhen、hook 条件)里对日期做加减,判词不再说「字段静默得到 null」,改说「写入被拒」,新码 `date-arithmetic-fail-closed` / `date-text-arithmetic-fail-closed`。二是定时相对扫描流程的条件里文本和数字比大小,判词不再说「触发它的写入照常落库」(扫描没有写入),改说「这一行的运行失败,别的什么都不发生」,新码 `type-mismatch-sweep-run-fails`。 - **为什么改**:两句话说的都是该位置不会发生的事:校验规则出错会拒写,不会得到 null;扫描由定时任务逐行启动,不是写入触发的。 - **风险与回滚**:分级不变,发布门不收也不放,只换文案和码。按旧码 `date-arithmetic` / `date-text-arithmetic` 识别失败即拒站点、按 `type-mismatch-run-fails` 识别扫描流程的使用方需改认新码。公式字段、advisory 校验规则、记录触发流程原码原句不动。回滚即 revert,无数据迁移。 - **先看**:`packages/formula/src/validate.ts`(`dateArm` / `dateArmFault` 和 `TYPE_MISMATCH_SWEEP_ARM`)、`packages/lint/src/validate-expressions.ts`(`FlowBareNames.timeRelativeSweep`)、`packages/mcp/src/mcp-http-tools.ts`(一行传键)。 - **两分钟验证**:`pnpm --filter @objectstack/lint exec vitest run src/validate-expressions.test.ts -t 30250`,预期 2 条通过:扫描流程判词不含 still lands,记录触发对照仍含;失败即拒规则的日期判词说写入被拒,公式字段对照仍说 null。 - **需要你做的**:无,席位落地。 Fixes #30250 Clause-②: yes (widening) — three codes join `@objectstack/formula`'s exported `ExpressionRefusalCode` / `EXPRESSION_REFUSAL_CODES` (`date-arithmetic-fail-closed`, `date-text-arithmetic-fail-closed`, `type-mismatch-sweep-run-fails`) and the optional key `timeRelativeSweep` joins the exported `ExprSchemaHint`. A fail-closed site's date verdicts move to the first two from `date-arithmetic` / `date-text-arithmetic`, and a time-relative sweep's type-mismatch verdict moves to the third from `type-mismatch-run-fails` (a consumer keyed to the old codes at those sites re-keys). Grading is unchanged at every site; what is refused, warned or passed is unchanged. Metadata-protocol: none — what an author can write is unchanged, and `os validate` reports no expression refusal code: its findings come from `@objectstack/lint`'s `ExprIssue` (`where`, `message`, `source`, `severity`, `path`), which carries no `code`. Only the message text changes there. Skills-docs: none — `skills/**` grep for `still lands` and `silently evaluates to null` hits `skills/objectstack-formula/SKILL.md:407` and `skills/objectstack-formula/evals/cel-predicates-formulas.json:87`, both about a record-triggered flow's null-guard edge, which still lands the write, so both stay true. The one hand-written doc sentence this PR makes false is fixed here: `content/docs/data-modeling/formulas.mdx` (type-soundness row). ## What changes Measured on `main` at `c5800818` before the change, and again after `origin/main` was merged. **How each site learns its scope.** (1) A fail-closed site already says so: `@objectstack/lint`'s `faultOutcomeOf` ledger and MCP `validate_expression` (site `validation`) pass `faultOutcome: 'fail-closed'` at a `record`-scoped site, and a formula field passes none. So the date verdicts read it with no new signal. (2) A time-relative sweep did not say so. The ledger answers `run-fails` for a record-triggered flow and for a sweep of the typed object alike, so `@objectstack/formula` could not tell them apart. The new optional `ExprSchemaHint.timeRelativeSweep` carries that one fact. It is read only under `faultOutcome: 'run-fails'` at a `'flattened'` site. | site | before | now | |:--|:--|:--| | fail-closed `record` site (`error` validation rule, field `requiredWhen` / `readonlyWhen`, option `visibleWhen`, hook `condition`): date arithmetic | `date-arithmetic` / `date-text-arithmetic`, error, "this faults at runtime, so the field silently evaluates to null." | `date-arithmetic-fail-closed` / `date-text-arithmetic-fail-closed`, error: "this faults at runtime, and here an expression that cannot be evaluated fails closed: the write (or the operation a hook guards) is refused, not skipped (it does not read null)." | | formula field, display predicate, advisory (`warning` / `info`) validation rule: date arithmetic | `date-arithmetic` / `date-text-arithmetic` | unchanged, code and sentence | | time-relative sweep of the typed object (`run-fails`): text or boolean against a number | `type-mismatch-run-fails`, error, "… the flow stops at this condition, the write that fired it still lands, and the work the flow exists to do is not done." | `type-mismatch-sweep-run-fails`, error: "… a flow condition that cannot be evaluated fails the run: the time-relative sweep starts one run per row it finds, the run for this row stops at this condition and the work the flow exists to do is not done for that row, and nothing else happens (the sweep logs the failure and goes on to the next row)." | | record-triggered flow (`run-fails`) | `type-mismatch-run-fails` | unchanged, code and sentence | **Why new codes.** A code names one message template (`expression-refusal.ts`, the contract), and each new sentence is a new template, as #30015 and #30016 did. The sweep sentence is read off `packages/triggers/trigger-schedule/src/time-relative-trigger.ts`: each matched row is launched with `{ record: row, event: 'time_relative' }`, and a failed run is caught per row, logged at `error` and counted, and the sweep goes on. **The nullable-comparison sentence (`validate-null-guards.ts` `OUTCOME_CLAUSE['run-fails']`) is unchanged.** It is never served on a sweep: `validate-expressions.ts` runs the null-guard twin only where `triggerKind === 'record_change'` (`nullGuardObject`), and no other caller passes `run-fails` to `checkNullGuards` / `nullGuardMessage`. On a record-triggered flow it is true. The new lint pin asserts that no finding on a sweep says "still lands", and that the record-triggered control still does. **Routing.** `@objectstack/lint`: `FlowBareNames` gains `timeRelativeSweep` (`triggerKind === 'time_relative'`), handed to the validator at every flow site that already gets the bare-name facts (start, edge, `decision`, inert node conditions). `@objectstack/mcp`: `validate_expression` with `site: 'flow_condition'` and `triggerKind: 'time_relative'` passes the same key, so the tool's answer stays the gate's (the gate-parity test compares messages). The date verdicts' `-run-fails` sentence already names no write, so it is served on a sweep as it is. ## Tests - `packages/formula/src/validate.test.ts`: - new: a fail-closed `record` site's `record.start_date + 1 > 3` and `record.end_date - record.start_date > duration("24h")` get the `-fail-closed` codes and the refused-write sentence, and not "evaluates to null". Controls: a formula field (`value`, no outcome) and an advisory rule (`fail-open`) keep `date-arithmetic` / `date-text-arithmetic` and the null sentence; - new: `name > 5` and `record.name > 5` under `run-fails` + `timeRelativeSweep` get `type-mismatch-sweep-run-fails`, the per-row sentence, and no "still lands" / "write that fired". With no outcome the key changes nothing (`type-mismatch-run-may-fail`). A sweep's date verdict stays `date-arithmetic-run-fails`; - changed: the #30016 test's last control asserted that a fail-closed rule says "silently evaluates to null", which is the defect. It now asserts that a formula field keeps that sentence. - unchanged: `errors with the run-fails wording where the caller declares a flow condition (#29926)` (the record-triggered "still lands" pin, `:801` on `main`). - `packages/formula/src/expression-refusal-codes.test.ts`: a `PINS` entry for each new code (exact params and message). `SWEEP_SCHEMAS` gains a fail-closed `record` schema with a date field and a flattened `run-fails` + `timeRelativeSweep` schema, so the sweep reaches all three codes and holds their params to their messages. - `packages/lint/src/validate-expressions.test.ts`: - new: a time-relative sweep's start condition `title > 5` gets the per-row sentence, and no finding on the flow says "still lands" (nor does the null-guard twin, which is not served there). Control: the same flow record-triggered keeps "the write that fired it still lands" on the type verdict and on the null-guard verdict of its edge; - new (replaces the `#30016 control`, which pinned the null sentence on a fail-closed rule): two `error` validation rules with date arithmetic say the write is refused, not null. Control: a formula field's `record.close_date + 1` keeps the null sentence; - changed: the #29959 / #30026 time-relative test asserted "the write that fired it still lands" on the sweep's three errors. It now asserts the per-row sentence and the absence of "still lands" / "write that fired". - unchanged: every record-triggered "still lands" pin. - `packages/mcp/src/mcp-validate-expression.test.ts`: unchanged. Its gate-parity rows for `triggerKind: 'time_relative'` compare the tool's messages with the gate's, so they pin the MCP half (ablation A2 below). - `packages/objectql/src/publish-type-mismatch-fail-closed.test.ts`: unchanged, 12/12 passed. <details><summary>Suites (head fc42e4a, under os-verify-lock, shared box)</summary> ``` formula test Test Files 64 passed (64) Tests 1649 passed (1649) lint test Test Files 195 passed (195) Tests 8034 passed (8034) mcp test Test Files 56 passed (56) Tests 678 passed (678) objectql src/publish-type-mismatch-fail-closed.test.ts Tests 12 passed (12) typecheck (tsc --noEmit + check:test-typecheck): formula 0, lint 0, mcp 0 red first, on main's validate.ts (head 81c14ba, pins only): formula 2 failed, lint 3 failed ``` </details> <details><summary>Ablations (scripts/ablation-replace.mjs; each restored: blob == HEAD, git diff HEAD empty, dist marker absent after rebuild)</summary> ``` A1 formula: the sweep arm off (`scope === 'flattened' && String(timeRelativeSweep) === 'ablated-30250-a1'`), rebuilt, marker present in 2 built files (ablation-dist-preflight exit 0) formula × on a time-relative sweep … names no write (#30250) · × type-mismatch-sweep-run-fails (PINS) · × the SWEEP Tests 3 failed | 179 passed (182) lint × refuses a type mismatch on a time-relative sweep … (#29959, #30026) · × names no firing write … (#30250) Tests 2 failed | 471 passed (473) mcp Tests 38 passed (38): parity holds, both halves read the same ablated validator (A2 is the MCP pin) A2 mcp: the sweep key not passed (`? { ablated30250a2: true } : {}`) × site flow_condition, triggerKind time_relative · × … sweepObject objectName · × … start names no objectName Tests 3 failed A3 formula: the fail-closed date arm off (`String(faultOutcome) === 'ablated-30250-a3'`), rebuilt, marker in dist formula (src) × on a fail-closed site says the write is refused … (#30250) · × date-arithmetic-fail-closed · × date-text-arithmetic-fail-closed · × the SWEEP — Tests 4 failed | 178 passed (182) lint (dist) × says a fail-closed validation rule's date arithmetic refuses the write … (#30250) Tests 1 failed | 472 passed (473) ``` </details> ## Acceptance notes - **An advisory (`warning` / `info`) validation rule's date arithmetic still says "silently evaluates to null".** There a fault skips the rule and the write proceeds (`type-mismatch-fail-open`'s sentence), so "evaluates to null" is not true either. Out of this card's ruling (fail-closed only), left as is and pinned as a control. Reported to the seat as a finding in the same family. - **`content/docs/data-modeling/formulas.mdx` date-arithmetic row** says "this always faulted to `null`". That describes a formula's runtime, and this PR changes no runtime, so it is left as is. - **Serial check.** The open PRs touching `packages/formula/src/validate.ts`, `packages/lint/src/validate-expressions.ts` or `packages/mcp/src/mcp-http-tools.ts` were not enumerated by this dev (the seat's claim cleared them at 12:18Z). `origin/main` was merged once at `fc42e4af` with no conflict, and none of the 8 incoming commits touches these packages. ## Verification All at head `fc42e4af` (`git rev-parse --short HEAD`), the commit this PR opens on (`origin/main` merged once). - Full build after the merge: `pnpm install --frozen-lockfile && turbo run build --filter='!@objectstack/docs' --concurrency=2`, 119/119 tasks, `VERDICT command-exit 0`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 88 derived families, each run on this head, all **exit 0**. `check-plugin-teardown-shape.mjs --self-test` first answered exit 3 because the shallow clone could not read its pinned fixture commit `621a4876`; after fetching that commit it answered exit 0 ("48 cases pass"), and the record carries that run. `--ran`: "88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED". Among them `check-adr-0087-registration --base origin/main`, `check-changeset-no-major`, `check-empty-changeset`, `check:doc-authoring`, `check:docs-transcript-drift`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:nul-bytes`, `lint check:doc-formula-expressions`. - `pnpm lint` (`eslint . --no-inline-config`): exit 0. - Full dogfood: not run. The title has no `!` and `Clause-②` has no `(narrowing)` arm. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CbntgiC5D1vTyg2pBV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 38d416f commit 67d5d53

9 files changed

Lines changed: 334 additions & 40 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/formula': patch
3+
'@objectstack/lint': patch
4+
'@objectstack/mcp': patch
5+
---
6+
7+
fix(formula,lint,mcp): a fail-closed date verdict says the write is refused, and a time-relative sweep's run-fails verdict names no firing write
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **`@objectstack/formula`: three codes join `ExpressionRefusalCode`** (`EXPRESSION_REFUSAL_CODES`), with the params of the code they split from:
13+
- `date-arithmetic-fail-closed` and `date-text-arithmetic-fail-closed`. On a site that fails closed (`faultOutcome: 'fail-closed'`: an `error` validation rule, a field `requiredWhen` / `readonlyWhen`, an option `visibleWhen`, a hook `condition`), date arithmetic used to be told "this faults at runtime, so the field silently evaluates to null". There the fault refuses the write instead, and the verdict now says so. A formula field and an advisory (`warning` / `info`) validation rule keep `date-arithmetic` / `date-text-arithmetic` and their sentence. A consumer keyed to the old codes on a fail-closed site re-keys to the new ones.
14+
- `type-mismatch-sweep-run-fails`. A time-relative sweep of the typed object is graded `run-fails` like a record-triggered flow, but no write fires its run: the sweep starts one run per row it finds. Its condition's text-vs-number verdict used to say "the write that fired it still lands". Served where the new optional `ExprSchemaHint.timeRelativeSweep` is set beside `faultOutcome: 'run-fails'`, it now says the run for that row fails and nothing else happens. A record-triggered flow keeps `type-mismatch-run-fails` and its sentence.
15+
- The grade is unchanged at every site. What is refused, warned or passed is unchanged.
16+
- **`@objectstack/lint`**: the flow walk passes `timeRelativeSweep` on a `time_relative` flow. The nullable-comparison verdict, which also says "still lands", runs on record-triggered flows only, so it is unchanged.
17+
- **`@objectstack/mcp`**: `validate_expression` with `site: 'flow_condition'` and `triggerKind: 'time_relative'` passes the same key, so its answer stays the gate's.

‎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, 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 or a `screen` field holds reads that value, so it is not judged as the field, and a bare name after a node that can bind any name (`script`, `connector_action`, `wait`, `subflow`, `map`, a screen with no fields) gets the warning instead, while its `record.<field>` read is still refused; 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 |
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, where no write fires the run and only the run for that row fails; a bare name that a declared flow variable or a `screen` field holds reads that value, so it is not judged as the field, and a bare name after a node that can bind any name (`script`, `connector_action`, `wait`, `subflow`, `map`, a screen with no fields) gets the warning instead, while its `record.<field>` read is still refused; 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/expression-refusal-codes.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,23 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
296296
'in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
297297
},
298298
],
299+
// [#30250] A fail-closed site: the fault refuses the write, nothing reads null.
300+
'date-arithmetic-fail-closed': [
301+
{
302+
produce: () =>
303+
onlyError('predicate', 'record.close_date + 1 > 3', {
304+
scope: 'record',
305+
fieldTypes: { close_date: 'date' },
306+
faultOutcome: 'fail-closed',
307+
}),
308+
params: { operands: 'google.protobuf.Timestamp + int', reference: 'record.close_date' },
309+
message:
310+
'date arithmetic `google.protobuf.Timestamp + int` — `record.close_date` is a date, and CEL can\'t do arithmetic ' +
311+
'on dates: this faults at runtime, and here an expression that cannot be evaluated fails closed: the write (or the operation ' +
312+
'a hook guards) is refused, not skipped (it does not read null). ' +
313+
'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
314+
},
315+
],
299316
// [#30016] A flattened (flow) condition: the fault fails the run, it never
300317
// reads null — `run-fails` where the run is proven to hold the field.
301318
'date-arithmetic-run-fails': [
@@ -343,6 +360,23 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
343360
'and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
344361
},
345362
],
363+
'date-text-arithmetic-fail-closed': [
364+
{
365+
produce: () =>
366+
onlyError('predicate', 'record.end_date - record.start_date > duration("24h")', {
367+
scope: 'record',
368+
fieldTypes: { start_date: 'date', end_date: 'date' },
369+
faultOutcome: 'fail-closed',
370+
}),
371+
params: { operands: 'DateText - DateText', reference: 'record.start_date' },
372+
message:
373+
'date arithmetic `DateText - DateText` — `record.start_date` is a date field, and the runtime holds a date field ' +
374+
'as its text (`YYYY-MM-DD`, or an ISO-8601 instant for a date-time), which CEL can\'t do arithmetic on: ' +
375+
'this faults at runtime, and here an expression that cannot be evaluated fails closed: the write (or the operation ' +
376+
'a hook guards) is refused, not skipped (it does not read null). ' +
377+
'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
378+
},
379+
],
346380
'date-text-arithmetic-run-fails': [
347381
{
348382
produce: () =>
@@ -458,6 +492,24 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
458492
'length, `double(name)` for its value (which itself faults on text that is not a number).',
459493
},
460494
],
495+
// [#30250] The same outcome on a time-relative sweep, which no write fires.
496+
'type-mismatch-sweep-run-fails': [
497+
{
498+
produce: () =>
499+
onlyError('predicate', 'name > 5', {
500+
scope: 'flattened', fields: ['name'], fieldTypes: { name: 'text' }, faultOutcome: 'run-fails', timeRelativeSweep: true,
501+
}),
502+
params: { operands: 'string > int', operator: '>', held: 'text', reference: 'name' },
503+
message:
504+
'type mismatch `string > int` — `name` holds text but is used with `>` against a number. CEL has no such operation, so ' +
505+
'wherever the value is not numeric text (an empty value included) the expression faults, and here ' +
506+
'a flow condition that cannot be evaluated fails the run: the time-relative sweep starts one run per row it finds, ' +
507+
'the run for this row stops at this condition and the work the flow exists to do is not done for that row, and ' +
508+
'nothing else happens (the sweep logs the failure and goes on to the next row). ' +
509+
'Compare like with like: use a number field, compare text with text, or convert explicitly — `size(name)` for its ' +
510+
'length, `double(name)` for its value (which itself faults on text that is not a number).',
511+
},
512+
],
461513
'type-mismatch-fail-open': [
462514
{
463515
produce: () =>
@@ -690,6 +742,24 @@ const SWEEP_SCHEMAS: readonly (ExprSchemaHint | undefined)[] = [
690742
fieldTypes: { name: 'text', active: 'boolean' },
691743
faultOutcome,
692744
})),
745+
// [#30250] A fail-closed site with a date field reaches the date verdicts'
746+
// `-fail-closed` codes; a time-relative sweep's `run-fails` condition
747+
// reaches `type-mismatch-sweep-run-fails`.
748+
{
749+
scope: 'record',
750+
objectName: 'deal',
751+
fields: ['close_date'],
752+
fieldTypes: { close_date: 'date' },
753+
faultOutcome: 'fail-closed',
754+
},
755+
{
756+
scope: 'flattened',
757+
objectName: 'deal',
758+
fields: ['amount', 'name', 'active'],
759+
fieldTypes: { name: 'text', active: 'boolean' },
760+
faultOutcome: 'run-fails',
761+
timeRelativeSweep: true,
762+
},
693763
// [#29959] A flattened (flow) condition with no declared outcome reaches
694764
// `type-mismatch-run-may-fail`, bare and through `record.`.
695765
{ scope: 'flattened', objectName: 'deal', fields: ['amount', 'name', 'active'], fieldTypes: { name: 'text', active: 'boolean' } },

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export type ExpressionSourceKind = 'array' | 'object' | 'number' | 'boolean' | '
6060
/**
6161
* The params every `type-mismatch*` code interpolates: the CEL operand types,
6262
* 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.
63+
* The codes differ in the outcome their sentence states, never in params.
6464
*/
6565
interface TypeMismatchParams {
6666
readonly operands: string;
@@ -166,15 +166,17 @@ export interface ExpressionRefusalParams {
166166
* Arithmetic on a date field against a number. `operands` are the CEL
167167
* operand types; `reference` is the field as written, when it is known. A
168168
* flattened (flow) condition is `date-arithmetic-run-fails` /
169-
* `date-arithmetic-run-may-fail`.
169+
* `date-arithmetic-run-may-fail`; a fail-closed site is
170+
* `date-arithmetic-fail-closed`. An advisory rule's stays this code.
170171
*/
171172
'date-arithmetic': { readonly operands: string; readonly reference?: string };
172173
/**
173174
* Arithmetic on a date field's runtime TEXT that the evaluator cannot answer
174175
* (`record.end_date - record.start_date`), judged with the field declared as
175176
* that text. `operands` are the CEL operand types (the text is `DateText`);
176177
* `reference` is the field as written, when it is known. A flattened (flow)
177-
* condition is `date-text-arithmetic-run-fails` / `-run-may-fail`.
178+
* condition is `date-text-arithmetic-run-fails` / `-run-may-fail`; a
179+
* fail-closed site is `date-text-arithmetic-fail-closed`.
178180
*/
179181
'date-text-arithmetic': { readonly operands: string; readonly reference?: string };
180182
/**
@@ -193,6 +195,15 @@ export interface ExpressionRefusalParams {
193195
'date-text-arithmetic-run-fails': { readonly operands: string; readonly reference?: string };
194196
/** [#30016] ERROR: `date-text-arithmetic`, at a `date-arithmetic-run-may-fail` site. */
195197
'date-text-arithmetic-run-may-fail': { readonly operands: string; readonly reference?: string };
198+
/**
199+
* [#30250] ERROR: `date-arithmetic` at a site that FAILS CLOSED (an `error`
200+
* validation rule, a field write rule, an option `visibleWhen`, a hook
201+
* `condition`) — the fault refuses the write (or aborts the operation a
202+
* hook guards). It never reads null.
203+
*/
204+
'date-arithmetic-fail-closed': { readonly operands: string; readonly reference?: string };
205+
/** [#30250] ERROR: `date-text-arithmetic`, at a `date-arithmetic-fail-closed` site. */
206+
'date-text-arithmetic-fail-closed': { readonly operands: string; readonly reference?: string };
196207
/**
197208
* A formula that computes a `google.protobuf.Duration`
198209
* (`today() - record.start_date`), which a field reads as `{}`. `reference`
@@ -219,6 +230,13 @@ export interface ExpressionRefusalParams {
219230
* lands.
220231
*/
221232
'type-mismatch-run-fails': TypeMismatchParams;
233+
/**
234+
* [#30250] ERROR: the same mismatch in a condition of a time-relative sweep
235+
* of the typed object (`ExprSchemaHint.timeRelativeSweep`) — the run for
236+
* that row fails at the condition; no write fired it, so nothing else
237+
* happens.
238+
*/
239+
'type-mismatch-sweep-run-fails': TypeMismatchParams;
222240
/**
223241
* [#29926] ERROR: the same mismatch in an advisory (`warning` / `info`)
224242
* validation rule — the rule is skipped, the write proceeds, and the
@@ -297,10 +315,13 @@ const CODE_TABLE = {
297315
'date-arithmetic-run-may-fail': true,
298316
'date-text-arithmetic-run-fails': true,
299317
'date-text-arithmetic-run-may-fail': true,
318+
'date-arithmetic-fail-closed': true,
319+
'date-text-arithmetic-fail-closed': true,
300320
'date-duration-value': true,
301321
'type-mismatch': true,
302322
'type-mismatch-fail-closed': true,
303323
'type-mismatch-run-fails': true,
324+
'type-mismatch-sweep-run-fails': true,
304325
'type-mismatch-fail-open': true,
305326
'type-mismatch-run-may-fail': true,
306327
'field-near-miss': true,

0 commit comments

Comments
 (0)