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
12 changes: 12 additions & 0 deletions .changeset/30016-flow-date-arithmetic-run-fails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/formula': patch
'@objectstack/lint': patch
---

fix(formula,lint): a date-arithmetic verdict in a flow condition says the run fails, not that the value reads null

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

- **`@objectstack/formula`: four codes join `ExpressionRefusalCode`**: `date-arithmetic-run-fails`, `date-arithmetic-run-may-fail`, `date-text-arithmetic-run-fails` and `date-text-arithmetic-run-may-fail`, with the same params as `date-arithmetic` / `date-text-arithmetic`. A flow condition (a `'flattened'` site) that does arithmetic on a date field used to be told the field "silently evaluates to null", but the engine's `evaluateCondition` throws on the fault on every flow, so the run fails at the condition. Where the caller declares the run proven to hold the field (`faultOutcome: 'run-fails'`, with `bareNamesUnproven` unset) the verdict is served under `-run-fails` and says the run fails; anywhere else in a flow it is served under `-run-may-fail` and says the run fails wherever the name holds the field's date when the condition runs (a flow started by an API call, an action or a parent flow binds what its entrance hands over, which can be a number). The grade is unchanged: an error at every site. A `record`-scoped site (a formula field, a validation rule, a display predicate) keeps `date-arithmetic` / `date-text-arithmetic` and their sentence. A consumer keyed to the old codes on a flow condition re-keys to the new ones.
- **`@objectstack/lint`**: the fault-outcome ledger's docblock names the date verdicts' flow codes; the routing is the existing `faultOutcome` / `bareNamesUnproven` hint, unchanged.
60 changes: 55 additions & 5 deletions packages/formula/src/expression-refusal-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,14 +295,37 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
'on dates: this faults at runtime, so the field silently evaluates to 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': [
{
produce: () =>
onlyError('predicate', 'close_date + 1 > 3', {
fields: ['close_date'],
fieldTypes: { close_date: 'date' },
faultOutcome: 'run-fails',
}),
params: { operands: 'google.protobuf.Timestamp + int', reference: 'close_date' },
message:
'date arithmetic `google.protobuf.Timestamp + int` — `close_date` is a date, and CEL can\'t do arithmetic ' +
'on dates: this faults at runtime, and ' +
'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the work the flow ' +
'exists to do is not done (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-arithmetic-run-may-fail': [
{
produce: () =>
onlyError('predicate', 'close_date + 1 > 3', { fields: ['close_date'], fieldTypes: { close_date: 'date' } }),
params: { operands: 'google.protobuf.Timestamp + int', reference: 'close_date' },
message:
'date arithmetic `google.protobuf.Timestamp + int` — `close_date` is a date, and CEL can\'t do arithmetic ' +
'on dates: this faults at runtime, so the field silently evaluates to null. Use `daysBetween(a, b)` for the span ' +
'in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
'on dates: this faults wherever `close_date` holds the date when the condition runs, and ' +
'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the work the flow ' +
'exists to do is not done (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': [
Expand All @@ -319,18 +342,40 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
'faults at runtime, so the field silently evaluates to 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: () =>
onlyError('predicate', 'end_at - start_at > duration("1h")', {
fields: ['start_at', 'end_at'],
fieldTypes: { start_at: 'datetime', end_at: 'datetime' },
faultOutcome: 'run-fails',
}),
params: { operands: 'DateText - DateText', reference: 'start_at' },
message:
'date arithmetic `DateText - DateText` — `start_at` 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, so the field silently evaluates to null. Use `daysBetween(a, b)` for the span in whole days, ' +
'and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
'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 ' +
'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the work the flow ' +
'exists to do is not done (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-may-fail': [
{
produce: () =>
onlyError('predicate', 'end_at - start_at > duration("1h")', {
fields: ['start_at', 'end_at'],
fieldTypes: { start_at: 'datetime', end_at: 'datetime' },
}),
params: { operands: 'DateText - DateText', reference: 'start_at' },
message:
'date arithmetic `DateText - DateText` — `start_at` 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 wherever `start_at` holds that text when the condition runs, and ' +
'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the work the flow ' +
'exists to do is not done (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-duration-value': [
Expand Down Expand Up @@ -594,6 +639,7 @@ const SWEEP_SOURCES: readonly string[] = [
'close_date + 1 > 3',
'record.close_date - record.close_date',
'today() - record.close_date',
'close_date - close_date > duration("1h")',
'record.name > 5',
'record.active + 1 > 5',
"record.account == 'a1' && record.account.name == 'x'",
Expand Down Expand Up @@ -631,6 +677,10 @@ const SWEEP_SCHEMAS: readonly (ExprSchemaHint | undefined)[] = [
optionFields: ['status'],
},
{ fields: ['status', 'close_date'], objectName: 'deal', fieldTypes: { close_date: 'date' } },
// [#30016] The same flow condition where the run is proven to hold the
// field: the date verdicts' `-run-fails` codes (the line above reaches
// `-run-may-fail`).
{ fields: ['status', 'close_date'], objectName: 'deal', fieldTypes: { close_date: 'date' }, faultOutcome: 'run-fails' },
// [#29926] The deal schema once per declared fault outcome, so the sweep
// reaches each `type-mismatch-*` code and holds its params to its message.
...(['fail-closed', 'run-fails', 'fail-open'] as const).map((faultOutcome): ExprSchemaHint => ({
Expand Down
27 changes: 25 additions & 2 deletions packages/formula/src/expression-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,35 @@ export interface ExpressionRefusalParams {
'bare-reference': { readonly name: string };
/**
* Arithmetic on a date field against a number. `operands` are the CEL
* operand types; `reference` is the field as written, when it is known.
* 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': { 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.
* `reference` is the field as written, when it is known. A flattened (flow)
* condition is `date-text-arithmetic-run-fails` / `-run-may-fail`.
*/
'date-text-arithmetic': { readonly operands: string; readonly reference?: string };
/**
* [#30016] ERROR: `date-arithmetic` in a flow condition whose run is proven
* to hold the field there (`faultOutcome: 'run-fails'`) — the condition
* faults, so the run fails at it. It never reads null.
*/
'date-arithmetic-run-fails': { readonly operands: string; readonly reference?: string };
/**
* [#30016] ERROR: `date-arithmetic` in any other flow condition — it faults,
* and so fails the run, wherever the name holds the field's date when the
* condition runs; what the run binds under the name is not proven there.
*/
'date-arithmetic-run-may-fail': { readonly operands: string; readonly reference?: string };
/** [#30016] ERROR: `date-text-arithmetic`, at a `date-arithmetic-run-fails` site. */
'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 };
/**
* A formula that computes a `google.protobuf.Duration`
* (`today() - record.start_date`), which a field reads as `{}`. `reference`
Expand Down Expand Up @@ -274,6 +293,10 @@ const CODE_TABLE = {
'bare-reference': true,
'date-arithmetic': true,
'date-text-arithmetic': true,
'date-arithmetic-run-fails': true,
'date-arithmetic-run-may-fail': true,
'date-text-arithmetic-run-fails': true,
'date-text-arithmetic-run-may-fail': true,
'date-duration-value': true,
'type-mismatch': true,
'type-mismatch-fail-closed': true,
Expand Down
15 changes: 13 additions & 2 deletions packages/formula/src/validate-date-text-arithmetic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,25 @@ describe('date subtraction the runtime cannot answer (#28944)', () => {
expect(since.message).toMatch(/daysBetween\(a, b\)/);
});

it('judges a flattened (flow) condition the same way', () => {
it('judges a flattened (flow) condition the same way, under the flow codes (#30016)', () => {
const flattened: ExprSchemaHint = {
fields: ['start_date', 'end_date'],
fieldTypes: { start_date: 'date', end_date: 'date' },
};
// No declared outcome: the run fails wherever the name holds the date's text.
const refused = validateExpression('predicate', 'end_date - start_date > duration("72h")', flattened);
expect(refused.errors.map((e) => e.code)).toEqual(['date-text-arithmetic']);
expect(refused.errors.map((e) => e.code)).toEqual(['date-text-arithmetic-run-may-fail']);
expect(refused.errors[0].params).toEqual({ operands: 'DateText - DateText', reference: 'start_date' });
expect(refused.errors[0].message).not.toMatch(/evaluates to null/);
// The run is proven to hold the field: it fails at the condition.
const proven = validateExpression('predicate', 'end_date - start_date > duration("72h")', { ...flattened, faultOutcome: 'run-fails' });
expect(proven.errors.map((e) => e.code)).toEqual(['date-text-arithmetic-run-fails']);
expect(proven.errors[0].message).toMatch(/this faults at runtime, and a flow condition that cannot be evaluated fails the run/);
// A node can rebind the bare names: not proven, whatever the outcome.
const unproven = validateExpression('predicate', 'end_date - start_date > duration("72h")', {
...flattened, faultOutcome: 'run-fails', bareNamesUnproven: true,
});
expect(unproven.errors.map((e) => e.code)).toEqual(['date-text-arithmetic-run-may-fail']);
expect(validateExpression('predicate', 'today() - start_date > duration("72h")', flattened).errors).toEqual([]);
});

Expand Down
28 changes: 28 additions & 0 deletions packages/formula/src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,34 @@ describe('validateExpression (ADR-0032)', () => {
expect(r.ok).toBe(true);
expect(r.errors).toHaveLength(0);
});

// [#30016] A flow condition's fault fails the run (`evaluateCondition`
// throws on every flow), so the flow codes never say it reads null. The
// grade stays an error at every site.
it('in a flow condition says the run fails, and keeps the null sentence on a record-scoped rule (#30016)', () => {
const flow = { ...schema, scope: 'flattened' } as const;
const codeOf = (hint: object) => {
const r = validateExpression('predicate', 'start_date + 1 > 3', { ...flow, ...hint });
expect(r.warnings).toHaveLength(0);
return r.errors.map((e) => [e.code, e.message] as const);
};
const [[provenCode, proven]] = codeOf({ faultOutcome: 'run-fails' });
expect(provenCode).toBe('date-arithmetic-run-fails');
expect(proven).toMatch(/this faults at runtime, and a flow condition that cannot be evaluated fails the run/);
expect(proven).not.toMatch(/evaluates to null/);
for (const hint of [{}, { faultOutcome: 'run-fails', bareNamesUnproven: true }]) {
const [[code, message]] = codeOf(hint);
expect(code).toBe('date-arithmetic-run-may-fail');
expect(message).toMatch(/this faults wherever `start_date` holds the date when the condition runs, and a flow condition/);
expect(message).not.toMatch(/evaluates to null/);
}
// A bare name a flow variable holds is not the field: no verdict.
expect(codeOf({ faultOutcome: 'run-fails', flowVariables: ['start_date'] })).toEqual([]);
// Control: a record-scoped validation rule keeps its code and sentence.
const rule = validateExpression('predicate', 'record.start_date + 1 > 3', { ...schema, faultOutcome: 'fail-closed' });
expect(rule.errors.map((e) => e.code)).toEqual(['date-arithmetic']);
expect(rule.errors[0].message).toMatch(/this faults at runtime, so the field silently evaluates to null/);
});
});

describe('introspection', () => {
Expand Down
Loading
Loading