fix(formula): give an over-budget expression a size prescription, not the dialect trailer (#7073) - #7209
Conversation
… the dialect trailer (#7073) `validateExpression` appended the same dialect trailer — "`predicate`s are bare CEL (e.g. `record.rating >= 4`)" — to every `celEngine.compile` refusal, byte for byte, including a `bounds` refusal. For a syntactically perfect, over-budget CEL (an 80-clause conjunction past `maxAstNodes` 256) that sentence sends the author to change the one thing that was never wrong; an AI author, which obeys the last sentence it is handed, rewrites the dialect and regresses. The refusal is unchanged. Only the prescription is now class-aware: on the engine's own `kind: 'bounds'` verdict the trailer becomes a size prescription naming the exceeded bound and its value (read from `parseCelToAstWithReason`, without `admitOverLimit` so no unbounded parse or measurement is paid for), with shrink / denormalise remedies and a caveat on splitting. A genuine dialect or syntax fault keeps the old trailer verbatim, and the #1491 braces hint is untouched — both directions pinned. Fixed once at the producer, so all ~10 ADR-0032 expression slots benefit. Also completes, text-only, `cel-pushdown-limits.ts`'s "nothing else needs to move at GA": a third lint gate (`validateStackExpressions`) reaches the same `sharingRules[].condition` and is mode-agnostic, so lint is stricter than the runtime during the rc grace window — benign, tightening-direction, self-healing at GA. No behaviour change there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016R9de1FqP7NvwKvqXi92Gh
|
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:
|
Fixes #7073
The defect
validateExpression(ADR-0032's shared validator) appended one trailer to everycelEngine.compilerefusal, byte for byte:That sentence is right for a dialect mistake and actively wrong for a bounds
refusal. Measured on
origin/mainbefore this PR, an 80-clause conjunction — perfectCEL, merely past
maxAstNodes256 — produced:The front half was right; the prescription taught the author to change the one thing
that was never wrong. An AI author, which obeys the last sentence it is handed,
rewrites the dialect and regresses.
The fix
Producer-side, once, per the card's ruling — not per consumer. The refusal itself is
unchanged (same inputs refused, same cel-js front half). Only the prescription is now
class-aware:
celEngine.compile's ownerror.kind === 'bounds';parseCelToAstWithReason— the samereason-carrying entrance
@objectstack/lint's RLS gate reads (At the GA limits flip, an over-budget RLS predicate reports underrls-predicate-unparseablewith SQL-vs-CEL prose — off-label for a bounds overrun #6778 / PR fix(lint): 超预算的 RLS 谓词有了自己的规则 idrls-predicate-over-budget(#6778) #6831, theconsumer-side instance of this family). Called without
admitOverLimit, so thispath pays for neither the unbounded parse nor the overrun measurement;
braces hint is untouched.
New message for the same input:
Why the remedies are slot-generic
This one producer serves all ~10 ADR-0032 expression slots, whose combination
semantics differ. PR #6831's RLS-specific sentence — splitting the top-level
&&widens the grant — is true for a security predicate and wrong-to-meaningless for a
formula value, so it is not reused. Shrinking and denormalising are safe
everywhere; splitting is offered only with the caveat that the site decides what
splitting means. A test pins that the message never claims anything about grants or
permissions.
Folded-in item:
cel-pushdown-limits.tsdocblock — TEXT ONLYPer the card's binding scope (「行为不动,只补 docblock 那句「nothing else」的完备性」),
this is a comment-only change. The docblock's "Nothing else needs to move at GA … in
particular
@objectstack/lint's two enforceability gates" is true of the two gatesit names but not complete: a third gate,
validateStackExpressions, reaches thesame
sharingRules[].conditionthroughvalidateExpressionand is mode-agnostic(the compile path never reads
celPushdownLimitsMode). So during the rc grace windowlint is stricter than the runtime. #6833's measurement graded that benign —
tightening-direction, self-healing at GA — and loosening lint to chase the grace window
would restore the silent acceptance #6132 closed. The docblock now says so; nothing
moves.
Verification
Build closure first (
pnpm --filter '@objectstack/formula^...' build), then:Those are the three direct consumers of
validateExpression:@objectstack/lint(
validateStackExpressions),@objectstack/service-automation(registerFlow) and@objectstack/mcp(thevalidate_expressiontool).@objectstack/lint's first run wasred on 14 files with an
authoring-rules.tsimport error — the stale-artefact trap,cleared by building
--filter '@objectstack/lint^...'first, not by anything in thischange.
Reverse verification — direction predicted, then measured
Predicted: red, on the bounds assertions only, with the flipped pins staying green.
Took the fix out with
git checkout origin/main -- packages/formula/src/validate.ts(never
git stash) and re-ran:All 7 reds are the bounds direction, across all three bounds (
maxAstNodes,maxDepth,maxListElements) and both CEL roles. The three dialect-fault pins and thebraces-hint pin stayed green in the same run — which is the point: a test asserting
only "the bounds message changed" would also pass on a fix that stripped the trailer
from genuine dialect faults, i.e. that shrank the refusal surface while appearing to
widen it. The received string above is also the defect itself, quoted from the run.
Message pins swept
Grepped the literal, the
toMatch(/…/)spelling and the line-leading-+cross-lineconcatenation for
bare CEL/are bare CEL/record.rating >= 4) /invalid CELacross
packages/andapps/. No test anywhere pins this producer's trailer on abounds-class input, so no pin had to be flipped. The nearby hits are separate
messages owned by other gates and untouched here:
service-automation/src/engine.ts:5456,lint/src/validate-visibility-predicates.ts:609,lint/src/validate-approval-approvers.ts:221. Theservice-automationsuites'toThrow(/template braces|bare CEL/)pins all feed single-brace inputs, which take thebracesHintpath this PR does not alter.Out of scope — filed as #7217
lint/src/validate-visibility-predicates.tshas the same defect shape at its ownconsumer-side gate:
parseCelToAstreturnsnullon a bounds fault too, so anover-budget
visibleWhenis reported as "visibility predicate is not valid CEL" withthe dialect hint. That gate deliberately does not go through
validateExpression, sothis PR does not reach it. Measured and filed unassigned rather than fixed here.
Generated by Claude Code