Skip to content

Commit 0e4747a

Browse files
fix(formula): an advisory validation rule's date verdict says the rule is skipped and the write proceeds (#30585)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6xDm8LQaqEyuhcYBN6LvNk)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - **改了什么**:advisory 校验规则(`severity: 'warning'` / `'info'`)里对日期做加减时,报错不再说「字段静默得到 null」,改说「规则被跳过、写入照常进行、只留一行日志」,配新码 `date-arithmetic-fail-open` / `date-text-arithmetic-fail-open`,文案与同位置的类型不匹配判词一字不差。 - **为什么改**:advisory 规则出错时运行时跳过该规则并放行写入,那里没有字段,也没有东西读到 null,旧句说的是不会发生的事。 - **风险与回滚**:分级不变(仍是 error),发布门不收也不放,只换文案和码。按旧码 `date-arithmetic` / `date-text-arithmetic` 识别 advisory 规则的使用方需改认新码。公式字段原码原句不动。回滚即 revert,无数据迁移。 - **先看**:`packages/formula/src/validate.ts` 的 `dateArm` 与 `dateArmFault`(`fail-open` 分支),`packages/formula/src/expression-refusal.ts` 的两个新码。 - **两分钟验证**:`pnpm --filter @objectstack/lint exec vitest run src/validate-expressions.test.ts -t 30494`,预期 1 条通过:warning / info 规则的日期判词说规则被跳过、写入照常,不含 null;公式字段对照仍说 null。 - **需要你做的**:无,席位落地。 Fixes #30494 Clause-②: yes (widening) — two codes join `@objectstack/formula`'s exported `ExpressionRefusalCode` / `EXPRESSION_REFUSAL_CODES`: `date-arithmetic-fail-open` and `date-text-arithmetic-fail-open`, each with the params of the code it splits from. An advisory rule's date verdicts move to them from `date-arithmetic` / `date-text-arithmetic` (a consumer keyed to the old codes on an advisory rule re-keys). The grade is unchanged; 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 are `@objectstack/lint`'s `ExprIssue` (`where`, `message`, `source`, `severity`, `path`), re-measured at `packages/lint/src/validate-expressions.ts:157`, which carries no `code`. Only the message text changes there. Skills-docs: none — `git grep -c "silently evaluates to null" -- skills` answers no hit (exit 1); control: `git grep -c "evaluates to" -- skills` hits `skills/objectstack-automation/SKILL.md`, `skills/objectstack-data/SKILL.md` and `skills/objectstack-data/rules/validation.md`, none of them about a date verdict on an advisory rule. ## What changes Measured on `main` with PR #30492 merged (`67d5d5375`), and again after `origin/main` was merged at `a4c3e738`. | site | before | now | |:--|:--|:--| | advisory (`warning` / `info`) validation rule (`faultOutcome: 'fail-open'`): date arithmetic | `date-arithmetic` / `date-text-arithmetic`, error, "this faults at runtime, so the field silently evaluates to null." | `date-arithmetic-fail-open` / `date-text-arithmetic-fail-open`, error: "this faults at runtime, and here the rule is advisory, so a rule that cannot be evaluated is skipped, not refused: 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." | | formula field, display predicate (no outcome) | `date-arithmetic` / `date-text-arithmetic` | unchanged, code and sentence | | fail-closed site, flow condition | the `-fail-closed` / `-run-fails` / `-run-may-fail` codes | unchanged | - **One outcome, one sentence.** `dateArmFault` closes the `fail-open` arm on `TYPE_MISMATCH_FAULT_ARMS['fail-open'].clause` itself, not a copy, so the date verdict and the type-mismatch verdict on the same rule cannot drift apart. - **One code per template**, as #30250 did for fail-closed: `expression-refusal.ts` is the contract that a code names one message template. - **No new signal.** `@objectstack/lint`'s `faultOutcomeOf` ledger already answers `fail-open` for a `warning` / `info` rule (`validate-expressions.ts:1358`), and MCP `validate_expression` already passes it for `site: 'validation'` with `severity: 'warning' | 'info'` (`mcp-http-tools.ts:666`). Neither package changes. ## Tests - `packages/formula/src/validate.test.ts`: - new: on a `fail-open` `record` site, `record.start_date + 1 > 3` and `record.end_date - record.start_date > duration("24h")` get the `-fail-open` codes and the fail-open sentence, and not "evaluates to null". Control: the same two predicates with no outcome keep `date-arithmetic` / `date-text-arithmetic` and the null sentence; - changed: the #30250 test's third control asserted that an advisory rule keeps `date-arithmetic` and the null sentence, which is this card's defect. That row is dropped; its formula-field controls stay. - `packages/formula/src/expression-refusal-codes.test.ts`: a `PINS` entry for each new code (exact params and message), and `SWEEP_SCHEMAS` gains the `fail-open` date schema, so the sweep reaches both codes and holds their params to their messages. - `packages/lint/src/validate-expressions.test.ts`, new: a `warning` rule and an `info` rule with date arithmetic say the rule is skipped and the write proceeds (not null, not refused). Control: a formula field's `record.close_date + 1` keeps the null sentence. - `packages/mcp/src/mcp-validate-expression.test.ts`: unchanged, verbatim. Measured once with a probe file that was deleted afterwards (not committed): `validate_expression` at `site: 'validation'` answers `date-arithmetic-fail-open` / `date-text-arithmetic-fail-open` for `severity: 'warning'` and `'info'`, `-fail-closed` for `'error'`, and `date-arithmetic` at `site: 'formula'`; each message equals `validateStackExpressions`' on the same rule. <details><summary>Suites and red-first</summary> ``` red first (pins only, on PR #30492's head, commit 89eebee): formula × on an advisory rule says the rule is skipped … (#30494) · × has a pin for every code Tests 2 failed | 181 passed (183) lint × says an advisory validation rule's date arithmetic skips the rule … (#30494) Tests 1 failed merged head dfc91e9 (origin/main a4c3e73 merged once), under os-verify-lock, shared box: formula test Test Files 64 passed (64) Tests 1652 passed (1652) lint test Test Files 195 passed (195) Tests 8093 passed (8093) mcp test Test Files 56 passed (56) Tests 678 passed (678) typecheck (formula, lint, mcp): exit 0 objectql src/publish-type-mismatch-fail-closed.test.ts Tests 12 passed (12) ``` </details> <details><summary>Ablation (scripts/ablation-replace.mjs; restored: blob == HEAD, git diff HEAD empty, dist marker absent after rebuild, tree clean)</summary> ``` A1 formula: the fail-open date arm off anchor faultOutcome === 'fail-closed' || faultOutcome === 'fail-open' ? faultOutcome : null replace faultOutcome === 'fail-closed' || String(faultOutcome) === 'ablated-30494-a1' ? faultOutcome : null anchor x1 -> x0, replacement x0 -> x1, blob 8931039 -> 19c5c422d2f2 rebuilt (tsup JS emitted; the DTS step exited 1 on the mutated return type), ablation-dist-preflight: marker present in dist/index.js and dist/index.mjs formula × on an advisory rule … (#30494) · × date-arithmetic-fail-open · × date-text-arithmetic-fail-open · × the SWEEP Tests 4 failed | 181 passed (185) lint × says an advisory validation rule's date arithmetic skips the rule … (#30494) Tests 1 failed | 473 passed (474) restore: blob 8931039 == HEAD, git diff HEAD empty; rebuild exit 0; preflight --absent: marker absent from all 6 built files, tree clean ``` </details> ## Acceptance notes - **`packages/lint/src/validate-expressions.ts:1338-1341` doc comment** names the date verdicts' `-run-fails`, `-run-may-fail` and `-fail-closed` arms and not the new `-fail-open` one. It says nothing false (it never says an advisory rule keeps the null sentence), and `packages/lint/src/**` source is outside this card's file surface, so it is left as is. - **The #30250 changeset** (`.changeset/30250-fail-closed-date-and-sweep-run-fails-sentences.md`) says an advisory rule keeps `date-arithmetic` / `date-text-arithmetic`. Both changesets ship in the same release; this PR's changeset says it replaces that note, and the other file is not edited here. - **`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 (as #30250 left it). - **Merge.** `origin/main` was merged once at `a4c3e738`. The branch had carried PR #30492's head as a merge until it landed as a squash, so git reported conflicts in the five files both sides touch. They were resolved by applying this branch's own diff (`ebda9aca..34b93ec`) to `origin/main`'s copies: it applied cleanly, and the changed lines of the resolved delta are identical to that diff's. ## Verification All at head `dfc91e9d` (`git rev-parse --short HEAD`), the commit this PR opens on (`origin/main` merged once, at `a4c3e738`). - 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`: change set of 6 paths vs merge base `a4c3e738b`, 58 derived families, each run on this head, all **exit 0** (each exit code recorded before any pipe). `check-plugin-teardown-shape.mjs --self-test` ran after fetching its pinned fixture commit `621a4876` into the shallow clone ("48 cases pass"). `--ran`: "58 derived famil(ies) accounted for — 58 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`, `check:closing-keyword-parity`. - `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_01Utp6CbJFWadws6f9r4B8Ke --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6b84fef commit 0e4747a

6 files changed

Lines changed: 165 additions & 19 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/formula': patch
3+
---
4+
5+
fix(formula): an advisory validation rule's date verdict says the rule is skipped and the write proceeds
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
- **`@objectstack/formula`: two codes join `ExpressionRefusalCode`** (`EXPRESSION_REFUSAL_CODES`), with the params of the code each splits from: `date-arithmetic-fail-open` and `date-text-arithmetic-fail-open`.
11+
- On an advisory (`severity: 'warning'` / `'info'`) validation rule (`faultOutcome: 'fail-open'`), date arithmetic used to be told "this faults at runtime, so the field silently evaluates to null". There is no field there, and nothing reads null: the rule is skipped and logged, and the write proceeds. The verdict now closes on the sentence `type-mismatch-fail-open` already uses for that outcome. This replaces the earlier note that an advisory rule keeps `date-arithmetic` / `date-text-arithmetic`.
12+
- A formula field keeps `date-arithmetic` / `date-text-arithmetic` and the null sentence. A consumer keyed to the old codes on an advisory rule re-keys to the new ones.
13+
- The grade is unchanged: the verdict is an error, as before. What is refused, warned or passed is unchanged.
14+
- `@objectstack/lint` and `@objectstack/mcp` `validate_expression` already pass `faultOutcome: 'fail-open'` for an advisory rule, so both serve the new sentence with no change of their own.

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,26 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
313313
'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
314314
},
315315
],
316+
// [#30494] An advisory (fail-open) rule: the fault skips the rule and the
317+
// write proceeds, nothing reads null.
318+
'date-arithmetic-fail-open': [
319+
{
320+
produce: () =>
321+
onlyError('predicate', 'record.close_date + 1 > 3', {
322+
scope: 'record',
323+
fieldTypes: { close_date: 'date' },
324+
faultOutcome: 'fail-open',
325+
}),
326+
params: { operands: 'google.protobuf.Timestamp + int', reference: 'record.close_date' },
327+
message:
328+
'date arithmetic `google.protobuf.Timestamp + int` — `record.close_date` is a date, and CEL can\'t do arithmetic ' +
329+
'on dates: ' +
330+
'this faults at runtime, and here the rule is advisory, so a rule that cannot be evaluated is skipped, not refused: ' +
331+
'the write proceeds, the rule is never applied (its advisory never fires), and the only trace is a log line saying ' +
332+
'it could not be evaluated. ' +
333+
'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
334+
},
335+
],
316336
// [#30016] A flattened (flow) condition: the fault fails the run, it never
317337
// reads null — `run-fails` where the run is proven to hold the field.
318338
'date-arithmetic-run-fails': [
@@ -377,6 +397,24 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
377397
'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
378398
},
379399
],
400+
'date-text-arithmetic-fail-open': [
401+
{
402+
produce: () =>
403+
onlyError('predicate', 'record.end_date - record.start_date > duration("24h")', {
404+
scope: 'record',
405+
fieldTypes: { start_date: 'date', end_date: 'date' },
406+
faultOutcome: 'fail-open',
407+
}),
408+
params: { operands: 'DateText - DateText', reference: 'record.start_date' },
409+
message:
410+
'date arithmetic `DateText - DateText` — `record.start_date` is a date field, and the runtime holds a date field ' +
411+
'as its text (`YYYY-MM-DD`, or an ISO-8601 instant for a date-time), which CEL can\'t do arithmetic on: ' +
412+
'this faults at runtime, and here the rule is advisory, so a rule that cannot be evaluated is skipped, not refused: ' +
413+
'the write proceeds, the rule is never applied (its advisory never fires), and the only trace is a log line saying ' +
414+
'it could not be evaluated. ' +
415+
'Use `daysBetween(a, b)` for the span in whole days, and `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)` to shift a date.',
416+
},
417+
],
380418
'date-text-arithmetic-run-fails': [
381419
{
382420
produce: () =>
@@ -752,6 +790,15 @@ const SWEEP_SCHEMAS: readonly (ExprSchemaHint | undefined)[] = [
752790
fieldTypes: { close_date: 'date' },
753791
faultOutcome: 'fail-closed',
754792
},
793+
// [#30494] The same date schema on an advisory rule reaches the date
794+
// verdicts' `-fail-open` codes.
795+
{
796+
scope: 'record',
797+
objectName: 'deal',
798+
fields: ['close_date'],
799+
fieldTypes: { close_date: 'date' },
800+
faultOutcome: 'fail-open',
801+
},
755802
{
756803
scope: 'flattened',
757804
objectName: 'deal',

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ export interface ExpressionRefusalParams {
167167
* operand types; `reference` is the field as written, when it is known. A
168168
* flattened (flow) condition is `date-arithmetic-run-fails` /
169169
* `date-arithmetic-run-may-fail`; a fail-closed site is
170-
* `date-arithmetic-fail-closed`. An advisory rule's stays this code.
170+
* `date-arithmetic-fail-closed`, and an advisory rule's is
171+
* `date-arithmetic-fail-open`.
171172
*/
172173
'date-arithmetic': { readonly operands: string; readonly reference?: string };
173174
/**
@@ -176,7 +177,8 @@ export interface ExpressionRefusalParams {
176177
* that text. `operands` are the CEL operand types (the text is `DateText`);
177178
* `reference` is the field as written, when it is known. A flattened (flow)
178179
* condition is `date-text-arithmetic-run-fails` / `-run-may-fail`; a
179-
* fail-closed site is `date-text-arithmetic-fail-closed`.
180+
* fail-closed site is `date-text-arithmetic-fail-closed`, an advisory rule's
181+
* `date-text-arithmetic-fail-open`.
180182
*/
181183
'date-text-arithmetic': { readonly operands: string; readonly reference?: string };
182184
/**
@@ -204,6 +206,14 @@ export interface ExpressionRefusalParams {
204206
'date-arithmetic-fail-closed': { readonly operands: string; readonly reference?: string };
205207
/** [#30250] ERROR: `date-text-arithmetic`, at a `date-arithmetic-fail-closed` site. */
206208
'date-text-arithmetic-fail-closed': { readonly operands: string; readonly reference?: string };
209+
/**
210+
* [#30494] ERROR: `date-arithmetic` in an advisory (`warning` / `info`)
211+
* validation rule, a site that FAILS OPEN — the fault skips the rule and the
212+
* write proceeds (the `type-mismatch-fail-open` outcome). It never reads null.
213+
*/
214+
'date-arithmetic-fail-open': { readonly operands: string; readonly reference?: string };
215+
/** [#30494] ERROR: `date-text-arithmetic`, at a `date-arithmetic-fail-open` site. */
216+
'date-text-arithmetic-fail-open': { readonly operands: string; readonly reference?: string };
207217
/**
208218
* A formula that computes a `google.protobuf.Duration`
209219
* (`today() - record.start_date`), which a field reads as `{}`. `reference`
@@ -317,6 +327,8 @@ const CODE_TABLE = {
317327
'date-text-arithmetic-run-may-fail': true,
318328
'date-arithmetic-fail-closed': true,
319329
'date-text-arithmetic-fail-closed': true,
330+
'date-arithmetic-fail-open': true,
331+
'date-text-arithmetic-fail-open': true,
320332
'date-duration-value': true,
321333
'type-mismatch': true,
322334
'type-mismatch-fail-closed': true,

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

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,14 +1139,45 @@ describe('validateExpression (ADR-0032)', () => {
11391139
);
11401140
expect(r.errors[0].message, source).not.toMatch(/evaluates to null/);
11411141
}
1142-
// Controls: a formula field (no outcome) keeps the null sentence; so does
1143-
// an advisory rule (`fail-open`), which #30250 does not cover.
1144-
for (const [role, source, hint, code] of [
1145-
['value', 'record.start_date + 1', schema, 'date-arithmetic'],
1146-
['value', 'record.end_date - record.start_date', schema, 'date-text-arithmetic'],
1147-
['predicate', 'record.start_date + 1 > 3', { ...schema, faultOutcome: 'fail-open' }, 'date-arithmetic'],
1142+
// Controls: a formula field (no outcome) keeps the null sentence. An
1143+
// advisory rule (`fail-open`) is #30494's, below.
1144+
for (const [role, source, code] of [
1145+
['value', 'record.start_date + 1', 'date-arithmetic'],
1146+
['value', 'record.end_date - record.start_date', 'date-text-arithmetic'],
11481147
] as const) {
1149-
const r = validateExpression(role, source, hint);
1148+
const r = validateExpression(role, source, schema);
1149+
expect(r.errors.map((e) => e.code), source).toEqual([code]);
1150+
expect(r.errors[0].message, source).toContain('this faults at runtime, so the field silently evaluates to null');
1151+
}
1152+
});
1153+
1154+
// [#30494] On an advisory (`warning` / `info`) validation rule a fault
1155+
// skips the rule and the write proceeds: nothing reads null. The date
1156+
// verdicts there close on the `type-mismatch-fail-open` clause, under their
1157+
// own codes. The grade stays an error.
1158+
it('on an advisory rule says the rule is skipped and the write proceeds, not that the field reads null (#30494)', () => {
1159+
const open = { ...schema, faultOutcome: 'fail-open' } as const;
1160+
for (const [source, code] of [
1161+
['record.start_date + 1 > 3', 'date-arithmetic-fail-open'],
1162+
['record.end_date - record.start_date > duration("24h")', 'date-text-arithmetic-fail-open'],
1163+
] as const) {
1164+
const r = validateExpression('predicate', source, open);
1165+
expect(r.warnings, source).toHaveLength(0);
1166+
expect(r.errors.map((e) => e.code), source).toEqual([code]);
1167+
expect(r.errors[0].message, source).toContain(
1168+
'this faults at runtime, and here the rule is advisory, so a rule that cannot be evaluated is skipped, not ' +
1169+
'refused: the write proceeds, the rule is never applied (its advisory never fires), and the only trace is a ' +
1170+
'log line saying it could not be evaluated.',
1171+
);
1172+
expect(r.errors[0].message, source).not.toMatch(/evaluates to null/);
1173+
}
1174+
// Control: the same predicates on a formula-field site (no outcome) keep
1175+
// their codes and the null sentence.
1176+
for (const [source, code] of [
1177+
['record.start_date + 1 > 3', 'date-arithmetic'],
1178+
['record.end_date - record.start_date > duration("24h")', 'date-text-arithmetic'],
1179+
] as const) {
1180+
const r = validateExpression('predicate', source, schema);
11501181
expect(r.errors.map((e) => e.code), source).toEqual([code]);
11511182
expect(r.errors[0].message, source).toContain('this faults at runtime, so the field silently evaluates to null');
11521183
}

‎packages/formula/src/validate.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -440,9 +440,10 @@ const DATE_FAIL_CLOSED_CLAUSE =
440440
* (`date − date + 1`, `today() + 30`) — always faults at runtime and never
441441
* recovers, so it blocks the build. A formula reads that fault as null; a
442442
* flattened (flow) condition's fault fails the run, under the
443-
* `date-arithmetic-run-fails` / `-run-may-fail` codes (#30016), and a
443+
* `date-arithmetic-run-fails` / `-run-may-fail` codes (#30016), a
444444
* fail-closed site's refuses the write, under `date-arithmetic-fail-closed`
445-
* (#30250, {@link dateArm}).
445+
* (#30250), and an advisory rule's skips the rule, under
446+
* `date-arithmetic-fail-open` (#30494, {@link dateArm}).
446447
* - `type-mismatch` (**warning**): a text/boolean field used with an
447448
* arithmetic/ordering operator against a number — nulls unless the text value
448449
* happens to be numeric, so it stays advisory. An **error** where the caller
@@ -611,25 +612,32 @@ function typeSoundnessIssue(
611612
* where it holds the date: `run-may-fail`. A `record`-scoped site that fails
612613
* closed (`faultOutcome: 'fail-closed'`: an `error` validation rule, a field
613614
* write rule, an option `visibleWhen`, a hook `condition`) refuses the write
614-
* on the fault: `fail-closed` (#30250). An advisory rule (`fail-open`) keeps
615-
* the null sentence. The grade stays an error everywhere.
615+
* on the fault: `fail-closed` (#30250). An advisory rule (`faultOutcome:
616+
* 'fail-open'`) is skipped on the fault and the write proceeds: `fail-open`,
617+
* the `type-mismatch-fail-open` clause (#30494). The grade stays an error
618+
* everywhere.
616619
*/
617-
type DateArm = 'run-fails' | 'run-may-fail' | 'fail-closed';
620+
type DateArm = 'run-fails' | 'run-may-fail' | 'fail-closed' | 'fail-open';
618621
function dateArm(
619622
scope: 'record' | 'flattened',
620623
faultOutcome: ExprSchemaHint['faultOutcome'],
621624
bareNamesUnproven: boolean | undefined,
622625
): DateArm | null {
623-
if (scope !== 'flattened') return faultOutcome === 'fail-closed' ? 'fail-closed' : null;
626+
if (scope !== 'flattened') {
627+
return faultOutcome === 'fail-closed' || faultOutcome === 'fail-open' ? faultOutcome : null;
628+
}
624629
return faultOutcome === 'run-fails' && !bareNamesUnproven ? 'run-fails' : 'run-may-fail';
625630
}
626631

627632
/**
628-
* [#30016, #30250] The fault clause of a date verdict whose fault does not read
629-
* null; `held` names what a flow condition's field holds at run time.
633+
* [#30016, #30250, #30494] The fault clause of a date verdict whose fault does
634+
* not read null; `held` names what a flow condition's field holds at run time.
635+
* An advisory rule's is the type mismatch's own `fail-open` clause, word for
636+
* word: one outcome, one sentence.
630637
*/
631638
function dateArmFault(arm: DateArm, ref: string | null, held: 'date' | 'text'): string {
632639
if (arm === 'fail-closed') return `this faults at runtime, and here ${DATE_FAIL_CLOSED_CLAUSE}`;
640+
if (arm === 'fail-open') return `this faults at runtime, and here ${TYPE_MISMATCH_FAULT_ARMS['fail-open'].clause}`;
633641
const fails =
634642
'a flow condition that cannot be evaluated fails the run: the flow stops at this condition and the ' +
635643
'work the flow exists to do is not done (it does not read null).';
@@ -645,8 +653,9 @@ function dateArmFault(arm: DateArm, ref: string | null, held: 'date' | 'text'):
645653
* - `date-text-arithmetic`: arithmetic on that text the evaluator faults on —
646654
* `record.end_date - record.start_date` (either field kind), `record.d -
647655
* duration('24h')` — so the expression reads null (in a flow condition the
648-
* run fails instead, and on a fail-closed site the write is refused:
649-
* `flowArm`, #30016, #30250). Any role.
656+
* run fails instead, on a fail-closed site the write is refused, and on an
657+
* advisory rule the rule is skipped: `flowArm`, #30016, #30250, #30494).
658+
* Any role.
650659
* - `date-duration-value`: a formula (`value` role) that computes a
651660
* `google.protobuf.Duration` — `today() - record.start_date` — which a field
652661
* serialises as `{}`. A predicate that COMPARES a duration

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,39 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
13821382
expect(formula[0]!.message).toContain('this faults at runtime, so the field silently evaluates to null');
13831383
});
13841384

1385+
// [#30494] An advisory (`warning` / `info`) validation rule fails open: a
1386+
// fault skips the rule and the write proceeds, so its date arithmetic says
1387+
// so, still an error. Control: a formula field keeps the null sentence.
1388+
it('says an advisory validation rule\'s date arithmetic skips the rule; a formula keeps the null sentence (#30494)', () => {
1389+
const issues = validateStackExpressions({
1390+
objects: [{
1391+
name: 'crm_opportunity',
1392+
fields: {
1393+
close_date: { type: 'date' },
1394+
end_date: { type: 'date' },
1395+
next_day: { type: 'formula', expression: 'record.close_date + 1' },
1396+
},
1397+
validations: [
1398+
{ name: 'late', condition: 'record.close_date + 1 > 3', severity: 'warning' },
1399+
{ name: 'long', condition: 'record.end_date - record.close_date > duration("24h")', severity: 'info' },
1400+
],
1401+
}],
1402+
});
1403+
const found = issues.filter(i => /^date arithmetic/.test(i.message));
1404+
expect(found.map(i => i.severity)).toEqual(['error', 'error', 'error']);
1405+
const rules = found.filter(i => /validation/.test(i.where));
1406+
expect(rules).toHaveLength(2);
1407+
for (const i of rules) {
1408+
expect(i.message, i.where).toContain(
1409+
'the rule is advisory, so a rule that cannot be evaluated is skipped, not refused: the write proceeds',
1410+
);
1411+
expect(i.message, i.where).not.toMatch(/evaluates to null|is refused, not skipped/);
1412+
}
1413+
const formula = found.filter(i => !/validation/.test(i.where));
1414+
expect(formula).toHaveLength(1);
1415+
expect(formula[0]!.message).toContain('this faults at runtime, so the field silently evaluates to null');
1416+
});
1417+
13851418
// [#29959] `seedRunVariables` seeds the flow's declared variables before it
13861419
// flattens the record, and the flattening skips a name already bound: a
13871420
// bare `title` here reads the NUMBER variable and `title > 5` runs. Only

0 commit comments

Comments
 (0)