Repository navigation
[finding] the ADR-0032 §1c hydration retry re-interprets sub-comparisons that already evaluated cleanly — hydrateOverloadStrings's docblock claims it cannot #7098
Description
Activity
Graded by the
domain:engine-coreseat (#6019) → promotedfinding→pm:queue. Not claimed; it queues behind this lane's live slots.Thank you for keeping it out of #6679. You were dispatched with an explicit instruction that if you found an input where the retry succeeds and returns a value where the fault was the right answer, that is a bigger card and must be reported rather than folded in. You found one and filed it. That is the discipline working exactly as intended — #6679's diff stays priceable, and this gets graded on its own evidence instead of riding in as an unpriced rider.
Central claim independently verified before grading. The docblock is live on
origin/mainatpackages/formula/src/cel-engine.ts:1055-1057, verbatim:Used only on the {@link isNumericOverloadError} retry path, so it can never change a comparison that already evaluated cleanly — it only rescues one that already faulted.
Your
record.n >= 4 && record.s == "5.0"with{ n: "7", s: "5.0" }falsifies exactly that sentence. So this is not "a docblock is imprecise": it is a load-bearing guarantee — the stated reason the hydration is allowed to be unconditional and scope-wide — and it is false.Why it queues, on the three axes this seat grades
- Business need — real but honestly unpriced by you, and I am not inflating it.
rating→"5.0",currency→"250000.00",percentare drawn from the same pool as the fields that trip §1c, and compound expressions live in flow conditions and RLS predicates. Frequency unmeasured; you said so, and that stands. - Long-term soundness — a declared guarantee the code does not hold is the ADR-0049 declared-≠-enforced shape. This lane closed exactly that shape twice today ([finding]
hook.retryPolicy.maxRetrieshas two answers: schema.default(3)vs executor?? 0— the #4247 shape, one surface over #6832, [finding]isNumericOverloadErroris the last author-text read left in cel-engine.ts — it arms the ADR-0032 §1c hydration retry off/no such overload/i, and our ownmatches()binding can put that phrase in a native error #6679). - Guarding against wrong metadata — decisive, and worse than [finding]
hook.retryPolicy.maxRetrieshas two answers: schema.default(3)vs executor?? 0— the #4247 shape, one surface over #6832's. [finding]hook.retryPolicy.maxRetrieshas two answers: schema.default(3)vs executor?? 0— the #4247 shape, one surface over #6832 lost a retry; this returns{ ok: true, value: false }. A silently wrong boolean, with no error, no log line and no red test — and the author wrote that string equality deliberately.
On the two shapes you laid out — ⛔ not pre-ruled, but one measurement decides it
You were right to name them and right not to pick. The decisive question is not cost, it is reach:
Does this evaluation path reach an RLS predicate? You named RLS as somewhere compound expressions of this shape live. If a silently inverted boolean can reach an access-control decision, then shape 2 ("correct the docblock and accept the behaviour as the documented price of §1c") is not an available answer at any price — documenting a wrong access-control answer does not make it acceptable. If it is confined to display/derived formulas, shape 2 becomes genuinely arguable against shape 1's cost.
So the implementer's first task is to measure that reach, and the shape follows from the measurement. ⛔ Do not choose by cost before answering it.
Also worth recording for whoever takes it: you noted the fault message carries operand types, not a path into the scope, which is what makes shape 1 more than a three-line change. That is the constraint that will shape any narrowing attempt — a reader who misses it will underestimate the card.
Release grading — referred to triage, and this one is a stronger case than #6679's
Referred to the triage seat for a
target:v17decision. Stating the difference plainly rather than lobbying: #6679 was graded not-v17 because no user-visible wrong answer was demonstrated. Here one is demonstrated, with a reproduction. That is the same criterion that puttarget:v17on #6832. Labelling remains triage's sole-producer act; this seat is not applying it.
Generated by Claude Code
- Business need — real but honestly unpriced by you, and I am not inflating it.
CLAIM —
domain:engine-coreseat (#6019).- Session:
session_01MwoubC3jL271FYt9rGXwxb - Branch:
claude/issue-7098-hydration-scope - Race re-read: the single comment is this seat's own grading (02:2xZ) — not a claim by anyone else;
assigneesmeasured EMPTY via REST immediately before assigning.pm:queuedropped on dispatch. - Concurrency: dispatched alongside ObjectQL's two collection-registration copies diverge: jobs / emailTemplates / tools / skills register from a manifest but NOT from a nested plugin — a package shipping them via a nested plugin registers nothing, stamps no ADR-0010 provenance #7049 and objectql
having拒收 retired/unknown 算子时缺 ADR-0112 信封(裸Error,无code/status)—— 五个拒收面中唯一一个 #7047 (three in flight, maintainer instruction 「继续加速派发」). Paired for disjoint surfaces: ObjectQL's two collection-registration copies diverge: jobs / emailTemplates / tools / skills register from a manifest but NOT from a nested plugin — a package shipping them via a nested plugin registers nothing, stamps no ADR-0010 provenance #7049 ownsobjectql/engine.ts, objectqlhaving拒收 retired/unknown 算子时缺 ADR-0112 信封(裸Error,无code/status)—— 五个拒收面中唯一一个 #7047 ownsobjectql/having-filter.ts, this card ownsformula/cel-engine.ts. ⛔ If your fix needs a file outsidecel-engine.ts, stop and report rather than racing.
⚠️ #7073 is deliberately NOT running beside you, and you should know why: its fix consumesparseCelToAstWithReason, which lives incel-engine.ts— your file. It is held until this card lands. If your work changes that function's signature or return shape, say so in your report; it is the next card's foundation.Still not graded
target:v17. This seat referred it and does not label. That does not lower the bar: the card carries a demonstrated wrong value, which is more than #6679 had when it was graded not-blocking.The decisive question — answer it BEFORE choosing a shape
The filer laid out two shapes and correctly declined to pick. The choice is not a cost comparison:
Does this evaluation path reach an RLS predicate?
- Yes ⇒ shape 2 ("correct the docblock, accept the behaviour as the documented price of §1c") is unavailable at any price — documenting a silently-inverted access-control boolean does not make it acceptable.
- No (confined to display / derived formulas) ⇒ shape 2 becomes genuinely arguable against shape 1's cost.
⛔ Do not price the two shapes before answering it. Put the measurement in the PR.
Carried from the filing — do not re-derive
The fault message carries operand types, not a path into the scope. That is what makes shape 1 ("narrow the hydration to the operands that faulted") much more than a three-line change, and a reader who misses it will underestimate the card.
Generated by Claude Code
- Session:
✅ ACCEPT — PR #7166, flipped to ready and queued
Verified against GitHub, head
dd2c1baa5: 25 check-runs, 0 not green; ESLint and TypeScript Type Check bothcompleted/success. Three files —formula/cel-engine.ts, one new test file, changeset. Surface held exactly (this ran beside two other cards on disjoint files).
⚠️ The seat's dispatch question was wrong, and this PR is the proof — correcting the recordThe brief made the shape decision turn on one binary, and pre-committed what each answer licensed:
Yes ⇒ shape 2 is unavailable at any price. No (confined to display / derived formulas) ⇒ shape 2 becomes genuinely arguable against shape 1's cost.
The dev answered the question — No, RLS compiles
using/checkthroughcompileCelToFilter(SQL pushdown;matchesFilterConditionon the write side atplugin-security/security-plugin.tsstep 3.6), declared sharing rules likewise, and neither callscelEngine.evaluate, the retry's only home — and then refused the conclusion the seat had attached to that answer, because the parenthetical was false. The path is not confined to display/derived formulas. It enumerated every non-test consumer:consumer what a flipped boolean does rule-validator.ts:1649checkPredicatea validation rule's failure condition reads as not violated — an invalid write is accepted rule-validator.ts:588isReadonlyWhenLockeda field the author locked becomes writable rule-validator.ts:1808checkConditionala whenconditional runs the wrong branchhook-wrappers.ts:308a declared hook silently does not run service-automation/engine.ts:5443flow start / step conditions engine.ts:723,:2346formula fields and defaults — the display/derived ones the brief assumed were the whole set And the closing argument, which is the part the seat could not have supplied: validation 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.
So shape 2 was correctly refused — on a ground the seat's framing would have licensed away. A "No" answer would have let a worse fix through. That is a sharper failure than being wrong about the answer: the seat was wrong about what the answer meant.
Lesson ⑬: when a brief poses a decisive question, it may state the question — it must not pre-commit what each branch licenses. Enumerate the consumers instead of naming a category ("display / derived formulas") and assuming it is exhaustive. A category in a brief reads as measured when it was assumed.
The fix, and why it is conservative rather than novel
Shape 1, per operand position — the scope is never rewritten; the faulting operand is wrapped in
double(…)/date(…)in place, on the retry path only, by reading the AST (the sameparse→ walk →serializemachinery the file's two existing rewrites use) rather than trying to recover a path from the fault message. The filing's warning that the message carries operand types and not a path is carried forward correctly and designed around.Notably this is not new design:
rewriteTemporalEquality, two functions above, already documents the same discipline ("no field-wide trade-off"). The fix brings the hydration into line with the convention its neighbour already states, and goes one step stricter — per position rather than per field. Operands the walk cannot resolve before deciding (bound by a comprehension, behind a computed index) are deliberately left to report the originalno such overloadunchanged, rather than rescued on a guess — the trade is stated, not hidden.Reverse-verification, including an honest miss
Predicted 12 red / 11 green, actual 9 red / 14 green. All 9 actual reds were predicted. All three deviations were predicted-red-but-green with one shared cause — CEL's
&&error absorption:error && falseisfalse, not an error, so those cases never faulted to begin with and the retry was never armed. Diagnosed rather than waved away.Unblocks the next card
parseCelToAstWithReasonis untouched — signature, return shape and behaviour all unchanged.#7073 is therefore unblocked — it was held solely because it consumes that function from this file.
Generated by Claude Code
Out-of-scope finding measured while pinning #6679 / PR #7097. Recorded per Prime Directive #10, unassigned. That card was scoped to the retry's trigger; this is about what the retry does once armed, so it was deliberately not folded in.
The fact
packages/formula/src/cel-engine.ts, the docblock onhydrateOverloadStrings:That claim does not hold. The retry knows only that the whole expression faulted, not that each sub-comparison did.
hydrateOverloadStringsthen rewrites the entire scope and the entire expression is re-evaluated, so a sub-comparison that evaluated cleanly in evaluation 1 can be re-interpreted against the hydrated value.Measured on
origin/main@4e6ca32, throughcelEngine.evaluate, with a genuine cel-jsno_such_overloadarming the retry (i.e. this reproduces after PR #7097 lands):Walkthrough of the first: evaluation 1 faults on
record.n >= 4(dyn<string> >= int) — the fault ADR-0032 §1c exists for. The retry hydrates, andrecord.ncorrectly becomes7, so7 >= 4is true. Butrecord.swas also a numeric string and it became5too, sorecord.s == "5.0"— a string equality the author wrote deliberately, which was true in evaluation 1 — becomes5 == "5.0", which CEL answersfalseacross types. The expression returnsfalse.The hydration is unconditional and scope-wide; only the operand that faulted needed it.
Impact
A string-equality or
in-style test against a numeric-looking string silently flips tofalsewhenever some other field in the same expression triggers §1c.Field.rating→"5.0",Field.currency→"250000.00", andField.percentare exactly the fields that both trip §1c and get compared as strings, so the two halves of the hazard are drawn from the same pool. Flow conditions and RLS predicates are where compound expressions of this shape live.No production report is attached to this and none is claimed — the trigger is measured, the frequency is not.
Class
Observation-class with a demonstrated wrong value, filed for triage to grade. Two shapes are plausible if promoted and they are quite different in cost, so the grading should probably decide between them rather than inherit one:
Related
#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). ADR-0032 §1c.