Skip to content

fix(formula): give an over-budget expression a size prescription, not the dialect trailer (#7073) - #7209

Merged
os-help merged 1 commit into
mainfrom
claude/issue-7073-bounds-trailer
Aug 10, 2026
Merged

fix(formula): give an over-budget expression a size prescription, not the dialect trailer (#7073)#7209
os-help merged 1 commit into
mainfrom
claude/issue-7073-bounds-trailer

Conversation

@os-help

@os-help os-help commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7073

The defect

validateExpression (ADR-0032's shared validator) appended one trailer to every
celEngine.compile refusal, byte for byte:

` — ${role}s are bare CEL (e.g. \`record.rating >= 4\`).`

That sentence is right for a dialect mistake and actively wrong for a bounds
refusal. Measured on origin/main before this PR, an 80-clause conjunction — perfect
CEL, merely past maxAstNodes 256 — produced:

invalid CEL predicate: Exceeded maxAstNodes (256)
> 1 | record.f0 == 0 && record.f1 == 1 && … && record.f79 == 79
                                                              ^ — predicates are bare CEL (e.g. `record.rating >= 4`).

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:

New message for the same input:

invalid CEL predicate: Exceeded maxAstNodes (256) … — this is valid CEL that exceeds the
`maxAstNodes` budget (limit 256) — a SIZE fault, not a dialect mistake, so re-spelling the
expression will not fix it. Shrink it (fewer clauses, shallower nesting, fewer list
elements), or precompute the heavy part into a stored field and reference that field
instead. Splitting it into several expressions changes how they combine at this authoring
site, so check that site's semantics before doing that.

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.ts docblock — TEXT ONLY

Per 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 gates
it names but not complete: a third gate, validateStackExpressions, reaches the
same sharingRules[].condition through validateExpression and is mode-agnostic
(the compile path never reads celPushdownLimitsMode). So during the rc grace window
lint 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:

$ pnpm --workspace-concurrency=2 --filter '@objectstack/formula' test -- --maxWorkers=2
 Test Files  23 passed (23)
      Tests  595 passed (595)

$ pnpm --workspace-concurrency=2 --filter '@objectstack/formula' typecheck
> tsc --noEmit                       (clean)

$ pnpm --workspace-concurrency=2 --filter '@objectstack/lint' test -- --maxWorkers=2
 Test Files  68 passed (68)
      Tests  1771 passed | 4 skipped (1775)

$ pnpm --workspace-concurrency=2 --filter '@objectstack/service-automation' test -- --maxWorkers=2
 Test Files  72 passed (72)
      Tests  885 passed (885)

$ pnpm --workspace-concurrency=2 --filter '@objectstack/mcp' test -- --maxWorkers=2
 Test Files  10 passed (10)
      Tests  114 passed (114)

$ node scripts/check-nul-bytes.mjs
check-nul-bytes: OK (scanned 6609 text file(s) …; no raw ASCII control bytes).

Those are the three direct consumers of validateExpression: @objectstack/lint
(validateStackExpressions), @objectstack/service-automation (registerFlow) and
@objectstack/mcp (the validate_expression tool). @objectstack/lint's first run was
red on 14 files with an authoring-rules.ts import error — the stale-artefact trap,
cleared by building --filter '@objectstack/lint^...' first, not by anything in this
change.

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:

 Test Files  1 failed | 22 passed (23)
      Tests  7 failed | 588 passed (595)

AssertionError: expected 'invalid CEL predicate: Exceeded maxAs…' to match /SIZE fault, not a dialect mistake/
+ Received: "invalid CEL predicate: Exceeded maxAstNodes (256) … ^ — predicates are bare CEL (e.g. `record.rating >= 4`)."

All 7 reds are the bounds direction, across all three bounds (maxAstNodes,
maxDepth, maxListElements) and both CEL roles. The three dialect-fault pins and the
braces-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-line
concatenation for bare CEL / are bare CEL / record.rating >= 4) / invalid CEL
across packages/ and apps/. No test anywhere pins this producer's trailer on a
bounds-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. The service-automation suites'
toThrow(/template braces|bare CEL/) pins all feed single-brace inputs, which take the
bracesHint path this PR does not alter.

Out of scope — filed as #7217

lint/src/validate-visibility-predicates.ts has the same defect shape at its own
consumer-side gate: parseCelToAst returns null on a bounds fault too, so an
over-budget visibleWhen is reported as "visibility predicate is not valid CEL" with
the dialect hint. That gate deliberately does not go through validateExpression, so
this PR does not reach it. Measured and filed unassigned rather than fixed here.


Generated by Claude Code

… 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
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 2:48am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/formula.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/formulas.mdx (via @objectstack/formula)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula)
  • content/docs/plugins/packages.mdx (via @objectstack/formula)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/formula)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v15.mdx (via @objectstack/formula)
  • content/docs/releases/v16.mdx (via @objectstack/formula)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants