Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/30250-fail-closed-date-and-sweep-run-fails-sentences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/formula': patch
'@objectstack/lint': patch
'@objectstack/mcp': patch
---

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

Clause-②: yes (widening)
Metadata-protocol: none

- **`@objectstack/formula`: three codes join `ExpressionRefusalCode`** (`EXPRESSION_REFUSAL_CODES`), with the params of the code they split from:
- `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.
- `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.
- The grade is unchanged at every site. What is refused, warned or passed is unchanged.
- **`@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.
- **`@objectstack/mcp`**: `validate_expression` with `site: 'flow_condition'` and `triggerKind: 'time_relative'` passes the same key, so its answer stays the gate's.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/formulas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ come at two severities: **errors** fail the build; **warnings** are advisory
| **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 |
| **Field must exist** — a typo'd `record.<field>` is flagged with a did-you-mean. | `record.amont` → `record.amount` | error |
| **Unknown function** — a misspelled or nonexistent stdlib call. | `isBlnk(record.x)` | error |
| **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 |
| **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 |
| **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) |
| **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 |

Expand Down
70 changes: 70 additions & 0 deletions packages/formula/src/expression-refusal-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,23 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
'in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
},
],
// [#30250] A fail-closed site: the fault refuses the write, nothing reads null.
'date-arithmetic-fail-closed': [
{
produce: () =>
onlyError('predicate', 'record.close_date + 1 > 3', {
scope: 'record',
fieldTypes: { close_date: 'date' },
faultOutcome: 'fail-closed',
}),
params: { operands: 'google.protobuf.Timestamp + int', reference: 'record.close_date' },
message:
'date arithmetic `google.protobuf.Timestamp + int` — `record.close_date` is a date, and CEL can\'t do arithmetic ' +
'on dates: 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). ' +
'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
},
],
// [#30016] A flattened (flow) condition: the fault fails the run, it never
// reads null — `run-fails` where the run is proven to hold the field.
'date-arithmetic-run-fails': [
Expand Down Expand Up @@ -343,6 +360,23 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
'and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
},
],
'date-text-arithmetic-fail-closed': [
{
produce: () =>
onlyError('predicate', 'record.end_date - record.start_date > duration("24h")', {
scope: 'record',
fieldTypes: { start_date: 'date', end_date: 'date' },
faultOutcome: 'fail-closed',
}),
params: { operands: 'DateText - DateText', reference: 'record.start_date' },
message:
'date arithmetic `DateText - DateText` — `record.start_date` is a date field, and the runtime holds a date field ' +
'as its text (`YYYY-MM-DD`, or an ISO-8601 instant for a date-time), which CEL can\'t do arithmetic on: ' +
'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). ' +
'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
},
],
'date-text-arithmetic-run-fails': [
{
produce: () =>
Expand Down Expand Up @@ -458,6 +492,24 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
'length, `double(name)` for its value (which itself faults on text that is not a number).',
},
],
// [#30250] The same outcome on a time-relative sweep, which no write fires.
'type-mismatch-sweep-run-fails': [
{
produce: () =>
onlyError('predicate', 'name > 5', {
scope: 'flattened', fields: ['name'], fieldTypes: { name: 'text' }, faultOutcome: 'run-fails', timeRelativeSweep: true,
}),
params: { operands: 'string > int', operator: '>', held: 'text', reference: 'name' },
message:
'type mismatch `string > int` — `name` holds text but is used with `>` against a number. CEL has no such operation, so ' +
'wherever the value is not numeric text (an empty value included) the expression faults, and here ' +
'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). ' +
'Compare like with like: use a number field, compare text with text, or convert explicitly — `size(name)` for its ' +
'length, `double(name)` for its value (which itself faults on text that is not a number).',
},
],
'type-mismatch-fail-open': [
{
produce: () =>
Expand Down Expand Up @@ -690,6 +742,24 @@ const SWEEP_SCHEMAS: readonly (ExprSchemaHint | undefined)[] = [
fieldTypes: { name: 'text', active: 'boolean' },
faultOutcome,
})),
// [#30250] A fail-closed site with a date field reaches the date verdicts'
// `-fail-closed` codes; a time-relative sweep's `run-fails` condition
// reaches `type-mismatch-sweep-run-fails`.
{
scope: 'record',
objectName: 'deal',
fields: ['close_date'],
fieldTypes: { close_date: 'date' },
faultOutcome: 'fail-closed',
},
{
scope: 'flattened',
objectName: 'deal',
fields: ['amount', 'name', 'active'],
fieldTypes: { name: 'text', active: 'boolean' },
faultOutcome: 'run-fails',
timeRelativeSweep: true,
},
// [#29959] A flattened (flow) condition with no declared outcome reaches
// `type-mismatch-run-may-fail`, bare and through `record.`.
{ scope: 'flattened', objectName: 'deal', fields: ['amount', 'name', 'active'], fieldTypes: { name: 'text', active: 'boolean' } },
Expand Down
27 changes: 24 additions & 3 deletions packages/formula/src/expression-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export type ExpressionSourceKind = 'array' | 'object' | 'number' | 'boolean' | '
/**
* The params every `type-mismatch*` code interpolates: the CEL operand types,
* the `operator`, what the field holds, and the field as written when known.
* The four codes differ in the outcome their sentence states, never in params.
* The codes differ in the outcome their sentence states, never in params.
*/
interface TypeMismatchParams {
readonly operands: string;
Expand Down Expand Up @@ -166,15 +166,17 @@ export interface ExpressionRefusalParams {
* Arithmetic on a date field against a number. `operands` are the CEL
* operand types; `reference` is the field as written, when it is known. A
* flattened (flow) condition is `date-arithmetic-run-fails` /
* `date-arithmetic-run-may-fail`.
* `date-arithmetic-run-may-fail`; a fail-closed site is
* `date-arithmetic-fail-closed`. An advisory rule's stays this code.
*/
'date-arithmetic': { readonly operands: string; readonly reference?: string };
/**
* Arithmetic on a date field's runtime TEXT that the evaluator cannot answer
* (`record.end_date - record.start_date`), judged with the field declared as
* that text. `operands` are the CEL operand types (the text is `DateText`);
* `reference` is the field as written, when it is known. A flattened (flow)
* condition is `date-text-arithmetic-run-fails` / `-run-may-fail`.
* condition is `date-text-arithmetic-run-fails` / `-run-may-fail`; a
* fail-closed site is `date-text-arithmetic-fail-closed`.
*/
'date-text-arithmetic': { readonly operands: string; readonly reference?: string };
/**
Expand All @@ -193,6 +195,15 @@ export interface ExpressionRefusalParams {
'date-text-arithmetic-run-fails': { readonly operands: string; readonly reference?: string };
/** [#30016] ERROR: `date-text-arithmetic`, at a `date-arithmetic-run-may-fail` site. */
'date-text-arithmetic-run-may-fail': { readonly operands: string; readonly reference?: string };
/**
* [#30250] ERROR: `date-arithmetic` at a site that FAILS CLOSED (an `error`
* validation rule, a field write rule, an option `visibleWhen`, a hook
* `condition`) — the fault refuses the write (or aborts the operation a
* hook guards). It never reads null.
*/
'date-arithmetic-fail-closed': { readonly operands: string; readonly reference?: string };
/** [#30250] ERROR: `date-text-arithmetic`, at a `date-arithmetic-fail-closed` site. */
'date-text-arithmetic-fail-closed': { readonly operands: string; readonly reference?: string };
/**
* A formula that computes a `google.protobuf.Duration`
* (`today() - record.start_date`), which a field reads as `{}`. `reference`
Expand All @@ -219,6 +230,13 @@ export interface ExpressionRefusalParams {
* lands.
*/
'type-mismatch-run-fails': TypeMismatchParams;
/**
* [#30250] ERROR: the same mismatch in a condition of a time-relative sweep
* of the typed object (`ExprSchemaHint.timeRelativeSweep`) — the run for
* that row fails at the condition; no write fired it, so nothing else
* happens.
*/
'type-mismatch-sweep-run-fails': TypeMismatchParams;
/**
* [#29926] ERROR: the same mismatch in an advisory (`warning` / `info`)
* validation rule — the rule is skipped, the write proceeds, and the
Expand Down Expand Up @@ -297,10 +315,13 @@ const CODE_TABLE = {
'date-arithmetic-run-may-fail': true,
'date-text-arithmetic-run-fails': true,
'date-text-arithmetic-run-may-fail': true,
'date-arithmetic-fail-closed': true,
'date-text-arithmetic-fail-closed': true,
'date-duration-value': true,
'type-mismatch': true,
'type-mismatch-fail-closed': true,
'type-mismatch-run-fails': true,
'type-mismatch-sweep-run-fails': true,
'type-mismatch-fail-open': true,
'type-mismatch-run-may-fail': true,
'field-near-miss': true,
Expand Down
Loading
Loading