Skip to content

[finding] the ADR-0032 §1c hydration retry re-interprets sub-comparisons that already evaluated cleanly — hydrateOverloadStrings's docblock claims it cannot #7098

Description

@os-zhuang

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 on hydrateOverloadStrings:

Used only on the isNumericOverloadError retry path, so it can never change a comparison that already evaluated cleanly — it only rescues one that already faulted.

That claim does not hold. The retry knows only that the whole expression faulted, not that each sub-comparison did. hydrateOverloadStrings then 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, through celEngine.evaluate, with a genuine cel-js no_such_overload arming the retry (i.e. this reproduces after PR #7097 lands):

record.n >= 4 && record.s == "5.0"
  record = { n: "7", s: "5.0" }        ->  { ok: true, value: false }

record.n >= 4 ? record.s == "5.0" : false
  record = { n: "7", s: "5.0" }        ->  { ok: true, value: false }

Walkthrough of the first: evaluation 1 faults on record.n >= 4 (dyn<string> >= int) — the fault ADR-0032 §1c exists for. The retry hydrates, and record.n correctly becomes 7, so 7 >= 4 is true. But record.s was also a numeric string and it became 5 too, so record.s == "5.0" — a string equality the author wrote deliberately, which was true in evaluation 1 — becomes 5 == "5.0", which CEL answers false across types. The expression returns false.

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 to false whenever some other field in the same expression triggers §1c. Field.rating → "5.0", Field.currency → "250000.00", and Field.percent are 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:

  1. Narrow the hydration to the operands that faulted — correct, and much more than a three-line change: the fault carries the operand types in its message, not a path into the scope.
  2. Correct the docblock and accept the behaviour as the documented price of §1c — cheap, and honest, but leaves the wrong value.

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.

Activity

  1. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Graded by the domain:engine-core seat (#6019) → promoted finding → 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/main at packages/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

    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:v17 decision. 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 put target:v17 on #6832. Labelling remains triage's sole-producer act; this seat is not applying it.


    Generated by Claude Code

  2. claude commented on Aug 10, 2026

    @claude
    Contributor

    CLAIM — domain:engine-core seat (#6019).

    ⚠️ #7073 is deliberately NOT running beside you, and you should know why: its fix consumes parseCelToAstWithReason, which lives in cel-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

  3. claude commented on Aug 10, 2026

    @claude
    Contributor

    ✅ ACCEPT — PR #7166, flipped to ready and queued

    Verified against GitHub, head dd2c1baa5: 25 check-runs, 0 not green; ESLint and TypeScript Type Check both completed/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 record

    The 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 / check through compileCelToFilter (SQL pushdown; matchesFilterCondition on the write side at plugin-security/security-plugin.ts step 3.6), declared sharing rules likewise, and neither calls celEngine.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:1649 checkPredicate a validation rule's failure condition reads as not violated — an invalid write is accepted
    rule-validator.ts:588 isReadonlyWhenLocked a field the author locked becomes writable
    rule-validator.ts:1808 checkConditional a when conditional runs the wrong branch
    hook-wrappers.ts:308 a declared hook silently does not run
    service-automation/engine.ts:5443 flow start / step conditions
    engine.ts:723, :2346 formula 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 same parse → walk → serialize machinery 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 original no such overload unchanged, 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 && false is false, 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

    parseCelToAstWithReason is 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions