fix(formula): the ADR-0032 §1c retry rewrites only the operands that faulted (#7098) - #7166
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Closes #7098.
The defect
hydrateOverloadStrings's docblock inpackages/formula/src/cel-engine.tsclaimed:That claim was false, and it was 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, not that each sub-comparison did.
Both of the filer's reproductions confirmed on this branch's base before any edit:
record.n >= 4faults and is correctly rescued. Butrecord.swas hydrated to the number5too, so the author's deliberate string equality — true in evaluation 1 — became5 == "5.0", which CEL answersfalseacross types. Silently wrong:{ ok: true }, no fault, no log line, no red test.The reach measurement, taken before the shape was chosen
Does this evaluation path reach an RLS predicate? No.
Row-level security compiles its
using/checkpredicates throughcompileCelToFilter— SQL pushdown on the read side, andmatchesFilterConditionagainst the post-image on the write side (plugin-security/security-plugin.tsstep 3.6). Declared sharing rules do the same (plugin-sharing/bootstrap-declared-sharing-rules.ts). Neither callscelEngine.evaluate, which is the only home of this retry. No access-control decision could be inverted by this defect.But the PM's binary was a false dichotomy, and shape 2 is still unavailable. The dispatch framed "No ⇒ confined to display / derived formulas ⇒ shape 2 becomes genuinely arguable". It is not so confined.
celEngine.evaluate's non-test consumers are:objectql/validation/rule-validator.ts:1649checkPredicateobjectql/validation/rule-validator.ts:1808checkConditionalwhenconditional runs the wrong branchobjectql/validation/rule-validator.ts:588isReadonlyWhenLockedobjectql/hook-wrappers.ts:308service-automation/engine.ts:5443objectql/engine.ts:723,:2346Validation rules are fail-closed on a fault (#4649) — but a silently flipped boolean is not a fault, so fail-closed never engages. Documenting a write-integrity gate that silently stops rejecting is no more acceptable than documenting an inverted access-control boolean; only one of the two is access control. Shape 2 (correct the docblock, accept the behaviour) was therefore not taken.
The fix — shape 1, per operand position
The coercion is now per operand position. The scope is never rewritten; the faulting operand is wrapped in
double(…)/date(…)in place, on the retry path only. This is the disciplinerewriteTemporalEqualityalready documents two functions above ("no field-wide trade-off") — so this is not novel design, it brings the hydration in line with the convention its neighbour already states — and one step stricter, since it is per position rather than per field.The filing carried forward that the fault message carries operand types, not a path into the scope, which is what makes this more than a three-line change. That is correct, and the fix does not try to read the operand out of the message. It reads the AST — the same
parse→ walk →serializemachinery the two existing rewrites in this file use — and resolves each operand against the scope values already in hand. An operand is rewritten only when all three hold:dynunderunlistedVariablesAreDyn;"02134"and free text still fault loudly.Together these make the docblock's guarantee true by construction rather than by assertion.
The measurement that sharpened the fix
Measured per operator on cel-js 8.0.0, against an int literal, a number-valued field, a
today()Timestamp and a Date-valued field:<<=>>=+-*/%no such overload: dyn<string> >= int==!=infalse/trueCEL equality is total, so
record.s == 5over{ s: "5" }is a cleanfalse, not a fault. This is the root of the defect: the string equality in the reproduction never faulted at all, so it always had an answer, and the scope-wide hydration overruled it. An earlier draft of this fix included==/!=/inand was wrong for exactly that reason; the measurement caught it.Field.datestrings not matching a Timestamp under==stays owned byrewriteTemporalEquality(#3183), which wraps them statically on the clean path where both sides are known from the source.What changes answer, stated plainly
Only expressions that already reached the §1c retry. Within those, the ones that also contain a string equality/inequality, a string membership test, the same field compared both as a number and as a string, or a numeric-looking string the expression returns rather than compares —
record.n >= 4 ? record.s : "none"returned the number5and now returns"5.0", so a textField.formulawas storing a different value than the record held.One class becomes a loud fault where it was silently rescued: an operand whose value the walk cannot read before deciding — bound by a comprehension (
record.items.exists(i, i.price > 100)) or behind a computed index. That is the deliberate trade. Silently rescuing an operand we cannot prove faulted is the defect being closed, so those report the originalno such overloadunchanged in shape and message.Reverse-verification
The new test file was run against the unmodified pre-fix source in a separate
origin/mainworktree, with red/green predicted per case first.Predicted 12 red / 11 green. Actual 9 red / 14 green. All 9 actual reds were predicted. The three deviations were all predicted-red-but-green, and have one cause: CEL's
&&error absorption.error && falseisfalse, not an error, so inthe fault is absorbed, the retry never armed, and there was nothing for the scope-wide hydration to corrupt. My prediction had assumed the retry armed in every
&&case. Verified by measuring each pre-fix directly. These rows are pinned anyway — they are the cases that must keep answering the same beside a faulting compare, and post-fix they do.Gates
Enumerated fresh from
origin/main: 64 inlint.yml. Nopackages/specfile is touched, so the 9 spec-filtered gates and the #6017 cross-seat declaration do not apply.@objectstack/formulatests@objectstack/objectqltests@objectstack/service-automationtests@objectstack/plugin-securitytestspnpm lintpnpm --filter @objectstack/formula typecheckpnpm check:engine-double-contractpnpm --filter @objectstack/lint check:doc-formula-expressionspnpm check:empty-changesetpnpm check:changeset-gate-self-testspnpm check:type-check-coveragepnpm buildCI is the authority; only a job conclusion of
completed: successcounts as green, and this stays draft until it is.Notes for the next card
parseCelToAstWithReasonis untouched — signature, return shape and behaviour are all unchanged. #7073, which consumes it, is unblocked by this landing and needs no adjustment for it.Related
#7098 · #6679 / PR #7097 (the trigger side of the same retry — narrows when it arms, not what it rewrites; this reproduces independently of it) · #1530 / #1534 (why the retry exists) · #4649 (validation fail-closed) · #3183 (
rewriteTemporalEquality, the per-occurrence precedent) · ADR-0032 §1c.Generated by Claude Code