Skip to content

Commit dd2c1ba

Browse files
committed
fix(formula): the ADR-0032 §1c retry rewrites only the operands that faulted (#7098)
`hydrateOverloadStrings` rewrote the whole scope and re-ran the whole expression on a docblock claim that it "can never change a comparison that already evaluated cleanly". The claim was false and load-bearing — it was the stated reason the hydration was allowed to be unconditional and scope-wide. The retry knows only that the WHOLE expression faulted, so every other comparison was re-interpreted against the hydrated values: record.n >= 4 && record.s == "5.0" with { n: "7", s: "5.0" } before -> { ok: true, value: false } after -> { ok: true, value: true } The author's deliberate string equality was true in evaluation 1 and was overruled silently — no fault, no log line, no red test. The coercion is now per operand POSITION, the discipline `rewriteTemporalEquality` already documents ("no field-wide trade-off") and one step stricter. The scope is never rewritten; the faulting operand is wrapped in `double(…)`/`date(…)` in place. An operand qualifies only when the operator RAISES on a string-versus-number/Timestamp pair, the counterpart is a number/Timestamp in this scope, and the operand is a §1c serialization artifact — so the docblock's guarantee now holds by construction. Measured per operator on cel-js 8.0.0: `<` `<=` `>` `>=` `+` `-` `*` `/` `%` fault and are eligible; `==`, `!=` and `in` ANSWER across types, so they already had an answer and are never rewritten. That is the root of the defect. Reach measured: this evaluator does not reach RLS — row-level security and declared sharing compile through `compileCelToFilter` / `matchesFilterCondition`, never through `celEngine.evaluate`. It does reach validation-rule predicates and `when` conditionals, `readonlyWhen`, hook conditions, automation conditions and formula fields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BTTPxkGkjPU9u8gT57PtiJ
1 parent 3e8e669 commit dd2c1ba

3 files changed

Lines changed: 483 additions & 33 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
---
2+
"@objectstack/formula": patch
3+
---
4+
5+
fix(formula): the ADR-0032 §1c retry rewrites only the operands that faulted (#7098)
6+
7+
**A CEL expression could return a silently wrong boolean.** No fault, no log
8+
line, no failing test — `{ ok: true }` with the wrong answer. If you have
9+
compound CEL that mixes a numeric comparison with a string equality over
10+
string-serialized fields, read the "which expressions change answer" list below:
11+
those expressions answer differently after this fix, and the new answer is the
12+
right one.
13+
14+
## What was wrong
15+
16+
When a comparison faults on a string-serialized numeric or date field
17+
(`record.rating >= 4` where `rating` reads back as `"5.0"`#1530 / #1534),
18+
ADR-0032 §1c hydrates and retries. The retry hydrated the **entire scope** and
19+
re-ran the **entire expression**, justified by a docblock claim that it
20+
21+
> can never change a comparison that already evaluated cleanly — it only rescues
22+
> one that already faulted.
23+
24+
That claim was false, and it was load-bearing: it was the stated reason the
25+
hydration was allowed to be unconditional and scope-wide. The retry knows only
26+
that the *whole expression* faulted, not that each sub-comparison did. So:
27+
28+
```text
29+
record.n >= 4 && record.s == "5.0" with { n: "7", s: "5.0" }
30+
before -> { ok: true, value: false } after -> { ok: true, value: true }
31+
```
32+
33+
`record.n >= 4` faults and is correctly rescued. But `record.s` was hydrated to
34+
the number `5` as well, so the author's deliberate string equality — **true**
35+
when it was evaluated the first time — became `5 == "5.0"`, which CEL answers
36+
`false` across types. The expression returned `false`, and nothing reported that
37+
a clean answer had been overruled.
38+
39+
## Which expressions change answer
40+
41+
Only expressions that **already reached the §1c retry** — i.e. some operand
42+
faulted `no such overload`. Everything that evaluates without faulting is
43+
untouched. Within that set, an expression changes answer when it also contains:
44+
45+
- **a string equality / inequality on a numeric-looking or ISO-date field**
46+
`record.n >= 4 && record.s == "5.0"`, and the `!=` and ternary forms. Now
47+
answers on the string the author wrote.
48+
- **a string membership test**`record.s in ["5.0", "x"]`.
49+
- **the same field compared as a number in one place and as a string in
50+
another**`record.n >= 4 && record.n == "7"`. Both answers are now correct
51+
at once; previously the second was collateral damage from the first.
52+
- **a numeric-looking string the expression RETURNS rather than compares**
53+
`record.n >= 4 ? record.s : "none"` returned the number `5`; it now returns
54+
the string `"5.0"`. A `Field.formula` of type text was storing a different
55+
value than the record held.
56+
57+
One class becomes a **loud fault where it used to be silently rescued**: an
58+
operand whose value the rewrite cannot read before deciding — bound by a
59+
comprehension (`record.items.exists(i, i.price > 100)`), or behind a computed
60+
index. That is the deliberate trade of this fix. Rescuing an operand we cannot
61+
prove faulted is exactly the defect being closed, so those report the original
62+
`no such overload` instead of guessing. The reported error is unchanged in shape
63+
and message.
64+
65+
## What replaces it
66+
67+
The coercion is now **per operand position** — the same discipline
68+
`rewriteTemporalEquality` already documents ("no field-wide trade-off"), one
69+
step stricter. The scope is never rewritten; the faulting operand is wrapped in
70+
`double(…)` or `date(…)` in place. An operand is rewritten only when all three
71+
hold, which makes the docblock's guarantee true by construction rather than by
72+
assertion:
73+
74+
1. the operator **raises** on a string-versus-number/Timestamp pair instead of
75+
answering one, so the comparison cannot have produced an answer;
76+
2. the counterpart is a number or a Timestamp **in this scope**, read off the
77+
values in hand rather than off a static type (every field is `dyn` under
78+
`unlistedVariablesAreDyn`);
79+
3. the operand's own value is a §1c serialization artifact — an entirely-numeric
80+
string or an ISO-8601 date. A zip like `"02134"`, or free text, still faults
81+
loudly.
82+
83+
Measured per operator on cel-js 8.0.0 and pinned in the new tests: `<` `<=` `>`
84+
`>=` `+` `-` `*` `/` `%` **fault** on a mixed pair and are eligible. `==`, `!=`
85+
and `in` **answer** across types — CEL equality is total — so they already had
86+
an answer and are never rewritten. That measurement is the root of the defect:
87+
the string equality above never faulted at all.
88+
89+
`Field.date` strings not matching a Timestamp under `==` remains owned by
90+
`rewriteTemporalEquality`, which wraps them statically on the clean path, where
91+
both sides are known from the source instead of inferred from an unrelated
92+
conjunct's fault.
93+
94+
## Reach
95+
96+
`celEngine.evaluate` — the only home of this retry — does **not** reach RLS.
97+
Row-level security compiles its `using` / `check` predicates through
98+
`compileCelToFilter` (SQL pushdown) and `matchesFilterCondition` (write-side
99+
post-image), and declared sharing rules do the same; neither calls this
100+
evaluator. No access-control decision could be inverted by this.
101+
102+
It does reach write-gating decisions, which is why the behaviour was not
103+
acceptable as documented: validation-rule predicates and `when` conditionals,
104+
`readonlyWhen`, hook `condition`s, automation/flow conditions, and formula
105+
fields and default values. A validation rule is **fail-closed** on a fault
106+
(#4649) — but a silently flipped boolean is not a fault, so a rule that should
107+
have rejected a write instead read as "not violated" and let it through.

packages/formula/src/cel-engine.ts

Lines changed: 197 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,36 +1073,197 @@ function isNumericOverloadError(err: unknown): boolean {
10731073
}
10741074

10751075
/**
1076-
* Recursively coerce string values that faulted a CEL overload into their
1077-
* intended primitive: entirely-numeric literals → `number` (#1534), and
1078-
* ISO-8601 date / date-time strings → `Date` (cel-js `google.protobuf.Timestamp`)
1079-
* (#1530). Used only on the {@link isNumericOverloadError} retry path, so it can
1080-
* never change a comparison that already evaluated cleanly — it only rescues one
1081-
* that already faulted. Strings that are neither (a zip like `"02134"`, free
1082-
* text) pass through untouched; if the retry still cannot type-check, the
1083-
* original loud error is preserved.
1076+
* The operators that RAISE on a string-versus-number/Timestamp operand pair
1077+
* instead of answering one. This membership is the entire basis of the §1c
1078+
* rescue below — for these operators a mixed pair cannot have produced an
1079+
* answer, so rewriting the operand cannot change one.
1080+
*
1081+
* Measured per operator on cel-js 8.0.0 (#7098), against an int literal, a
1082+
* number-valued field, a `today()` Timestamp and a Date-valued field:
1083+
*
1084+
* - `<` `<=` `>` `>=` `+` `-` `*` `/` `%` — **fault**, every shape:
1085+
* `no such overload: dyn<string> >= int`. Listed.
1086+
* - `==` `!=` — **answer**, `false` / `true`. CEL equality is defined across
1087+
* types, so `record.s == 5` over `{ s: "5" }` is a clean `false`, not a
1088+
* fault. DELIBERATELY ABSENT: coercing an equality is exactly the defect
1089+
* this function closes — the author's string equality already had an answer.
1090+
* The separate problem that a `Field.date` string never equals a Timestamp is
1091+
* owned by {@link rewriteTemporalEquality}, which wraps it statically and
1092+
* per-occurrence on the CLEAN path, where the two sides are known from the
1093+
* source rather than guessed from an unrelated conjunct's fault.
1094+
* - `in` — **answers** too (`"7" in [1, 7]` is a clean `false`). Absent for the
1095+
* same reason.
10841096
*/
1085-
function hydrateOverloadStrings(value: unknown): unknown {
1086-
if (typeof value === 'string') {
1087-
const trimmed = value.trim();
1088-
if (trimmed.length > 0) {
1089-
if (NUMERIC_STRING_RE.test(trimmed)) {
1090-
const n = Number(trimmed);
1091-
if (Number.isFinite(n)) return n;
1092-
} else if (ISO_TEMPORAL_STRING_RE.test(trimmed)) {
1093-
const ms = Date.parse(trimmed);
1094-
if (!Number.isNaN(ms)) return new Date(ms);
1095-
}
1096-
}
1097-
return value;
1097+
const COERCIBLE_OPS: ReadonlySet<string> = new Set([
1098+
'<', '<=', '>', '>=', '+', '-', '*', '/', '%',
1099+
]);
1100+
1101+
/** What an operand will actually BE at evaluation time — see {@link operandKind}. */
1102+
type OperandKind = 'number' | 'temporal' | 'string' | 'unknown';
1103+
1104+
/**
1105+
* The scope path a node names, or null when it names none: `record.n` →
1106+
* `['record','n']`, a bare `status` (the flattened flow scope) → `['status']`,
1107+
* and `record.items[0].price` / `record["n"]` → the same walk through a CONSTANT
1108+
* index. Null for everything else — a call, an arithmetic sub-tree, a variable
1109+
* bound by a comprehension — which is what keeps the rewrite below to operands
1110+
* whose runtime value we can actually read before deciding.
1111+
*/
1112+
function scopePath(node: unknown): string[] | null {
1113+
if (!isCelNode(node)) return null;
1114+
if (node.op === 'id' && typeof node.args === 'string') return [node.args];
1115+
if (node.op === '.' && Array.isArray(node.args) && node.args.length === 2) {
1116+
const [base, member] = node.args;
1117+
if (typeof member !== 'string') return null;
1118+
const head = scopePath(base);
1119+
return head ? [...head, member] : null;
10981120
}
1099-
if (Array.isArray(value)) return value.map(hydrateOverloadStrings);
1100-
if (value && typeof value === 'object' && !(value instanceof Date)) {
1101-
const out: Record<string, unknown> = {};
1102-
for (const [k, v] of Object.entries(value)) out[k] = hydrateOverloadStrings(v);
1103-
return out;
1121+
if (node.op === '[]' && Array.isArray(node.args) && node.args.length === 2) {
1122+
const [base, index] = node.args;
1123+
if (!isCelNode(index) || index.op !== 'value') return null;
1124+
const key = index.args;
1125+
if (typeof key !== 'string' && typeof key !== 'bigint' && typeof key !== 'number') return null;
1126+
const head = scopePath(base);
1127+
return head ? [...head, String(key)] : null;
11041128
}
1105-
return value;
1129+
return null;
1130+
}
1131+
1132+
/** Resolve a {@link scopePath} against the scope; `undefined` when any hop is absent. */
1133+
function resolveScopePath(scope: Record<string, unknown>, path: readonly string[]): unknown {
1134+
let cur: unknown = scope;
1135+
for (const seg of path) {
1136+
if (cur == null || typeof cur !== 'object') return undefined;
1137+
cur = (cur as Record<string, unknown>)[seg];
1138+
}
1139+
return cur;
1140+
}
1141+
1142+
/** The {@link OperandKind} of a concrete runtime value. */
1143+
function valueKind(v: unknown): OperandKind {
1144+
if (typeof v === 'number' || typeof v === 'bigint') return 'number';
1145+
if (v instanceof Date) return 'temporal';
1146+
if (typeof v === 'string') return 'string';
1147+
return 'unknown';
1148+
}
1149+
1150+
/**
1151+
* What the operand will actually be when cel-js evaluates it — read off the
1152+
* literal, off the known return type of a stdlib call, or (for a scope path) off
1153+
* the value ALREADY IN HAND in this scope. Reading the scope rather than the
1154+
* static type is what makes the "this comparison provably faulted" test exact
1155+
* under `unlistedVariablesAreDyn`, where every field is statically `dyn`.
1156+
*
1157+
* `unknown` is the safe answer and the common one: an arithmetic sub-tree, a
1158+
* comprehension variable, an absent key. An `unknown` counterpart never licenses
1159+
* a rewrite.
1160+
*/
1161+
function operandKind(node: unknown, scope: Record<string, unknown>): OperandKind {
1162+
if (!isCelNode(node)) return 'unknown';
1163+
if (node.op === 'value') return valueKind(node.args);
1164+
if (isTemporalCall(node)) return 'temporal';
1165+
if (node.op === 'call' && Array.isArray(node.args) && typeof node.args[0] === 'string') {
1166+
const fn = node.args[0];
1167+
if (fn === 'date' || fn === 'datetime') return 'temporal';
1168+
if (fn === 'double' || fn === 'int' || fn === 'uint') return 'number';
1169+
return 'unknown';
1170+
}
1171+
const path = scopePath(node);
1172+
if (!path) return 'unknown';
1173+
const resolved = resolveScopePath(scope, path);
1174+
return resolved === undefined ? 'unknown' : valueKind(resolved);
1175+
}
1176+
1177+
/**
1178+
* The coercion this operand needs to meet `counterpart`, or null when it is not
1179+
* one ADR-0032 §1c rescues: entirely-numeric literals → `double(…)` (#1534) and
1180+
* ISO-8601 date / date-time strings → `date(…)` (#1530). Strings that are
1181+
* neither — a zip like `"02134"`, free text — return null and the original loud
1182+
* fault is preserved, exactly as before.
1183+
*
1184+
* The coercion must MATCH the counterpart: a numeric string opposite a Timestamp
1185+
* (or an ISO string opposite a number) is a genuine mismatch, not a §1c
1186+
* serialization artifact, and is left to fault.
1187+
*/
1188+
function coercionFor(value: unknown, counterpart: OperandKind): 'double' | 'date' | null {
1189+
if (typeof value !== 'string') return null;
1190+
const trimmed = value.trim();
1191+
if (trimmed.length === 0) return null;
1192+
if (counterpart === 'number' && NUMERIC_STRING_RE.test(trimmed)) {
1193+
return Number.isFinite(Number(trimmed)) ? 'double' : null;
1194+
}
1195+
if (counterpart === 'temporal' && ISO_TEMPORAL_STRING_RE.test(trimmed)) {
1196+
return Number.isNaN(Date.parse(trimmed)) ? null : 'date';
1197+
}
1198+
return null;
1199+
}
1200+
1201+
/** Wrap an AST node in a one-argument stdlib call (`double(x)` / `date(x)`). */
1202+
function wrapInCall(fn: string, node: CelNode): CelNode {
1203+
return { op: 'call', args: [fn, [node]] };
1204+
}
1205+
1206+
/**
1207+
* #7098 — coerce the operands that PROVABLY faulted, and only those.
1208+
*
1209+
* The predecessor of this function hydrated the whole scope and re-ran the
1210+
* expression, on a docblock claim that "it can never change a comparison that
1211+
* already evaluated cleanly". That claim was false and load-bearing: the retry
1212+
* knows only that the WHOLE expression faulted, so rewriting the scope
1213+
* re-interprets every OTHER comparison too. `record.n >= 4 && record.s == "5.0"`
1214+
* over `{ n: "7", s: "5.0" }` faults on the first conjunct, hydrates BOTH fields,
1215+
* and answers `false` — the author's deliberate string equality was `true` in
1216+
* evaluation 1, and nothing reports that it was overruled.
1217+
*
1218+
* So the rewrite is now **per-occurrence**, the same discipline
1219+
* {@link rewriteTemporalEquality} already documents ("no field-wide trade-off"),
1220+
* and one step stricter — it is per operand POSITION, so a field compared to an
1221+
* int in one conjunct and to a string literal in another keeps both answers.
1222+
*
1223+
* An operand is rewritten only where all three hold, which together make the
1224+
* docblock's guarantee true by construction rather than by assertion:
1225+
* 1. the operator is one of {@link COERCIBLE_OPS} — no string↔number/Timestamp
1226+
* overload exists, so a mixed pair cannot have produced an answer;
1227+
* 2. the counterpart is a number or a Timestamp *in this scope*, established by
1228+
* {@link operandKind} against the values in hand, not by static type;
1229+
* 3. this operand's own value is a §1c serialization artifact
1230+
* ({@link coercionFor}).
1231+
*
1232+
* Returns the rewritten source, or null when no operand qualifies — in which
1233+
* case the caller preserves the original loud error rather than guessing. That
1234+
* is the deliberate trade: shapes the walk cannot read (a comprehension
1235+
* variable, a computed index) now FAULT where they were once silently rescued,
1236+
* because a silent rescue of an operand we cannot prove faulted is precisely the
1237+
* defect this closes.
1238+
*/
1239+
function rewriteFaultedOperands(source: string, scope: Record<string, unknown>): string | null {
1240+
let ast: unknown;
1241+
try {
1242+
ast = (recordScopeEnv ??= buildScopedEnv([])).parse(source).ast;
1243+
} catch {
1244+
return null;
1245+
}
1246+
let changed = false;
1247+
const visit = (node: unknown): void => {
1248+
if (!isCelNode(node)) return;
1249+
if (COERCIBLE_OPS.has(node.op) && Array.isArray(node.args) && node.args.length === 2) {
1250+
const args = node.args as unknown[];
1251+
for (const side of [0, 1] as const) {
1252+
const operand = args[side];
1253+
const path = scopePath(operand);
1254+
if (!path) continue;
1255+
const counterpart = operandKind(args[1 - side], scope);
1256+
if (counterpart !== 'number' && counterpart !== 'temporal') continue;
1257+
const fn = coercionFor(resolveScopePath(scope, path), counterpart);
1258+
if (!fn) continue;
1259+
args[side] = wrapInCall(fn, operand as CelNode);
1260+
changed = true;
1261+
}
1262+
}
1263+
if (Array.isArray(node.args)) for (const child of node.args) visit(child);
1264+
};
1265+
visit(ast);
1266+
return changed ? serialize(ast as Parameters<typeof serialize>[0]) : null;
11061267
}
11071268

11081269
/**
@@ -1303,14 +1464,17 @@ export const celEngine: DialectEngine = {
13031464
// date/datetime fields (`end_date` → `"2026-06-20"`) on
13041465
// `record.end_date <= daysFromNow(60)` (#1530), since cel-js compares the
13051466
// raw string against the `google.protobuf.Timestamp` from `today()` etc.
1306-
// Hydrate those strings to number / Date and retry ONCE. This only runs
1307-
// after a fault, so a comparison that already evaluated cleanly is never
1308-
// re-interpreted; if the retry still cannot type-check, the original loud
1309-
// error is reported.
1467+
// Coerce those operands — and ONLY those — and retry ONCE. #7098: the
1468+
// coercion is per operand POSITION, not scope-wide, so a comparison that
1469+
// already evaluated cleanly is never re-interpreted; the scope itself is
1470+
// never rewritten, so a numeric-looking string RETURNED by the expression
1471+
// keeps its type too. When no operand provably faulted, or the retry still
1472+
// cannot type-check, the original loud error is reported.
13101473
if (!isNumericOverloadError(err)) throw err;
1311-
const hydrated = hydrateOverloadStrings(scope) as Record<string, unknown>;
1474+
const coercedSource = rewriteFaultedOperands(evalSource, scope);
1475+
if (coercedSource === null) throw err;
13121476
try {
1313-
const raw = env.evaluate(evalSource, hydrated);
1477+
const raw = env.evaluate(coercedSource, scope);
13141478
return { ok: true, value: coerce(raw) as T };
13151479
} catch {
13161480
// Hydration did not resolve it — surface the original fault, not the

0 commit comments

Comments
 (0)